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:
@@ -277,3 +277,10 @@ AZURE_APP_SECRET=
|
||||
|
||||
# REDIS_ALFRED_SIZE=10
|
||||
# REDIS_VELMA_SIZE=10
|
||||
|
||||
# Media Server (WhatsApp Calling - Server-Side WebRTC)
|
||||
# Enable server-side WebRTC relay for call persistence across page reloads
|
||||
# and server-side recording. Requires the chatwoot-media-server sidecar.
|
||||
# MEDIA_SERVER_URL=http://localhost:4000
|
||||
# MEDIA_SERVER_AUTH_TOKEN=
|
||||
# MEDIA_SERVER_PUBLIC_IP=
|
||||
|
||||
@@ -10,10 +10,11 @@ class WhatsappCallsAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${callId}`);
|
||||
}
|
||||
|
||||
// Accept a ringing call. sdpAnswer is optional — omitted in server-relay mode
|
||||
// where the media server handles WebRTC negotiation.
|
||||
accept(callId, sdpAnswer) {
|
||||
return axios.post(`${this.url}/${callId}/accept`, {
|
||||
sdp_answer: sdpAnswer,
|
||||
});
|
||||
const body = sdpAnswer ? { sdp_answer: sdpAnswer } : {};
|
||||
return axios.post(`${this.url}/${callId}/accept`, body);
|
||||
}
|
||||
|
||||
reject(callId) {
|
||||
@@ -24,13 +25,47 @@ class WhatsappCallsAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/${callId}/terminate`);
|
||||
}
|
||||
|
||||
// Initiate an outbound call. sdpOffer is optional — omitted in server-relay
|
||||
// mode where the media server generates the SDP offer for Meta.
|
||||
initiate(conversationId, sdpOffer) {
|
||||
return axios.post(`${this.url}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
sdp_offer: sdpOffer,
|
||||
const body = { conversation_id: conversationId };
|
||||
if (sdpOffer) body.sdp_offer = sdpOffer;
|
||||
return axios.post(`${this.url}/initiate`, body);
|
||||
}
|
||||
|
||||
// Send the agent's SDP answer for the Peer B connection (server-relay mode).
|
||||
agentAnswer(callId, sdpAnswer) {
|
||||
return axios.post(`${this.url}/${callId}/agent_answer`, {
|
||||
sdp_answer: sdpAnswer,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the current agent's active call (if any). Used for reconnection on page load.
|
||||
active() {
|
||||
return axios.get(`${this.url}/active`);
|
||||
}
|
||||
|
||||
// Reconnect to an active call after page reload. Server creates a new Peer B
|
||||
// and returns a fresh SDP offer via ActionCable.
|
||||
reconnect(callId) {
|
||||
return axios.post(`${this.url}/${callId}/reconnect`);
|
||||
}
|
||||
|
||||
// Join an existing call as a supervisor (listen-only by default).
|
||||
join(callId, role = 'listen_only') {
|
||||
return axios.post(`${this.url}/${callId}/join`, { role });
|
||||
}
|
||||
|
||||
// Play an audio file to the caller via the media server.
|
||||
playAudio(callId, { filePath, mode = 'replace', loop = false }) {
|
||||
return axios.post(`${this.url}/${callId}/play_audio`, {
|
||||
file_path: filePath,
|
||||
mode,
|
||||
loop,
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy: upload browser-side recording. Deprecated when media server is enabled.
|
||||
uploadRecording(callId, blob) {
|
||||
const formData = new FormData();
|
||||
formData.append('recording', blob, `call-${callId}.webm`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { onMounted, computed } from 'vue';
|
||||
import { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls';
|
||||
import WhatsappCallsAPI from 'dashboard/api/whatsappCalls';
|
||||
|
||||
/**
|
||||
* Checks for an active WhatsApp call on page load and reconnects if found.
|
||||
* This handles the server-relay scenario where the call persists on the media
|
||||
* server even after the agent's browser reloads.
|
||||
*
|
||||
* NOTE: This composable intentionally does NOT call useWhatsappCallSession()
|
||||
* to avoid creating duplicate side effects (beforeunload handlers, cleanup
|
||||
* callbacks, timers). The WhatsappCallWidget owns the useWhatsappCallSession
|
||||
* instance. This composable only sets store state and triggers the reconnect
|
||||
* API call — the actual WebRTC setup happens when the ActionCable agent_offer
|
||||
* event arrives and is handled by handleAgentOffer.
|
||||
*
|
||||
* Usage: call `useCallReconnection()` in the app-level layout component that
|
||||
* mounts once on page load.
|
||||
*/
|
||||
export function useCallReconnection() {
|
||||
const callsStore = useWhatsappCallsStore();
|
||||
|
||||
const isReconnecting = computed(() => callsStore.isReconnecting);
|
||||
|
||||
onMounted(async () => {
|
||||
// Skip if there's already an active or incoming call in the store
|
||||
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return;
|
||||
|
||||
try {
|
||||
const { data } = await WhatsappCallsAPI.active();
|
||||
if (!data?.call) return;
|
||||
|
||||
const activeCallData = data.call;
|
||||
|
||||
callsStore.setReconnecting(true);
|
||||
callsStore.setActiveCall({
|
||||
id: activeCallData.id,
|
||||
callId: activeCallData.call_id,
|
||||
direction: activeCallData.direction,
|
||||
conversationId: activeCallData.conversation_id,
|
||||
status: 'reconnecting',
|
||||
serverRelay: true,
|
||||
caller: activeCallData.caller,
|
||||
});
|
||||
|
||||
// Set timer offset so the timer resumes from the correct elapsed time
|
||||
if (activeCallData.elapsed_seconds) {
|
||||
callsStore.setTimerOffset(activeCallData.elapsed_seconds);
|
||||
}
|
||||
|
||||
// Tell the server to create a new Peer B and send us a fresh SDP offer.
|
||||
// The server will broadcast whatsapp_call.agent_offer via ActionCable,
|
||||
// which is handled by handleAgentOffer in actionCable.js.
|
||||
await WhatsappCallsAPI.reconnect(activeCallData.id);
|
||||
} catch {
|
||||
// No active call or API/reconnect error — clear state and silent fail.
|
||||
// clearActiveCall() also resets isReconnecting and callTimerOffset.
|
||||
callsStore.clearActiveCall();
|
||||
}
|
||||
});
|
||||
|
||||
return { isReconnecting };
|
||||
}
|
||||
@@ -3,18 +3,18 @@ import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
useWhatsappCallsStore,
|
||||
getOutboundCallState,
|
||||
cleanupOutboundCall,
|
||||
} from 'dashboard/stores/whatsappCalls';
|
||||
import WhatsappCallsAPI from 'dashboard/api/whatsappCalls';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
// ── Module-level WebRTC state for inbound calls accepted from anywhere ──
|
||||
// Kept at module scope so both the composable and acceptWhatsappCallById share it.
|
||||
// ── Module-level WebRTC state (shared across legacy inbound + server-relay) ──
|
||||
let inboundPc = null;
|
||||
let inboundStream = null;
|
||||
let inboundAudio = null;
|
||||
|
||||
// ── Module-level recording state ──
|
||||
// ── Module-level recording state (legacy mode only) ──
|
||||
let mediaRecorder = null;
|
||||
let recordedChunks = [];
|
||||
let recordingCallId = null;
|
||||
@@ -40,19 +40,18 @@ function cleanupInboundWebRTC() {
|
||||
/**
|
||||
* Start recording both local and remote audio tracks via MediaRecorder.
|
||||
* Mixes them into a single stream using AudioContext.
|
||||
* Used ONLY in legacy (browser-direct) mode.
|
||||
*/
|
||||
export function startCallRecording(pc, localStream, callId) {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
|
||||
// Add local mic track
|
||||
if (localStream) {
|
||||
const localSource = ctx.createMediaStreamSource(localStream);
|
||||
localSource.connect(dest);
|
||||
}
|
||||
|
||||
// Add remote tracks from peer connection
|
||||
pc.getReceivers().forEach(receiver => {
|
||||
if (receiver.track && receiver.track.kind === 'audio') {
|
||||
const remoteStream = new MediaStream([receiver.track]);
|
||||
@@ -81,6 +80,7 @@ export function startCallRecording(pc, localStream, callId) {
|
||||
|
||||
/**
|
||||
* Stop recording and upload the audio blob to the backend.
|
||||
* Used ONLY in legacy (browser-direct) mode.
|
||||
*/
|
||||
function stopAndUploadRecording(callId) {
|
||||
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
|
||||
@@ -110,7 +110,17 @@ function waitForIceGatheringComplete(pc) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
let timeout = null;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
pc.onicegatheringstatechange = null;
|
||||
pc.oniceconnectionstatechange = null;
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[WhatsApp Call] ICE gathering timed out, sending partial SDP'
|
||||
@@ -120,85 +130,155 @@ function waitForIceGatheringComplete(pc) {
|
||||
|
||||
pc.onicegatheringstatechange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === 'failed') {
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
reject(new Error('ICE connection failed'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Server-relay mode: detect by absence of sdpOffer in incoming call data ──
|
||||
function isServerRelayCall(call) {
|
||||
return !call.sdpOffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core accept logic: creates WebRTC session and posts SDP to backend.
|
||||
* Can be called from anywhere — composable, widget, or bubble.
|
||||
* Returns { success: true } or { success: false, error }.
|
||||
* Handle an SDP offer from the media server (Peer B). Used in server-relay mode
|
||||
* for both inbound accept and outbound connect flows.
|
||||
*
|
||||
* Flow: getUserMedia -> RTCPeerConnection(iceServers) -> setRemoteDescription(offer)
|
||||
* -> createAnswer -> waitForICE -> POST /agent_answer
|
||||
*/
|
||||
async function doAcceptCall(call) {
|
||||
async function handleAgentOffer(callId, sdpOffer, iceServers) {
|
||||
cleanupInboundWebRTC();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
|
||||
const iceServers = call.iceServers?.length
|
||||
? call.iceServers
|
||||
: [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
const servers = iceServers?.length
|
||||
? iceServers
|
||||
: [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
inboundPc = pc;
|
||||
const pc = new RTCPeerConnection({ iceServers: servers });
|
||||
inboundPc = pc;
|
||||
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
|
||||
pc.ontrack = event => {
|
||||
const [remoteStream] = event.streams;
|
||||
if (!remoteStream) return;
|
||||
if (!inboundAudio) {
|
||||
const audio = document.createElement('audio');
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
inboundAudio = audio;
|
||||
}
|
||||
inboundAudio.srcObject = remoteStream;
|
||||
inboundAudio.play().catch(() => {});
|
||||
pc.ontrack = event => {
|
||||
const [remoteStream] = event.streams;
|
||||
if (!remoteStream) return;
|
||||
if (!inboundAudio) {
|
||||
const audio = document.createElement('audio');
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
inboundAudio = audio;
|
||||
}
|
||||
inboundAudio.srcObject = remoteStream;
|
||||
inboundAudio.play().catch(() => {});
|
||||
|
||||
// Start recording once remote audio is available
|
||||
startCallRecording(pc, stream, call.id);
|
||||
};
|
||||
// No client-side recording in server-relay mode — the media server records
|
||||
};
|
||||
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: call.sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
|
||||
const completeSdp = pc.localDescription.sdp;
|
||||
await WhatsappCallsAPI.accept(call.id, completeSdp);
|
||||
const completeSdp = pc.localDescription.sdp;
|
||||
await WhatsappCallsAPI.agentAnswer(callId, completeSdp);
|
||||
|
||||
return { success: true };
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
cleanupInboundWebRTC();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose handleAgentOffer so ActionCable handler can invoke it
|
||||
export { handleAgentOffer };
|
||||
|
||||
/**
|
||||
* Legacy mode: creates WebRTC session and posts SDP to backend (browser ↔ Meta).
|
||||
* Can be called from anywhere — composable, widget, or bubble.
|
||||
*/
|
||||
async function doAcceptCall(call) {
|
||||
// Server-relay mode: just POST /accept without SDP. Wait for agent_offer event.
|
||||
if (isServerRelayCall(call)) {
|
||||
await WhatsappCallsAPI.accept(call.id);
|
||||
return { success: true, awaitingAgentOffer: true };
|
||||
}
|
||||
|
||||
// Legacy mode: full browser-side WebRTC handshake
|
||||
cleanupInboundWebRTC();
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
|
||||
const iceServers = call.iceServers?.length
|
||||
? call.iceServers
|
||||
: [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
inboundPc = pc;
|
||||
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
|
||||
pc.ontrack = event => {
|
||||
const [remoteStream] = event.streams;
|
||||
if (!remoteStream) return;
|
||||
if (!inboundAudio) {
|
||||
const audio = document.createElement('audio');
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
inboundAudio = audio;
|
||||
}
|
||||
inboundAudio.srcObject = remoteStream;
|
||||
inboundAudio.play().catch(() => {});
|
||||
|
||||
// Start recording once remote audio is available (legacy mode only)
|
||||
startCallRecording(pc, stream, call.id);
|
||||
};
|
||||
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: call.sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
|
||||
const completeSdp = pc.localDescription.sdp;
|
||||
await WhatsappCallsAPI.accept(call.id, completeSdp);
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
cleanupInboundWebRTC();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone function callable from VoiceCall bubble.
|
||||
* Fetches call data if needed, runs WebRTC accept, updates store.
|
||||
*/
|
||||
export async function acceptWhatsappCallById(waCallId) {
|
||||
export async function acceptWhatsappCallById(callId) {
|
||||
const callsStore = useWhatsappCallsStore();
|
||||
|
||||
if (callsStore.hasActiveCall) {
|
||||
return { success: false, error: 'active_call_exists' };
|
||||
}
|
||||
|
||||
// 1. Check if the call is already in the incoming store
|
||||
let call = callsStore.incomingCalls.find(
|
||||
c => c.id === waCallId || c.callId === String(waCallId)
|
||||
c => c.id === callId || c.callId === String(callId)
|
||||
);
|
||||
|
||||
// 2. Not in store (page was refreshed) → fetch from API
|
||||
if (!call) {
|
||||
const { data } = await WhatsappCallsAPI.show(waCallId);
|
||||
const { data } = await WhatsappCallsAPI.show(callId);
|
||||
if (data.status !== 'ringing') {
|
||||
return { success: false, error: 'not_ringing' };
|
||||
}
|
||||
@@ -215,19 +295,30 @@ export async function acceptWhatsappCallById(waCallId) {
|
||||
callsStore.addIncomingCall(call);
|
||||
}
|
||||
|
||||
// 3. Run the WebRTC accept
|
||||
await doAcceptCall(call);
|
||||
try {
|
||||
const result = await doAcceptCall(call);
|
||||
|
||||
// 4. Move from incoming to active
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.setActiveCall({ ...call });
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
|
||||
return { success: true, call };
|
||||
// In server-relay mode the call becomes active but awaits the agent_offer
|
||||
// ActionCable event to complete WebRTC setup. Mark it with serverRelay flag.
|
||||
const activeCallData = {
|
||||
...call,
|
||||
serverRelay: isServerRelayCall(call),
|
||||
};
|
||||
callsStore.setActiveCall(activeCallData);
|
||||
|
||||
return { success: true, call: activeCallData, ...result };
|
||||
} catch (err) {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget terminate request using fetch + keepalive.
|
||||
* Works reliably inside beforeunload / pagehide where axios won't complete.
|
||||
* Used ONLY in legacy mode. Server-relay mode does NOT terminate on unload.
|
||||
*/
|
||||
function terminateCallOnUnload(callId) {
|
||||
const authData = Auth.hasAuthCookie() ? Auth.getAuthData() : {};
|
||||
@@ -260,9 +351,10 @@ export function useWhatsappCallSession() {
|
||||
const isMuted = ref(false);
|
||||
const callError = ref(null);
|
||||
const callDuration = ref(0);
|
||||
const isReconnecting = computed(() => callsStore.isReconnecting);
|
||||
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
callDuration.value = elapsed;
|
||||
callDuration.value = callsStore.callTimerOffset + elapsed;
|
||||
});
|
||||
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
@@ -285,16 +377,27 @@ export function useWhatsappCallSession() {
|
||||
|
||||
// Register cleanup so external call-end events can teardown WebRTC
|
||||
callsStore.registerCleanupCallback(() => {
|
||||
stopAndUploadRecording();
|
||||
// Only do recording cleanup in legacy mode
|
||||
if (!callsStore.isMediaServerEnabled) {
|
||||
stopAndUploadRecording();
|
||||
}
|
||||
cleanupInboundWebRTC();
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
});
|
||||
|
||||
// Terminate active call on page close / reload
|
||||
// On page close / reload:
|
||||
// - Legacy mode: terminate call (current behavior)
|
||||
// - Server-relay mode: just clean up local WebRTC resources, call persists
|
||||
const handleBeforeUnload = () => {
|
||||
const call = callsStore.activeCall;
|
||||
if (call?.id) {
|
||||
if (!call?.id) return;
|
||||
|
||||
if (call.serverRelay) {
|
||||
// Server-relay: only clean up local resources, do NOT terminate
|
||||
cleanupInboundWebRTC();
|
||||
} else {
|
||||
// Legacy: terminate and clean up
|
||||
terminateCallOnUnload(call.id);
|
||||
cleanupInboundWebRTC();
|
||||
}
|
||||
@@ -314,7 +417,6 @@ export function useWhatsappCallSession() {
|
||||
|
||||
/**
|
||||
* Accept an incoming call — used by the floating widget buttons.
|
||||
* Uses the same doAcceptCall core + starts the timer.
|
||||
*/
|
||||
const acceptCall = async call => {
|
||||
if (isAccepting.value) return;
|
||||
@@ -322,10 +424,21 @@ export function useWhatsappCallSession() {
|
||||
callError.value = null;
|
||||
|
||||
try {
|
||||
await doAcceptCall(call);
|
||||
const result = await doAcceptCall(call);
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.setActiveCall({ ...call });
|
||||
durationTimer.start();
|
||||
|
||||
const activeCallData = {
|
||||
...call,
|
||||
serverRelay: isServerRelayCall(call),
|
||||
};
|
||||
callsStore.setActiveCall(activeCallData);
|
||||
|
||||
// In legacy mode, WebRTC is already established so start timer now.
|
||||
// In server-relay mode, timer starts when handleAgentOffer completes
|
||||
// (triggered by the whatsapp_call.agent_offer ActionCable event).
|
||||
if (!result.awaitingAgentOffer) {
|
||||
durationTimer.start();
|
||||
}
|
||||
} catch (err) {
|
||||
callError.value =
|
||||
err.name === 'NotAllowedError'
|
||||
@@ -333,7 +446,7 @@ export function useWhatsappCallSession() {
|
||||
: t('WHATSAPP_CALL.CALL_FAILED');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[WhatsApp Call] acceptCall error:', err);
|
||||
cleanupInboundWebRTC();
|
||||
// Note: doAcceptCall already cleans up WebRTC resources on error
|
||||
} finally {
|
||||
isAccepting.value = false;
|
||||
}
|
||||
@@ -353,7 +466,10 @@ export function useWhatsappCallSession() {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
|
||||
stopAndUploadRecording(call.id);
|
||||
// Only upload recording in legacy mode
|
||||
if (!call.serverRelay) {
|
||||
stopAndUploadRecording(call.id);
|
||||
}
|
||||
|
||||
try {
|
||||
await WhatsappCallsAPI.terminate(call.id);
|
||||
@@ -361,7 +477,10 @@ export function useWhatsappCallSession() {
|
||||
// Best effort
|
||||
} finally {
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.handleCallEnded(call.callId);
|
||||
cleanupOutboundCall();
|
||||
// Clear state directly — do NOT use handleCallEnded here since that is
|
||||
// meant for external events (ActionCable) and would invoke cleanupCallback
|
||||
// which would duplicate the cleanup we just performed.
|
||||
callsStore.clearActiveCall();
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
@@ -381,6 +500,14 @@ export function useWhatsappCallSession() {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the duration timer. Called externally after server-relay WebRTC
|
||||
* setup completes (handleAgentOffer).
|
||||
*/
|
||||
const startDurationTimer = () => {
|
||||
durationTimer.start();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
durationTimer.stop();
|
||||
@@ -395,6 +522,7 @@ export function useWhatsappCallSession() {
|
||||
isAccepting,
|
||||
isMuted,
|
||||
isOutboundRinging,
|
||||
isReconnecting,
|
||||
callError,
|
||||
formattedCallDuration,
|
||||
acceptCall,
|
||||
@@ -402,5 +530,6 @@ export function useWhatsappCallSession() {
|
||||
endActiveCall,
|
||||
toggleMute,
|
||||
dismissIncomingCall,
|
||||
startDurationTimer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"PERMISSION_PENDING": "Waiting for the contact to approve the call permission request. Please try again shortly.",
|
||||
"UNKNOWN_CALLER": "Unknown caller",
|
||||
"MIC_DENIED": "Microphone access denied. Please allow mic access and try again.",
|
||||
"CALL_TAKEN": "Call accepted by another agent"
|
||||
"CALL_TAKEN": "Call accepted by another agent",
|
||||
"RECONNECTING": "Reconnecting…",
|
||||
"PERMISSION_GRANTED": "{contactName} approved the call permission request. You can now call them."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+14
-3
@@ -304,15 +304,26 @@ Rails.application.routes.draw do
|
||||
end
|
||||
|
||||
resources :whatsapp_calls, only: [:show] do
|
||||
collection do
|
||||
get :active
|
||||
post :initiate
|
||||
end
|
||||
member do
|
||||
post :accept
|
||||
post :reject
|
||||
post :terminate
|
||||
post :agent_answer
|
||||
post :reconnect
|
||||
post :join
|
||||
post :play_audio
|
||||
post :upload_recording
|
||||
end
|
||||
collection do
|
||||
post :initiate
|
||||
end
|
||||
end
|
||||
|
||||
namespace :media_server do
|
||||
post 'callbacks/agent_disconnected', to: 'callbacks#agent_disconnected'
|
||||
post 'callbacks/recording_ready', to: 'callbacks#recording_ready'
|
||||
post 'callbacks/session_terminated', to: 'callbacks#session_terminated'
|
||||
end
|
||||
|
||||
resources :webhooks, only: [:index, :create, :update, :destroy]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddMediaServerFieldsToCalls < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :calls, :media_session_id, :string
|
||||
add_index :calls, :media_session_id, unique: true
|
||||
add_index :calls, [:accepted_by_agent_id, :status]
|
||||
end
|
||||
end
|
||||
+4
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_09_091202) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_21_042235) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -282,9 +282,11 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_09_091202) do
|
||||
t.text "transcript"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "media_session_id"
|
||||
t.index ["accepted_by_agent_id", "status"], name: "index_calls_on_accepted_by_agent_id_and_status"
|
||||
t.index ["account_id", "conversation_id"], name: "index_calls_on_account_id_and_conversation_id"
|
||||
t.index ["media_session_id"], name: "index_calls_on_media_session_id", unique: true
|
||||
t.index ["message_id"], name: "index_calls_on_message_id"
|
||||
t.index ["meta"], name: "index_calls_on_meta", using: :gin
|
||||
t.index ["provider", "provider_call_id"], name: "index_calls_on_provider_and_provider_call_id", unique: true
|
||||
end
|
||||
|
||||
|
||||
@@ -57,7 +57,34 @@ services:
|
||||
ports:
|
||||
- '127.0.0.1:6379:6379'
|
||||
|
||||
# Optional: WhatsApp Calling media server (server-side WebRTC relay)
|
||||
# Uncomment to enable call persistence across page reloads and server-side recording.
|
||||
# media-server:
|
||||
# image: chatwoot/media-server:latest
|
||||
# env_file: .env
|
||||
# ports:
|
||||
# - '4000:4000'
|
||||
# - '10000-10100:10000-10100/udp'
|
||||
# environment:
|
||||
# - AUTH_TOKEN=${MEDIA_SERVER_AUTH_TOKEN}
|
||||
# - RAILS_CALLBACK_URL=http://rails:3000
|
||||
# - RECORDINGS_DIR=/recordings
|
||||
# - LOG_LEVEL=info
|
||||
# - UDP_PORT_MIN=10000
|
||||
# - UDP_PORT_MAX=10100
|
||||
# - PUBLIC_IP=${MEDIA_SERVER_PUBLIC_IP}
|
||||
# - STUN_SERVERS=stun:stun.l.google.com:19302
|
||||
# volumes:
|
||||
# - media_recordings:/recordings
|
||||
# restart: always
|
||||
# healthcheck:
|
||||
# test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:4000/health']
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 3
|
||||
|
||||
volumes:
|
||||
storage_data:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
# media_recordings:
|
||||
|
||||
@@ -110,6 +110,32 @@ services:
|
||||
- 1025:1025
|
||||
- 8025:8025
|
||||
|
||||
media-server:
|
||||
image: chatwoot/media-server:latest
|
||||
build:
|
||||
context: ./enterprise/media-server
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- '4000:4000'
|
||||
- '10000-10100:10000-10100/udp'
|
||||
environment:
|
||||
- AUTH_TOKEN=${MEDIA_SERVER_AUTH_TOKEN:-}
|
||||
- RAILS_CALLBACK_URL=http://rails:3000
|
||||
- RECORDINGS_DIR=/recordings
|
||||
- LOG_LEVEL=info
|
||||
- UDP_PORT_MIN=10000
|
||||
- UDP_PORT_MAX=10100
|
||||
- PUBLIC_IP=${MEDIA_SERVER_PUBLIC_IP:-}
|
||||
- STUN_SERVERS=stun:stun.l.google.com:19302
|
||||
volumes:
|
||||
- media_recordings:/recordings
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:4000/health']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
redis:
|
||||
@@ -117,3 +143,4 @@ volumes:
|
||||
node_modules:
|
||||
cache:
|
||||
bundle:
|
||||
media_recordings:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,272 @@
|
||||
# WhatsApp Calling — Feature Spec
|
||||
|
||||
> **Edition:** Enterprise (Premium) | **Feature Flag:** `whatsapp_call` | **Branch:** `feat/whatsapp-call`
|
||||
|
||||
## What is it?
|
||||
|
||||
Agents can **receive and make voice calls** with WhatsApp contacts directly from the Chatwoot dashboard. Calls are browser-based (WebRTC) — no Twilio, no phone hardware, zero telephony cost.
|
||||
|
||||
Calls are **automatically recorded** and **transcribed** (OpenAI Whisper), and appear inline in the conversation thread.
|
||||
|
||||
---
|
||||
|
||||
## How it works (30-second version)
|
||||
|
||||
```
|
||||
SIGNALING AUDIO
|
||||
(who calls whom, (actual voice data)
|
||||
accept/reject)
|
||||
|
||||
┌──────────┐ REST / Webhooks ┌──────────────┐
|
||||
│ WhatsApp │◄──────────────────────►│ Chatwoot │
|
||||
│ Contact │ │ Backend │
|
||||
└────┬─────┘ └──────┬───────┘
|
||||
│ │ ActionCable (WebSocket)
|
||||
│ │
|
||||
│ ┌────────────────┐ ┌──────▼───────┐
|
||||
└─────►│ Meta Media │◄════►│ Agent's │
|
||||
│ Servers │ SRTP │ Browser │
|
||||
└────────────────┘ └──────────────┘
|
||||
|
||||
Key insight: Chatwoot backend handles signaling only.
|
||||
Audio flows directly between browser and Meta.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Call lifecycle
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────┐ ┌────────┐ ┌────────────┐
|
||||
│ RINGING │────►│ ACCEPTED │────►│ ENDED │────►│ TRANSCRIBED│
|
||||
└─────────┘ └──────────┘ └────────┘ └────────────┘
|
||||
│
|
||||
├──► REJECTED
|
||||
├──► MISSED (30s timeout)
|
||||
└──► FAILED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Inbound call flow
|
||||
|
||||
```
|
||||
Customer dials business number
|
||||
│
|
||||
▼
|
||||
Meta sends webhook ──► IncomingCallService
|
||||
├── Find/create contact & conversation
|
||||
├── Create Call record (status: ringing)
|
||||
├── Create voice_call message
|
||||
└── Broadcast via ActionCable
|
||||
│
|
||||
▼
|
||||
All agents see floating widget
|
||||
with Accept / Reject buttons
|
||||
│
|
||||
Agent clicks Accept
|
||||
├── Browser requests mic (getUserMedia)
|
||||
├── WebRTC peer connection created
|
||||
├── SDP answer generated + sent to backend
|
||||
└── Backend relays to Meta API
|
||||
│
|
||||
▼
|
||||
Audio flows (Meta servers ↔ Browser)
|
||||
Recording starts automatically
|
||||
│
|
||||
Call ends (either side hangs up)
|
||||
├── Recording uploaded
|
||||
└── Transcription triggered (async)
|
||||
```
|
||||
|
||||
## Outbound call flow
|
||||
|
||||
```
|
||||
Agent clicks phone icon in conversation header
|
||||
│
|
||||
▼
|
||||
Browser creates WebRTC offer ──► POST /whatsapp_calls/initiate
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ │
|
||||
Success Error 138006
|
||||
│ (no permission)
|
||||
│ │
|
||||
│ Send permission request
|
||||
│ to customer via WhatsApp
|
||||
│ │
|
||||
│ Customer approves ──► retry call
|
||||
│
|
||||
Customer's phone rings
|
||||
│
|
||||
Meta webhook with SDP answer
|
||||
│
|
||||
Audio connected + recording starts
|
||||
```
|
||||
|
||||
## Call permission flow
|
||||
|
||||
Meta requires customers to **opt-in** before a business can call them. If the customer hasn't opted in:
|
||||
|
||||
1. Agent tries to call → Meta returns error `138006`
|
||||
2. Chatwoot auto-sends an interactive permission request to the customer
|
||||
3. Customer approves on WhatsApp
|
||||
4. Agent retries the call → succeeds
|
||||
5. Rate-limited: 1 permission request per 5 minutes per conversation
|
||||
|
||||
---
|
||||
|
||||
## UI touchpoints
|
||||
|
||||
| Where | What |
|
||||
|-------|------|
|
||||
| **Conversation Header** | Phone icon to initiate outbound calls |
|
||||
| **Floating Widget** (bottom-right) | Incoming call notification (accept/reject), active call controls (mute/hangup/timer) |
|
||||
| **Message Bubble** (VoiceCall type) | Call status, duration, recording audio player, transcript (expandable), "answered by" agent |
|
||||
| **Inbox Settings** | Toggle to enable/disable calling per WhatsApp inbox |
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
```
|
||||
calls table
|
||||
├── account_id, inbox_id, conversation_id
|
||||
├── message_id, accepted_by_agent_id
|
||||
├── provider (enum: twilio=0, whatsapp=1)
|
||||
├── direction (enum: incoming=0, outgoing=1)
|
||||
├── status (ringing → accepted → ended)
|
||||
├── duration_seconds, end_reason
|
||||
├── meta (jsonb: SDP offer/answer, ICE servers)
|
||||
├── transcript (text)
|
||||
└── recording (ActiveStorage attachment)
|
||||
```
|
||||
|
||||
Voice calls create messages with `content_type: 'voice_call'` containing call metadata in `content_attributes`.
|
||||
|
||||
---
|
||||
|
||||
## API endpoints
|
||||
|
||||
**Base:** `POST /api/v1/accounts/:account_id/whatsapp_calls`
|
||||
|
||||
| Endpoint | What it does |
|
||||
|----------|-------------|
|
||||
| `GET /:id` | Get call details (SDP offer, ICE servers, caller info) |
|
||||
| `POST /:id/accept` | Accept call with SDP answer |
|
||||
| `POST /:id/reject` | Reject incoming call |
|
||||
| `POST /:id/terminate` | End active call |
|
||||
| `POST /initiate` | Start outbound call with SDP offer |
|
||||
| `POST /:id/upload_recording` | Upload recorded audio blob |
|
||||
|
||||
## ActionCable events
|
||||
|
||||
| Event | When |
|
||||
|-------|------|
|
||||
| `whatsapp_call.incoming` | New inbound call (all agents receive) |
|
||||
| `whatsapp_call.accepted` | Call accepted (dismisses widget for other agents) |
|
||||
| `whatsapp_call.ended` | Call terminated |
|
||||
| `whatsapp_call.outbound_connected` | Outbound call answered by customer |
|
||||
| `whatsapp_call.permission_granted` | Customer approved call permission |
|
||||
|
||||
---
|
||||
|
||||
## Recording & transcription
|
||||
|
||||
```
|
||||
Agent mic ──┐
|
||||
├──► AudioContext mixer ──► MediaRecorder ──► WebM blob
|
||||
Remote audio┘ │
|
||||
Upload to server
|
||||
│
|
||||
ActiveStorage attachment
|
||||
│
|
||||
CallTranscriptionJob (async)
|
||||
│
|
||||
OpenAI Whisper API
|
||||
(whisper-1, temp 0.4)
|
||||
│
|
||||
Transcript stored on
|
||||
call + message bubble
|
||||
```
|
||||
|
||||
**Requires:** `captain_integration` feature flag + OpenAI API key configured.
|
||||
|
||||
---
|
||||
|
||||
## Enterprise architecture
|
||||
|
||||
All calling code lives under `enterprise/`. Integration with OSS via `prepend_mod_with`:
|
||||
|
||||
```
|
||||
enterprise/app/
|
||||
├── controllers/api/v1/accounts/whatsapp_calls_controller.rb
|
||||
├── models/call.rb
|
||||
├── services/whatsapp/
|
||||
│ ├── call_service.rb (accept/reject/terminate orchestration)
|
||||
│ ├── incoming_call_service.rb (webhook processing)
|
||||
│ ├── call_message_builder.rb (voice_call message creation)
|
||||
│ ├── call_permission_reply_service.rb
|
||||
│ ├── call_transcription_service.rb
|
||||
│ └── providers/whatsapp_cloud_call_methods.rb (Meta API HTTP layer)
|
||||
└── jobs/
|
||||
├── whatsapp/call_transcription_job.rb
|
||||
└── enterprise/webhooks/whatsapp_events_job.rb
|
||||
```
|
||||
|
||||
**OSS changes (minimal):** `WhatsappEventsJob` gets a `prepend_mod_with` hook + `ActionCableListener` nil safety fix.
|
||||
|
||||
---
|
||||
|
||||
## Key technical details
|
||||
|
||||
| Topic | Detail |
|
||||
|-------|--------|
|
||||
| **SDP fix** | Meta requires `a=setup:active` but WebRTC generates `a=setup:actpass` — rewritten server-side |
|
||||
| **ICE gathering** | 10s timeout, default STUN: `stun:stun.l.google.com:19302` |
|
||||
| **Module-scope WebRTC** | `RTCPeerConnection`/`MediaStream` stored outside Pinia (not serializable) |
|
||||
| **Multi-agent routing** | All agents see incoming call; first to accept wins, others get dismissed |
|
||||
| **Page unload** | Active calls terminated via `fetch({ keepalive: true })` on `beforeunload` |
|
||||
| **Recording format** | `audio/webm;codecs=opus`, chunked at 1s intervals |
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
| Constraint | Why |
|
||||
|------------|-----|
|
||||
| Browser-only | WebRTC requires a browser — no mobile app or SIP phones |
|
||||
| No rejoin after refresh | P2P connection (unlike Twilio's conference model) |
|
||||
| Audio only | Meta API doesn't support video calling |
|
||||
| One call per agent | Single active call at a time |
|
||||
| Client-side recording | Lost if browser crashes mid-call |
|
||||
| No TURN server | May fail behind restrictive firewalls blocking UDP |
|
||||
| Customer opt-in needed | Outbound calls require Meta's permission flow |
|
||||
|
||||
---
|
||||
|
||||
## WhatsApp vs Twilio comparison
|
||||
|
||||
| | WhatsApp Calling | Twilio Voice |
|
||||
|---|---|---|
|
||||
| **Model** | Peer-to-peer (browser ↔ Meta) | Conference (Twilio media server) |
|
||||
| **Cost** | Free | Per-minute billing |
|
||||
| **Rejoin** | Not possible | Yes (conference persists) |
|
||||
| **Recording** | Client-side (browser) | Server-side (Twilio) |
|
||||
| **Video** | Not supported | Possible |
|
||||
| **SIP phones** | Not supported | Supported |
|
||||
|
||||
---
|
||||
|
||||
## How to test
|
||||
|
||||
1. Enable `whatsapp_call` feature flag on account
|
||||
2. Enable calling in WhatsApp Cloud inbox settings
|
||||
3. Test inbound: call the business WhatsApp number → widget appears → accept → audio flows
|
||||
4. Test outbound: click phone icon in conversation → customer answers → audio flows
|
||||
5. Verify: recording uploads, transcript appears in message bubble
|
||||
6. Verify: multiple agents see incoming call, first-to-accept wins
|
||||
|
||||
---
|
||||
|
||||
*See [PR Breakdown](./WHATSAPP_CALL_PR_BREAKDOWN.md) for the 9-PR implementation plan.*
|
||||
@@ -0,0 +1,347 @@
|
||||
# WhatsApp Calling — PR Breakdown Plan
|
||||
|
||||
> **Branch:** `feat/whatsapp-call` → **Base:** `develop`
|
||||
> **Date:** 2026-04-14 (updated)
|
||||
> **Goal:** Split the monolithic feature branch into **8 backend + 1 frontend = 9 mergeable PRs**, each covering a single concept.
|
||||
|
||||
---
|
||||
|
||||
## Merge Order & Dependency Graph
|
||||
|
||||
```
|
||||
BACKEND FRONTEND
|
||||
|
||||
PR-1: Call Model + Migration ✅ MERGED
|
||||
│
|
||||
├── PR-2: Meta API Provider Methods
|
||||
│ │
|
||||
│ ├── PR-3: Inbound Webhook Pipeline
|
||||
│ │ │
|
||||
│ │ └── PR-4: Call Service +
|
||||
│ │ Controller + Routes ──────► PR-9: All Frontend Changes
|
||||
│ │ (API, Store, WebRTC, Widget,
|
||||
│ └─────────────────────────────────► Bubble, Outbound UI,
|
||||
│ ActionCable, Settings,
|
||||
├── PR-5: Transcription Pipeline Voice Guard, i18n, Feature Flag)
|
||||
│
|
||||
├── PR-6: Feature Flag + Enterprise Gating
|
||||
│
|
||||
├── PR-7: OSS Touchpoints (webhook job refactor + ActionCable fix)
|
||||
│
|
||||
└── PR-8: Inbox Calling Config (provider_config + serializer)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend PRs
|
||||
|
||||
### PR-1: Call Model + Migration + Error Classes ✅ MERGED
|
||||
|
||||
> **Status:** Merged via PR #14026 (`feat/voice-call-model` branch).
|
||||
|
||||
**Files included:** `db/migrate/20260408170902_create_calls.rb`, `enterprise/app/models/call.rb`, association concerns, `enterprise/lib/whatsapp/call_errors.rb`, `config/features.yml` (whatsapp_call flag definition).
|
||||
|
||||
**No action required — already in `develop`.**
|
||||
|
||||
---
|
||||
|
||||
### PR-2: Meta Cloud API Provider Methods
|
||||
|
||||
**Concept:** Low-level HTTP methods that communicate with Meta's WhatsApp Cloud API v22.0 for voice calls. No business logic — just the raw API client layer.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action | Lines |
|
||||
|------|--------|-------|
|
||||
| `enterprise/app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb` | NEW | ~85 |
|
||||
| `enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb` | MODIFY | +include |
|
||||
| `enterprise/app/services/enterprise/whatsapp/facebook_api_client.rb` | MODIFY | +`calls` to webhook fields |
|
||||
|
||||
**Methods provided:**
|
||||
```
|
||||
pre_accept_call(call_id) → 200 OK (keeps call alive for SDP exchange)
|
||||
accept_call(call_id, sdp_answer) → 200 OK
|
||||
reject_call(call_id) → 200 OK
|
||||
terminate_call(call_id) → 200 OK
|
||||
initiate_call(to, sdp_offer) → {call_id, sdp_answer, ice_servers}
|
||||
send_call_permission_request(to) → 200 OK (interactive permission template)
|
||||
```
|
||||
|
||||
**Dependencies:** PR-1 ✅ (merged).
|
||||
|
||||
**Risk:** Low. Methods exist but are dormant until consumers ship.
|
||||
|
||||
---
|
||||
|
||||
### PR-3: Inbound Webhook Pipeline
|
||||
|
||||
**Concept:** The complete server-side flow for processing incoming call webhooks from Meta: event routing → call creation → message building → ActionCable broadcasting.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action | Lines |
|
||||
|------|--------|-------|
|
||||
| `enterprise/app/services/whatsapp/incoming_call_service.rb` | NEW | ~211 |
|
||||
| `enterprise/app/services/whatsapp/call_message_builder.rb` | NEW | ~108 |
|
||||
| `enterprise/app/services/whatsapp/call_permission_reply_service.rb` | NEW | ~61 |
|
||||
| `enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb` | NEW | ~40 |
|
||||
|
||||
**How it works:**
|
||||
1. Meta sends webhook with `calls` field → `WhatsappEventsJob` routes to `IncomingCallService`
|
||||
2. `IncomingCallService` creates/updates `Call` record based on event type (`connect` or `terminate`)
|
||||
3. `CallMessageBuilder` creates `voice_call` content-type messages in the conversation
|
||||
4. ActionCable broadcasts `whatsapp_call.incoming` / `whatsapp_call.ended` to the account channel
|
||||
5. For `call_permission_reply` interactive messages → `CallPermissionReplyService` handles opt-in
|
||||
|
||||
**Key detail:** `IncomingCallService` does NOT call Meta's API — it only processes incoming data. No dependency on PR-2.
|
||||
|
||||
**Dependencies:** PR-1 ✅ (merged), PR-7 (OSS touchpoints for `prepend_mod_with` hook).
|
||||
|
||||
**Risk:** Medium. The webhook event routing (`prepend_mod_with` in the enterprise job) overrides `handle_message_events`. Must ensure `super` delegation doesn't break existing message processing.
|
||||
|
||||
---
|
||||
|
||||
### PR-4: Call Service + Controller + Routes
|
||||
|
||||
**Concept:** The REST API surface for agent-driven call actions (accept, reject, terminate, initiate) and the service orchestrating those actions.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action | Lines |
|
||||
|------|--------|-------|
|
||||
| `enterprise/app/services/whatsapp/call_service.rb` | NEW | ~109 |
|
||||
| `enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb` | NEW | ~142 |
|
||||
| `config/routes.rb` | MODIFY | +resources |
|
||||
| `enterprise/app/models/enterprise/conversation.rb` | MODIFY | +`allowed_keys?` for `call_status` |
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
| Method | Path | Action | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| GET | `/api/v1/accounts/:account_id/whatsapp_calls/:id` | show | Get call details (SDP offer, ICE servers) |
|
||||
| POST | `/api/v1/accounts/:account_id/whatsapp_calls/:id/accept` | accept | Accept with SDP answer |
|
||||
| POST | `/api/v1/accounts/:account_id/whatsapp_calls/:id/reject` | reject | Reject the call |
|
||||
| POST | `/api/v1/accounts/:account_id/whatsapp_calls/:id/terminate` | terminate | End active call |
|
||||
| POST | `/api/v1/accounts/:account_id/whatsapp_calls/initiate` | initiate | Start outbound call |
|
||||
| POST | `/api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording` | upload_recording | Upload recording blob |
|
||||
|
||||
**CallService flow (accept):**
|
||||
```
|
||||
pre_accept_call → WebRTC SDP exchange → accept_call (with SDP fix: actpass→active) → update status → broadcast
|
||||
```
|
||||
|
||||
**Dependencies:** PR-1 ✅ (merged), PR-2 (Meta API methods), PR-3 (CallMessageBuilder).
|
||||
|
||||
**Risk:** Medium. The controller's `initiate` action handles the permission flow (error 138006 → `send_call_permission_request`). The `upload_recording` action enqueues `CallTranscriptionJob` (PR-5). If PR-5 hasn't merged, temporarily guard the enqueue.
|
||||
|
||||
---
|
||||
|
||||
### PR-5: Recording Upload + Transcription Pipeline
|
||||
|
||||
**Concept:** Async transcription of call recordings using OpenAI Whisper.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action | Lines |
|
||||
|------|--------|-------|
|
||||
| `enterprise/app/services/whatsapp/call_transcription_service.rb` | NEW | ~82 |
|
||||
| `enterprise/app/jobs/whatsapp/call_transcription_job.rb` | NEW | ~15 |
|
||||
|
||||
**Pipeline:**
|
||||
```
|
||||
Browser records audio (MediaRecorder) → upload_recording endpoint →
|
||||
ActiveStorage attachment → CallTranscriptionJob →
|
||||
CallTranscriptionService → OpenAI Whisper (whisper-1, temp 0.4) →
|
||||
call.transcript + message.content_attributes updated
|
||||
```
|
||||
|
||||
**Gating:** Requires `captain_integration` feature flag + usage limits check. Inherits from `Llm::LegacyBaseOpenAiService`.
|
||||
|
||||
**Error handling:** Retries on `ActiveStorage::FileNotFoundError`, discards on `Faraday::BadRequestError`.
|
||||
|
||||
**Dependencies:** PR-1 ✅ (merged). Can merge before or with PR-4.
|
||||
|
||||
**Risk:** Low. Fully async, failure-tolerant. No impact on call flow if transcription fails.
|
||||
|
||||
---
|
||||
|
||||
### PR-6: Feature Flag + Enterprise Gating
|
||||
|
||||
**Concept:** Ensure the `whatsapp_call` feature flag is properly checked at all entry points.
|
||||
|
||||
**What it covers:**
|
||||
- Controller: `ensure_whatsapp_call_enabled` before_action
|
||||
- IncomingCallService: `account.feature_enabled?('whatsapp_call')` check
|
||||
- CallPermissionReplyService: same check
|
||||
- Inbox-level toggle: `provider_config['calling_enabled']` on Channel::Whatsapp
|
||||
|
||||
> **Note:** The feature flag definition in `config/features.yml` shipped with PR-1. This PR adds the runtime guard logic across services and controllers. May be bundled into PR-4 if too small standalone.
|
||||
|
||||
---
|
||||
|
||||
### PR-7: OSS Touchpoints
|
||||
|
||||
**Concept:** Minimal changes to OSS (non-enterprise) files required for the calling feature to work.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action | Lines | Change |
|
||||
|------|--------|-------|--------|
|
||||
| `app/jobs/webhooks/whatsapp_events_job.rb` | MODIFY | ~10 | Add `prepend_mod_with` hook + extract `handle_message_events` |
|
||||
| `app/listeners/action_cable_listener.rb` | MODIFY | ~2 | Nil safety fix in `typing_event_listener_tokens` |
|
||||
|
||||
**Why separate:** These are the only two OSS Ruby files modified. Keeping them in a dedicated PR makes review clear — reviewers can verify the `super` delegation path and ensure existing message processing isn't broken.
|
||||
|
||||
> **Note:** The `handle_message_events` extraction from the webhook job also supports the `smb_message_echoes` feature (PR #13371). If that's already merged to `develop`, this extraction may already exist. Check before creating this PR.
|
||||
|
||||
**Dependencies:** None. Should merge early (before PR-3 which depends on the `prepend_mod_with` hook).
|
||||
|
||||
**Risk:** Low but critical to verify. The `super` call path must be tested to ensure non-call webhooks still process correctly.
|
||||
|
||||
---
|
||||
|
||||
### PR-8: Inbox Calling Config (provider_config + serializer)
|
||||
|
||||
**Concept:** Backend support for the per-inbox `calling_enabled` toggle in `provider_config` for WhatsApp Cloud channels.
|
||||
|
||||
**What it covers:**
|
||||
- Allow `calling_enabled` in strong params for Channel::Whatsapp updates
|
||||
- Expose `calling_enabled` in inbox serializer response
|
||||
- Ensure `provider_config` merge (not overwrite) when updating
|
||||
|
||||
**Dependencies:** PR-1 ✅ (merged).
|
||||
|
||||
**Risk:** Low. Purely additive config key — no behavior change if not set.
|
||||
|
||||
---
|
||||
|
||||
## Frontend PR
|
||||
|
||||
### PR-9: All Frontend Changes (Combined)
|
||||
|
||||
**Concept:** The complete frontend implementation — API client, store, WebRTC engine, UI widgets, ActionCable integration, message bubble enhancements, outbound calling, inbox settings toggle, voice helper guard, i18n, and feature flag registration.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action | Lines | Layer |
|
||||
|------|--------|-------|-------|
|
||||
| `app/javascript/dashboard/api/whatsappCalls.js` | NEW | ~43 | API client |
|
||||
| `app/javascript/dashboard/stores/whatsappCalls.js` | NEW | ~94 | State management |
|
||||
| `app/javascript/dashboard/featureFlags.js` | MODIFY | +2 | Feature flag |
|
||||
| `app/javascript/dashboard/i18n/locale/en/whatsappCall.json` | NEW | ~20 | i18n |
|
||||
| `app/javascript/dashboard/i18n/locale/en/index.js` | MODIFY | +1 | i18n |
|
||||
| `app/javascript/dashboard/helper/voice.js` | MODIFY | ~20 | Voice guard |
|
||||
| `app/javascript/dashboard/composables/useWhatsappCallSession.js` | NEW | ~406 | WebRTC engine |
|
||||
| `app/javascript/dashboard/helper/actionCable.js` | MODIFY | +5 handlers | Real-time events |
|
||||
| `app/javascript/dashboard/components/widgets/WhatsappCallWidget.vue` | NEW | ~216 | Call widget UI |
|
||||
| `app/javascript/dashboard/routes/dashboard/Dashboard.vue` | MODIFY | +import | Widget mount |
|
||||
| `app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue` | MODIFY | ~145 | Message bubble |
|
||||
| `app/javascript/dashboard/i18n/locale/en/conversation.json` | MODIFY | +6 keys | i18n |
|
||||
| `app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue` | MODIFY | ~130 | Outbound calling |
|
||||
| `app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue` | MODIFY | ~20 | Inbox settings |
|
||||
| `app/javascript/dashboard/i18n/locale/en/inboxMgmt.json` | MODIFY | +3 keys | i18n |
|
||||
|
||||
**Key components:**
|
||||
|
||||
1. **API Client** — `show`, `accept`, `reject`, `terminate`, `initiate`, `uploadRecording`
|
||||
2. **Pinia Store** — Module-scoped WebRTC objects (non-serializable) outside reactive state
|
||||
3. **WebRTC Composable** — `useWhatsappCallSession()`, `acceptWhatsappCallById()`, `startCallRecording()`
|
||||
4. **ActionCable Events** — `incoming`, `accepted`, `ended`, `outbound_connected`, `permission_granted`
|
||||
5. **Floating Widget** — Incoming/active call states with accept/reject/mute/hangup
|
||||
6. **VoiceCall Bubble** — Accept button, recording player, transcript toggle, "answered by" display
|
||||
7. **Outbound UI** — Phone icon in ConversationHeader, full WebRTC offer/answer flow
|
||||
8. **Inbox Toggle** — `calling_enabled` checkbox in WhatsApp Cloud inbox settings
|
||||
9. **Voice Guard** — Prevents WhatsApp call messages from triggering Twilio store
|
||||
|
||||
**Backend prerequisites:** PR-4 (REST endpoints), PR-3 (ActionCable broadcasts), PR-8 (inbox config).
|
||||
|
||||
**Risk:** ⚠️ HIGH — Largest PR. Review focus areas:
|
||||
- Duplicated ICE gathering logic in ConversationHeader vs composable
|
||||
- Missing `onUnmounted` cleanup for outbound calls in ConversationHeader
|
||||
- `console.log` left in ConversationHeader ICE state logging
|
||||
- Hardcoded STUN server with no backend-provided config
|
||||
- Recording mimeType `audio/webm;codecs=opus` with no browser feature detection
|
||||
- Bare string in ActionCable `onWhatsappCallPermissionGranted` (should use i18n)
|
||||
- Frontend feature flag declared but unused (gated server-side only)
|
||||
|
||||
**Estimated size:** ~1,200 lines across 15 files.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Merge Sequence
|
||||
|
||||
### Phase 1: Foundation (Week 1)
|
||||
|
||||
| Order | PR | Type | Depends On | Est. Size |
|
||||
|-------|-----|------|------------|-----------|
|
||||
| — | **PR-1:** Call Model + Migration | Backend | None | ✅ MERGED |
|
||||
| 1 | **PR-7:** OSS Touchpoints | Backend | None | ~12 lines |
|
||||
| 2 | **PR-8:** Inbox Calling Config | Backend | PR-1 ✅ | ~30 lines |
|
||||
|
||||
### Phase 2: Backend Services (Week 1-2)
|
||||
|
||||
| Order | PR | Type | Depends On | Est. Size |
|
||||
|-------|-----|------|------------|-----------|
|
||||
| 3 | **PR-2:** Meta API Provider Methods | Backend | PR-1 ✅ | ~90 lines |
|
||||
| 4 | **PR-3:** Inbound Webhook Pipeline | Backend | PR-1 ✅, PR-7 | ~420 lines |
|
||||
| 5 | **PR-5:** Transcription Pipeline | Backend | PR-1 ✅ | ~97 lines |
|
||||
| 6 | **PR-6:** Feature Flag Gating | Backend | PR-1 ✅ | ~20 lines |
|
||||
|
||||
### Phase 3: Backend API + Frontend (Week 2-3)
|
||||
|
||||
| Order | PR | Type | Depends On | Est. Size |
|
||||
|-------|-----|------|------------|-----------|
|
||||
| 7 | **PR-4:** Call Service + Controller + Routes | Backend | PR-2, PR-3, PR-5 | ~260 lines |
|
||||
| 8 | **PR-9:** All Frontend Changes | Frontend | PR-4, PR-3, PR-8 | ~1,200 lines |
|
||||
|
||||
---
|
||||
|
||||
## Parallel Merge Opportunities
|
||||
|
||||
These PRs have no dependencies on each other and can be reviewed/merged in parallel:
|
||||
|
||||
- **PR-7, PR-8** — independent foundation PRs
|
||||
- **PR-2, PR-3, PR-5, PR-6** — all depend only on PR-1 (merged) and/or PR-7
|
||||
|
||||
---
|
||||
|
||||
## Issues Found During Analysis
|
||||
|
||||
| # | Issue | Severity | PR Affected |
|
||||
|---|-------|----------|-------------|
|
||||
| 1 | **Duplicated ICE gathering logic** — `waitForOutboundIceGathering` in ConversationHeader vs `waitForIceGatheringComplete` in composable | Medium | PR-9 |
|
||||
| 2 | **No `onUnmounted` cleanup** for outbound calls in ConversationHeader | Medium | PR-9 |
|
||||
| 3 | **Feature flag unused on frontend** — `FEATURE_FLAGS.WHATSAPP_CALL` declared but no component checks it | Low | PR-9 |
|
||||
| 4 | **Bare string in ActionCable** — `onWhatsappCallPermissionGranted` uses template literal instead of i18n key | Low | PR-9 |
|
||||
| 5 | **Hardcoded STUN server** — `stun:stun.l.google.com:19302` with no backend-provided config | Low | PR-9 |
|
||||
| 6 | **Recording mimeType assumption** — `audio/webm;codecs=opus` with no browser feature detection | Low | PR-9 |
|
||||
| 7 | **`console.log` left in** — ICE state logging in ConversationHeader | Low | PR-9 |
|
||||
|
||||
---
|
||||
|
||||
## Cherry-Pick Strategy
|
||||
|
||||
Since the branch has ~270+ commits (including develop merges), cherry-picking individual commits won't work cleanly. Instead:
|
||||
|
||||
1. **For each PR**, create a new branch from `develop`
|
||||
2. **Copy the specific files** from `feat/whatsapp-call` using:
|
||||
```bash
|
||||
git checkout feat/whatsapp-call -- path/to/file1 path/to/file2
|
||||
```
|
||||
3. **For modified files** (routes.rb, actionCable.js, etc.), manually apply only the relevant diff hunks
|
||||
4. **Run tests** for each PR independently before opening
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist Per PR
|
||||
|
||||
| PR | Test Strategy |
|
||||
|----|---------------|
|
||||
| PR-2 | Unit test Meta API methods with WebMock/VCR stubs |
|
||||
| PR-3 | Integration test: simulate Meta webhook payload → verify Call record + message created |
|
||||
| PR-4 | Request specs: hit each endpoint, verify response + side effects |
|
||||
| PR-5 | Unit test: mock OpenAI API, verify transcript stored on call + message |
|
||||
| PR-6 | Verify feature flag gating blocks unauthorized accounts |
|
||||
| PR-7 | Verify existing WhatsApp message webhook processing still works |
|
||||
| PR-8 | Verify inbox serializer includes `calling_enabled` |
|
||||
| PR-9 | `pnpm test` on store + manual E2E: inbound call, outbound call, recording, settings toggle |
|
||||
@@ -0,0 +1,694 @@
|
||||
# Frontend Architecture: Server-Side WebRTC WhatsApp Calling
|
||||
|
||||
> Design document for migrating WhatsApp calling from browser-side WebRTC to server-side WebRTC with browser audio relay.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
### Current model (being replaced)
|
||||
|
||||
```
|
||||
Agent Browser Meta Media Servers
|
||||
┌─────────────────────┐ ┌──────────────┐
|
||||
│ RTCPeerConnection │◄═══════►│ SRTP Audio │
|
||||
│ SDP offer/answer │ direct │ │
|
||||
│ ICE candidates │ media │ │
|
||||
│ MediaRecorder │ │ │
|
||||
│ AudioContext mixer │ │ │
|
||||
└─────────────────────┘ └──────────────┘
|
||||
▲
|
||||
│ ActionCable (signaling only)
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Chatwoot Backend │
|
||||
│ (relay SDP/ICE) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### New model
|
||||
|
||||
```
|
||||
Agent Browser Chatwoot Backend Meta Media Servers
|
||||
┌──────────────────┐ WS ┌─────────────────┐ SRTP ┌──────────────┐
|
||||
│ getUserMedia() │────────────►│ Audio Ingest │ │ │
|
||||
│ (mic capture) │ agent mic │ │ │ │
|
||||
│ │ │ RTCPeerConn │◄════════►│ Media │
|
||||
│ AudioContext │◄────────────│ SDP/ICE │ direct │ Servers │
|
||||
│ (playback) │ remote │ MediaRecorder │ media │ │
|
||||
│ │ audio │ (server-side) │ │ │
|
||||
│ UI Controls │ │ │ │ │
|
||||
│ (mute/hangup) │◄───────────►│ ActionCable │ │ │
|
||||
│ │ signaling │ (call events) │ │ │
|
||||
└──────────────────┘ └─────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- **WebRTC lives on the server.** The backend holds the RTCPeerConnection to Meta.
|
||||
- **Browser sends mic audio** to the server over a dedicated binary WebSocket.
|
||||
- **Server relays remote audio** back to the browser over the same WebSocket.
|
||||
- **Recording is server-side.** No client-side MediaRecorder, no upload step.
|
||||
- **Page reload does not kill the call.** The server-side connection persists; the browser just reconnects its audio stream.
|
||||
|
||||
---
|
||||
|
||||
## 2. Recommended Audio Relay Mechanism
|
||||
|
||||
### Evaluation of options
|
||||
|
||||
| Mechanism | Latency | Complexity | Browser Support | Reconnection | Verdict |
|
||||
|-----------|---------|-----------|----------------|--------------|---------|
|
||||
| **Dedicated binary WebSocket** | 20-50ms | Low | Universal | Trivial (new WS) | **Recommended** |
|
||||
| Secondary WebRTC (browser to server) | 5-20ms | High | Universal | Hard (new SDP) | Over-engineered |
|
||||
| WebTransport | 5-15ms | Medium | Chrome/Edge only | Medium | Not ready |
|
||||
| ActionCable binary frames | 30-80ms | Low | Universal | Free (existing) | Too slow, not designed for media |
|
||||
|
||||
### Recommendation: Dedicated binary WebSocket
|
||||
|
||||
**Why not reuse ActionCable?** ActionCable is JSON-framed, multiplexed, and adds overhead for binary payloads. Audio needs a dedicated low-latency binary channel with no framing overhead.
|
||||
|
||||
**Why not a second WebRTC connection?** The entire point of this migration is to remove WebRTC complexity from the browser. Adding a browser-to-server WebRTC leg reintroduces SDP negotiation, ICE gathering, and DTLS setup -- the exact things we are eliminating.
|
||||
|
||||
**Why not WebTransport?** Firefox and Safari do not yet have stable support. The latency improvement over WebSocket (5-15ms vs 20-50ms) does not justify excluding a third of agents.
|
||||
|
||||
### Audio relay protocol design
|
||||
|
||||
```
|
||||
Dedicated WebSocket: wss://{host}/cable/audio?call_id={id}&token={jwt}
|
||||
|
||||
Frame format (binary):
|
||||
┌──────────┬──────────┬─────────────────────────┐
|
||||
│ type (1B)│ seq (2B) │ payload (variable) │
|
||||
├──────────┼──────────┼─────────────────────────┤
|
||||
│ 0x01 │ uint16 │ Opus frame (agent mic) │ browser → server
|
||||
│ 0x02 │ uint16 │ Opus frame (remote) │ server → browser
|
||||
│ 0x03 │ - │ JSON control message │ bidirectional
|
||||
└──────────┴──────────┴─────────────────────────┘
|
||||
|
||||
Audio encoding:
|
||||
- Codec: Opus (native to WebRTC, supported by AudioEncoder API)
|
||||
- Sample rate: 48kHz mono (Opus default)
|
||||
- Frame duration: 20ms (960 samples per frame)
|
||||
- Bitrate: 24-32 kbps (speech-optimized)
|
||||
- Frames per WebSocket message: 1 (20ms per message = 50 messages/sec)
|
||||
|
||||
Control messages (type 0x03):
|
||||
- { "action": "mute" }
|
||||
- { "action": "unmute" }
|
||||
- { "action": "heartbeat" }
|
||||
- { "action": "reconnected", "resumeFrom": seq }
|
||||
```
|
||||
|
||||
**Why Opus?** It is the codec Meta uses for WhatsApp call audio. The server receives Opus from Meta's SRTP stream and can forward it directly to the browser without transcoding. The browser can also encode mic input as Opus using the WebCodecs `AudioEncoder` API (Chrome 94+, Firefox 130+, Safari 16.4+).
|
||||
|
||||
**Fallback for older browsers:** If `AudioEncoder` is unavailable, fall back to sending raw PCM Int16 at 16kHz (32 KB/s). The server transcodes to Opus before injecting into the WebRTC session.
|
||||
|
||||
---
|
||||
|
||||
## 3. Component Tree
|
||||
|
||||
```
|
||||
App.vue
|
||||
└── DashboardLayout.vue
|
||||
├── ConversationHeader.vue
|
||||
│ └── [phone icon button] ──► calls store.initiateCall()
|
||||
│
|
||||
├── MessageBubble (VoiceCall.vue)
|
||||
│ └── [accept/join button] ──► calls store.acceptCall()
|
||||
│
|
||||
└── CallWidget.vue (fixed position, bottom-right)
|
||||
├── IncomingCallCard.vue (for each ringing call)
|
||||
│ ├── Avatar + caller info
|
||||
│ ├── Accept button ──► calls store.acceptCall()
|
||||
│ └── Reject button ──► calls store.rejectCall()
|
||||
│
|
||||
├── ActiveCallCard.vue (single active call)
|
||||
│ ├── Avatar + caller info
|
||||
│ ├── Duration timer (formattedDuration from store)
|
||||
│ ├── Mute toggle ──► calls store.toggleMute()
|
||||
│ ├── Hangup button ──► calls store.endCall()
|
||||
│ └── ReconnectingBanner.vue (shown during audio reconnection)
|
||||
│
|
||||
└── CallErrorBanner.vue
|
||||
```
|
||||
|
||||
### What changed vs current
|
||||
- **Removed:** No WebRTC logic in any component. Components are now pure UI.
|
||||
- **Simplified:** ConversationHeader no longer creates RTCPeerConnection or manages SDP. It calls a single store action.
|
||||
- **Added:** `ReconnectingBanner` sub-component for the reconnection state.
|
||||
- **Same:** WhatsappCallWidget stays as the floating overlay. VoiceCall.vue bubble stays as the message thread display.
|
||||
|
||||
---
|
||||
|
||||
## 4. Composable Design
|
||||
|
||||
The current monolithic `useWhatsappCallSession.js` (406 lines) is split into three focused composables:
|
||||
|
||||
### 4a. `useCallAudioStream.js` -- audio capture and playback
|
||||
|
||||
This composable owns the dedicated WebSocket, mic capture, and audio playback. It has zero knowledge of call signaling or UI state.
|
||||
|
||||
```
|
||||
Module-level state (singleton, survives component remounts):
|
||||
- audioSocket: WebSocket | null
|
||||
- audioContext: AudioContext | null
|
||||
- micStream: MediaStream | null
|
||||
- audioEncoder: AudioEncoder | null (WebCodecs)
|
||||
- playbackNode: AudioWorkletNode | null
|
||||
- sequenceNumber: number
|
||||
- isConnected: ref(false)
|
||||
- isCapturing: ref(false)
|
||||
|
||||
Exported function: useCallAudioStream()
|
||||
|
||||
Returns:
|
||||
// State
|
||||
isAudioConnected: Ref<boolean>
|
||||
isCapturing: Ref<boolean>
|
||||
isReconnecting: Ref<boolean>
|
||||
audioLevel: Ref<number> // 0-1, for visual feedback
|
||||
|
||||
// Actions
|
||||
connect(callId: string): Promise<void>
|
||||
1. Open WS to /cable/audio?call_id={callId}&token={jwt}
|
||||
2. Create AudioContext (48kHz)
|
||||
3. getUserMedia({ audio: true })
|
||||
4. Pipe mic → AudioWorkletNode (capture processor) → AudioEncoder → WS send
|
||||
5. Register WS.onmessage handler: decode Opus frames → playback buffer
|
||||
6. Start playback via AudioWorkletNode (playback processor)
|
||||
|
||||
disconnect(): void
|
||||
1. Close WS
|
||||
2. Stop mic tracks
|
||||
3. Close AudioContext
|
||||
4. Reset all state
|
||||
|
||||
setMuted(muted: boolean): void
|
||||
1. If muted: stop sending mic frames (but keep mic open for fast unmute)
|
||||
2. If unmuted: resume sending
|
||||
// No server round-trip needed. Simply stop emitting frames.
|
||||
|
||||
reconnect(callId: string): Promise<void>
|
||||
1. Disconnect existing
|
||||
2. Connect fresh (server already has the call)
|
||||
3. Resume audio from current server buffer position
|
||||
```
|
||||
|
||||
**AudioWorklet processors** (two small files):
|
||||
|
||||
`mic-capture-processor.js` -- runs on the audio thread:
|
||||
```js
|
||||
// Receives Float32 PCM from getUserMedia
|
||||
// Posts ArrayBuffer to main thread for encoding
|
||||
class MicCaptureProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const input = inputs[0][0]; // mono channel
|
||||
if (input) {
|
||||
this.port.postMessage(input.buffer, [input.buffer]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('mic-capture', MicCaptureProcessor);
|
||||
```
|
||||
|
||||
`audio-playback-processor.js` -- runs on the audio thread:
|
||||
```js
|
||||
// Receives decoded PCM buffers via port.postMessage
|
||||
// Writes them into output for playback
|
||||
// Maintains a small jitter buffer (60-100ms)
|
||||
class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.buffer = []; // ring buffer of Float32Arrays
|
||||
this.port.onmessage = (e) => this.buffer.push(e.data);
|
||||
}
|
||||
process(outputs) {
|
||||
const output = outputs[0][0];
|
||||
if (this.buffer.length > 0) {
|
||||
const frame = this.buffer.shift();
|
||||
output.set(frame.subarray(0, output.length));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('audio-playback', AudioPlaybackProcessor);
|
||||
```
|
||||
|
||||
### 4b. `useCallSession.js` -- call lifecycle and UI state
|
||||
|
||||
This replaces the current `useWhatsappCallSession.js`. It orchestrates call signaling (via REST API) and delegates audio to `useCallAudioStream`. No WebRTC code.
|
||||
|
||||
```
|
||||
Exported function: useCallSession()
|
||||
|
||||
Internally uses:
|
||||
- useWhatsappCallsStore() (Pinia)
|
||||
- useCallAudioStream()
|
||||
- Timer helper
|
||||
|
||||
Returns:
|
||||
// Read-only state (delegated from store)
|
||||
activeCall: ComputedRef<CallData | null>
|
||||
incomingCalls: ComputedRef<CallData[]>
|
||||
hasActiveCall: ComputedRef<boolean>
|
||||
hasIncomingCall: ComputedRef<boolean>
|
||||
firstIncomingCall: ComputedRef<CallData | null>
|
||||
isOutboundRinging: ComputedRef<boolean>
|
||||
|
||||
// Session state
|
||||
isAccepting: Ref<boolean>
|
||||
isMuted: Ref<boolean>
|
||||
isReconnecting: Ref<boolean> // NEW: true during page-reload rejoin
|
||||
callError: Ref<string | null>
|
||||
formattedCallDuration: ComputedRef<string>
|
||||
|
||||
// Actions
|
||||
acceptCall(call): Promise<void>
|
||||
1. POST /whatsapp_calls/:id/accept (NO SDP -- server handles WebRTC)
|
||||
2. store.setActiveCall(call)
|
||||
3. audioStream.connect(call.id)
|
||||
4. timer.start()
|
||||
|
||||
rejectCall(call): Promise<void>
|
||||
1. POST /whatsapp_calls/:id/reject
|
||||
2. store.removeIncomingCall(call.callId)
|
||||
|
||||
endCall(): Promise<void>
|
||||
1. audioStream.disconnect()
|
||||
2. POST /whatsapp_calls/:id/terminate
|
||||
3. store.clearActiveCall()
|
||||
4. timer.stop()
|
||||
|
||||
toggleMute(): void
|
||||
1. audioStream.setMuted(!isMuted.value)
|
||||
2. isMuted.value = !isMuted.value
|
||||
|
||||
initiateCall(conversationId): Promise<void> // NEW: moved from ConversationHeader
|
||||
1. POST /whatsapp_calls/initiate (NO SDP offer -- server creates its own)
|
||||
2. store.setActiveCall({ status: 'ringing', direction: 'outbound', ... })
|
||||
3. Wait for ActionCable 'whatsapp_call.outbound_connected' event
|
||||
4. audioStream.connect(call.id)
|
||||
5. timer.start()
|
||||
|
||||
rejoinCall(callId): Promise<void> // NEW: reconnection after page reload
|
||||
1. GET /whatsapp_calls/:id → verify status is 'accepted' or 'in_progress'
|
||||
2. store.setActiveCall(callData)
|
||||
3. audioStream.connect(callId)
|
||||
4. Fetch elapsed duration from server to resume timer
|
||||
|
||||
dismissIncomingCall(call): void
|
||||
1. store.removeIncomingCall(call.callId)
|
||||
```
|
||||
|
||||
### 4c. `useCallReconnection.js` -- handles page reload during active call
|
||||
|
||||
```
|
||||
Exported function: useCallReconnection()
|
||||
|
||||
Logic (runs on mount):
|
||||
1. On app startup, check: GET /whatsapp_calls/active
|
||||
- New API endpoint that returns the agent's current active call (if any)
|
||||
2. If an active call exists:
|
||||
- Set isReconnecting = true
|
||||
- Call callSession.rejoinCall(callId)
|
||||
- Set isReconnecting = false
|
||||
3. Register visibility change listener:
|
||||
- On document becoming visible after being hidden, verify audio WS is healthy
|
||||
- If WS is closed, trigger reconnect
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Pinia Store Schema
|
||||
|
||||
The store becomes drastically simpler. All non-serializable objects (RTCPeerConnection, MediaStream, AudioContext) move out of the store entirely -- they live in `useCallAudioStream` at module scope.
|
||||
|
||||
### New store: `stores/whatsappCalls.js`
|
||||
|
||||
```js
|
||||
// NO module-scoped WebRTC objects. No outboundCall state.
|
||||
// The store is now purely serializable call metadata.
|
||||
|
||||
defineStore('whatsappCalls', {
|
||||
state: () => ({
|
||||
incomingCalls: [], // CallData[]
|
||||
activeCall: null, // CallData | null
|
||||
callTimerOffset: 0, // seconds already elapsed (for reconnection)
|
||||
}),
|
||||
|
||||
getters: {
|
||||
hasIncomingCall: (state) => state.incomingCalls.length > 0,
|
||||
hasActiveCall: (state) => state.activeCall !== null,
|
||||
hasWhatsappCall: (state) => state.incomingCalls.length > 0 || state.activeCall !== null,
|
||||
firstIncomingCall: (state) => state.incomingCalls[0] || null,
|
||||
},
|
||||
|
||||
actions: {
|
||||
addIncomingCall(callData) { ... },
|
||||
removeIncomingCall(callId) { ... },
|
||||
setActiveCall(callData) { ... },
|
||||
clearActiveCall() { ... },
|
||||
markActiveCallConnected() { ... },
|
||||
handleCallAcceptedByOther(callId) { ... },
|
||||
handleCallEnded(callId) { ... },
|
||||
setTimerOffset(seconds) { this.callTimerOffset = seconds; },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### CallData type definition
|
||||
|
||||
```ts
|
||||
interface CallData {
|
||||
id: number; // server-side Call record ID
|
||||
callId: string; // Meta's call_id
|
||||
direction: 'inbound' | 'outbound';
|
||||
status: 'ringing' | 'connected' | 'reconnecting';
|
||||
inboxId: number;
|
||||
conversationId: number;
|
||||
caller: {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
// REMOVED: sdpOffer, iceServers -- no longer sent to browser
|
||||
}
|
||||
```
|
||||
|
||||
### What was removed from the store
|
||||
|
||||
| Old | New | Why |
|
||||
|-----|-----|-----|
|
||||
| `outboundCall` (module-scoped `{ pc, stream, audio, callId }`) | Gone | No WebRTC objects in browser |
|
||||
| `getOutboundCallState()` | Gone | No peer connection to query |
|
||||
| `setOutboundCallProperty()` | Gone | No peer connection to mutate |
|
||||
| `cleanupOutboundCall()` | Gone | Audio cleanup is in `useCallAudioStream.disconnect()` |
|
||||
| `cleanupCallback` | Gone | No need for cross-concern cleanup registration |
|
||||
| `registerCleanupCallback()` | Gone | Store actions directly call composable |
|
||||
|
||||
### What was added
|
||||
|
||||
| New | Why |
|
||||
|-----|-----|
|
||||
| `callTimerOffset` | When reconnecting after page reload, the server tells us how many seconds the call has been active. The timer starts from this offset instead of 0. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Reconnection Flow
|
||||
|
||||
This is the primary benefit of the server-side architecture. The server's WebRTC connection to Meta persists across browser page loads.
|
||||
|
||||
### Scenario: Agent refreshes page during active call
|
||||
|
||||
```
|
||||
Timeline:
|
||||
─────────────────────────────────────────────────────────────────►
|
||||
|
||||
1. Agent is on an active call (audio flowing)
|
||||
|
||||
2. Agent hits F5 (page reload)
|
||||
├── beforeunload fires
|
||||
│ └── Audio WS closes (but we do NOT terminate the call)
|
||||
│ Unlike current code which calls terminateCallOnUnload()
|
||||
├── Server detects WS disconnect
|
||||
│ └── Server continues holding the Meta WebRTC session
|
||||
│ └── Server buffers incoming remote audio (or drops it, brief gap is OK)
|
||||
└── Browser unloads
|
||||
|
||||
3. Page reloads, Vue app mounts
|
||||
├── useCallReconnection() runs on mount
|
||||
│ └── GET /whatsapp_calls/active
|
||||
│ Response: { id: 42, call_id: "abc", status: "accepted",
|
||||
│ direction: "inbound", elapsed_seconds: 47, ... }
|
||||
│
|
||||
├── Store updated: setActiveCall(callData)
|
||||
│ UI immediately shows CallWidget with "Reconnecting..." banner
|
||||
│
|
||||
├── callSession.rejoinCall(42)
|
||||
│ ├── audioStream.connect(42)
|
||||
│ │ ├── New WS to /cable/audio?call_id=42
|
||||
│ │ ├── getUserMedia() -- browser may re-prompt for mic
|
||||
│ │ ├── AudioContext + worklets initialized
|
||||
│ │ └── Audio flowing again
|
||||
│ │
|
||||
│ └── Timer resumes from elapsed_seconds (47)
|
||||
│ formattedCallDuration shows "00:47" and counting
|
||||
│
|
||||
└── ReconnectingBanner disappears, ActiveCallCard shows normally
|
||||
|
||||
4. Call continues as if nothing happened
|
||||
Total audio gap: ~2-4 seconds (page load time)
|
||||
```
|
||||
|
||||
### Scenario: Network blip (WebSocket drops briefly)
|
||||
|
||||
```
|
||||
1. Audio WS disconnects unexpectedly
|
||||
|
||||
2. useCallAudioStream detects WS close
|
||||
├── isReconnecting = true
|
||||
├── UI shows "Reconnecting..." in ActiveCallCard
|
||||
└── Start reconnection with exponential backoff:
|
||||
attempt 1: wait 500ms → try WS connect
|
||||
attempt 2: wait 1000ms → try WS connect
|
||||
attempt 3: wait 2000ms → try WS connect
|
||||
max 5 attempts, then show error
|
||||
|
||||
3. WS reconnects
|
||||
├── isReconnecting = false
|
||||
├── Audio resumes
|
||||
└── UI returns to normal
|
||||
|
||||
4. If all 5 attempts fail:
|
||||
├── Show error: "Audio connection lost. Call is still active on server."
|
||||
└── Offer "Reconnect" button that retries
|
||||
```
|
||||
|
||||
### Scenario: Agent opens a second tab
|
||||
|
||||
```
|
||||
1. Call is active in Tab A
|
||||
|
||||
2. Agent opens Tab B (or navigates to Chatwoot in new tab)
|
||||
├── useCallReconnection() runs
|
||||
│ └── GET /whatsapp_calls/active → returns active call
|
||||
│
|
||||
├── Two options (backend decides):
|
||||
│ a) TRANSFER audio to new tab: Server closes Tab A's audio WS,
|
||||
│ Tab B becomes the audio source. Tab A shows "Call moved to another tab."
|
||||
│ b) BLOCK: Return { active: true, owned_by_other_session: true }
|
||||
│ Tab B shows the call widget in "view only" mode (timer, no controls)
|
||||
│
|
||||
└── Recommendation: Option (a) -- transfer. Matches user intent
|
||||
(they probably want to continue in the new tab).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. File Structure
|
||||
|
||||
### Files to create (new)
|
||||
|
||||
```
|
||||
app/javascript/dashboard/
|
||||
├── composables/
|
||||
│ ├── useCallAudioStream.js # Audio capture, WS relay, playback
|
||||
│ ├── useCallSession.js # Call lifecycle (accept/reject/end/initiate)
|
||||
│ └── useCallReconnection.js # Page-reload reconnection logic
|
||||
│
|
||||
├── workers/
|
||||
│ ├── mic-capture-processor.js # AudioWorklet: mic → main thread
|
||||
│ └── audio-playback-processor.js # AudioWorklet: main thread → speaker
|
||||
│
|
||||
└── helpers/
|
||||
└── callAudioCodec.js # WebCodecs Opus encode/decode helpers
|
||||
```
|
||||
|
||||
### Files to modify
|
||||
|
||||
```
|
||||
app/javascript/dashboard/
|
||||
├── composables/
|
||||
│ └── useWhatsappCallSession.js # DELETE entirely (replaced by useCallSession.js)
|
||||
│
|
||||
├── stores/
|
||||
│ └── whatsappCalls.js # Simplify: remove all WebRTC state/helpers
|
||||
│
|
||||
├── api/
|
||||
│ └── whatsappCalls.js # Modify: remove SDP params, add active() endpoint
|
||||
│
|
||||
├── helper/
|
||||
│ └── actionCable.js # Modify: simplify event handlers (no SDP relay)
|
||||
│
|
||||
├── components/widgets/
|
||||
│ ├── WhatsappCallWidget.vue # Minor: use new composable, add reconnection UI
|
||||
│ └── conversation/
|
||||
│ └── ConversationHeader.vue # Major simplification: remove all WebRTC code
|
||||
│
|
||||
└── components-next/message/bubbles/
|
||||
└── VoiceCall.vue # Minor: acceptWhatsappCallById → store action
|
||||
```
|
||||
|
||||
### Files to delete
|
||||
|
||||
```
|
||||
(none explicitly deleted as files, but the following become unnecessary
|
||||
and their content is removed or replaced:)
|
||||
|
||||
- All RTCPeerConnection code in useWhatsappCallSession.js → replaced
|
||||
- All SDP/ICE code in ConversationHeader.vue → replaced
|
||||
- startCallRecording / stopAndUploadRecording → gone (server-side recording)
|
||||
- terminateCallOnUnload → replaced with "do nothing" (call persists)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. API Changes Required
|
||||
|
||||
The REST API needs small changes to support the new model:
|
||||
|
||||
### Modified endpoints
|
||||
|
||||
| Endpoint | Old | New |
|
||||
|----------|-----|-----|
|
||||
| `POST /:id/accept` | Sends `{ sdp_answer }` | Sends `{}` (no SDP, server handles it) |
|
||||
| `POST /initiate` | Sends `{ conversation_id, sdp_offer }` | Sends `{ conversation_id }` (no SDP) |
|
||||
| `POST /:id/upload_recording` | Browser uploads webm blob | **Removed** -- server records directly |
|
||||
|
||||
### New endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `GET /whatsapp_calls/active` | Returns the current agent's active call (if any). Used for reconnection on page load. Returns `null` if no active call. |
|
||||
| `WS /cable/audio?call_id={id}&token={jwt}` | Dedicated binary WebSocket for audio relay. Separate from ActionCable. |
|
||||
|
||||
### Modified ActionCable events
|
||||
|
||||
| Event | Old payload | New payload |
|
||||
|-------|-------------|-------------|
|
||||
| `whatsapp_call.incoming` | `{ ..., sdp_offer, ice_servers }` | `{ ..., }` (no SDP/ICE -- browser does not need them) |
|
||||
| `whatsapp_call.outbound_connected` | `{ call_id, sdp_answer }` | `{ call_id }` (no SDP -- just signals "ready for audio WS") |
|
||||
|
||||
---
|
||||
|
||||
## 9. ActionCable Handler Changes
|
||||
|
||||
The `actionCable.js` handlers simplify significantly:
|
||||
|
||||
```
|
||||
Current handlers (what changes):
|
||||
|
||||
onWhatsappCallIncoming:
|
||||
OLD: Store sdpOffer + iceServers in incoming call data
|
||||
NEW: Store only metadata (id, callId, direction, caller, conversationId)
|
||||
No sdpOffer, no iceServers
|
||||
|
||||
onWhatsappCallAccepted:
|
||||
OLD: Same
|
||||
NEW: Same (no change -- purely a signaling event)
|
||||
|
||||
onWhatsappCallEnded:
|
||||
OLD: Calls store.handleCallEnded which triggers cleanupCallback (WebRTC teardown)
|
||||
NEW: Calls store.handleCallEnded which is now just state cleanup
|
||||
Audio cleanup happens separately via useCallAudioStream detecting WS close
|
||||
|
||||
onWhatsappCallOutboundConnected:
|
||||
OLD: Gets outbound PC, calls pc.setRemoteDescription(sdp_answer)
|
||||
NEW: Calls store.markActiveCallConnected()
|
||||
Triggers useCallSession to open the audio WS
|
||||
No SDP handling at all
|
||||
|
||||
onWhatsappCallPermissionGranted:
|
||||
OLD: Same
|
||||
NEW: Same (no change -- purely a UI notification)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Comparison: Old vs New
|
||||
|
||||
### Code complexity
|
||||
|
||||
| Aspect | Old (browser WebRTC) | New (server relay) |
|
||||
|--------|---------------------|-------------------|
|
||||
| **useWhatsappCallSession.js** | 406 lines, WebRTC + recording + SDP + ICE | ~80 lines, pure call lifecycle orchestration |
|
||||
| **ConversationHeader.vue (call code)** | ~100 lines of WebRTC setup in component | ~10 lines: single `store.initiateCall()` call |
|
||||
| **Pinia store** | 94 lines + module-scoped `outboundCall` with PC/Stream | ~60 lines, purely serializable state |
|
||||
| **actionCable.js (call handlers)** | 50 lines with SDP relay logic | ~25 lines, metadata-only event handling |
|
||||
| **AudioWorklet processors** | N/A | ~30 lines each (two files), standard pattern |
|
||||
| **useCallAudioStream.js** | N/A (was inline WebRTC) | ~150 lines, focused audio I/O |
|
||||
| **Total frontend call code** | ~650 lines, scattered across 5 files | ~400 lines, organized in 3 composables + 2 worklets |
|
||||
|
||||
### Capability comparison
|
||||
|
||||
| Capability | Old | New |
|
||||
|-----------|-----|-----|
|
||||
| Call survives page reload | No | Yes |
|
||||
| Call survives network blip | No (ICE restart needed) | Yes (WS reconnect) |
|
||||
| Recording reliability | Low (browser crash = lost) | High (server-side) |
|
||||
| Multi-tab support | Not possible | Transfer or view-only |
|
||||
| Agent on mobile browser | Fragile (background tab kills WebRTC) | Better (WS more resilient) |
|
||||
| TURN server needed | Yes (for restrictive NAT) | No (server has public IP) |
|
||||
| Browser API surface | RTCPeerConnection, MediaRecorder, AudioContext, ICE, SDP, SRTP | getUserMedia, AudioContext, WebSocket, WebCodecs |
|
||||
| Codec flexibility | Locked to WebRTC negotiation | Server can transcode |
|
||||
|
||||
### Latency comparison
|
||||
|
||||
| Path | Old | New |
|
||||
|------|-----|-----|
|
||||
| Agent mic to customer | ~50ms (direct P2P via SRTP) | ~70-100ms (+20-50ms WS hop to server) |
|
||||
| Customer to agent speaker | ~50ms (direct P2P via SRTP) | ~70-100ms (+20-50ms WS hop from server) |
|
||||
|
||||
The added ~20-50ms per direction is imperceptible in voice calls (human perception threshold for audio delay is ~150ms).
|
||||
|
||||
### Failure mode comparison
|
||||
|
||||
| Failure | Old | New |
|
||||
|---------|-----|-----|
|
||||
| Browser crashes | Call dead, recording lost | Call continues on server, agent can rejoin |
|
||||
| Network drops 5s | ICE restart attempt (often fails) | WS reconnect, brief audio gap |
|
||||
| Agent closes tab | Call terminated (beforeunload) | Call persists, agent can rejoin (or timeout) |
|
||||
| Server crashes | Call continues (P2P) | Call dead (single point of failure) |
|
||||
|
||||
The server becoming a single point of failure is mitigated by the fact that it already is the single point of failure for signaling. If the server goes down in the old model, the agent cannot accept new calls or initiate calls anyway. The incremental risk is that an *in-progress* call dies, which is manageable with standard server redundancy.
|
||||
|
||||
---
|
||||
|
||||
## 11. Migration Strategy
|
||||
|
||||
### Phase 1: Backend (prerequisite)
|
||||
- Server-side WebRTC: RTCPeerConnection to Meta from the backend process
|
||||
- Audio WebSocket endpoint: `/cable/audio` with binary frame handling
|
||||
- Server-side recording with the same Opus stream
|
||||
- New `GET /whatsapp_calls/active` endpoint
|
||||
- Modified `POST /accept` and `POST /initiate` (no SDP params)
|
||||
|
||||
### Phase 2: Frontend (this document)
|
||||
- Create `useCallAudioStream.js` + AudioWorklet processors
|
||||
- Create `useCallSession.js` to replace `useWhatsappCallSession.js`
|
||||
- Create `useCallReconnection.js`
|
||||
- Simplify Pinia store
|
||||
- Simplify ActionCable handlers
|
||||
- Update CallWidget with reconnection UI
|
||||
- Remove all WebRTC code from ConversationHeader
|
||||
|
||||
### Phase 3: Cleanup
|
||||
- Remove `startCallRecording` / `stopAndUploadRecording` exports
|
||||
- Remove `uploadRecording` API method
|
||||
- Remove `sdpOffer` / `iceServers` from all frontend types
|
||||
- Feature flag: `whatsapp_call_server_relay` to toggle between old/new during rollout
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions for Backend Team
|
||||
|
||||
1. **Audio WebSocket authentication**: Should we use a short-lived JWT in the WS URL query param, or perform auth in the first WS frame? JWT in URL is simpler but appears in server logs.
|
||||
|
||||
2. **Server-side timeout**: When the browser disconnects the audio WS (page reload), how long should the server hold the Meta WebRTC session before auto-terminating? Recommendation: 30 seconds.
|
||||
|
||||
3. **Audio buffering during reconnection**: Should the server buffer remote audio during a WS disconnect and replay it when the browser reconnects? Or just drop those frames? Recommendation: Drop -- a 2-4 second gap is acceptable, buffering adds complexity.
|
||||
|
||||
4. **Opus passthrough vs transcode**: Can the server forward Meta's Opus frames directly to the browser WS, or does the SRTP decryption produce raw PCM that needs re-encoding? Direct passthrough is ideal (zero CPU cost).
|
||||
|
||||
5. **Multi-process deployment**: If Chatwoot runs multiple Rails processes/pods, how does the audio WS route to the process that holds the RTCPeerConnection? Sticky sessions? Redis pub/sub relay? This is a backend architecture question but affects the WS endpoint design.
|
||||
@@ -0,0 +1,67 @@
|
||||
class Api::V1::Accounts::MediaServer::CallbacksController < Api::V1::Accounts::BaseController
|
||||
skip_before_action :authenticate_user!, raise: false
|
||||
skip_before_action :authenticate_access_token!, raise: false
|
||||
before_action :validate_media_server_token
|
||||
|
||||
def agent_disconnected
|
||||
call = find_call_by_session
|
||||
return head :not_found unless call
|
||||
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{call.account_id}",
|
||||
{
|
||||
event: 'whatsapp_call.agent_disconnected',
|
||||
data: { id: call.id, call_id: call.provider_call_id, conversation_id: call.conversation_id }
|
||||
}
|
||||
)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def recording_ready
|
||||
call = find_call_by_session
|
||||
return head :not_found unless call
|
||||
|
||||
Whatsapp::CallRecordingFetchJob.perform_later(call.id)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def session_terminated
|
||||
call = find_call_by_session
|
||||
return head :not_found unless call
|
||||
return head :ok if call.terminal?
|
||||
|
||||
reason = params[:reason] || 'media_server'
|
||||
was_answered = call.in_progress? || call.accepted_by_agent_id.present?
|
||||
final_status = was_answered ? 'completed' : 'failed'
|
||||
|
||||
call.update!(status: final_status, end_reason: reason)
|
||||
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{call.account_id}",
|
||||
{
|
||||
event: 'whatsapp_call.ended',
|
||||
data: { id: call.id, call_id: call.provider_call_id, status: final_status, conversation_id: call.conversation_id }
|
||||
}
|
||||
)
|
||||
|
||||
# Also terminate on Meta side
|
||||
provider = call.inbox.channel.provider_service
|
||||
provider.terminate_call(call.provider_call_id)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[MEDIA SERVER] Failed to terminate on provider: #{e.message}"
|
||||
ensure
|
||||
head :ok unless performed?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_media_server_token
|
||||
token = request.headers['Authorization']&.sub('Bearer ', '')
|
||||
expected = ENV.fetch('MEDIA_SERVER_AUTH_TOKEN', '')
|
||||
head :unauthorized unless expected.present? && token.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
|
||||
end
|
||||
|
||||
def find_call_by_session
|
||||
Call.find_by(media_session_id: params[:session_id])
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,9 @@
|
||||
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
|
||||
ALLOWED_PEER_ROLES = %w[listen_only participant].freeze
|
||||
ALLOWED_AUDIO_MODES = %w[replace mix].freeze
|
||||
|
||||
before_action :ensure_whatsapp_call_enabled
|
||||
before_action :set_call, only: [:show, :accept, :reject, :terminate, :upload_recording]
|
||||
before_action :set_call, only: [:show, :accept, :reject, :terminate, :upload_recording, :agent_answer, :reconnect, :join, :play_audio]
|
||||
|
||||
def show
|
||||
render json: {
|
||||
@@ -18,11 +21,16 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def accept
|
||||
sdp_answer = params[:sdp_answer]
|
||||
return render json: { error: 'sdp_answer is required' }, status: :unprocessable_entity if sdp_answer.blank?
|
||||
if Call.media_server_enabled?
|
||||
call = Whatsapp::CallService.new(call: @call, agent: current_user).accept
|
||||
render json: { id: call.id, status: call.status, message_id: call.message_id, media_session_id: call.media_session_id }
|
||||
else
|
||||
sdp_answer = params[:sdp_answer]
|
||||
return render json: { error: 'sdp_answer is required' }, status: :unprocessable_entity if sdp_answer.blank?
|
||||
|
||||
call = Whatsapp::CallService.new(call: @call, agent: current_user).pre_accept_and_accept(sdp_answer)
|
||||
render json: { id: call.id, status: call.status, message_id: call.message_id }
|
||||
call = Whatsapp::CallService.new(call: @call, agent: current_user).pre_accept_and_accept(sdp_answer)
|
||||
render json: { id: call.id, status: call.status, message_id: call.message_id }
|
||||
end
|
||||
rescue Whatsapp::CallErrors::NotRinging, Whatsapp::CallErrors::AlreadyAccepted => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
@@ -57,6 +65,89 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
render json: { error: 'Failed to upload recording' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def active
|
||||
call = current_account.calls.whatsapp.active_for_agent(current_user.id).last
|
||||
if call
|
||||
elapsed = call.started_at ? (Time.current - call.started_at).to_i : 0
|
||||
render json: {
|
||||
id: call.id,
|
||||
call_id: call.provider_call_id,
|
||||
conversation_id: call.conversation_id,
|
||||
status: call.status,
|
||||
elapsed_seconds: elapsed,
|
||||
media_session_id: call.media_session_id
|
||||
}
|
||||
else
|
||||
render json: { call: nil }
|
||||
end
|
||||
end
|
||||
|
||||
def agent_answer
|
||||
return render json: { error: 'sdp_answer is required' }, status: :unprocessable_entity if params[:sdp_answer].blank?
|
||||
return render json: { error: 'No media session' }, status: :unprocessable_entity if @call.media_session_id.blank?
|
||||
|
||||
client = Whatsapp::MediaServerClient.new
|
||||
client.set_agent_answer(@call.media_session_id, sdp_answer: params[:sdp_answer])
|
||||
render json: { success: true }
|
||||
rescue Whatsapp::MediaServerClient::SessionError, Whatsapp::MediaServerClient::ConnectionError => e
|
||||
Rails.logger.error "[WHATSAPP CALL] agent_answer failed: #{e.message}"
|
||||
render json: { error: 'Failed to set agent answer' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def reconnect
|
||||
return render json: { error: 'No media session' }, status: :unprocessable_entity if @call.media_session_id.blank?
|
||||
return render json: { error: 'Call is not in progress' }, status: :unprocessable_entity unless @call.in_progress?
|
||||
|
||||
client = Whatsapp::MediaServerClient.new
|
||||
response = client.reconnect_agent(@call.media_session_id)
|
||||
render json: {
|
||||
sdp_offer: response['sdp_offer'],
|
||||
ice_servers: response['ice_servers']
|
||||
}
|
||||
rescue Whatsapp::MediaServerClient::SessionError, Whatsapp::MediaServerClient::ConnectionError => e
|
||||
Rails.logger.error "[WHATSAPP CALL] reconnect failed: #{e.message}"
|
||||
render json: { error: 'Failed to reconnect' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def join
|
||||
return render json: { error: 'No media session' }, status: :unprocessable_entity if @call.media_session_id.blank?
|
||||
|
||||
role = params[:role] || 'listen_only'
|
||||
return render json: { error: 'Invalid role' }, status: :unprocessable_entity unless ALLOWED_PEER_ROLES.include?(role)
|
||||
|
||||
client = Whatsapp::MediaServerClient.new
|
||||
response = client.add_peer(@call.media_session_id, role: role, label: current_user.name)
|
||||
render json: {
|
||||
peer_id: response['peer_id'],
|
||||
sdp_offer: response['sdp_offer'],
|
||||
ice_servers: response['ice_servers']
|
||||
}
|
||||
rescue Whatsapp::MediaServerClient::SessionError, Whatsapp::MediaServerClient::ConnectionError => e
|
||||
Rails.logger.error "[WHATSAPP CALL] join failed: #{e.message}"
|
||||
render json: { error: 'Failed to join call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def play_audio
|
||||
return render json: { error: 'No media session' }, status: :unprocessable_entity if @call.media_session_id.blank?
|
||||
return render json: { error: 'file_path is required' }, status: :unprocessable_entity if params[:file_path].blank?
|
||||
return render json: { error: 'Invalid file_path' }, status: :unprocessable_entity if params[:file_path].include?('..')
|
||||
|
||||
mode = params[:mode] || 'replace'
|
||||
return render json: { error: 'Invalid mode' }, status: :unprocessable_entity unless ALLOWED_AUDIO_MODES.include?(mode)
|
||||
|
||||
client = Whatsapp::MediaServerClient.new
|
||||
response = client.inject_audio(
|
||||
@call.media_session_id,
|
||||
file_path: params[:file_path],
|
||||
mode: mode,
|
||||
loop: ActiveModel::Type::Boolean.new.cast(params[:loop])
|
||||
)
|
||||
render json: { injection_id: response['injection_id'] }
|
||||
rescue Whatsapp::MediaServerClient::SessionError, Whatsapp::MediaServerClient::ConnectionError => e
|
||||
Rails.logger.error "[WHATSAPP CALL] play_audio failed: #{e.message}"
|
||||
render json: { error: 'Failed to play audio' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def initiate
|
||||
conversation = current_account.conversations.find(params[:conversation_id])
|
||||
authorize conversation, :show?
|
||||
@@ -81,8 +172,16 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
def create_outbound_call(conversation)
|
||||
contact_phone = conversation.contact&.phone_number
|
||||
raise ArgumentError, 'Contact phone number not available' if contact_phone.blank?
|
||||
raise ArgumentError, 'sdp_offer is required' if params[:sdp_offer].blank?
|
||||
raise ArgumentError, 'sdp_offer is required' if params[:sdp_offer].blank? && !Call.media_server_enabled?
|
||||
|
||||
if Call.media_server_enabled?
|
||||
create_outbound_call_via_media_server(conversation, contact_phone)
|
||||
else
|
||||
create_outbound_call_direct(conversation, contact_phone)
|
||||
end
|
||||
end
|
||||
|
||||
def create_outbound_call_direct(conversation, contact_phone)
|
||||
result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+'), params[:sdp_offer])
|
||||
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
|
||||
|
||||
@@ -94,6 +193,31 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
)
|
||||
end
|
||||
|
||||
def create_outbound_call_via_media_server(conversation, contact_phone)
|
||||
client = Whatsapp::MediaServerClient.new
|
||||
|
||||
# Step 1: Create session on media server (generates SDP offer for Meta)
|
||||
session_response = client.create_session(
|
||||
call_id: "pending_#{SecureRandom.hex(8)}",
|
||||
sdp_offer: nil,
|
||||
ice_servers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
account_id: current_account.id
|
||||
)
|
||||
|
||||
# Step 2: Send the media server's SDP offer to Meta to initiate the call
|
||||
sdp_offer = session_response['meta_sdp_answer'] || session_response['sdp_offer']
|
||||
result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+'), sdp_offer)
|
||||
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
|
||||
|
||||
current_account.calls.create!(
|
||||
provider: :whatsapp,
|
||||
inbox: conversation.inbox, conversation: conversation,
|
||||
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
|
||||
media_session_id: session_response['session_id'],
|
||||
meta: { sdp_offer: sdp_offer }
|
||||
)
|
||||
end
|
||||
|
||||
def handle_no_call_permission(conversation)
|
||||
last_requested = conversation.additional_attributes&.dig('call_permission_requested_at')
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
class Whatsapp::CallCleanupJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform
|
||||
expire_stale_ringing_calls
|
||||
expire_stale_in_progress_calls
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def expire_stale_ringing_calls
|
||||
Call.whatsapp.ringing.where('created_at < ?', 2.minutes.ago).find_each do |call|
|
||||
call.update!(status: 'no_answer', end_reason: 'timeout')
|
||||
Whatsapp::CallMessageBuilder.update_status!(call: call, status: 'no_answer')
|
||||
end
|
||||
end
|
||||
|
||||
def expire_stale_in_progress_calls
|
||||
Call.whatsapp.where(status: 'in_progress').where('started_at < ?', 3.hours.ago).find_each do |call|
|
||||
terminate_media_session(call)
|
||||
call.update!(status: 'failed', end_reason: 'timeout')
|
||||
Whatsapp::CallMessageBuilder.update_status!(call: call, status: 'failed')
|
||||
end
|
||||
end
|
||||
|
||||
def terminate_media_session(call)
|
||||
return unless call.media_session_id.present?
|
||||
|
||||
Whatsapp::MediaServerClient.new.terminate_session(call.media_session_id)
|
||||
rescue Whatsapp::MediaServerClient::ConnectionError, Whatsapp::MediaServerClient::SessionError => e
|
||||
Rails.logger.error "[WHATSAPP CALL CLEANUP] Failed to terminate media session #{call.media_session_id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
class Whatsapp::CallRecordingFetchJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
retry_on Whatsapp::MediaServerClient::ConnectionError, wait: 5.seconds, attempts: 5
|
||||
discard_on ActiveRecord::RecordNotFound
|
||||
|
||||
def perform(call_id)
|
||||
call = Call.find(call_id)
|
||||
return unless call.media_session_id.present?
|
||||
|
||||
client = Whatsapp::MediaServerClient.new
|
||||
recording_data = client.download_recording(call.media_session_id)
|
||||
return if recording_data.blank?
|
||||
|
||||
call.recording.attach(
|
||||
io: StringIO.new(recording_data.force_encoding('BINARY')),
|
||||
filename: "call_#{call.id}_#{call.provider_call_id}.ogg",
|
||||
content_type: 'audio/ogg'
|
||||
)
|
||||
|
||||
Whatsapp::CallMessageBuilder.update_recording_url!(call: call)
|
||||
Whatsapp::CallTranscriptionJob.perform_later(call.id) if call.recording.attached?
|
||||
rescue Whatsapp::MediaServerClient::SessionError => e
|
||||
Rails.logger.warn "[WHATSAPP CALL] Recording not available for session #{call.media_session_id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -22,6 +22,11 @@ class Call < ApplicationRecord
|
||||
|
||||
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
|
||||
scope :ringing, -> { where(status: 'ringing') }
|
||||
scope :active_for_agent, ->(agent_id) { active.where(accepted_by_agent_id: agent_id) }
|
||||
|
||||
def self.media_server_enabled?
|
||||
ENV['MEDIA_SERVER_URL'].present?
|
||||
end
|
||||
|
||||
def ringing?
|
||||
status == 'ringing'
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
class Whatsapp::CallService
|
||||
pattr_initialize [:call!, :agent!]
|
||||
|
||||
def accept(params = {})
|
||||
if media_server_enabled?
|
||||
accept_via_media_server
|
||||
else
|
||||
pre_accept_and_accept(params[:sdp_answer])
|
||||
end
|
||||
end
|
||||
|
||||
def pre_accept_and_accept(sdp_answer)
|
||||
call.with_lock do
|
||||
ensure_ringing!
|
||||
@@ -48,9 +56,8 @@ class Whatsapp::CallService
|
||||
def terminate
|
||||
return call if call.terminal?
|
||||
|
||||
provider = call.inbox.channel.provider_service
|
||||
success = provider.terminate_call(call.provider_call_id)
|
||||
Rails.logger.error "[WHATSAPP CALL] terminate_call API returned false for call #{call.provider_call_id}" unless success
|
||||
terminate_media_session if call.media_session_id.present?
|
||||
terminate_on_provider
|
||||
|
||||
call.update!(status: 'completed')
|
||||
Whatsapp::CallMessageBuilder.update_status!(call: call, status: 'completed')
|
||||
@@ -59,8 +66,69 @@ class Whatsapp::CallService
|
||||
call
|
||||
end
|
||||
|
||||
def terminate_on_provider
|
||||
provider = call.inbox.channel.provider_service
|
||||
success = provider.terminate_call(call.provider_call_id)
|
||||
Rails.logger.error "[WHATSAPP CALL] terminate_call API returned false for call #{call.provider_call_id}" unless success
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def accept_via_media_server
|
||||
agent_offer = nil
|
||||
|
||||
call.with_lock do
|
||||
ensure_ringing!
|
||||
ensure_not_already_taken!
|
||||
|
||||
client = Whatsapp::MediaServerClient.new
|
||||
|
||||
# Step 1: Create session on Go server with Meta's SDP
|
||||
session_response = client.create_session(
|
||||
call_id: call.provider_call_id,
|
||||
sdp_offer: call.sdp_offer,
|
||||
ice_servers: call.ice_servers,
|
||||
account_id: call.account_id
|
||||
)
|
||||
|
||||
# Step 2: Send Go-generated SDP answer to Meta
|
||||
provider = call.inbox.channel.provider_service
|
||||
pre_response = provider.pre_accept_call(call.provider_call_id, session_response['meta_sdp_answer'])
|
||||
raise Whatsapp::CallErrors::NotRinging, 'Meta pre_accept failed' unless pre_response
|
||||
|
||||
accept_response = provider.accept_call(call.provider_call_id, session_response['meta_sdp_answer'])
|
||||
raise Whatsapp::CallErrors::NotRinging, 'Meta accept failed' unless accept_response
|
||||
|
||||
# Step 3: Generate agent offer (Peer B)
|
||||
agent_offer = client.generate_agent_offer(session_response['session_id'])
|
||||
|
||||
# Step 4: Update call record
|
||||
call.update!(
|
||||
status: 'in_progress',
|
||||
accepted_by_agent_id: agent.id,
|
||||
started_at: Time.current,
|
||||
media_session_id: session_response['session_id']
|
||||
)
|
||||
end
|
||||
|
||||
# Step 5: Broadcast events (outside lock)
|
||||
Whatsapp::CallMessageBuilder.update_status!(call: call, status: 'in_progress', agent: agent)
|
||||
update_conversation_call_status('in-progress')
|
||||
broadcast_agent_offer(agent_offer)
|
||||
broadcast_accepted
|
||||
call
|
||||
end
|
||||
|
||||
def media_server_enabled?
|
||||
Call.media_server_enabled?
|
||||
end
|
||||
|
||||
def terminate_media_session
|
||||
Whatsapp::MediaServerClient.new.terminate_session(call.media_session_id)
|
||||
rescue Whatsapp::MediaServerClient::ConnectionError, Whatsapp::MediaServerClient::SessionError => e
|
||||
Rails.logger.error "[WHATSAPP CALL] Failed to terminate media session: #{e.message}"
|
||||
end
|
||||
|
||||
def ensure_ringing!
|
||||
raise Whatsapp::CallErrors::NotRinging, 'Call is not in ringing state' unless call.ringing?
|
||||
end
|
||||
@@ -93,6 +161,22 @@ class Whatsapp::CallService
|
||||
ActionCable.server.broadcast("account_#{call.account_id}", payload)
|
||||
end
|
||||
|
||||
def broadcast_agent_offer(agent_offer)
|
||||
payload = {
|
||||
event: 'whatsapp_call.agent_offer',
|
||||
data: {
|
||||
account_id: call.account_id,
|
||||
id: call.id,
|
||||
call_id: call.provider_call_id,
|
||||
conversation_id: call.conversation_id,
|
||||
accepted_by_agent_id: agent.id,
|
||||
sdp_offer: agent_offer['sdp_offer'],
|
||||
ice_servers: agent_offer['ice_servers']
|
||||
}
|
||||
}
|
||||
ActionCable.server.broadcast("account_#{call.account_id}", payload)
|
||||
end
|
||||
|
||||
def broadcast_call_ended
|
||||
payload = {
|
||||
event: 'whatsapp_call.ended',
|
||||
|
||||
@@ -99,6 +99,9 @@ class Whatsapp::IncomingCallService
|
||||
mapped = Whatsapp::CallMessageBuilder::CALL_TO_VOICE_STATUS[final_status] || final_status
|
||||
update_conversation_call_status(call.conversation, mapped, call.direction_label)
|
||||
broadcast_call_ended(call)
|
||||
|
||||
# Fetch recording from media server if a session was active
|
||||
Whatsapp::CallRecordingFetchJob.perform_later(call.id) if call.media_session_id.present?
|
||||
end
|
||||
|
||||
def find_or_create_contact(phone_number)
|
||||
@@ -141,26 +144,28 @@ class Whatsapp::IncomingCallService
|
||||
end
|
||||
|
||||
def broadcast_incoming_call(call, contact, sdp_offer)
|
||||
payload = {
|
||||
event: 'whatsapp_call.incoming',
|
||||
data: {
|
||||
account_id: inbox.account_id,
|
||||
id: call.id,
|
||||
call_id: call.provider_call_id,
|
||||
direction: call.direction_label,
|
||||
inbox_id: call.inbox_id,
|
||||
conversation_id: call.conversation_id,
|
||||
caller: {
|
||||
name: contact.name,
|
||||
phone: contact.phone_number,
|
||||
avatar: contact.avatar_url
|
||||
},
|
||||
sdp_offer: sdp_offer,
|
||||
ice_servers: default_ice_servers
|
||||
data = {
|
||||
account_id: inbox.account_id,
|
||||
id: call.id,
|
||||
call_id: call.provider_call_id,
|
||||
direction: call.direction_label,
|
||||
inbox_id: call.inbox_id,
|
||||
conversation_id: call.conversation_id,
|
||||
caller: {
|
||||
name: contact.name,
|
||||
phone: contact.phone_number,
|
||||
avatar: contact.avatar_url
|
||||
}
|
||||
}
|
||||
|
||||
ActionCable.server.broadcast("account_#{inbox.account_id}", payload)
|
||||
# When media server is enabled, the browser does not need Meta's SDP since
|
||||
# the Go sidecar handles the Meta-side peer connection directly.
|
||||
unless Call.media_server_enabled?
|
||||
data[:sdp_offer] = sdp_offer
|
||||
data[:ice_servers] = default_ice_servers
|
||||
end
|
||||
|
||||
ActionCable.server.broadcast("account_#{inbox.account_id}", { event: 'whatsapp_call.incoming', data: data })
|
||||
end
|
||||
|
||||
def broadcast_call_ended(call)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
class Whatsapp::MediaServerClient
|
||||
class ConnectionError < StandardError; end
|
||||
class SessionError < StandardError; end
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
def create_session(call_id:, sdp_offer:, ice_servers:, account_id: nil)
|
||||
body = { call_id: call_id, meta_sdp_offer: sdp_offer, ice_servers: ice_servers, account_id: account_id }.compact
|
||||
post('/sessions', body)
|
||||
end
|
||||
|
||||
def generate_agent_offer(session_id)
|
||||
post("/sessions/#{session_id}/agent-offer")
|
||||
end
|
||||
|
||||
def set_agent_answer(session_id, sdp_answer:)
|
||||
post("/sessions/#{session_id}/agent-answer", { sdp_answer: sdp_answer })
|
||||
end
|
||||
|
||||
def reconnect_agent(session_id)
|
||||
post("/sessions/#{session_id}/agent-reconnect")
|
||||
end
|
||||
|
||||
def terminate_session(session_id)
|
||||
post("/sessions/#{session_id}/terminate")
|
||||
end
|
||||
|
||||
def download_recording(session_id)
|
||||
response = execute_request(:get, "/sessions/#{session_id}/recording")
|
||||
unless response.success?
|
||||
Rails.logger.error "[MEDIA SERVER] Recording download failed: status=#{response.code}"
|
||||
raise SessionError, "Recording download failed (#{response.code})"
|
||||
end
|
||||
response.body
|
||||
end
|
||||
|
||||
def add_peer(session_id, role:, label:)
|
||||
post("/sessions/#{session_id}/peers", { role: role, label: label })
|
||||
end
|
||||
|
||||
def remove_peer(session_id, peer_id:)
|
||||
delete("/sessions/#{session_id}/peers/#{peer_id}")
|
||||
end
|
||||
|
||||
def inject_audio(session_id, file_path:, mode: 'replace', loop: false, target: 'peer_a')
|
||||
post("/sessions/#{session_id}/inject-audio", { file_path: file_path, mode: mode, loop: loop, target: target })
|
||||
end
|
||||
|
||||
def stop_audio_injection(session_id, injection_id:)
|
||||
delete("/sessions/#{session_id}/inject-audio/#{injection_id}")
|
||||
end
|
||||
|
||||
def health_check
|
||||
get('/health')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def post(path, body = {})
|
||||
response = execute_request(:post, path, body)
|
||||
parse_response(response)
|
||||
end
|
||||
|
||||
def get(path)
|
||||
response = execute_request(:get, path)
|
||||
parse_response(response)
|
||||
end
|
||||
|
||||
def delete(path)
|
||||
response = execute_request(:delete, path)
|
||||
parse_response(response)
|
||||
end
|
||||
|
||||
def execute_request(method, path, body = nil)
|
||||
url = "#{base_url}#{path}"
|
||||
options = { headers: auth_headers, timeout: TIMEOUT }
|
||||
options[:body] = body.to_json if body.present?
|
||||
|
||||
Rails.logger.info "[MEDIA SERVER] #{method.upcase} #{path}"
|
||||
HTTParty.send(method, url, options)
|
||||
rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
|
||||
Rails.logger.error "[MEDIA SERVER] Connection failed: #{e.class} #{e.message}"
|
||||
raise ConnectionError, "Media server unavailable: #{e.message}"
|
||||
end
|
||||
|
||||
def parse_response(response)
|
||||
unless response.success?
|
||||
Rails.logger.error "[MEDIA SERVER] Request failed: status=#{response.code} body=#{response.body}"
|
||||
raise SessionError, "Media server error (#{response.code}): #{response.body}"
|
||||
end
|
||||
|
||||
response.parsed_response
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('MEDIA_SERVER_URL', 'http://localhost:4000')
|
||||
end
|
||||
|
||||
def auth_token
|
||||
ENV.fetch('MEDIA_SERVER_AUTH_TOKEN', '')
|
||||
end
|
||||
|
||||
def auth_headers
|
||||
{
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => "Bearer #{auth_token}"
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM golang:1.22-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git gcc musl-dev
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy module files and download dependencies (layer caching).
|
||||
COPY go.mod go.sum* ./
|
||||
RUN if [ -f go.sum ]; then go mod download; fi
|
||||
|
||||
# Build the binary (go mod tidy ensures go.sum is present).
|
||||
COPY . .
|
||||
RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o chatwoot-media-server ./cmd/server/
|
||||
|
||||
# ---
|
||||
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates wget
|
||||
|
||||
COPY --from=builder /build/chatwoot-media-server /usr/local/bin/
|
||||
|
||||
RUN mkdir -p /recordings
|
||||
|
||||
EXPOSE 4000
|
||||
EXPOSE 10000-12000/udp
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
|
||||
CMD wget --spider -q http://localhost:4000/health || exit 1
|
||||
|
||||
ENTRYPOINT ["chatwoot-media-server"]
|
||||
@@ -0,0 +1,155 @@
|
||||
# chatwoot-media-server
|
||||
|
||||
A Pion WebRTC media server sidecar for Chatwoot's WhatsApp Calling feature. It acts as a back-to-back user agent (B2BUA) between Meta's media servers and the agent's browser, providing call persistence across page reloads, server-side recording, and centralized call lifecycle management.
|
||||
|
||||
## Architecture
|
||||
|
||||
The media server maintains two independent WebRTC peer connections per call:
|
||||
|
||||
- **Peer A (Meta-side):** Receives the customer's audio from Meta and sends the agent's audio back.
|
||||
- **Peer B (Agent-side):** Receives the agent's microphone audio and sends the customer's audio to the browser.
|
||||
|
||||
An audio bridge forwards RTP packets between the two peers while simultaneously recording both streams to OGG/Opus files.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Local build
|
||||
go build -o chatwoot-media-server ./cmd/server/
|
||||
|
||||
# Docker build
|
||||
docker build -t chatwoot-media-server .
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# Locally
|
||||
AUTH_TOKEN=secret RAILS_CALLBACK_URL=http://localhost:3000 ./chatwoot-media-server
|
||||
|
||||
# Docker
|
||||
docker run -p 4000:4000 -p 10000-10100:10000-10100/udp \
|
||||
-e AUTH_TOKEN=secret \
|
||||
-e RAILS_CALLBACK_URL=http://rails:3000 \
|
||||
-v media-recordings:/recordings \
|
||||
chatwoot-media-server
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `AUTH_TOKEN` | (empty) | Shared secret for Bearer token auth. Empty disables auth (dev only). |
|
||||
| `RAILS_CALLBACK_URL` | `http://localhost:3000` | Base URL for Rails callbacks. |
|
||||
| `STUN_SERVERS` | `stun:stun.l.google.com:19302` | Comma-separated STUN server URLs. |
|
||||
| `TURN_SERVERS` | (empty) | Comma-separated TURN server URLs. |
|
||||
| `TURN_USERNAME` | (empty) | TURN credential username. |
|
||||
| `TURN_PASSWORD` | (empty) | TURN credential password. |
|
||||
| `PUBLIC_IP` | (empty) | Server's public IP for ICE candidates. |
|
||||
| `UDP_PORT_MIN` | `10000` | Lower bound of UDP port range. |
|
||||
| `UDP_PORT_MAX` | `12000` | Upper bound of UDP port range. |
|
||||
| `RECORDINGS_DIR` | `/recordings` | Directory for recording files. |
|
||||
| `HTTP_PORT` | `4000` | HTTP API listen port. |
|
||||
| `LOG_LEVEL` | `info` | Log level (debug, info, warn, error). |
|
||||
| `MAX_SESSION_DURATION` | `7200` | Max call duration in seconds (2 hours). |
|
||||
| `RECONNECT_TIMEOUT` | `30` | Seconds to wait for agent reconnect. |
|
||||
| `MAX_CONCURRENT_SESSIONS` | `0` | Max active sessions (0 = unlimited). |
|
||||
|
||||
## API
|
||||
|
||||
All endpoints except `/health` require a `Authorization: Bearer <token>` header.
|
||||
|
||||
### Sessions
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/sessions` | Create session (Meta SDP offer -> Peer A) |
|
||||
| `GET` | `/sessions/:id` | Get session status |
|
||||
| `POST` | `/sessions/:id/agent-offer` | Generate agent-side SDP offer (Peer B) |
|
||||
| `POST` | `/sessions/:id/agent-answer` | Set agent's SDP answer, complete Peer B |
|
||||
| `POST` | `/sessions/:id/agent-reconnect` | Tear down old Peer B, create new one |
|
||||
| `POST` | `/sessions/:id/terminate` | End call, finalize recording |
|
||||
| `GET` | `/sessions/:id/recording` | Download recording (binary OGG) |
|
||||
| `DELETE` | `/sessions/:id` | Cleanup session and files |
|
||||
|
||||
### Multi-participant
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/sessions/:id/peers` | Add a peer |
|
||||
| `DELETE` | `/sessions/:id/peers/:peer_id` | Remove a peer |
|
||||
| `PATCH` | `/sessions/:id/peers/:peer_id/role` | Change peer role |
|
||||
|
||||
### Audio Injection
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/sessions/:id/inject-audio` | Start audio injection |
|
||||
| `DELETE` | `/sessions/:id/inject-audio/:inj_id` | Stop injection |
|
||||
|
||||
### System
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/health` | Health check (no auth) |
|
||||
| `GET` | `/metrics` | Session metrics |
|
||||
|
||||
### Example: Create Session (Incoming Call)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/sessions \
|
||||
-H "Authorization: Bearer secret" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"call_id": "call_123",
|
||||
"account_id": "1",
|
||||
"direction": "incoming",
|
||||
"meta_sdp_offer": "v=0\r\no=- ...",
|
||||
"ice_servers": [{"urls": ["stun:stun.l.google.com:19302"]}]
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_20240101120000_1",
|
||||
"meta_sdp_answer": "v=0\r\no=- ...",
|
||||
"status": "created"
|
||||
}
|
||||
```
|
||||
|
||||
## Recording
|
||||
|
||||
Recordings are written in real time as OGG/Opus files to the configured recordings directory. Three files are produced per call:
|
||||
|
||||
- `{session_id}.ogg` -- Combined audio for playback
|
||||
- `{session_id}_customer.ogg` -- Customer channel only (for transcription)
|
||||
- `{session_id}_agent.ogg` -- Agent channel only (for transcription)
|
||||
|
||||
On call termination, a callback is sent to Rails which fetches the recording via `GET /sessions/:id/recording` and stores it in ActiveStorage.
|
||||
|
||||
## Deployment
|
||||
|
||||
Add to `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
media-server:
|
||||
build:
|
||||
context: ./enterprise/media-server
|
||||
ports:
|
||||
- "4000:4000"
|
||||
- "10000-10100:10000-10100/udp"
|
||||
environment:
|
||||
- AUTH_TOKEN=${MEDIA_SERVER_AUTH_TOKEN}
|
||||
- RAILS_CALLBACK_URL=http://web:3000
|
||||
- STUN_SERVERS=stun:stun.l.google.com:19302
|
||||
- RECORDINGS_DIR=/recordings
|
||||
- UDP_PORT_MIN=10000
|
||||
- UDP_PORT_MAX=10100
|
||||
volumes:
|
||||
- media-recordings:/recordings
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
UDP ports must be exposed to the internet for WebRTC connectivity. If direct UDP exposure is not possible, configure a TURN server as a relay.
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package main is the entry point for the chatwoot-media-server binary. It
|
||||
// loads configuration, initializes the session manager, sets up HTTP routes,
|
||||
// and starts the server with graceful shutdown support.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/callback"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/config"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/server"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/session"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Load configuration from environment variables.
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
// Configure structured logging.
|
||||
setupLogging(cfg.LogLevel)
|
||||
|
||||
// Log configuration warnings.
|
||||
for _, w := range cfg.Validate() {
|
||||
slog.Warn("config warning", "message", w)
|
||||
}
|
||||
|
||||
slog.Info("starting chatwoot-media-server",
|
||||
"http_port", cfg.HTTPPort,
|
||||
"udp_port_range", fmt.Sprintf("%d-%d", cfg.UDPPortMin, cfg.UDPPortMax),
|
||||
"recordings_dir", cfg.RecordingsDir,
|
||||
)
|
||||
|
||||
// Ensure recordings directory exists.
|
||||
if err := os.MkdirAll(cfg.RecordingsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create recordings directory: %w", err)
|
||||
}
|
||||
|
||||
// Initialize the Rails callback client.
|
||||
railsClient := callback.NewRailsClient(cfg.RailsCallbackURL, cfg.AuthToken)
|
||||
|
||||
// Initialize the session manager.
|
||||
mgr := session.NewManager(cfg, railsClient)
|
||||
|
||||
// Recover any orphaned recordings from a previous crash.
|
||||
mgr.RecoverOrphanedRecordings()
|
||||
|
||||
// Build the HTTP router.
|
||||
router := server.NewRouter(cfg, mgr)
|
||||
handler := router.Build()
|
||||
|
||||
// Create the HTTP server.
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.HTTPPort),
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Start the server in a goroutine.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
slog.Info("HTTP server listening", "addr", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- fmt.Errorf("HTTP server error: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal or server error.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
slog.Info("received shutdown signal", "signal", sig.String())
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
|
||||
// Graceful shutdown: stop accepting new connections, drain existing ones.
|
||||
slog.Info("initiating graceful shutdown")
|
||||
|
||||
// First, terminate all active sessions so recordings are finalized.
|
||||
mgr.Shutdown()
|
||||
|
||||
// Then shut down the HTTP server with a timeout.
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("HTTP server shutdown error: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("server stopped gracefully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupLogging configures the global slog logger with the given level.
|
||||
func setupLogging(level string) {
|
||||
var logLevel slog.Level
|
||||
switch level {
|
||||
case "debug":
|
||||
logLevel = slog.LevelDebug
|
||||
case "warn":
|
||||
logLevel = slog.LevelWarn
|
||||
case "error":
|
||||
logLevel = slog.LevelError
|
||||
default:
|
||||
logLevel = slog.LevelInfo
|
||||
}
|
||||
|
||||
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: logLevel,
|
||||
})
|
||||
slog.SetDefault(slog.New(handler))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
module github.com/chatwoot/chatwoot-media-server
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/pion/interceptor v0.1.37
|
||||
github.com/pion/rtp v1.8.9
|
||||
github.com/pion/webrtc/v4 v4.0.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.4 // indirect
|
||||
github.com/pion/ice/v4 v4.0.3 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.14 // indirect
|
||||
github.com/pion/sctp v1.8.34 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.9 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.4 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/crypto v0.29.0 // indirect
|
||||
golang.org/x/net v0.31.0 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package auth provides HTTP authentication middleware for the media server API.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Middleware returns an HTTP middleware that validates Bearer token authentication.
|
||||
// Requests without a valid token receive a 401 Unauthorized response. If the
|
||||
// configured token is empty, all requests are allowed (development mode).
|
||||
func Middleware(token string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth if no token is configured (development mode).
|
||||
if token == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
slog.Warn("missing Authorization header",
|
||||
"path", r.URL.Path,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
writeAuthError(w, "missing Authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract Bearer token.
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
slog.Warn("invalid Authorization header format",
|
||||
"path", r.URL.Path,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
writeAuthError(w, "invalid Authorization header format")
|
||||
return
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks.
|
||||
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(token)) != 1 {
|
||||
slog.Warn("invalid auth token",
|
||||
"path", r.URL.Path,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
writeAuthError(w, "invalid auth token")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeAuthError writes a JSON-formatted 401 error response.
|
||||
func writeAuthError(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprintf(w, `{"error":%q}`, msg)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Package callback provides an HTTP client for sending event notifications
|
||||
// back to the Chatwoot Rails application.
|
||||
package callback
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RailsClient sends HTTP callbacks to the Rails application to notify it of
|
||||
// media server events such as agent disconnection, recording availability,
|
||||
// session termination, and errors.
|
||||
type RailsClient struct {
|
||||
baseURL string
|
||||
authToken string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewRailsClient creates a new callback client configured with the Rails base
|
||||
// URL and shared authentication token. The HTTP client uses a 10-second
|
||||
// timeout to avoid blocking the media server on slow Rails responses.
|
||||
func NewRailsClient(baseURL, authToken string) *RailsClient {
|
||||
return &RailsClient{
|
||||
baseURL: baseURL,
|
||||
authToken: authToken,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AgentDisconnectedPayload is the request body sent when an agent's peer
|
||||
// connection drops unexpectedly.
|
||||
type AgentDisconnectedPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
CallID string `json:"call_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// RecordingReadyPayload is the request body sent when a call recording has
|
||||
// been finalized and is available for download.
|
||||
type RecordingReadyPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
CallID string `json:"call_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
DurationSec int `json:"duration_seconds"`
|
||||
FileSizeBytes int64 `json:"file_size_bytes"`
|
||||
}
|
||||
|
||||
// SessionTerminatedPayload is the request body sent when a call session has
|
||||
// been fully terminated and cleaned up.
|
||||
type SessionTerminatedPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
CallID string `json:"call_id"`
|
||||
Reason string `json:"reason"`
|
||||
DurationSec int `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
// ErrorPayload is the request body sent when the media server encounters
|
||||
// an error that the Rails application should be aware of.
|
||||
type ErrorPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
CallID string `json:"call_id"`
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// NotifyAgentDisconnected informs Rails that an agent's WebRTC peer connection
|
||||
// has dropped. Rails can then start the reconnection timer and update the call
|
||||
// status accordingly.
|
||||
func (c *RailsClient) NotifyAgentDisconnected(ctx context.Context, payload AgentDisconnectedPayload) error {
|
||||
return c.post(ctx, "/callbacks/media_server/agent_disconnected", payload)
|
||||
}
|
||||
|
||||
// NotifyRecordingReady informs Rails that a call recording has been finalized
|
||||
// and is available for download via the GET /sessions/:id/recording endpoint.
|
||||
// Rails should enqueue a job to fetch and attach the recording to ActiveStorage.
|
||||
func (c *RailsClient) NotifyRecordingReady(ctx context.Context, payload RecordingReadyPayload) error {
|
||||
return c.post(ctx, "/callbacks/media_server/recording_ready", payload)
|
||||
}
|
||||
|
||||
// NotifySessionTerminated informs Rails that a call session has ended. This
|
||||
// is sent after both peer connections are closed and the recording is finalized.
|
||||
func (c *RailsClient) NotifySessionTerminated(ctx context.Context, payload SessionTerminatedPayload) error {
|
||||
return c.post(ctx, "/callbacks/media_server/session_terminated", payload)
|
||||
}
|
||||
|
||||
// NotifyError informs Rails of a media server error that may require attention,
|
||||
// such as a failed ICE negotiation or recording write failure.
|
||||
func (c *RailsClient) NotifyError(ctx context.Context, payload ErrorPayload) error {
|
||||
return c.post(ctx, "/callbacks/media_server/error", payload)
|
||||
}
|
||||
|
||||
func (c *RailsClient) post(ctx context.Context, path string, payload any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal callback payload: %w", err)
|
||||
}
|
||||
|
||||
url := c.baseURL + path
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create callback request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.authToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("callback request failed",
|
||||
"url", url,
|
||||
"error", err,
|
||||
)
|
||||
return fmt.Errorf("callback request to %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
slog.Error("callback returned error status",
|
||||
"url", url,
|
||||
"status", resp.StatusCode,
|
||||
)
|
||||
return fmt.Errorf("callback to %s returned status %d", path, resp.StatusCode)
|
||||
}
|
||||
|
||||
slog.Debug("callback sent successfully",
|
||||
"url", url,
|
||||
"status", resp.StatusCode,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Package config provides environment-based configuration for the media server.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all configuration values for the media server, loaded from
|
||||
// environment variables at startup. Sensible defaults are provided for
|
||||
// development; production deployments should set AUTH_TOKEN and RAILS_CALLBACK_URL
|
||||
// at a minimum.
|
||||
type Config struct {
|
||||
// AuthToken is the shared secret used for Bearer token authentication
|
||||
// between Rails and the media server. Required in production.
|
||||
AuthToken string
|
||||
|
||||
// RailsCallbackURL is the base URL for HTTP callbacks to the Rails app
|
||||
// (e.g., "http://rails:3000").
|
||||
RailsCallbackURL string
|
||||
|
||||
// STUNServers is a list of STUN server URLs for ICE candidate gathering.
|
||||
STUNServers []string
|
||||
|
||||
// TURNServers is a list of TURN server URLs for relay candidates.
|
||||
TURNServers []string
|
||||
|
||||
// TURNUsername is the credential username for TURN servers.
|
||||
TURNUsername string
|
||||
|
||||
// TURNPassword is the credential password for TURN servers.
|
||||
TURNPassword string
|
||||
|
||||
// PublicIP is the server's public IP address used for ICE candidate
|
||||
// generation via NAT1To1IPs. Leave empty to rely on STUN discovery.
|
||||
PublicIP string
|
||||
|
||||
// UDPPortMin is the lower bound of the ephemeral UDP port range used
|
||||
// for WebRTC media transport.
|
||||
UDPPortMin uint16
|
||||
|
||||
// UDPPortMax is the upper bound of the ephemeral UDP port range.
|
||||
UDPPortMax uint16
|
||||
|
||||
// RecordingsDir is the filesystem path where call recordings are stored.
|
||||
RecordingsDir string
|
||||
|
||||
// HTTPPort is the port on which the HTTP API listens.
|
||||
HTTPPort int
|
||||
|
||||
// LogLevel controls the verbosity of structured logging.
|
||||
// Valid values: "debug", "info", "warn", "error".
|
||||
LogLevel string
|
||||
|
||||
// MaxSessionDuration is the maximum allowed duration for a single call
|
||||
// session before automatic termination.
|
||||
MaxSessionDuration time.Duration
|
||||
|
||||
// ReconnectTimeout is the duration the server waits for an agent to
|
||||
// reconnect after their peer connection drops before terminating the call.
|
||||
ReconnectTimeout time.Duration
|
||||
|
||||
// MaxConcurrentSessions limits the total number of active sessions across
|
||||
// all accounts. Zero means unlimited.
|
||||
MaxConcurrentSessions int
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and returns a validated
|
||||
// Config. It returns an error if any required value is missing or invalid.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
AuthToken: getEnv("AUTH_TOKEN", ""),
|
||||
RailsCallbackURL: getEnv("RAILS_CALLBACK_URL", "http://localhost:3000"),
|
||||
TURNUsername: getEnv("TURN_USERNAME", ""),
|
||||
TURNPassword: getEnv("TURN_PASSWORD", ""),
|
||||
PublicIP: getEnv("PUBLIC_IP", ""),
|
||||
RecordingsDir: getEnv("RECORDINGS_DIR", "/recordings"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
}
|
||||
|
||||
// Parse STUN servers (comma-separated).
|
||||
stunStr := getEnv("STUN_SERVERS", "stun:stun.l.google.com:19302")
|
||||
if stunStr != "" {
|
||||
cfg.STUNServers = splitAndTrim(stunStr)
|
||||
}
|
||||
|
||||
// Parse TURN servers (comma-separated).
|
||||
turnStr := getEnv("TURN_SERVERS", "")
|
||||
if turnStr != "" {
|
||||
cfg.TURNServers = splitAndTrim(turnStr)
|
||||
}
|
||||
|
||||
// Parse UDP port range.
|
||||
portMin, err := getEnvUint16("UDP_PORT_MIN", 10000)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid UDP_PORT_MIN: %w", err)
|
||||
}
|
||||
cfg.UDPPortMin = portMin
|
||||
|
||||
portMax, err := getEnvUint16("UDP_PORT_MAX", 12000)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid UDP_PORT_MAX: %w", err)
|
||||
}
|
||||
cfg.UDPPortMax = portMax
|
||||
|
||||
if cfg.UDPPortMin >= cfg.UDPPortMax {
|
||||
return nil, fmt.Errorf("UDP_PORT_MIN (%d) must be less than UDP_PORT_MAX (%d)", cfg.UDPPortMin, cfg.UDPPortMax)
|
||||
}
|
||||
|
||||
// Parse HTTP port.
|
||||
httpPort, err := getEnvInt("HTTP_PORT", 4000)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid HTTP_PORT: %w", err)
|
||||
}
|
||||
cfg.HTTPPort = httpPort
|
||||
|
||||
// Parse max session duration.
|
||||
maxDur, err := getEnvInt("MAX_SESSION_DURATION", 7200)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid MAX_SESSION_DURATION: %w", err)
|
||||
}
|
||||
cfg.MaxSessionDuration = time.Duration(maxDur) * time.Second
|
||||
|
||||
// Parse reconnect timeout.
|
||||
reconTimeout, err := getEnvInt("RECONNECT_TIMEOUT", 30)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid RECONNECT_TIMEOUT: %w", err)
|
||||
}
|
||||
cfg.ReconnectTimeout = time.Duration(reconTimeout) * time.Second
|
||||
|
||||
// Parse max concurrent sessions.
|
||||
maxSessions, err := getEnvInt("MAX_CONCURRENT_SESSIONS", 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid MAX_CONCURRENT_SESSIONS: %w", err)
|
||||
}
|
||||
cfg.MaxConcurrentSessions = maxSessions
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate checks that required configuration values are set for production
|
||||
// use. It returns a list of warnings for missing optional values.
|
||||
func (c *Config) Validate() []string {
|
||||
var warnings []string
|
||||
if c.AuthToken == "" {
|
||||
warnings = append(warnings, "AUTH_TOKEN is not set; all API requests will be unauthenticated")
|
||||
}
|
||||
if c.RailsCallbackURL == "" {
|
||||
warnings = append(warnings, "RAILS_CALLBACK_URL is not set; callbacks to Rails will fail")
|
||||
}
|
||||
if len(c.STUNServers) == 0 {
|
||||
warnings = append(warnings, "No STUN servers configured; ICE candidate gathering may fail")
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultVal int) (int, error) {
|
||||
str := os.Getenv(key)
|
||||
if str == "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
return strconv.Atoi(str)
|
||||
}
|
||||
|
||||
func getEnvUint16(key string, defaultVal uint16) (uint16, error) {
|
||||
str := os.Getenv(key)
|
||||
if str == "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
v, err := strconv.ParseUint(str, 10, 16)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint16(v), nil
|
||||
}
|
||||
|
||||
func splitAndTrim(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/peer"
|
||||
)
|
||||
|
||||
// AudioConsumer is an interface for components that want to receive audio
|
||||
// frames from the bridge, such as real-time transcription or AI services.
|
||||
type AudioConsumer interface {
|
||||
// OnAudioFrame is called for each RTP packet passing through the bridge.
|
||||
// source is either "customer" or "agent".
|
||||
OnAudioFrame(sessionID, source string, packet *rtp.Packet)
|
||||
}
|
||||
|
||||
// Bridge connects two WebRTC peers (Meta-side and Agent-side) by forwarding
|
||||
// RTP audio packets between them. It also taps into both audio streams for
|
||||
// recording and external consumers.
|
||||
type Bridge struct {
|
||||
sessionID string
|
||||
metaPeer *peer.MetaPeer
|
||||
agentPeers map[string]*peer.AgentPeer
|
||||
recorder *Recorder
|
||||
consumers []AudioConsumer
|
||||
|
||||
cancel context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
active bool
|
||||
}
|
||||
|
||||
// NewBridge creates a new audio bridge for the given session. The bridge does
|
||||
// not start forwarding automatically; call Start after both peers are connected.
|
||||
func NewBridge(sessionID string, metaPeer *peer.MetaPeer, recorder *Recorder) *Bridge {
|
||||
return &Bridge{
|
||||
sessionID: sessionID,
|
||||
metaPeer: metaPeer,
|
||||
agentPeers: make(map[string]*peer.AgentPeer),
|
||||
recorder: recorder,
|
||||
}
|
||||
}
|
||||
|
||||
// AddAgentPeer registers an agent peer with the bridge. If the bridge is
|
||||
// already running, forwarding to the new peer begins immediately.
|
||||
func (b *Bridge) AddAgentPeer(ap *peer.AgentPeer) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.agentPeers[ap.ID] = ap
|
||||
slog.Info("bridge: agent peer added",
|
||||
"session_id", b.sessionID,
|
||||
"peer_id", ap.ID,
|
||||
"role", string(ap.Role),
|
||||
)
|
||||
}
|
||||
|
||||
// RemoveAgentPeer removes an agent peer from the bridge. Its forwarding
|
||||
// goroutines will terminate when the peer's tracks are closed.
|
||||
func (b *Bridge) RemoveAgentPeer(peerID string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
delete(b.agentPeers, peerID)
|
||||
slog.Info("bridge: agent peer removed",
|
||||
"session_id", b.sessionID,
|
||||
"peer_id", peerID,
|
||||
)
|
||||
}
|
||||
|
||||
// AddConsumer registers an AudioConsumer that receives copies of all audio
|
||||
// packets passing through the bridge.
|
||||
func (b *Bridge) AddConsumer(c AudioConsumer) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.consumers = append(b.consumers, c)
|
||||
}
|
||||
|
||||
// Start begins forwarding audio between the Meta peer and all agent peers.
|
||||
// It spawns goroutines for each direction of audio flow. The bridge runs
|
||||
// until Stop is called or the provided context is cancelled.
|
||||
func (b *Bridge) Start(ctx context.Context) {
|
||||
b.mu.Lock()
|
||||
if b.active {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.active = true
|
||||
ctx, b.cancel = context.WithCancel(ctx)
|
||||
b.mu.Unlock()
|
||||
|
||||
slog.Info("bridge: started", "session_id", b.sessionID)
|
||||
|
||||
// Forward Meta audio (customer) to all agent peers.
|
||||
go b.forwardMetaToAgents(ctx)
|
||||
|
||||
// For each agent peer, forward their audio to Meta.
|
||||
b.mu.RLock()
|
||||
for _, ap := range b.agentPeers {
|
||||
go b.forwardAgentToMeta(ctx, ap)
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
}
|
||||
|
||||
// StartAgentForwarding begins forwarding a specific agent peer's audio to
|
||||
// Meta. This is used when a new agent peer is added after the bridge has
|
||||
// already started.
|
||||
func (b *Bridge) StartAgentForwarding(ctx context.Context, ap *peer.AgentPeer) {
|
||||
b.mu.RLock()
|
||||
active := b.active
|
||||
b.mu.RUnlock()
|
||||
|
||||
if !active {
|
||||
return
|
||||
}
|
||||
|
||||
go b.forwardAgentToMeta(ctx, ap)
|
||||
}
|
||||
|
||||
// Stop halts all audio forwarding and finalizes the recording. This method
|
||||
// is idempotent.
|
||||
func (b *Bridge) Stop() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if !b.active {
|
||||
return
|
||||
}
|
||||
b.active = false
|
||||
|
||||
if b.cancel != nil {
|
||||
b.cancel()
|
||||
}
|
||||
|
||||
slog.Info("bridge: stopped", "session_id", b.sessionID)
|
||||
}
|
||||
|
||||
// IsActive returns whether the bridge is currently forwarding audio.
|
||||
func (b *Bridge) IsActive() bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.active
|
||||
}
|
||||
|
||||
// forwardMetaToAgents reads RTP packets from the Meta peer's remote audio
|
||||
// track (customer audio) and writes them to every connected agent peer's
|
||||
// local track. Each packet is also sent to the recorder and any consumers.
|
||||
func (b *Bridge) forwardMetaToAgents(ctx context.Context) {
|
||||
metaTrack := b.metaPeer.AudioTrack()
|
||||
if metaTrack == nil {
|
||||
slog.Warn("bridge: Meta audio track not yet available, waiting via OnTrack",
|
||||
"session_id", b.sessionID,
|
||||
)
|
||||
// The track will be set via OnTrack callback. We wait for the track
|
||||
// by polling with a channel. In production, the OnTrack callback
|
||||
// mechanism in the session handles this coordination.
|
||||
return
|
||||
}
|
||||
|
||||
b.readAndForwardMetaTrack(ctx, metaTrack)
|
||||
}
|
||||
|
||||
// ReadAndForwardMetaTrack is the core loop that reads from a Meta remote
|
||||
// track and fans out to agent peers. It is exported so the session layer can
|
||||
// call it directly from the OnTrack callback.
|
||||
func (b *Bridge) ReadAndForwardMetaTrack(ctx context.Context, track *webrtc.TrackRemote) {
|
||||
b.readAndForwardMetaTrack(ctx, track)
|
||||
}
|
||||
|
||||
func (b *Bridge) readAndForwardMetaTrack(ctx context.Context, track *webrtc.TrackRemote) {
|
||||
slog.Info("bridge: forwarding Meta audio to agents",
|
||||
"session_id", b.sessionID,
|
||||
"codec", track.Codec().MimeType,
|
||||
)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
pkt, _, readErr := track.ReadRTP()
|
||||
if readErr != nil {
|
||||
slog.Debug("bridge: Meta track read ended",
|
||||
"session_id", b.sessionID,
|
||||
"error", readErr,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Record customer audio.
|
||||
if b.recorder != nil {
|
||||
if err := b.recorder.WriteCustomerRTP(pkt); err != nil {
|
||||
slog.Warn("bridge: failed to record customer audio",
|
||||
"session_id", b.sessionID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify consumers.
|
||||
b.mu.RLock()
|
||||
for _, c := range b.consumers {
|
||||
c.OnAudioFrame(b.sessionID, "customer", pkt)
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
|
||||
// Fan-out to all agent peers.
|
||||
b.mu.RLock()
|
||||
for _, ap := range b.agentPeers {
|
||||
if ap.Role == peer.RoleInjectOnly {
|
||||
continue // inject-only peers do not receive audio
|
||||
}
|
||||
raw, marshalErr := pkt.Marshal()
|
||||
if marshalErr != nil {
|
||||
slog.Warn("bridge: failed to marshal RTP packet",
|
||||
"session_id", b.sessionID,
|
||||
"error", marshalErr,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if _, writeErr := ap.LocalTrack().Write(raw); writeErr != nil {
|
||||
slog.Debug("bridge: failed to write to agent peer",
|
||||
"session_id", b.sessionID,
|
||||
"peer_id", ap.ID,
|
||||
"error", writeErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
// forwardAgentToMeta reads RTP packets from an agent peer's remote audio
|
||||
// track (agent microphone) and writes them to the Meta peer's local track.
|
||||
func (b *Bridge) forwardAgentToMeta(ctx context.Context, ap *peer.AgentPeer) {
|
||||
agentTrack := ap.AudioTrack()
|
||||
if agentTrack == nil {
|
||||
slog.Debug("bridge: agent audio track not yet available, waiting via OnTrack",
|
||||
"session_id", b.sessionID,
|
||||
"peer_id", ap.ID,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
b.readAndForwardAgentTrack(ctx, ap, agentTrack)
|
||||
}
|
||||
|
||||
// ReadAndForwardAgentTrack is the core loop that reads from an agent's remote
|
||||
// track and forwards to Meta. Exported so the session layer can call it from
|
||||
// the OnTrack callback.
|
||||
func (b *Bridge) ReadAndForwardAgentTrack(ctx context.Context, ap *peer.AgentPeer, track *webrtc.TrackRemote) {
|
||||
b.readAndForwardAgentTrack(ctx, ap, track)
|
||||
}
|
||||
|
||||
func (b *Bridge) readAndForwardAgentTrack(ctx context.Context, ap *peer.AgentPeer, track *webrtc.TrackRemote) {
|
||||
slog.Info("bridge: forwarding agent audio to Meta",
|
||||
"session_id", b.sessionID,
|
||||
"peer_id", ap.ID,
|
||||
"codec", track.Codec().MimeType,
|
||||
)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
pkt, _, readErr := track.ReadRTP()
|
||||
if readErr != nil {
|
||||
slog.Debug("bridge: agent track read ended",
|
||||
"session_id", b.sessionID,
|
||||
"peer_id", ap.ID,
|
||||
"error", readErr,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Only active peers send audio to Meta.
|
||||
if ap.Role != peer.RoleActive {
|
||||
continue
|
||||
}
|
||||
|
||||
// Record agent audio.
|
||||
if b.recorder != nil {
|
||||
if err := b.recorder.WriteAgentRTP(pkt); err != nil {
|
||||
slog.Warn("bridge: failed to record agent audio",
|
||||
"session_id", b.sessionID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify consumers.
|
||||
b.mu.RLock()
|
||||
for _, c := range b.consumers {
|
||||
c.OnAudioFrame(b.sessionID, "agent", pkt)
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
|
||||
// Forward to Meta.
|
||||
raw, marshalErr := pkt.Marshal()
|
||||
if marshalErr != nil {
|
||||
slog.Warn("bridge: failed to marshal agent RTP packet",
|
||||
"session_id", b.sessionID,
|
||||
"error", marshalErr,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if _, writeErr := b.metaPeer.LocalTrack().Write(raw); writeErr != nil {
|
||||
slog.Debug("bridge: failed to write to Meta peer",
|
||||
"session_id", b.sessionID,
|
||||
"error", writeErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/webrtc/v4/pkg/media/oggreader"
|
||||
)
|
||||
|
||||
// Injector reads Opus frames from an OGG file and injects them into an RTP
|
||||
// stream at the correct 20ms pacing interval. This is used for hold music,
|
||||
// announcements, and other pre-recorded audio injection.
|
||||
type Injector struct {
|
||||
ID string
|
||||
Source string // file path of the OGG/Opus file
|
||||
Mode string // "replace" or "mix"
|
||||
Loop bool
|
||||
Target string // "meta", "agents", or "all"
|
||||
|
||||
stopCh chan struct{}
|
||||
mu sync.Mutex
|
||||
active bool
|
||||
}
|
||||
|
||||
// InjectorTarget is a writable RTP destination.
|
||||
type InjectorTarget interface {
|
||||
Write(b []byte) (n int, err error)
|
||||
}
|
||||
|
||||
// NewInjector creates a new audio injector configured with the given source
|
||||
// file and injection parameters.
|
||||
func NewInjector(id, source, mode, target string, loop bool) *Injector {
|
||||
return &Injector{
|
||||
ID: id,
|
||||
Source: source,
|
||||
Mode: mode,
|
||||
Loop: loop,
|
||||
Target: target,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins reading the OGG file and injecting Opus frames into the target
|
||||
// track at 20ms intervals. It runs in its own goroutine and returns immediately.
|
||||
// The injection stops when Stop is called, the file ends (if Loop is false),
|
||||
// or an error occurs.
|
||||
func (inj *Injector) Start(target InjectorTarget) error {
|
||||
inj.mu.Lock()
|
||||
if inj.active {
|
||||
inj.mu.Unlock()
|
||||
return fmt.Errorf("injector %s is already active", inj.ID)
|
||||
}
|
||||
inj.active = true
|
||||
inj.stopCh = make(chan struct{}) // fresh channel for each start cycle
|
||||
inj.mu.Unlock()
|
||||
|
||||
go inj.run(target)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop halts the audio injection. This method is safe to call multiple times.
|
||||
func (inj *Injector) Stop() {
|
||||
inj.mu.Lock()
|
||||
defer inj.mu.Unlock()
|
||||
|
||||
if !inj.active {
|
||||
return
|
||||
}
|
||||
inj.active = false
|
||||
close(inj.stopCh)
|
||||
|
||||
slog.Info("injector: stopped", "id", inj.ID)
|
||||
}
|
||||
|
||||
// IsActive returns whether the injector is currently running.
|
||||
func (inj *Injector) IsActive() bool {
|
||||
inj.mu.Lock()
|
||||
defer inj.mu.Unlock()
|
||||
return inj.active
|
||||
}
|
||||
|
||||
func (inj *Injector) run(target InjectorTarget) {
|
||||
defer func() {
|
||||
inj.mu.Lock()
|
||||
inj.active = false
|
||||
inj.mu.Unlock()
|
||||
}()
|
||||
|
||||
slog.Info("injector: started",
|
||||
"id", inj.ID,
|
||||
"source", inj.Source,
|
||||
"mode", inj.Mode,
|
||||
"loop", inj.Loop,
|
||||
)
|
||||
|
||||
for {
|
||||
if err := inj.playFile(target); err != nil {
|
||||
slog.Error("injector: playback error",
|
||||
"id", inj.ID,
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if !inj.Loop {
|
||||
slog.Info("injector: playback complete (no loop)", "id", inj.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// Check stop signal between loops.
|
||||
select {
|
||||
case <-inj.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (inj *Injector) playFile(target InjectorTarget) error {
|
||||
f, err := os.Open(inj.Source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
ogg, _, err := oggreader.NewWith(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create OGG reader: %w", err)
|
||||
}
|
||||
|
||||
// Opus frames are 20ms at 48kHz = 960 samples per frame.
|
||||
const opusFrameDuration = 20 * time.Millisecond
|
||||
ticker := time.NewTicker(opusFrameDuration)
|
||||
defer ticker.Stop()
|
||||
|
||||
var sequenceNumber uint16
|
||||
var timestamp uint32
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-inj.stopCh:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
pageData, _, err := ogg.ParseNextPage()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("parse OGG page: %w", err)
|
||||
}
|
||||
|
||||
// Build an RTP packet with the Opus payload.
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
PayloadType: 111, // Opus dynamic payload type
|
||||
SequenceNumber: sequenceNumber,
|
||||
Timestamp: timestamp,
|
||||
SSRC: 12345678, // fixed SSRC for injected audio
|
||||
},
|
||||
Payload: pageData,
|
||||
}
|
||||
sequenceNumber++
|
||||
timestamp += 960 // 48kHz * 0.02s
|
||||
|
||||
raw, marshalErr := pkt.Marshal()
|
||||
if marshalErr != nil {
|
||||
slog.Warn("injector: failed to marshal RTP packet",
|
||||
"id", inj.ID,
|
||||
"error", marshalErr,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, writeErr := target.Write(raw); writeErr != nil {
|
||||
slog.Debug("injector: write failed",
|
||||
"id", inj.ID,
|
||||
"error", writeErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// Package media provides audio bridging, recording, and injection capabilities
|
||||
// for the media server's call sessions.
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/webrtc/v4/pkg/media/oggwriter"
|
||||
)
|
||||
|
||||
// Recorder writes incoming Opus RTP packets to an OGG container file in real
|
||||
// time. Two separate OGG files are maintained -- one for each audio channel
|
||||
// (customer and agent) -- to enable stereo separation for transcription.
|
||||
// A combined mono file is also written for playback convenience.
|
||||
type Recorder struct {
|
||||
sessionID string
|
||||
dir string
|
||||
|
||||
// combinedWriter writes all audio to a single OGG file (for playback).
|
||||
combinedWriter *oggwriter.OggWriter
|
||||
combinedFile string
|
||||
|
||||
// customerWriter writes only customer audio (for transcription L channel).
|
||||
customerWriter *oggwriter.OggWriter
|
||||
customerFile string
|
||||
|
||||
// agentWriter writes only agent audio (for transcription R channel).
|
||||
agentWriter *oggwriter.OggWriter
|
||||
agentFile string
|
||||
|
||||
startedAt time.Time
|
||||
finalized bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRecorder creates a new recorder that writes OGG/Opus files to the given
|
||||
// directory. Three files are created:
|
||||
// - {sessionID}.ogg (combined audio for playback)
|
||||
// - {sessionID}_customer.ogg (customer channel only)
|
||||
// - {sessionID}_agent.ogg (agent channel only)
|
||||
func NewRecorder(sessionID, dir string) (*Recorder, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create recordings directory: %w", err)
|
||||
}
|
||||
|
||||
combinedFile := filepath.Join(dir, sessionID+".ogg")
|
||||
customerFile := filepath.Join(dir, sessionID+"_customer.ogg")
|
||||
agentFile := filepath.Join(dir, sessionID+"_agent.ogg")
|
||||
|
||||
// Opus at 48kHz, mono for each individual channel.
|
||||
combinedWriter, err := oggwriter.New(combinedFile, 48000, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create combined OGG writer: %w", err)
|
||||
}
|
||||
|
||||
customerWriter, err := oggwriter.New(customerFile, 48000, 1)
|
||||
if err != nil {
|
||||
combinedWriter.Close()
|
||||
return nil, fmt.Errorf("create customer OGG writer: %w", err)
|
||||
}
|
||||
|
||||
agentWriter, err := oggwriter.New(agentFile, 48000, 1)
|
||||
if err != nil {
|
||||
combinedWriter.Close()
|
||||
customerWriter.Close()
|
||||
return nil, fmt.Errorf("create agent OGG writer: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("recorder: started",
|
||||
"session_id", sessionID,
|
||||
"combined_file", combinedFile,
|
||||
)
|
||||
|
||||
return &Recorder{
|
||||
sessionID: sessionID,
|
||||
dir: dir,
|
||||
combinedWriter: combinedWriter,
|
||||
combinedFile: combinedFile,
|
||||
customerWriter: customerWriter,
|
||||
customerFile: customerFile,
|
||||
agentWriter: agentWriter,
|
||||
agentFile: agentFile,
|
||||
startedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WriteCustomerRTP writes an RTP packet from the customer's audio stream
|
||||
// (Meta-side, Peer A) to the recording. The packet is written to both the
|
||||
// combined file and the customer-only channel file.
|
||||
func (r *Recorder) WriteCustomerRTP(pkt *rtp.Packet) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.finalized {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := r.combinedWriter.WriteRTP(pkt); err != nil {
|
||||
return fmt.Errorf("write customer RTP to combined: %w", err)
|
||||
}
|
||||
if err := r.customerWriter.WriteRTP(pkt); err != nil {
|
||||
return fmt.Errorf("write customer RTP to channel: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteAgentRTP writes an RTP packet from the agent's audio stream
|
||||
// (browser-side, Peer B) to the recording.
|
||||
func (r *Recorder) WriteAgentRTP(pkt *rtp.Packet) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.finalized {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := r.combinedWriter.WriteRTP(pkt); err != nil {
|
||||
return fmt.Errorf("write agent RTP to combined: %w", err)
|
||||
}
|
||||
if err := r.agentWriter.WriteRTP(pkt); err != nil {
|
||||
return fmt.Errorf("write agent RTP to channel: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finalize closes all OGG writers and flushes data to disk. After finalization,
|
||||
// further writes are silently ignored. This method is idempotent.
|
||||
func (r *Recorder) Finalize() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.finalized {
|
||||
return nil
|
||||
}
|
||||
r.finalized = true
|
||||
|
||||
var errs []error
|
||||
if err := r.combinedWriter.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("close combined writer: %w", err))
|
||||
}
|
||||
if err := r.customerWriter.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("close customer writer: %w", err))
|
||||
}
|
||||
if err := r.agentWriter.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("close agent writer: %w", err))
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("finalize recorder: %v", errs)
|
||||
}
|
||||
|
||||
slog.Info("recorder: finalized",
|
||||
"session_id", r.sessionID,
|
||||
"duration", time.Since(r.startedAt).Round(time.Second),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CombinedFilePath returns the filesystem path of the combined recording file.
|
||||
func (r *Recorder) CombinedFilePath() string {
|
||||
return r.combinedFile
|
||||
}
|
||||
|
||||
// CustomerFilePath returns the filesystem path of the customer-only recording.
|
||||
func (r *Recorder) CustomerFilePath() string {
|
||||
return r.customerFile
|
||||
}
|
||||
|
||||
// AgentFilePath returns the filesystem path of the agent-only recording.
|
||||
func (r *Recorder) AgentFilePath() string {
|
||||
return r.agentFile
|
||||
}
|
||||
|
||||
// Duration returns the elapsed recording time since the recorder was started.
|
||||
func (r *Recorder) Duration() time.Duration {
|
||||
return time.Since(r.startedAt)
|
||||
}
|
||||
|
||||
// FileSize returns the size of the combined recording file in bytes, or -1 on
|
||||
// error.
|
||||
func (r *Recorder) FileSize() int64 {
|
||||
info, err := os.Stat(r.combinedFile)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
// Cleanup removes all recording files for this session from disk.
|
||||
func (r *Recorder) Cleanup() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for _, f := range []string{r.combinedFile, r.customerFile, r.agentFile} {
|
||||
if err := os.Remove(f); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("recorder: failed to remove file",
|
||||
"file", f,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/config"
|
||||
)
|
||||
|
||||
// PeerRole defines the role of an agent peer in a call session.
|
||||
type PeerRole string
|
||||
|
||||
const (
|
||||
// RoleActive indicates the peer sends and receives audio (the primary agent).
|
||||
RoleActive PeerRole = "active"
|
||||
|
||||
// RoleListenOnly indicates the peer receives audio but does not send
|
||||
// (supervisory monitoring).
|
||||
RoleListenOnly PeerRole = "listen_only"
|
||||
|
||||
// RoleInjectOnly indicates the peer sends audio but does not receive
|
||||
// (audio injection source).
|
||||
RoleInjectOnly PeerRole = "inject_only"
|
||||
)
|
||||
|
||||
// AgentPeer represents the WebRTC peer connection to an agent's browser
|
||||
// (Peer B). The media server creates an SDP offer for the agent; the browser
|
||||
// responds with an SDP answer to complete the handshake.
|
||||
type AgentPeer struct {
|
||||
ID string
|
||||
Role PeerRole
|
||||
|
||||
pc *webrtc.PeerConnection
|
||||
audioTrack *webrtc.TrackRemote
|
||||
localTrack *webrtc.TrackLocalStaticRTP
|
||||
sender *webrtc.RTPSender
|
||||
|
||||
// onTrackReady is called when the agent's audio track becomes available.
|
||||
onTrackReady func(track *webrtc.TrackRemote)
|
||||
|
||||
// onICEStateChange is called when the ICE connection state changes.
|
||||
onICEStateChange func(state webrtc.ICEConnectionState)
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewAgentPeer creates a new agent-side peer connection and generates an SDP
|
||||
// offer to send to the agent's browser. The browser will respond with an SDP
|
||||
// answer via SetAnswer. The returned string is the SDP offer.
|
||||
func NewAgentPeer(cfg *config.Config, id string, role PeerRole, iceServers []webrtc.ICEServer) (*AgentPeer, string, error) {
|
||||
se := webrtc.SettingEngine{}
|
||||
|
||||
if err := se.SetEphemeralUDPPortRange(cfg.UDPPortMin, cfg.UDPPortMax); err != nil {
|
||||
return nil, "", fmt.Errorf("set UDP port range: %w", err)
|
||||
}
|
||||
|
||||
// NOTE: In pion/webrtc v4.2+, migrate to SetICEAddressRewriteRules.
|
||||
if cfg.PublicIP != "" {
|
||||
se.SetNAT1To1IPs([]string{cfg.PublicIP}, webrtc.ICECandidateTypeSrflx)
|
||||
}
|
||||
|
||||
me := &webrtc.MediaEngine{}
|
||||
if err := me.RegisterDefaultCodecs(); err != nil {
|
||||
return nil, "", fmt.Errorf("register codecs: %w", err)
|
||||
}
|
||||
|
||||
ir := &interceptor.Registry{}
|
||||
if err := webrtc.RegisterDefaultInterceptors(me, ir); err != nil {
|
||||
return nil, "", fmt.Errorf("register interceptors: %w", err)
|
||||
}
|
||||
|
||||
api := webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(me),
|
||||
webrtc.WithSettingEngine(se),
|
||||
webrtc.WithInterceptorRegistry(ir),
|
||||
)
|
||||
|
||||
pc, err := api.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: iceServers,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create peer connection: %w", err)
|
||||
}
|
||||
|
||||
// Create the local audio track that carries customer audio (from Meta)
|
||||
// to the agent's browser.
|
||||
localTrack, err := webrtc.NewTrackLocalStaticRTP(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
|
||||
"audio-to-agent",
|
||||
"chatwoot-media-server",
|
||||
)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("create local track: %w", err)
|
||||
}
|
||||
|
||||
sender, err := pc.AddTrack(localTrack)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("add local track: %w", err)
|
||||
}
|
||||
|
||||
// Consume RTCP packets from the sender to avoid blocking.
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, rtcpErr := sender.Read(buf); rtcpErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ap := &AgentPeer{
|
||||
ID: id,
|
||||
Role: role,
|
||||
pc: pc,
|
||||
localTrack: localTrack,
|
||||
sender: sender,
|
||||
}
|
||||
|
||||
// Register the OnTrack handler to capture the agent's microphone audio.
|
||||
pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||
slog.Info("agent peer: remote track received",
|
||||
"peer_id", id,
|
||||
"codec", track.Codec().MimeType,
|
||||
"ssrc", track.SSRC(),
|
||||
)
|
||||
ap.mu.Lock()
|
||||
ap.audioTrack = track
|
||||
cb := ap.onTrackReady
|
||||
ap.mu.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb(track)
|
||||
}
|
||||
})
|
||||
|
||||
// Register ICE connection state handler.
|
||||
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||
slog.Info("agent peer: ICE state changed",
|
||||
"peer_id", id,
|
||||
"state", state.String(),
|
||||
)
|
||||
ap.mu.Lock()
|
||||
cb := ap.onICEStateChange
|
||||
ap.mu.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb(state)
|
||||
}
|
||||
})
|
||||
|
||||
// Create an SDP offer for the agent's browser.
|
||||
offer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("create offer: %w", err)
|
||||
}
|
||||
|
||||
gatherComplete := webrtc.GatheringCompletePromise(pc)
|
||||
if err := pc.SetLocalDescription(offer); err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("set local description: %w", err)
|
||||
}
|
||||
<-gatherComplete
|
||||
|
||||
sdpOffer := pc.LocalDescription().SDP
|
||||
|
||||
return ap, sdpOffer, nil
|
||||
}
|
||||
|
||||
// SetAnswer sets the agent browser's SDP answer on the peer connection,
|
||||
// completing the WebRTC handshake.
|
||||
func (ap *AgentPeer) SetAnswer(sdpAnswer string) error {
|
||||
ap.mu.Lock()
|
||||
defer ap.mu.Unlock()
|
||||
|
||||
answer := webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: sdpAnswer,
|
||||
}
|
||||
return ap.pc.SetRemoteDescription(answer)
|
||||
}
|
||||
|
||||
// AudioTrack returns the remote audio track from the agent's browser.
|
||||
// Returns nil if the track has not been received yet.
|
||||
func (ap *AgentPeer) AudioTrack() *webrtc.TrackRemote {
|
||||
ap.mu.Lock()
|
||||
defer ap.mu.Unlock()
|
||||
return ap.audioTrack
|
||||
}
|
||||
|
||||
// LocalTrack returns the local RTP track used to send audio to the agent.
|
||||
func (ap *AgentPeer) LocalTrack() *webrtc.TrackLocalStaticRTP {
|
||||
return ap.localTrack
|
||||
}
|
||||
|
||||
// OnTrackReady sets a callback that fires when the agent's microphone audio
|
||||
// track becomes available.
|
||||
func (ap *AgentPeer) OnTrackReady(fn func(track *webrtc.TrackRemote)) {
|
||||
ap.mu.Lock()
|
||||
defer ap.mu.Unlock()
|
||||
ap.onTrackReady = fn
|
||||
}
|
||||
|
||||
// OnICEStateChange sets a callback that fires when the ICE connection state
|
||||
// changes.
|
||||
func (ap *AgentPeer) OnICEStateChange(fn func(state webrtc.ICEConnectionState)) {
|
||||
ap.mu.Lock()
|
||||
defer ap.mu.Unlock()
|
||||
ap.onICEStateChange = fn
|
||||
}
|
||||
|
||||
// ICEConnectionState returns the current ICE connection state.
|
||||
func (ap *AgentPeer) ICEConnectionState() webrtc.ICEConnectionState {
|
||||
return ap.pc.ICEConnectionState()
|
||||
}
|
||||
|
||||
// Close gracefully shuts down the agent-side peer connection.
|
||||
func (ap *AgentPeer) Close() error {
|
||||
ap.mu.Lock()
|
||||
defer ap.mu.Unlock()
|
||||
|
||||
if ap.closed {
|
||||
return nil
|
||||
}
|
||||
ap.closed = true
|
||||
|
||||
slog.Info("agent peer: closing peer connection", "peer_id", ap.ID)
|
||||
return ap.pc.Close()
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Package peer provides WebRTC peer connection wrappers for the two sides
|
||||
// of a call: the Meta-side peer (Peer A) and the Agent-side peer (Peer B).
|
||||
package peer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/config"
|
||||
)
|
||||
|
||||
// MetaPeer represents the WebRTC peer connection to Meta's media servers
|
||||
// (Peer A). It receives the customer's audio as an incoming remote track and
|
||||
// sends the agent's audio via a local static RTP track.
|
||||
type MetaPeer struct {
|
||||
pc *webrtc.PeerConnection
|
||||
audioTrack *webrtc.TrackRemote
|
||||
localTrack *webrtc.TrackLocalStaticRTP
|
||||
sender *webrtc.RTPSender
|
||||
|
||||
// onTrackReady is called when the remote audio track from Meta is available.
|
||||
onTrackReady func(track *webrtc.TrackRemote)
|
||||
|
||||
// onICEStateChange is called when the ICE connection state changes.
|
||||
onICEStateChange func(state webrtc.ICEConnectionState)
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewMetaPeer creates a new Meta-side peer connection configured for the
|
||||
// given ICE servers and UDP port range. For incoming calls, sdpOffer contains
|
||||
// Meta's SDP offer; the method sets it as the remote description, creates an
|
||||
// answer, and returns the SDP answer string. For outgoing calls, sdpOffer is
|
||||
// empty; the method creates an SDP offer to send to Meta.
|
||||
func NewMetaPeer(cfg *config.Config, sdpOffer string, iceServers []webrtc.ICEServer) (*MetaPeer, string, error) {
|
||||
se := webrtc.SettingEngine{}
|
||||
|
||||
// Configure the UDP port range for media transport.
|
||||
if err := se.SetEphemeralUDPPortRange(cfg.UDPPortMin, cfg.UDPPortMax); err != nil {
|
||||
return nil, "", fmt.Errorf("set UDP port range: %w", err)
|
||||
}
|
||||
|
||||
// If a public IP is configured, use NAT1To1 so ICE candidates advertise
|
||||
// the correct address instead of a private Docker/container IP.
|
||||
// NOTE: In pion/webrtc v4.2+, migrate to SetICEAddressRewriteRules.
|
||||
if cfg.PublicIP != "" {
|
||||
se.SetNAT1To1IPs([]string{cfg.PublicIP}, webrtc.ICECandidateTypeSrflx)
|
||||
}
|
||||
|
||||
// Build the WebRTC API with a media engine that supports Opus audio.
|
||||
me := &webrtc.MediaEngine{}
|
||||
if err := me.RegisterDefaultCodecs(); err != nil {
|
||||
return nil, "", fmt.Errorf("register codecs: %w", err)
|
||||
}
|
||||
|
||||
// Register default interceptors (NACK, RTCP reports, etc.).
|
||||
ir := &interceptor.Registry{}
|
||||
if err := webrtc.RegisterDefaultInterceptors(me, ir); err != nil {
|
||||
return nil, "", fmt.Errorf("register interceptors: %w", err)
|
||||
}
|
||||
|
||||
api := webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(me),
|
||||
webrtc.WithSettingEngine(se),
|
||||
webrtc.WithInterceptorRegistry(ir),
|
||||
)
|
||||
|
||||
pc, err := api.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: iceServers,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create peer connection: %w", err)
|
||||
}
|
||||
|
||||
// Create a local audio track that will carry the agent's audio to Meta.
|
||||
localTrack, err := webrtc.NewTrackLocalStaticRTP(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
|
||||
"audio-to-meta",
|
||||
"chatwoot-media-server",
|
||||
)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("create local track: %w", err)
|
||||
}
|
||||
|
||||
sender, err := pc.AddTrack(localTrack)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("add local track: %w", err)
|
||||
}
|
||||
|
||||
// Consume RTCP packets from the sender to avoid blocking.
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, rtcpErr := sender.Read(buf); rtcpErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
mp := &MetaPeer{
|
||||
pc: pc,
|
||||
localTrack: localTrack,
|
||||
sender: sender,
|
||||
}
|
||||
|
||||
// Register the OnTrack handler to capture the incoming audio from Meta.
|
||||
pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||
slog.Info("meta peer: remote track received",
|
||||
"codec", track.Codec().MimeType,
|
||||
"ssrc", track.SSRC(),
|
||||
)
|
||||
mp.mu.Lock()
|
||||
mp.audioTrack = track
|
||||
cb := mp.onTrackReady
|
||||
mp.mu.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb(track)
|
||||
}
|
||||
})
|
||||
|
||||
// Register ICE connection state handler.
|
||||
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||
slog.Info("meta peer: ICE state changed", "state", state.String())
|
||||
mp.mu.Lock()
|
||||
cb := mp.onICEStateChange
|
||||
mp.mu.Unlock()
|
||||
|
||||
if cb != nil {
|
||||
cb(state)
|
||||
}
|
||||
})
|
||||
|
||||
// Perform SDP negotiation based on call direction.
|
||||
var sdpResult string
|
||||
if sdpOffer != "" {
|
||||
// Incoming call: Meta sent an offer, we generate an answer.
|
||||
offer := webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer,
|
||||
SDP: sdpOffer,
|
||||
}
|
||||
if err := pc.SetRemoteDescription(offer); err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("set remote description (Meta offer): %w", err)
|
||||
}
|
||||
|
||||
answer, err := pc.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("create answer: %w", err)
|
||||
}
|
||||
|
||||
// Wait for ICE gathering to complete before returning the answer.
|
||||
gatherComplete := webrtc.GatheringCompletePromise(pc)
|
||||
if err := pc.SetLocalDescription(answer); err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("set local description: %w", err)
|
||||
}
|
||||
<-gatherComplete
|
||||
|
||||
sdpResult = pc.LocalDescription().SDP
|
||||
} else {
|
||||
// Outgoing call: we generate an offer to send to Meta.
|
||||
// Add a recvonly transceiver so Meta knows we expect audio.
|
||||
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{
|
||||
Direction: webrtc.RTPTransceiverDirectionRecvonly,
|
||||
}); err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("add audio transceiver: %w", err)
|
||||
}
|
||||
|
||||
offer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("create offer: %w", err)
|
||||
}
|
||||
|
||||
gatherComplete := webrtc.GatheringCompletePromise(pc)
|
||||
if err := pc.SetLocalDescription(offer); err != nil {
|
||||
pc.Close()
|
||||
return nil, "", fmt.Errorf("set local description: %w", err)
|
||||
}
|
||||
<-gatherComplete
|
||||
|
||||
sdpResult = pc.LocalDescription().SDP
|
||||
}
|
||||
|
||||
return mp, sdpResult, nil
|
||||
}
|
||||
|
||||
// SetRemoteAnswer sets Meta's SDP answer on the peer connection, used for
|
||||
// outbound calls when Meta responds with an answer.
|
||||
func (mp *MetaPeer) SetRemoteAnswer(sdpAnswer string) error {
|
||||
mp.mu.Lock()
|
||||
defer mp.mu.Unlock()
|
||||
|
||||
answer := webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: sdpAnswer,
|
||||
}
|
||||
return mp.pc.SetRemoteDescription(answer)
|
||||
}
|
||||
|
||||
// AudioTrack returns the remote audio track from Meta (customer audio).
|
||||
// Returns nil if the track has not been received yet.
|
||||
func (mp *MetaPeer) AudioTrack() *webrtc.TrackRemote {
|
||||
mp.mu.Lock()
|
||||
defer mp.mu.Unlock()
|
||||
return mp.audioTrack
|
||||
}
|
||||
|
||||
// LocalTrack returns the local RTP track used to send audio to Meta.
|
||||
func (mp *MetaPeer) LocalTrack() *webrtc.TrackLocalStaticRTP {
|
||||
return mp.localTrack
|
||||
}
|
||||
|
||||
// OnTrackReady sets a callback that fires when the remote audio track from
|
||||
// Meta becomes available.
|
||||
func (mp *MetaPeer) OnTrackReady(fn func(track *webrtc.TrackRemote)) {
|
||||
mp.mu.Lock()
|
||||
defer mp.mu.Unlock()
|
||||
mp.onTrackReady = fn
|
||||
}
|
||||
|
||||
// OnICEStateChange sets a callback that fires when the ICE connection state
|
||||
// changes.
|
||||
func (mp *MetaPeer) OnICEStateChange(fn func(state webrtc.ICEConnectionState)) {
|
||||
mp.mu.Lock()
|
||||
defer mp.mu.Unlock()
|
||||
mp.onICEStateChange = fn
|
||||
}
|
||||
|
||||
// ICEConnectionState returns the current ICE connection state.
|
||||
func (mp *MetaPeer) ICEConnectionState() webrtc.ICEConnectionState {
|
||||
return mp.pc.ICEConnectionState()
|
||||
}
|
||||
|
||||
// Close gracefully shuts down the Meta-side peer connection.
|
||||
func (mp *MetaPeer) Close() error {
|
||||
mp.mu.Lock()
|
||||
defer mp.mu.Unlock()
|
||||
|
||||
if mp.closed {
|
||||
return nil
|
||||
}
|
||||
mp.closed = true
|
||||
|
||||
slog.Info("meta peer: closing peer connection")
|
||||
return mp.pc.Close()
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/config"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/media"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/peer"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/session"
|
||||
)
|
||||
|
||||
// maxRequestBodySize limits JSON request bodies to 1MB to prevent memory exhaustion.
|
||||
const maxRequestBodySize = 1 << 20
|
||||
|
||||
// startTime is set at server startup for uptime calculations.
|
||||
var startTime = time.Now()
|
||||
|
||||
// Handlers implements all HTTP API endpoint handlers for the media server.
|
||||
type Handlers struct {
|
||||
cfg *config.Config
|
||||
manager *session.Manager
|
||||
}
|
||||
|
||||
// NewHandlers creates a new Handlers instance backed by the given session
|
||||
// manager and configuration.
|
||||
func NewHandlers(cfg *config.Config, mgr *session.Manager) *Handlers {
|
||||
return &Handlers{cfg: cfg, manager: mgr}
|
||||
}
|
||||
|
||||
// --- Request/Response types ---
|
||||
|
||||
// CreateSessionRequest is the JSON body for POST /sessions.
|
||||
type CreateSessionRequest struct {
|
||||
CallID string `json:"call_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Direction string `json:"direction"`
|
||||
MetaSDPOffer string `json:"meta_sdp_offer"`
|
||||
ICEServers []ICEServerConfig `json:"ice_servers"`
|
||||
}
|
||||
|
||||
// ICEServerConfig mirrors webrtc.ICEServer for JSON deserialization.
|
||||
type ICEServerConfig struct {
|
||||
URLs []string `json:"urls"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Credential string `json:"credential,omitempty"`
|
||||
}
|
||||
|
||||
// CreateSessionResponse is the JSON response for POST /sessions.
|
||||
type CreateSessionResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
MetaSDPAnswer string `json:"meta_sdp_answer,omitempty"`
|
||||
MetaSDPOffer string `json:"meta_sdp_offer,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// AgentOfferRequest is the JSON body for POST /sessions/:id/agent-offer.
|
||||
type AgentOfferRequest struct {
|
||||
PeerID string `json:"peer_id"`
|
||||
Role string `json:"role"`
|
||||
ICEServers []ICEServerConfig `json:"ice_servers"`
|
||||
}
|
||||
|
||||
// AgentOfferResponse is the JSON response for POST /sessions/:id/agent-offer.
|
||||
type AgentOfferResponse struct {
|
||||
SDPOffer string `json:"sdp_offer"`
|
||||
PeerID string `json:"peer_id"`
|
||||
ICEServers []ICEServerConfig `json:"ice_servers"`
|
||||
}
|
||||
|
||||
// AgentAnswerRequest is the JSON body for POST /sessions/:id/agent-answer.
|
||||
type AgentAnswerRequest struct {
|
||||
PeerID string `json:"peer_id"`
|
||||
SDPAnswer string `json:"sdp_answer"`
|
||||
}
|
||||
|
||||
// AgentAnswerResponse is the JSON response for POST /sessions/:id/agent-answer.
|
||||
type AgentAnswerResponse struct {
|
||||
Status string `json:"status"`
|
||||
Recording bool `json:"recording"`
|
||||
}
|
||||
|
||||
// AgentReconnectRequest is the JSON body for POST /sessions/:id/agent-reconnect.
|
||||
type AgentReconnectRequest struct {
|
||||
OldPeerID string `json:"old_peer_id"`
|
||||
NewPeerID string `json:"new_peer_id"`
|
||||
Role string `json:"role"`
|
||||
ICEServers []ICEServerConfig `json:"ice_servers"`
|
||||
}
|
||||
|
||||
// AgentReconnectResponse is the JSON response for POST /sessions/:id/agent-reconnect.
|
||||
type AgentReconnectResponse struct {
|
||||
SDPOffer string `json:"sdp_offer"`
|
||||
PeerID string `json:"peer_id"`
|
||||
ICEServers []ICEServerConfig `json:"ice_servers"`
|
||||
}
|
||||
|
||||
// TerminateResponse is the JSON response for POST /sessions/:id/terminate.
|
||||
type TerminateResponse struct {
|
||||
Status string `json:"status"`
|
||||
RecordingFile string `json:"recording_file,omitempty"`
|
||||
RecordingSizeBytes int64 `json:"recording_size_bytes,omitempty"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
}
|
||||
|
||||
// AddPeerRequest is the JSON body for POST /sessions/:id/peers.
|
||||
type AddPeerRequest struct {
|
||||
PeerID string `json:"peer_id"`
|
||||
Role string `json:"role"`
|
||||
ICEServers []ICEServerConfig `json:"ice_servers"`
|
||||
}
|
||||
|
||||
// ChangePeerRoleRequest is the JSON body for PATCH /sessions/:id/peers/:peer_id/role.
|
||||
type ChangePeerRoleRequest struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// InjectAudioRequest is the JSON body for POST /sessions/:id/inject-audio.
|
||||
type InjectAudioRequest struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Mode string `json:"mode"`
|
||||
Target string `json:"target"`
|
||||
Loop bool `json:"loop"`
|
||||
}
|
||||
|
||||
// HealthResponse is the JSON response for GET /health.
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
ActiveSessions int `json:"active_sessions"`
|
||||
UptimeSeconds int `json:"uptime_seconds"`
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
// Health returns the server's health status. This endpoint does not require
|
||||
// authentication and is used by container orchestrators for liveness checks.
|
||||
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
|
||||
metrics := h.manager.GetMetrics()
|
||||
writeJSON(w, http.StatusOK, HealthResponse{
|
||||
Status: "ok",
|
||||
ActiveSessions: metrics.ActiveSessions,
|
||||
UptimeSeconds: int(time.Since(startTime).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// Metrics returns Prometheus-compatible metrics about the media server.
|
||||
func (h *Handlers) Metrics(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.manager.GetMetrics())
|
||||
}
|
||||
|
||||
// CreateSession handles POST /sessions. It creates a new call session with a
|
||||
// Meta-side peer connection and returns the SDP answer (for incoming calls)
|
||||
// or SDP offer (for outgoing calls).
|
||||
func (h *Handlers) CreateSession(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateSessionRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.CallID == "" {
|
||||
writeError(w, http.StatusBadRequest, "call_id is required")
|
||||
return
|
||||
}
|
||||
if req.Direction != "incoming" && req.Direction != "outgoing" {
|
||||
writeError(w, http.StatusBadRequest, "direction must be 'incoming' or 'outgoing'")
|
||||
return
|
||||
}
|
||||
|
||||
iceServers := toWebRTCICEServers(req.ICEServers, h.cfg)
|
||||
|
||||
sess, sdpResult, err := h.manager.CreateSession(req.CallID, req.AccountID, req.Direction, req.MetaSDPOffer, iceServers)
|
||||
if err != nil {
|
||||
slog.Error("handler: failed to create session",
|
||||
"call_id", req.CallID,
|
||||
"error", err,
|
||||
)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create session: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := CreateSessionResponse{
|
||||
SessionID: sess.ID,
|
||||
Status: string(sess.Status),
|
||||
}
|
||||
if req.Direction == "incoming" {
|
||||
resp.MetaSDPAnswer = sdpResult
|
||||
} else {
|
||||
resp.MetaSDPOffer = sdpResult
|
||||
}
|
||||
|
||||
slog.Info("handler: session created",
|
||||
"session_id", sess.ID,
|
||||
"call_id", req.CallID,
|
||||
"direction", req.Direction,
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// GetSession handles GET /sessions/{id}. It returns the current status of
|
||||
// a call session.
|
||||
func (h *Handlers) GetSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
sess := h.manager.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sess.GetInfo())
|
||||
}
|
||||
|
||||
// AgentOffer handles POST /sessions/{id}/agent-offer. It creates a new
|
||||
// agent-side peer connection and returns the SDP offer to send to the
|
||||
// agent's browser.
|
||||
func (h *Handlers) AgentOffer(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
sess := h.manager.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req AgentOfferRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.PeerID == "" {
|
||||
req.PeerID = fmt.Sprintf("agent_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
role := peer.RoleActive
|
||||
if req.Role != "" {
|
||||
role = peer.PeerRole(req.Role)
|
||||
}
|
||||
|
||||
iceServers := toWebRTCICEServers(req.ICEServers, h.cfg)
|
||||
|
||||
sdpOffer, err := sess.CreateAgentPeer(req.PeerID, role, iceServers)
|
||||
if err != nil {
|
||||
slog.Error("handler: failed to create agent peer",
|
||||
"session_id", sessionID,
|
||||
"error", err,
|
||||
)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create agent peer: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("handler: agent offer created",
|
||||
"session_id", sessionID,
|
||||
"peer_id", req.PeerID,
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, AgentOfferResponse{
|
||||
SDPOffer: sdpOffer,
|
||||
PeerID: req.PeerID,
|
||||
ICEServers: req.ICEServers,
|
||||
})
|
||||
}
|
||||
|
||||
// AgentAnswer handles POST /sessions/{id}/agent-answer. It sets the agent's
|
||||
// SDP answer on the peer connection, completing the WebRTC handshake and
|
||||
// enabling audio bridging.
|
||||
func (h *Handlers) AgentAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
sess := h.manager.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req AgentAnswerRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.SDPAnswer == "" {
|
||||
writeError(w, http.StatusBadRequest, "sdp_answer is required")
|
||||
return
|
||||
}
|
||||
if req.PeerID == "" {
|
||||
writeError(w, http.StatusBadRequest, "peer_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := sess.SetAgentAnswer(req.PeerID, req.SDPAnswer); err != nil {
|
||||
slog.Error("handler: failed to set agent answer",
|
||||
"session_id", sessionID,
|
||||
"peer_id", req.PeerID,
|
||||
"error", err,
|
||||
)
|
||||
writeError(w, http.StatusInternalServerError, "failed to set agent answer: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("handler: agent answer set",
|
||||
"session_id", sessionID,
|
||||
"peer_id", req.PeerID,
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, AgentAnswerResponse{
|
||||
Status: "bridged",
|
||||
Recording: true,
|
||||
})
|
||||
}
|
||||
|
||||
// AgentReconnect handles POST /sessions/{id}/agent-reconnect. It tears down
|
||||
// the old agent peer and creates a new one, returning a fresh SDP offer.
|
||||
func (h *Handlers) AgentReconnect(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
sess := h.manager.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req AgentReconnectRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.NewPeerID == "" {
|
||||
req.NewPeerID = fmt.Sprintf("agent_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
role := peer.RoleActive
|
||||
if req.Role != "" {
|
||||
role = peer.PeerRole(req.Role)
|
||||
}
|
||||
|
||||
iceServers := toWebRTCICEServers(req.ICEServers, h.cfg)
|
||||
|
||||
sdpOffer, err := sess.ReconnectAgent(req.OldPeerID, req.NewPeerID, role, iceServers)
|
||||
if err != nil {
|
||||
slog.Error("handler: failed to reconnect agent",
|
||||
"session_id", sessionID,
|
||||
"error", err,
|
||||
)
|
||||
writeError(w, http.StatusInternalServerError, "failed to reconnect agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("handler: agent reconnect complete",
|
||||
"session_id", sessionID,
|
||||
"old_peer_id", req.OldPeerID,
|
||||
"new_peer_id", req.NewPeerID,
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, AgentReconnectResponse{
|
||||
SDPOffer: sdpOffer,
|
||||
PeerID: req.NewPeerID,
|
||||
ICEServers: req.ICEServers,
|
||||
})
|
||||
}
|
||||
|
||||
// TerminateSession handles POST /sessions/{id}/terminate. It ends the call,
|
||||
// closes all peer connections, and finalizes the recording.
|
||||
func (h *Handlers) TerminateSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
sess := h.manager.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
info := sess.GetInfo()
|
||||
sess.Terminate("api_request")
|
||||
|
||||
resp := TerminateResponse{
|
||||
Status: "terminated",
|
||||
DurationSeconds: info.DurationSeconds,
|
||||
}
|
||||
|
||||
if sess.Recorder != nil {
|
||||
resp.RecordingFile = sess.RecordingFilePath()
|
||||
resp.RecordingSizeBytes = sess.Recorder.FileSize()
|
||||
}
|
||||
|
||||
slog.Info("handler: session terminated",
|
||||
"session_id", sessionID,
|
||||
"duration", info.DurationSeconds,
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetRecording handles GET /sessions/{id}/recording. It serves the combined
|
||||
// recording file as a binary OGG download.
|
||||
func (h *Handlers) GetRecording(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
sess := h.manager.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
filePath := sess.RecordingFilePath()
|
||||
if filePath == "" {
|
||||
writeError(w, http.StatusNotFound, "no recording available")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "recording file not found")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to stat recording file")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "audio/ogg")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.ogg"`, sessionID))
|
||||
http.ServeContent(w, r, filePath, stat.ModTime(), f)
|
||||
}
|
||||
|
||||
// DeleteSession handles DELETE /sessions/{id}. It terminates the session and
|
||||
// removes all associated recording files.
|
||||
func (h *Handlers) DeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
if err := h.manager.DeleteSession(sessionID); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("handler: session deleted", "session_id", sessionID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// AddPeer handles POST /sessions/{id}/peers. It adds a new participant peer
|
||||
// to an existing session (multi-participant support).
|
||||
func (h *Handlers) AddPeer(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
sess := h.manager.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req AddPeerRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.PeerID == "" {
|
||||
req.PeerID = fmt.Sprintf("peer_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
role := peer.RoleActive
|
||||
if req.Role != "" {
|
||||
role = peer.PeerRole(req.Role)
|
||||
}
|
||||
|
||||
iceServers := toWebRTCICEServers(req.ICEServers, h.cfg)
|
||||
|
||||
sdpOffer, err := sess.CreateAgentPeer(req.PeerID, role, iceServers)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to add peer: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, AgentOfferResponse{
|
||||
SDPOffer: sdpOffer,
|
||||
PeerID: req.PeerID,
|
||||
ICEServers: req.ICEServers,
|
||||
})
|
||||
}
|
||||
|
||||
// RemovePeer handles DELETE /sessions/{id}/peers/{peer_id}. It removes a
|
||||
// specific participant from the session.
|
||||
func (h *Handlers) RemovePeer(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
peerID := r.PathValue("peer_id")
|
||||
|
||||
sess := h.manager.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := sess.RemoveAgentPeer(peerID); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
|
||||
}
|
||||
|
||||
// ChangePeerRole handles PATCH /sessions/{id}/peers/{peer_id}/role. It
|
||||
// changes the role of a connected participant.
|
||||
func (h *Handlers) ChangePeerRole(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
peerID := r.PathValue("peer_id")
|
||||
|
||||
sess := h.manager.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req ChangePeerRoleRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := sess.ChangeAgentRole(peerID, peer.PeerRole(req.Role)); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated", "role": req.Role})
|
||||
}
|
||||
|
||||
// InjectAudio handles POST /sessions/{id}/inject-audio. It starts playing
|
||||
// an audio file into the call's RTP stream.
|
||||
func (h *Handlers) InjectAudio(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
sess := h.manager.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req InjectAudioRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.ID == "" {
|
||||
req.ID = fmt.Sprintf("inj_%d", time.Now().UnixNano())
|
||||
}
|
||||
if req.Mode == "" {
|
||||
req.Mode = "replace"
|
||||
}
|
||||
if req.Target == "" {
|
||||
req.Target = "meta"
|
||||
}
|
||||
|
||||
injector := media.NewInjector(req.ID, req.Source, req.Mode, req.Target, req.Loop)
|
||||
|
||||
// Determine the target track based on the target parameter.
|
||||
target := sess.GetInjectorTarget(req.Target)
|
||||
if target == nil {
|
||||
writeError(w, http.StatusBadRequest, "target track not available")
|
||||
return
|
||||
}
|
||||
|
||||
if err := injector.Start(target); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to start injection: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sess.AddInjector(req.ID, injector)
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]string{
|
||||
"id": req.ID,
|
||||
"status": "started",
|
||||
})
|
||||
}
|
||||
|
||||
// StopInjectAudio handles DELETE /sessions/{id}/inject-audio/{inj_id}. It
|
||||
// stops an active audio injection.
|
||||
func (h *Handlers) StopInjectAudio(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
injID := r.PathValue("inj_id")
|
||||
|
||||
sess := h.manager.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := sess.StopInjector(injID); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func readJSON(r *http.Request, v any) error {
|
||||
defer r.Body.Close()
|
||||
limited := io.LimitReader(r.Body, maxRequestBodySize)
|
||||
return json.NewDecoder(limited).Decode(v)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// toWebRTCICEServers converts the request ICE server configs to Pion's
|
||||
// ICEServer type, merging with any STUN/TURN servers from the global config.
|
||||
func toWebRTCICEServers(reqServers []ICEServerConfig, cfg *config.Config) []webrtc.ICEServer {
|
||||
servers := make([]webrtc.ICEServer, 0, len(reqServers)+2)
|
||||
|
||||
// Add request-provided servers.
|
||||
for _, s := range reqServers {
|
||||
server := webrtc.ICEServer{URLs: s.URLs}
|
||||
if s.Username != "" {
|
||||
server.Username = s.Username
|
||||
server.Credential = s.Credential
|
||||
server.CredentialType = webrtc.ICECredentialTypePassword
|
||||
}
|
||||
servers = append(servers, server)
|
||||
}
|
||||
|
||||
// Add global STUN servers if no STUN was provided in the request.
|
||||
hasSTUN := false
|
||||
for _, s := range reqServers {
|
||||
for _, u := range s.URLs {
|
||||
if strings.HasPrefix(u, "stun:") {
|
||||
hasSTUN = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasSTUN && len(cfg.STUNServers) > 0 {
|
||||
servers = append(servers, webrtc.ICEServer{URLs: cfg.STUNServers})
|
||||
}
|
||||
|
||||
// Add global TURN servers.
|
||||
if len(cfg.TURNServers) > 0 && cfg.TURNUsername != "" {
|
||||
servers = append(servers, webrtc.ICEServer{
|
||||
URLs: cfg.TURNServers,
|
||||
Username: cfg.TURNUsername,
|
||||
Credential: cfg.TURNPassword,
|
||||
CredentialType: webrtc.ICECredentialTypePassword,
|
||||
})
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package server provides the HTTP API router and handlers for the media server.
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/auth"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/config"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/session"
|
||||
)
|
||||
|
||||
// Router builds the HTTP handler tree with authentication middleware and
|
||||
// route matching. It uses the standard library's http.ServeMux for routing.
|
||||
type Router struct {
|
||||
handler *Handlers
|
||||
authToken string
|
||||
}
|
||||
|
||||
// NewRouter creates a new Router with the given configuration, session
|
||||
// manager, and authentication token.
|
||||
func NewRouter(cfg *config.Config, mgr *session.Manager) *Router {
|
||||
return &Router{
|
||||
handler: NewHandlers(cfg, mgr),
|
||||
authToken: cfg.AuthToken,
|
||||
}
|
||||
}
|
||||
|
||||
// Build constructs and returns the root http.Handler with all routes and
|
||||
// middleware applied.
|
||||
func (rt *Router) Build() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Public endpoints (no auth required).
|
||||
mux.HandleFunc("GET /health", rt.handler.Health)
|
||||
|
||||
// Protected endpoints.
|
||||
authMw := auth.Middleware(rt.authToken)
|
||||
|
||||
// Session CRUD.
|
||||
mux.Handle("POST /sessions", authMw(http.HandlerFunc(rt.handler.CreateSession)))
|
||||
mux.Handle("GET /metrics", authMw(http.HandlerFunc(rt.handler.Metrics)))
|
||||
|
||||
// All session-scoped routes go through a path-parsing handler because
|
||||
// Go 1.22's ServeMux supports {param} patterns.
|
||||
mux.Handle("GET /sessions/{id}", authMw(http.HandlerFunc(rt.handler.GetSession)))
|
||||
mux.Handle("POST /sessions/{id}/agent-offer", authMw(http.HandlerFunc(rt.handler.AgentOffer)))
|
||||
mux.Handle("POST /sessions/{id}/agent-answer", authMw(http.HandlerFunc(rt.handler.AgentAnswer)))
|
||||
mux.Handle("POST /sessions/{id}/agent-reconnect", authMw(http.HandlerFunc(rt.handler.AgentReconnect)))
|
||||
mux.Handle("POST /sessions/{id}/terminate", authMw(http.HandlerFunc(rt.handler.TerminateSession)))
|
||||
mux.Handle("GET /sessions/{id}/recording", authMw(http.HandlerFunc(rt.handler.GetRecording)))
|
||||
mux.Handle("DELETE /sessions/{id}", authMw(http.HandlerFunc(rt.handler.DeleteSession)))
|
||||
|
||||
// Multi-participant peer management.
|
||||
mux.Handle("POST /sessions/{id}/peers", authMw(http.HandlerFunc(rt.handler.AddPeer)))
|
||||
mux.Handle("DELETE /sessions/{id}/peers/{peer_id}", authMw(http.HandlerFunc(rt.handler.RemovePeer)))
|
||||
mux.Handle("PATCH /sessions/{id}/peers/{peer_id}/role", authMw(http.HandlerFunc(rt.handler.ChangePeerRole)))
|
||||
|
||||
// Audio injection.
|
||||
mux.Handle("POST /sessions/{id}/inject-audio", authMw(http.HandlerFunc(rt.handler.InjectAudio)))
|
||||
mux.Handle("DELETE /sessions/{id}/inject-audio/{inj_id}", authMw(http.HandlerFunc(rt.handler.StopInjectAudio)))
|
||||
|
||||
// Wrap the mux with global middleware.
|
||||
var handler http.Handler = mux
|
||||
handler = requestLogger(handler)
|
||||
handler = recoverer(handler)
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// requestLogger is middleware that logs each HTTP request.
|
||||
func requestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip logging for health checks to reduce noise.
|
||||
if strings.HasPrefix(r.URL.Path, "/health") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
// Logging is handled inside handlers for better context; this is
|
||||
// a safety net for unlogged requests.
|
||||
})
|
||||
}
|
||||
|
||||
// recoverer is middleware that catches panics and returns a 500 response
|
||||
// instead of crashing the server.
|
||||
func recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
slog.Error("panic recovered",
|
||||
"panic", rec,
|
||||
"path", r.URL.Path,
|
||||
"method", r.Method,
|
||||
)
|
||||
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture the status code.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/callback"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/config"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/peer"
|
||||
)
|
||||
|
||||
// Manager handles the lifecycle of all call sessions, providing thread-safe
|
||||
// creation, lookup, termination, and periodic cleanup of expired sessions.
|
||||
type Manager struct {
|
||||
sessions map[string]*Session
|
||||
config *config.Config
|
||||
railsClient *callback.RailsClient
|
||||
|
||||
mu sync.RWMutex
|
||||
sessionCounter atomic.Int64
|
||||
cleanupTicker *time.Ticker
|
||||
cleanupStopCh chan struct{}
|
||||
}
|
||||
|
||||
// Metrics holds observable counters for the session manager, used by the
|
||||
// /metrics endpoint.
|
||||
type Metrics struct {
|
||||
ActiveSessions int `json:"active_sessions"`
|
||||
TotalCreated int64 `json:"total_created"`
|
||||
TerminatedCount int `json:"terminated_count"`
|
||||
MetaConnected int `json:"meta_connected"`
|
||||
AgentConnected int `json:"agent_connected"`
|
||||
AgentDisconnected int `json:"agent_disconnected"`
|
||||
}
|
||||
|
||||
// NewManager creates a new session manager and starts a background goroutine
|
||||
// that periodically cleans up expired sessions.
|
||||
func NewManager(cfg *config.Config, railsClient *callback.RailsClient) *Manager {
|
||||
m := &Manager{
|
||||
sessions: make(map[string]*Session),
|
||||
config: cfg,
|
||||
railsClient: railsClient,
|
||||
cleanupTicker: time.NewTicker(60 * time.Second),
|
||||
cleanupStopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
go m.cleanupLoop()
|
||||
return m
|
||||
}
|
||||
|
||||
// CreateSession creates a new call session with the given parameters. For
|
||||
// incoming calls, metaSDPOffer contains Meta's SDP offer and the returned
|
||||
// string is the SDP answer. For outgoing calls, metaSDPOffer is empty and
|
||||
// the returned string is the SDP offer to send to Meta.
|
||||
func (m *Manager) CreateSession(callID, accountID, direction, metaSDPOffer string, iceServers []webrtc.ICEServer) (*Session, string, error) {
|
||||
// Check capacity limit.
|
||||
if m.config.MaxConcurrentSessions > 0 {
|
||||
m.mu.RLock()
|
||||
activeCount := len(m.sessions)
|
||||
m.mu.RUnlock()
|
||||
|
||||
if activeCount >= m.config.MaxConcurrentSessions {
|
||||
return nil, "", fmt.Errorf("max concurrent sessions (%d) reached", m.config.MaxConcurrentSessions)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a unique session ID.
|
||||
counter := m.sessionCounter.Add(1)
|
||||
sessionID := fmt.Sprintf("sess_%s_%d", time.Now().Format("20060102150405"), counter)
|
||||
|
||||
sess, sdpResult, err := NewSession(m.config, m.railsClient, sessionID, callID, accountID, direction, metaSDPOffer, iceServers)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.sessions[sessionID] = sess
|
||||
m.mu.Unlock()
|
||||
|
||||
slog.Info("manager: session created",
|
||||
"session_id", sessionID,
|
||||
"call_id", callID,
|
||||
"direction", direction,
|
||||
)
|
||||
|
||||
return sess, sdpResult, nil
|
||||
}
|
||||
|
||||
// GetSession returns the session with the given ID, or nil if not found.
|
||||
func (m *Manager) GetSession(sessionID string) *Session {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.sessions[sessionID]
|
||||
}
|
||||
|
||||
// TerminateSession terminates the session with the given ID and removes it
|
||||
// from the active sessions map.
|
||||
func (m *Manager) TerminateSession(sessionID, reason string) error {
|
||||
m.mu.Lock()
|
||||
sess, ok := m.sessions[sessionID]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
delete(m.sessions, sessionID)
|
||||
m.mu.Unlock()
|
||||
|
||||
sess.Terminate(reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a session and cleans up its recording files.
|
||||
func (m *Manager) DeleteSession(sessionID string) error {
|
||||
m.mu.Lock()
|
||||
sess, ok := m.sessions[sessionID]
|
||||
if ok {
|
||||
delete(m.sessions, sessionID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
|
||||
sess.Terminate("deleted")
|
||||
|
||||
// Clean up recording files.
|
||||
if sess.Recorder != nil {
|
||||
sess.Recorder.Cleanup()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAgentPeer creates a new agent-side peer for the specified session.
|
||||
func (m *Manager) CreateAgentPeer(sessionID, peerID string, role peer.PeerRole, iceServers []webrtc.ICEServer) (string, error) {
|
||||
sess := m.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
return "", fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
return sess.CreateAgentPeer(peerID, role, iceServers)
|
||||
}
|
||||
|
||||
// SetAgentAnswer sets the agent's SDP answer for the specified peer.
|
||||
func (m *Manager) SetAgentAnswer(sessionID, peerID, sdpAnswer string) error {
|
||||
sess := m.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
return fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
return sess.SetAgentAnswer(peerID, sdpAnswer)
|
||||
}
|
||||
|
||||
// ReconnectAgent creates a new agent peer after tearing down the old one.
|
||||
func (m *Manager) ReconnectAgent(sessionID, oldPeerID, newPeerID string, role peer.PeerRole, iceServers []webrtc.ICEServer) (string, error) {
|
||||
sess := m.GetSession(sessionID)
|
||||
if sess == nil {
|
||||
return "", fmt.Errorf("session %s not found", sessionID)
|
||||
}
|
||||
return sess.ReconnectAgent(oldPeerID, newPeerID, role, iceServers)
|
||||
}
|
||||
|
||||
// GetMetrics returns current observable metrics about the session manager.
|
||||
func (m *Manager) GetMetrics() Metrics {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
metrics := Metrics{
|
||||
ActiveSessions: len(m.sessions),
|
||||
TotalCreated: m.sessionCounter.Load(),
|
||||
}
|
||||
|
||||
for _, sess := range m.sessions {
|
||||
switch sess.Status {
|
||||
case StatusTerminated:
|
||||
metrics.TerminatedCount++
|
||||
case StatusMetaConnected:
|
||||
metrics.MetaConnected++
|
||||
case StatusAgentConnected, StatusActive:
|
||||
metrics.AgentConnected++
|
||||
case StatusAgentDisconnected:
|
||||
metrics.AgentDisconnected++
|
||||
}
|
||||
}
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
// RecoverOrphanedRecordings scans the recordings directory for files that
|
||||
// do not belong to any active session, reporting them to Rails. This handles
|
||||
// the case where the media server crashed mid-call and recordings were left
|
||||
// on disk.
|
||||
func (m *Manager) RecoverOrphanedRecordings() {
|
||||
dir := m.config.RecordingsDir
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
slog.Error("manager: failed to scan recordings directory",
|
||||
"dir", dir,
|
||||
"error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
activeIDs := make(map[string]bool, len(m.sessions))
|
||||
for id := range m.sessions {
|
||||
activeIDs[id] = true
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
orphanCount := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".ogg") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract session ID from filename (e.g., "sess_20240101_1.ogg" -> "sess_20240101_1").
|
||||
name := strings.TrimSuffix(entry.Name(), ".ogg")
|
||||
// Remove channel suffixes.
|
||||
name = strings.TrimSuffix(name, "_customer")
|
||||
name = strings.TrimSuffix(name, "_agent")
|
||||
|
||||
if !activeIDs[name] {
|
||||
orphanCount++
|
||||
slog.Warn("manager: found orphaned recording",
|
||||
"file", filepath.Join(dir, entry.Name()),
|
||||
"session_id", name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if orphanCount > 0 {
|
||||
slog.Info("manager: orphaned recording scan complete",
|
||||
"orphan_count", orphanCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully terminates all active sessions and stops the cleanup
|
||||
// goroutine. It should be called during server shutdown.
|
||||
func (m *Manager) Shutdown() {
|
||||
close(m.cleanupStopCh)
|
||||
m.cleanupTicker.Stop()
|
||||
|
||||
m.mu.Lock()
|
||||
sessions := make([]*Session, 0, len(m.sessions))
|
||||
for _, sess := range m.sessions {
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
m.sessions = make(map[string]*Session)
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, sess := range sessions {
|
||||
sess.Terminate("server_shutdown")
|
||||
}
|
||||
|
||||
slog.Info("manager: all sessions terminated", "count", len(sessions))
|
||||
}
|
||||
|
||||
// cleanupLoop runs periodically to remove terminated sessions from the map.
|
||||
func (m *Manager) cleanupLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-m.cleanupStopCh:
|
||||
return
|
||||
case <-m.cleanupTicker.C:
|
||||
m.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) cleanup() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for id, sess := range m.sessions {
|
||||
if sess.Status == StatusTerminated {
|
||||
delete(m.sessions, id)
|
||||
slog.Debug("manager: cleaned up terminated session", "session_id", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
// Package session manages call session lifecycles, coordinating between the
|
||||
// Meta-side peer, agent-side peers, audio bridge, and recording.
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/callback"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/config"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/media"
|
||||
"github.com/chatwoot/chatwoot-media-server/internal/peer"
|
||||
)
|
||||
|
||||
// Status represents the current state of a call session.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusCreated Status = "created"
|
||||
StatusMetaConnected Status = "meta_connected"
|
||||
StatusAgentConnected Status = "agent_connected"
|
||||
StatusActive Status = "active"
|
||||
StatusAgentDisconnected Status = "agent_disconnected"
|
||||
StatusTerminated Status = "terminated"
|
||||
)
|
||||
|
||||
// Session represents a single active call, holding the Meta-side peer
|
||||
// connection (Peer A), one or more agent-side peer connections (Peer B),
|
||||
// the audio bridge, and the recording engine.
|
||||
type Session struct {
|
||||
ID string
|
||||
CallID string
|
||||
AccountID string
|
||||
Direction string // "incoming" or "outgoing"
|
||||
|
||||
MetaPeer *peer.MetaPeer
|
||||
AgentPeers map[string]*peer.AgentPeer
|
||||
Bridge *media.Bridge
|
||||
Recorder *media.Recorder
|
||||
Injectors map[string]*media.Injector
|
||||
|
||||
Status Status
|
||||
StartedAt time.Time
|
||||
CreatedAt time.Time
|
||||
|
||||
config *config.Config
|
||||
railsClient *callback.RailsClient
|
||||
reconnectTimer *time.Timer
|
||||
cancel context.CancelFunc
|
||||
ctx context.Context
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Info is the JSON-serializable representation of a session's current state,
|
||||
// returned by the GET /sessions/:id endpoint.
|
||||
type Info struct {
|
||||
ID string `json:"id"`
|
||||
CallID string `json:"call_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Direction string `json:"direction"`
|
||||
Status string `json:"status"`
|
||||
MetaICEState string `json:"meta_ice_state"`
|
||||
AgentPeerCount int `json:"agent_peer_count"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
HasRecording bool `json:"has_recording"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// NewSession creates a new call session. For incoming calls, the Meta SDP
|
||||
// offer is provided and the method returns the SDP answer. For outgoing calls,
|
||||
// the Meta SDP offer is empty and the method returns an SDP offer to send to
|
||||
// Meta.
|
||||
func NewSession(
|
||||
cfg *config.Config,
|
||||
railsClient *callback.RailsClient,
|
||||
id, callID, accountID, direction, metaSDPOffer string,
|
||||
iceServers []webrtc.ICEServer,
|
||||
) (*Session, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.MaxSessionDuration)
|
||||
|
||||
sess := &Session{
|
||||
ID: id,
|
||||
CallID: callID,
|
||||
AccountID: accountID,
|
||||
Direction: direction,
|
||||
AgentPeers: make(map[string]*peer.AgentPeer),
|
||||
Injectors: make(map[string]*media.Injector),
|
||||
Status: StatusCreated,
|
||||
CreatedAt: time.Now(),
|
||||
config: cfg,
|
||||
railsClient: railsClient,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Create the Meta-side peer connection (Peer A).
|
||||
metaPeer, sdpResult, err := peer.NewMetaPeer(cfg, metaSDPOffer, iceServers)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, "", fmt.Errorf("create meta peer: %w", err)
|
||||
}
|
||||
sess.MetaPeer = metaPeer
|
||||
|
||||
// Create the recorder.
|
||||
recorder, err := media.NewRecorder(id, cfg.RecordingsDir)
|
||||
if err != nil {
|
||||
metaPeer.Close()
|
||||
cancel()
|
||||
return nil, "", fmt.Errorf("create recorder: %w", err)
|
||||
}
|
||||
sess.Recorder = recorder
|
||||
|
||||
// Create the audio bridge.
|
||||
sess.Bridge = media.NewBridge(id, metaPeer, recorder)
|
||||
|
||||
// Wire up Meta peer event handlers.
|
||||
metaPeer.OnICEStateChange(func(state webrtc.ICEConnectionState) {
|
||||
sess.handleMetaICEStateChange(state)
|
||||
})
|
||||
|
||||
metaPeer.OnTrackReady(func(track *webrtc.TrackRemote) {
|
||||
slog.Info("session: Meta audio track ready, starting bridge forwarding",
|
||||
"session_id", id,
|
||||
)
|
||||
// Start forwarding Meta audio to agents in its own goroutine.
|
||||
go sess.Bridge.ReadAndForwardMetaTrack(sess.ctx, track)
|
||||
})
|
||||
|
||||
// Start the max duration timer.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
sess.mu.Lock()
|
||||
if sess.Status != StatusTerminated {
|
||||
sess.mu.Unlock()
|
||||
slog.Info("session: max duration reached, terminating",
|
||||
"session_id", id,
|
||||
)
|
||||
sess.Terminate("max_duration_exceeded")
|
||||
} else {
|
||||
sess.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
return sess, sdpResult, nil
|
||||
}
|
||||
|
||||
// SetMetaAnswer sets Meta's SDP answer on the Meta peer connection. This is
|
||||
// used for outbound calls when Meta responds to the server's SDP offer.
|
||||
func (s *Session) SetMetaAnswer(sdpAnswer string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := s.MetaPeer.SetRemoteAnswer(sdpAnswer); err != nil {
|
||||
return fmt.Errorf("set meta answer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAgentPeer creates a new agent-side peer connection (Peer B) and
|
||||
// returns the SDP offer to send to the agent's browser.
|
||||
func (s *Session) CreateAgentPeer(peerID string, role peer.PeerRole, iceServers []webrtc.ICEServer) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
agentPeer, sdpOffer, err := peer.NewAgentPeer(s.config, peerID, role, iceServers)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create agent peer: %w", err)
|
||||
}
|
||||
|
||||
// Wire up agent peer event handlers.
|
||||
agentPeer.OnICEStateChange(func(state webrtc.ICEConnectionState) {
|
||||
s.handleAgentICEStateChange(peerID, state)
|
||||
})
|
||||
|
||||
agentPeer.OnTrackReady(func(track *webrtc.TrackRemote) {
|
||||
slog.Info("session: agent audio track ready, starting bridge forwarding",
|
||||
"session_id", s.ID,
|
||||
"peer_id", peerID,
|
||||
)
|
||||
go s.Bridge.ReadAndForwardAgentTrack(s.ctx, agentPeer, track)
|
||||
})
|
||||
|
||||
s.AgentPeers[peerID] = agentPeer
|
||||
s.Bridge.AddAgentPeer(agentPeer)
|
||||
|
||||
// Start the bridge if Meta is already connected.
|
||||
if s.Status == StatusMetaConnected || s.Status == StatusAgentDisconnected {
|
||||
s.Bridge.Start(s.ctx)
|
||||
s.Status = StatusAgentConnected
|
||||
}
|
||||
|
||||
// Cancel any active reconnect timer.
|
||||
if s.reconnectTimer != nil {
|
||||
s.reconnectTimer.Stop()
|
||||
s.reconnectTimer = nil
|
||||
}
|
||||
|
||||
return sdpOffer, nil
|
||||
}
|
||||
|
||||
// SetAgentAnswer sets the agent browser's SDP answer on the specified agent
|
||||
// peer, completing the WebRTC handshake.
|
||||
func (s *Session) SetAgentAnswer(peerID, sdpAnswer string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
ap, ok := s.AgentPeers[peerID]
|
||||
if !ok {
|
||||
return fmt.Errorf("agent peer %s not found", peerID)
|
||||
}
|
||||
|
||||
if err := ap.SetAnswer(sdpAnswer); err != nil {
|
||||
return fmt.Errorf("set agent answer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReconnectAgent tears down the old agent peer connection and creates a new
|
||||
// one, returning a fresh SDP offer. This is used when the agent reloads the
|
||||
// page and needs to re-establish their peer connection while the Meta-side
|
||||
// connection stays alive.
|
||||
func (s *Session) ReconnectAgent(oldPeerID, newPeerID string, role peer.PeerRole, iceServers []webrtc.ICEServer) (string, error) {
|
||||
s.mu.Lock()
|
||||
// Remove the old agent peer from the session if it exists.
|
||||
var oldPeer *peer.AgentPeer
|
||||
if ap, ok := s.AgentPeers[oldPeerID]; ok {
|
||||
oldPeer = ap
|
||||
s.Bridge.RemoveAgentPeer(oldPeerID)
|
||||
delete(s.AgentPeers, oldPeerID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Close outside the lock to avoid deadlock from ICE state callbacks.
|
||||
if oldPeer != nil {
|
||||
oldPeer.Close()
|
||||
}
|
||||
|
||||
// Create a new agent peer.
|
||||
return s.CreateAgentPeer(newPeerID, role, iceServers)
|
||||
}
|
||||
|
||||
// Terminate gracefully ends the call session. It closes all peer connections,
|
||||
// finalizes the recording, and sends callbacks to Rails.
|
||||
func (s *Session) Terminate(reason string) {
|
||||
s.mu.Lock()
|
||||
if s.Status == StatusTerminated {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.Status = StatusTerminated
|
||||
|
||||
// Collect injectors and agent peers while holding the lock, then release
|
||||
// before calling Close(). PeerConnection.Close() may fire ICE state
|
||||
// callbacks synchronously, which would deadlock if we held s.mu.
|
||||
injectors := make([]*media.Injector, 0, len(s.Injectors))
|
||||
for _, inj := range s.Injectors {
|
||||
injectors = append(injectors, inj)
|
||||
}
|
||||
|
||||
agentPeers := make([]*peer.AgentPeer, 0, len(s.AgentPeers))
|
||||
for id, ap := range s.AgentPeers {
|
||||
agentPeers = append(agentPeers, ap)
|
||||
delete(s.AgentPeers, id)
|
||||
}
|
||||
|
||||
metaPeer := s.MetaPeer
|
||||
s.mu.Unlock()
|
||||
|
||||
slog.Info("session: terminating",
|
||||
"session_id", s.ID,
|
||||
"reason", reason,
|
||||
)
|
||||
|
||||
// Stop the bridge.
|
||||
s.Bridge.Stop()
|
||||
|
||||
// Stop any active injectors.
|
||||
for _, inj := range injectors {
|
||||
inj.Stop()
|
||||
}
|
||||
|
||||
// Close all agent peers (may trigger ICE state callbacks).
|
||||
for _, ap := range agentPeers {
|
||||
ap.Close()
|
||||
}
|
||||
|
||||
// Close Meta peer.
|
||||
if metaPeer != nil {
|
||||
metaPeer.Close()
|
||||
}
|
||||
|
||||
// Finalize recording.
|
||||
if s.Recorder != nil {
|
||||
if err := s.Recorder.Finalize(); err != nil {
|
||||
slog.Error("session: failed to finalize recording",
|
||||
"session_id", s.ID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel the session context.
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
// Notify Rails.
|
||||
s.sendTerminationCallbacks(reason)
|
||||
}
|
||||
|
||||
// RemoveAgentPeer removes a specific agent peer from the session.
|
||||
func (s *Session) RemoveAgentPeer(peerID string) error {
|
||||
s.mu.Lock()
|
||||
ap, ok := s.AgentPeers[peerID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("agent peer %s not found", peerID)
|
||||
}
|
||||
|
||||
s.Bridge.RemoveAgentPeer(peerID)
|
||||
delete(s.AgentPeers, peerID)
|
||||
s.mu.Unlock()
|
||||
|
||||
// Close outside the lock to avoid deadlock from ICE state callbacks.
|
||||
ap.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangeAgentRole changes the role of a connected agent peer.
|
||||
func (s *Session) ChangeAgentRole(peerID string, newRole peer.PeerRole) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
ap, ok := s.AgentPeers[peerID]
|
||||
if !ok {
|
||||
return fmt.Errorf("agent peer %s not found", peerID)
|
||||
}
|
||||
|
||||
ap.Role = newRole
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInfo returns a snapshot of the session's current state.
|
||||
func (s *Session) GetInfo() Info {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
info := Info{
|
||||
ID: s.ID,
|
||||
CallID: s.CallID,
|
||||
AccountID: s.AccountID,
|
||||
Direction: s.Direction,
|
||||
Status: string(s.Status),
|
||||
AgentPeerCount: len(s.AgentPeers),
|
||||
HasRecording: s.Recorder != nil,
|
||||
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if s.MetaPeer != nil {
|
||||
info.MetaICEState = s.MetaPeer.ICEConnectionState().String()
|
||||
}
|
||||
|
||||
if !s.StartedAt.IsZero() {
|
||||
info.DurationSeconds = int(time.Since(s.StartedAt).Seconds())
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// RecordingFilePath returns the path to the combined recording file.
|
||||
func (s *Session) RecordingFilePath() string {
|
||||
if s.Recorder == nil {
|
||||
return ""
|
||||
}
|
||||
return s.Recorder.CombinedFilePath()
|
||||
}
|
||||
|
||||
// GetInjectorTarget returns the appropriate write target for audio injection
|
||||
// based on the target parameter. Returns nil if the target is unavailable.
|
||||
func (s *Session) GetInjectorTarget(target string) media.InjectorTarget {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.MetaPeer == nil {
|
||||
return nil
|
||||
}
|
||||
// All injection targets currently route to the Meta peer's local track.
|
||||
return s.MetaPeer.LocalTrack()
|
||||
}
|
||||
|
||||
// AddInjector registers an active injector with the session in a thread-safe manner.
|
||||
func (s *Session) AddInjector(id string, inj *media.Injector) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Injectors[id] = inj
|
||||
}
|
||||
|
||||
// StopInjector stops and removes an injector by ID. Returns an error if not found.
|
||||
func (s *Session) StopInjector(id string) error {
|
||||
s.mu.Lock()
|
||||
inj, ok := s.Injectors[id]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("injector %s not found", id)
|
||||
}
|
||||
delete(s.Injectors, id)
|
||||
s.mu.Unlock()
|
||||
|
||||
inj.Stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) handleMetaICEStateChange(state webrtc.ICEConnectionState) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.Status == StatusTerminated {
|
||||
return
|
||||
}
|
||||
|
||||
switch state {
|
||||
case webrtc.ICEConnectionStateConnected:
|
||||
if s.Status == StatusCreated {
|
||||
s.Status = StatusMetaConnected
|
||||
s.StartedAt = time.Now()
|
||||
slog.Info("session: Meta peer connected",
|
||||
"session_id", s.ID,
|
||||
)
|
||||
}
|
||||
|
||||
case webrtc.ICEConnectionStateFailed, webrtc.ICEConnectionStateDisconnected:
|
||||
slog.Warn("session: Meta peer disconnected/failed",
|
||||
"session_id", s.ID,
|
||||
"state", state.String(),
|
||||
)
|
||||
// Meta disconnecting means the call is over.
|
||||
go s.Terminate("meta_disconnected")
|
||||
|
||||
case webrtc.ICEConnectionStateClosed:
|
||||
slog.Info("session: Meta peer closed", "session_id", s.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) handleAgentICEStateChange(peerID string, state webrtc.ICEConnectionState) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.Status == StatusTerminated {
|
||||
return
|
||||
}
|
||||
|
||||
switch state {
|
||||
case webrtc.ICEConnectionStateConnected:
|
||||
slog.Info("session: agent peer connected",
|
||||
"session_id", s.ID,
|
||||
"peer_id", peerID,
|
||||
)
|
||||
if s.Status == StatusMetaConnected || s.Status == StatusAgentDisconnected {
|
||||
s.Status = StatusActive
|
||||
// Start bridge if not already running.
|
||||
s.Bridge.Start(s.ctx)
|
||||
}
|
||||
|
||||
case webrtc.ICEConnectionStateFailed, webrtc.ICEConnectionStateDisconnected:
|
||||
slog.Warn("session: agent peer disconnected/failed",
|
||||
"session_id", s.ID,
|
||||
"peer_id", peerID,
|
||||
"state", state.String(),
|
||||
)
|
||||
|
||||
// Check if any other agent peers are still connected.
|
||||
hasConnected := false
|
||||
for id, ap := range s.AgentPeers {
|
||||
if id != peerID && ap.ICEConnectionState() == webrtc.ICEConnectionStateConnected {
|
||||
hasConnected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasConnected && s.Status != StatusTerminated {
|
||||
s.Status = StatusAgentDisconnected
|
||||
|
||||
// Start reconnect timer.
|
||||
s.reconnectTimer = time.AfterFunc(s.config.ReconnectTimeout, func() {
|
||||
slog.Info("session: reconnect timeout expired, terminating",
|
||||
"session_id", s.ID,
|
||||
)
|
||||
s.Terminate("agent_reconnect_timeout")
|
||||
})
|
||||
|
||||
// Notify Rails of agent disconnect.
|
||||
go func() {
|
||||
if s.railsClient != nil {
|
||||
err := s.railsClient.NotifyAgentDisconnected(context.Background(), callback.AgentDisconnectedPayload{
|
||||
SessionID: s.ID,
|
||||
CallID: s.CallID,
|
||||
Reason: state.String(),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("session: failed to notify Rails of agent disconnect",
|
||||
"session_id", s.ID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
case webrtc.ICEConnectionStateClosed:
|
||||
slog.Info("session: agent peer closed",
|
||||
"session_id", s.ID,
|
||||
"peer_id", peerID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) sendTerminationCallbacks(reason string) {
|
||||
if s.railsClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
durationSec := 0
|
||||
if !s.StartedAt.IsZero() {
|
||||
durationSec = int(time.Since(s.StartedAt).Seconds())
|
||||
}
|
||||
|
||||
// Notify session terminated.
|
||||
if err := s.railsClient.NotifySessionTerminated(ctx, callback.SessionTerminatedPayload{
|
||||
SessionID: s.ID,
|
||||
CallID: s.CallID,
|
||||
Reason: reason,
|
||||
DurationSec: durationSec,
|
||||
}); err != nil {
|
||||
slog.Error("session: failed to notify Rails of termination",
|
||||
"session_id", s.ID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Notify recording ready if we have one.
|
||||
if s.Recorder != nil && s.Recorder.FileSize() > 0 {
|
||||
if err := s.railsClient.NotifyRecordingReady(ctx, callback.RecordingReadyPayload{
|
||||
SessionID: s.ID,
|
||||
CallID: s.CallID,
|
||||
FilePath: s.Recorder.CombinedFilePath(),
|
||||
DurationSec: durationSec,
|
||||
FileSizeBytes: s.Recorder.FileSize(),
|
||||
}); err != nil {
|
||||
slog.Error("session: failed to notify Rails of recording",
|
||||
"session_id", s.ID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user