Compare commits
39
Commits
@@ -277,3 +277,10 @@ AZURE_APP_SECRET=
|
|||||||
|
|
||||||
# REDIS_ALFRED_SIZE=10
|
# REDIS_ALFRED_SIZE=10
|
||||||
# REDIS_VELMA_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=
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ backend: bin/rails s -p 3000
|
|||||||
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
|
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
|
||||||
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
|
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
|
||||||
vite: bin/vite dev
|
vite: bin/vite dev
|
||||||
|
media_server: cd enterprise/media-server && AUTH_TOKEN=${MEDIA_SERVER_AUTH_TOKEN:-devtoken} RAILS_CALLBACK_URL=http://localhost:3000 PUBLIC_IP=${MEDIA_SERVER_PUBLIC_IP:-127.0.0.1} RECORDINGS_DIR=/tmp/chatwoot-recordings HTTP_PORT=4000 go run ./cmd/server
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
class WhatsappCallsAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('whatsapp_calls', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
show(callId) {
|
||||||
|
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) {
|
||||||
|
const body = sdpAnswer ? { sdp_answer: sdpAnswer } : {};
|
||||||
|
return axios.post(`${this.url}/${callId}/accept`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(callId) {
|
||||||
|
return axios.post(`${this.url}/${callId}/reject`);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate(callId) {
|
||||||
|
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) {
|
||||||
|
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`);
|
||||||
|
return axios.post(`${this.url}/${callId}/upload_recording`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new WhatsappCallsAPI();
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||||
|
import { acceptWhatsappCallById } from 'dashboard/composables/useWhatsappCallSession';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||||
@@ -30,8 +32,11 @@ const BG_COLOR_MAP = {
|
|||||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const { contentAttributes, messageType } = useMessageContext();
|
const { contentAttributes, messageType } = useMessageContext();
|
||||||
|
|
||||||
|
// NOTE: contentAttributes.data keys are camelCase because MessageList.vue
|
||||||
|
// applies useCamelCase(messages, { deep: true }) before rendering.
|
||||||
const data = computed(() => contentAttributes.value?.data);
|
const data = computed(() => contentAttributes.value?.data);
|
||||||
const status = computed(() => data.value?.status?.toString());
|
const status = computed(() => data.value?.status?.toString());
|
||||||
|
|
||||||
@@ -40,6 +45,52 @@ const isFailed = computed(() =>
|
|||||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Call source and metadata — all camelCase due to deep transform
|
||||||
|
const isWhatsappCall = computed(() => data.value?.callSource === 'whatsapp');
|
||||||
|
const callId = computed(() => data.value?.callId);
|
||||||
|
const acceptedBy = computed(() => data.value?.acceptedBy);
|
||||||
|
const durationSeconds = computed(() => data.value?.durationSeconds);
|
||||||
|
const recordingUrl = computed(() => data.value?.recordingUrl);
|
||||||
|
const transcript = computed(() => data.value?.transcript);
|
||||||
|
const isJoining = ref(false);
|
||||||
|
const showTranscript = ref(false);
|
||||||
|
|
||||||
|
const formattedDuration = computed(() => {
|
||||||
|
const seconds = durationSeconds.value;
|
||||||
|
if (!seconds || seconds <= 0) return '';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show join/accept button logic
|
||||||
|
// 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(
|
||||||
|
status.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const joinButtonLabel = computed(() => {
|
||||||
|
if (isWhatsappCall.value && status.value === VOICE_CALL_STATUS.RINGING) {
|
||||||
|
return 'CONVERSATION.VOICE_CALL.ACCEPT_CALL';
|
||||||
|
}
|
||||||
|
return 'CONVERSATION.VOICE_CALL.JOIN_CALL';
|
||||||
|
});
|
||||||
|
|
||||||
const labelKey = computed(() => {
|
const labelKey = computed(() => {
|
||||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||||
@@ -53,6 +104,15 @@ const labelKey = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const subtextKey = computed(() => {
|
const subtextKey = computed(() => {
|
||||||
|
if (
|
||||||
|
acceptedBy.value?.name &&
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS, VOICE_CALL_STATUS.COMPLETED].includes(
|
||||||
|
status.value
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||||
return isOutbound.value
|
return isOutbound.value
|
||||||
@@ -64,12 +124,39 @@ const subtextKey = computed(() => {
|
|||||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const answeredByText = computed(() => {
|
||||||
|
if (!acceptedBy.value?.name) return '';
|
||||||
|
return acceptedBy.value.name;
|
||||||
|
});
|
||||||
|
|
||||||
const iconName = computed(() => {
|
const iconName = computed(() => {
|
||||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||||
});
|
});
|
||||||
|
|
||||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||||
|
|
||||||
|
const handleJoinCall = async () => {
|
||||||
|
if (isJoining.value) return;
|
||||||
|
isJoining.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isWhatsappCall.value) {
|
||||||
|
const result = await acceptWhatsappCallById(callId.value);
|
||||||
|
if (result?.success && result.call) {
|
||||||
|
router.push({
|
||||||
|
name: 'inbox_conversation',
|
||||||
|
params: { conversation_id: result.call.conversationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[WhatsApp Call] Accept from bubble failed:', err);
|
||||||
|
} finally {
|
||||||
|
isJoining.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -90,14 +177,75 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex overflow-hidden flex-col flex-grow">
|
<div class="flex overflow-hidden flex-col flex-grow gap-0.5">
|
||||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||||
{{ $t(labelKey) }}
|
{{ $t(labelKey) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-n-slate-11">
|
<span v-if="answeredByText" class="text-xs text-n-slate-11">
|
||||||
|
{{
|
||||||
|
$t('CONVERSATION.VOICE_CALL.ANSWERED_BY', {
|
||||||
|
name: answeredByText,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="subtextKey" class="text-xs text-n-slate-11">
|
||||||
{{ $t(subtextKey) }}
|
{{ $t(subtextKey) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="formattedDuration && status === VOICE_CALL_STATUS.COMPLETED"
|
||||||
|
class="text-xs text-n-slate-10"
|
||||||
|
>
|
||||||
|
{{ formattedDuration }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="showJoinButton"
|
||||||
|
:disabled="isJoining"
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-n-teal-9 hover:bg-n-teal-10 rounded-lg transition-colors shrink-0"
|
||||||
|
:class="{ 'opacity-75 cursor-wait': isJoining }"
|
||||||
|
@click="handleJoinCall"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
v-if="isJoining"
|
||||||
|
class="i-ph-circle-notch-bold text-sm animate-spin"
|
||||||
|
/>
|
||||||
|
<i v-else class="i-ph-phone-bold text-sm" />
|
||||||
|
{{ $t(joinButtonLabel) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="recordingUrl && status === VOICE_CALL_STATUS.COMPLETED"
|
||||||
|
class="px-3 pb-2"
|
||||||
|
>
|
||||||
|
<audio controls class="w-full h-8" :src="recordingUrl">
|
||||||
|
{{ $t('CONVERSATION.VOICE_CALL.AUDIO_NOT_SUPPORTED') }}
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="transcript && status === VOICE_CALL_STATUS.COMPLETED"
|
||||||
|
class="px-3 pb-3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 text-xs text-n-slate-11 hover:text-n-slate-12 transition-colors"
|
||||||
|
@click="showTranscript = !showTranscript"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="text-sm"
|
||||||
|
:class="
|
||||||
|
showTranscript ? 'i-ph-caret-up-bold' : 'i-ph-caret-down-bold'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
{{ $t('CONVERSATION.VOICE_CALL.TRANSCRIPT') }}
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
v-if="showTranscript"
|
||||||
|
class="mt-1 text-xs leading-relaxed text-n-slate-11 whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
{{ transcript }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseBubble>
|
</BaseBubble>
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<script setup>
|
||||||
|
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();
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeCall,
|
||||||
|
incomingCalls,
|
||||||
|
hasActiveCall,
|
||||||
|
hasIncomingCall,
|
||||||
|
isAccepting,
|
||||||
|
isMuted,
|
||||||
|
isOutboundRinging,
|
||||||
|
isReconnecting,
|
||||||
|
callError,
|
||||||
|
formattedCallDuration,
|
||||||
|
acceptCall,
|
||||||
|
rejectCall,
|
||||||
|
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();
|
||||||
|
|
||||||
|
const startAutoRejectTimer = call => {
|
||||||
|
if (autoRejectTimers.has(call.callId)) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
dismissIncomingCall(call);
|
||||||
|
autoRejectTimers.delete(call.callId);
|
||||||
|
}, 30000);
|
||||||
|
autoRejectTimers.set(call.callId, timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAutoRejectTimer = callId => {
|
||||||
|
const timer = autoRejectTimers.get(callId);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
autoRejectTimers.delete(callId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccept = async call => {
|
||||||
|
clearAutoRejectTimer(call.callId);
|
||||||
|
await acceptCall(call);
|
||||||
|
if (activeCall.value) {
|
||||||
|
router.push({
|
||||||
|
name: 'inbox_conversation',
|
||||||
|
params: { conversation_id: call.conversationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async call => {
|
||||||
|
clearAutoRejectTimer(call.callId);
|
||||||
|
await rejectCall(call);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndCall = async () => {
|
||||||
|
await endActiveCall();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start auto-reject timers for each newly added incoming call
|
||||||
|
watch(
|
||||||
|
incomingCalls,
|
||||||
|
calls => {
|
||||||
|
calls.forEach(call => startAutoRejectTimer(call));
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
autoRejectTimers.forEach(timer => clearTimeout(timer));
|
||||||
|
autoRejectTimers.clear();
|
||||||
|
emitter.off('whatsapp_call:agent_webrtc_connected', onAgentWebRTCConnected);
|
||||||
|
emitter.off('whatsapp_call:permission_granted', onPermissionGranted);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="hasIncomingCall || hasActiveCall"
|
||||||
|
class="fixed ltr:right-4 rtl:left-4 bottom-20 z-50 flex flex-col gap-2 w-72"
|
||||||
|
>
|
||||||
|
<!-- Error banner -->
|
||||||
|
<div
|
||||||
|
v-if="callError"
|
||||||
|
class="px-3 py-2 bg-n-ruby-3 border border-n-ruby-6 rounded-lg text-xs text-n-ruby-11"
|
||||||
|
>
|
||||||
|
{{ callError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Incoming calls (shown when there's no active call yet) -->
|
||||||
|
<template v-if="!hasActiveCall">
|
||||||
|
<div
|
||||||
|
v-for="call in incomingCalls"
|
||||||
|
:key="call.callId"
|
||||||
|
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="call.caller?.avatar"
|
||||||
|
:name="call.caller?.name || call.caller?.phone"
|
||||||
|
:size="40"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||||
|
{{
|
||||||
|
call.caller?.name ||
|
||||||
|
call.caller?.phone ||
|
||||||
|
t('WHATSAPP_CALL.UNKNOWN_CALLER')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-n-slate-11 truncate">
|
||||||
|
{{ t('WHATSAPP_CALL.INCOMING_WHATSAPP_CALL') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||||
|
:title="t('WHATSAPP_CALL.REJECT')"
|
||||||
|
@click="handleReject(call)"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||||
|
:disabled="isAccepting"
|
||||||
|
:title="t('WHATSAPP_CALL.ACCEPT')"
|
||||||
|
@click="handleAccept(call)"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
v-if="isAccepting"
|
||||||
|
class="text-lg text-white i-ph-circle-notch animate-spin"
|
||||||
|
/>
|
||||||
|
<i v-else class="text-lg text-white i-ph-phone-bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Active call widget -->
|
||||||
|
<div
|
||||||
|
v-if="hasActiveCall"
|
||||||
|
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||||
|
:class="{ 'animate-pulse': isOutboundRinging }"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="activeCall.caller?.avatar"
|
||||||
|
:name="activeCall.caller?.name || activeCall.caller?.phone"
|
||||||
|
:size="40"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||||
|
{{
|
||||||
|
activeCall.caller?.name ||
|
||||||
|
activeCall.caller?.phone ||
|
||||||
|
t('WHATSAPP_CALL.UNKNOWN_CALLER')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-sm"
|
||||||
|
:class="
|
||||||
|
isOutboundRinging || isReconnecting
|
||||||
|
? 'text-n-slate-11'
|
||||||
|
: 'font-mono text-n-teal-9'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<!-- Mute toggle -->
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-9 h-9 rounded-full transition-colors"
|
||||||
|
:class="
|
||||||
|
isMuted
|
||||||
|
? 'bg-n-amber-9 hover:bg-n-amber-10'
|
||||||
|
: 'bg-n-slate-4 hover:bg-n-slate-5'
|
||||||
|
"
|
||||||
|
:title="isMuted ? t('WHATSAPP_CALL.UNMUTE') : t('WHATSAPP_CALL.MUTE')"
|
||||||
|
@click="toggleMute"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="text-base text-white"
|
||||||
|
:class="
|
||||||
|
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<!-- Hang up -->
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-9 h-9 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||||
|
:title="t('WHATSAPP_CALL.HANG_UP')"
|
||||||
|
@click="handleEndCall"
|
||||||
|
>
|
||||||
|
<i class="text-base text-white i-ph-phone-x-bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -13,6 +13,14 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
|||||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||||
import { useInbox } from 'dashboard/composables/useInbox';
|
import { useInbox } from 'dashboard/composables/useInbox';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import WhatsappCallsAPI from 'dashboard/api/whatsappCalls';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
|
import {
|
||||||
|
useWhatsappCallsStore,
|
||||||
|
setOutboundCallProperty,
|
||||||
|
} from 'dashboard/stores/whatsappCalls';
|
||||||
|
import { startCallRecording } from 'dashboard/composables/useWhatsappCallSession';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
chat: {
|
chat: {
|
||||||
@@ -30,7 +38,9 @@ const store = useStore();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const conversationHeader = ref(null);
|
const conversationHeader = ref(null);
|
||||||
const { width } = useElementSize(conversationHeader);
|
const { width } = useElementSize(conversationHeader);
|
||||||
const { isAWebWidgetInbox } = useInbox();
|
const { isAWebWidgetInbox, isAWhatsAppCloudChannel } = useInbox();
|
||||||
|
const whatsappCallsStore = useWhatsappCallsStore();
|
||||||
|
const isInitiatingCall = ref(false);
|
||||||
|
|
||||||
const currentChat = computed(() => store.getters.getSelectedChat);
|
const currentChat = computed(() => store.getters.getSelectedChat);
|
||||||
const accountId = computed(() => store.getters.getCurrentAccountId);
|
const accountId = computed(() => store.getters.getCurrentAccountId);
|
||||||
@@ -91,6 +101,211 @@ const hasMultipleInboxes = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||||
|
|
||||||
|
const canInitiateWhatsappCall = computed(() => {
|
||||||
|
if (!isAWhatsAppCloudChannel.value) return false;
|
||||||
|
if (!inbox.value?.calling_enabled) return false;
|
||||||
|
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, reject) => {
|
||||||
|
if (pc.iceGatheringState === 'complete') {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = null;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
pc.onicegatheringstatechange = null;
|
||||||
|
pc.oniceconnectionstatechange = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
pc.onicegatheringstatechange = () => {
|
||||||
|
if (pc.iceGatheringState === 'complete') {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
if (pc.iceConnectionState === 'failed') {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('ICE connection failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 recordCallId = null;
|
||||||
|
try {
|
||||||
|
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
pc = new RTCPeerConnection({
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||||
|
});
|
||||||
|
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
|
||||||
|
|
||||||
|
pc.ontrack = event => {
|
||||||
|
const [stream] = event.streams;
|
||||||
|
if (!stream) return;
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
audio.srcObject = stream;
|
||||||
|
audio.autoplay = true;
|
||||||
|
document.body.appendChild(audio);
|
||||||
|
setOutboundCallProperty('audio', audio);
|
||||||
|
whatsappCallsStore.markActiveCallConnected();
|
||||||
|
if (recordCallId) startCallRecording(pc, localStream, recordCallId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
await waitForOutboundIceGathering(pc);
|
||||||
|
const completeSdp = pc.localDescription.sdp;
|
||||||
|
|
||||||
|
const response = await WhatsappCallsAPI.initiate(
|
||||||
|
currentChat.value.id,
|
||||||
|
completeSdp
|
||||||
|
);
|
||||||
|
|
||||||
|
const callStatus = response.data?.status;
|
||||||
|
if (
|
||||||
|
callStatus === 'permission_requested' ||
|
||||||
|
callStatus === 'permission_pending'
|
||||||
|
) {
|
||||||
|
pc.close();
|
||||||
|
localStream.getTracks().forEach(track => track.stop());
|
||||||
|
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;
|
||||||
|
recordCallId = response.data?.id;
|
||||||
|
setOutboundCallProperty('pc', pc);
|
||||||
|
setOutboundCallProperty('stream', localStream);
|
||||||
|
setOutboundCallProperty('callId', outboundCallId);
|
||||||
|
|
||||||
|
whatsappCallsStore.setActiveCall({
|
||||||
|
id: response.data?.id,
|
||||||
|
callId: outboundCallId,
|
||||||
|
direction: 'outbound',
|
||||||
|
status: 'ringing',
|
||||||
|
serverRelay: false,
|
||||||
|
conversationId: currentChat.value.id,
|
||||||
|
caller: {
|
||||||
|
name: currentContact.value?.name,
|
||||||
|
phone: currentContact.value?.phone_number,
|
||||||
|
avatar: currentContact.value?.thumbnail,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (pc) pc.close();
|
||||||
|
if (localStream) localStream.getTracks().forEach(track => track.stop());
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initiateWhatsappCall = () => {
|
||||||
|
if (isMediaServerEnabled.value) {
|
||||||
|
return initiateServerRelayCall();
|
||||||
|
}
|
||||||
|
return initiateLegacyCall();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -152,6 +367,19 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
|||||||
:parent-width="width"
|
:parent-width="width"
|
||||||
class="hidden md:flex"
|
class="hidden md:flex"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
v-if="canInitiateWhatsappCall"
|
||||||
|
v-tooltip="$t('WHATSAPP_CALL.INITIATE_CALL')"
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded-lg text-n-slate-11 hover:text-n-slate-12 hover:bg-n-slate-3 transition-colors"
|
||||||
|
:disabled="isInitiatingCall"
|
||||||
|
@click="initiateWhatsappCall"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
v-if="isInitiatingCall"
|
||||||
|
class="text-base i-ph-circle-notch animate-spin"
|
||||||
|
/>
|
||||||
|
<i v-else class="text-base i-ph-phone-bold" />
|
||||||
|
</button>
|
||||||
<MoreActions :conversation-id="currentChat.id" />
|
<MoreActions :conversation-id="currentChat.id" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||||
|
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 (shared across legacy inbound + server-relay) ──
|
||||||
|
let inboundPc = null;
|
||||||
|
let inboundStream = null;
|
||||||
|
let inboundAudio = null;
|
||||||
|
|
||||||
|
// ── Module-level recording state (legacy mode only) ──
|
||||||
|
let mediaRecorder = null;
|
||||||
|
let recordedChunks = [];
|
||||||
|
let recordingCallId = null;
|
||||||
|
|
||||||
|
function cleanupInboundWebRTC() {
|
||||||
|
if (inboundStream) {
|
||||||
|
inboundStream.getTracks().forEach(track => track.stop());
|
||||||
|
inboundStream = null;
|
||||||
|
}
|
||||||
|
if (inboundPc) {
|
||||||
|
inboundPc.close();
|
||||||
|
inboundPc = null;
|
||||||
|
}
|
||||||
|
if (inboundAudio) {
|
||||||
|
inboundAudio.srcObject = null;
|
||||||
|
if (inboundAudio.parentNode) {
|
||||||
|
inboundAudio.parentNode.removeChild(inboundAudio);
|
||||||
|
}
|
||||||
|
inboundAudio = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
|
||||||
|
if (localStream) {
|
||||||
|
const localSource = ctx.createMediaStreamSource(localStream);
|
||||||
|
localSource.connect(dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
pc.getReceivers().forEach(receiver => {
|
||||||
|
if (receiver.track && receiver.track.kind === 'audio') {
|
||||||
|
const remoteStream = new MediaStream([receiver.track]);
|
||||||
|
const remoteSource = ctx.createMediaStreamSource(remoteStream);
|
||||||
|
remoteSource.connect(dest);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
recordedChunks = [];
|
||||||
|
recordingCallId = callId;
|
||||||
|
const recorder = new MediaRecorder(dest.stream, {
|
||||||
|
mimeType: 'audio/webm;codecs=opus',
|
||||||
|
});
|
||||||
|
|
||||||
|
recorder.ondataavailable = e => {
|
||||||
|
if (e.data.size > 0) recordedChunks.push(e.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder = recorder;
|
||||||
|
recorder.start(1000);
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[WhatsApp Call] Failed to start recording:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
const id = callId || recordingCallId;
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
if (recordedChunks.length === 0 || !id) return;
|
||||||
|
|
||||||
|
const blob = new Blob(recordedChunks, { type: 'audio/webm' });
|
||||||
|
recordedChunks = [];
|
||||||
|
recordingCallId = null;
|
||||||
|
|
||||||
|
WhatsappCallsAPI.uploadRecording(id, blob).catch(err => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[WhatsApp Call] Failed to upload recording:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.stop();
|
||||||
|
mediaRecorder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForIceGatheringComplete(pc) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (pc.iceGatheringState === 'complete') {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
pc.onicegatheringstatechange = () => {
|
||||||
|
if (pc.iceGatheringState === 'complete') {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
if (pc.iceConnectionState === 'failed') {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('ICE connection failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server-relay mode detection ──
|
||||||
|
// Prefer the explicit flag set by Rails whenever it's available (call object
|
||||||
|
// from GET /whatsapp_calls/:id, inbox serializer, or the incoming ActionCable
|
||||||
|
// broadcast). Fall back to the presence of sdpOffer so legacy deployments keep
|
||||||
|
// working when the field isn't set.
|
||||||
|
function isServerRelayCall(call) {
|
||||||
|
if (call?.mediaServerEnabled === true) return true;
|
||||||
|
if (call?.mediaServerEnabled === false) return false;
|
||||||
|
return !call?.sdpOffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 handleAgentOffer(callId, sdpOffer, iceServers) {
|
||||||
|
cleanupInboundWebRTC();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
inboundStream = stream;
|
||||||
|
|
||||||
|
const servers = iceServers?.length
|
||||||
|
? iceServers
|
||||||
|
: [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({ iceServers: servers });
|
||||||
|
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(() => {});
|
||||||
|
|
||||||
|
// No client-side recording in server-relay mode — the media server records
|
||||||
|
};
|
||||||
|
|
||||||
|
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.agentAnswer(callId, completeSdp);
|
||||||
|
|
||||||
|
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(callId) {
|
||||||
|
const callsStore = useWhatsappCallsStore();
|
||||||
|
|
||||||
|
if (callsStore.hasActiveCall) {
|
||||||
|
return { success: false, error: 'active_call_exists' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let call = callsStore.incomingCalls.find(
|
||||||
|
c => c.id === callId || c.callId === String(callId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!call) {
|
||||||
|
const { data } = await WhatsappCallsAPI.show(callId);
|
||||||
|
if (data.status !== 'ringing') {
|
||||||
|
return { success: false, error: 'not_ringing' };
|
||||||
|
}
|
||||||
|
call = {
|
||||||
|
id: data.id,
|
||||||
|
callId: data.call_id,
|
||||||
|
direction: data.direction,
|
||||||
|
inboxId: data.inbox_id,
|
||||||
|
conversationId: data.conversation_id,
|
||||||
|
sdpOffer: data.sdp_offer,
|
||||||
|
iceServers: data.ice_servers,
|
||||||
|
mediaServerEnabled: data.media_server_enabled,
|
||||||
|
caller: data.caller,
|
||||||
|
};
|
||||||
|
callsStore.addIncomingCall(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await doAcceptCall(call);
|
||||||
|
|
||||||
|
callsStore.removeIncomingCall(call.callId);
|
||||||
|
|
||||||
|
// 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() : {};
|
||||||
|
const accountId =
|
||||||
|
window.location.pathname.includes('/app/accounts') &&
|
||||||
|
window.location.pathname.split('/')[3];
|
||||||
|
if (!accountId) return;
|
||||||
|
|
||||||
|
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
keepalive: true,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'access-token': authData['access-token'] || '',
|
||||||
|
'token-type': authData['token-type'] || '',
|
||||||
|
client: authData.client || '',
|
||||||
|
expiry: authData.expiry || '',
|
||||||
|
uid: authData.uid || '',
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Composable (used by WhatsappCallWidget for floating UI + timer) ──
|
||||||
|
export function useWhatsappCallSession() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const callsStore = useWhatsappCallsStore();
|
||||||
|
|
||||||
|
const isAccepting = ref(false);
|
||||||
|
const isMuted = ref(false);
|
||||||
|
const callError = ref(null);
|
||||||
|
const callDuration = ref(0);
|
||||||
|
const isReconnecting = computed(() => callsStore.isReconnecting);
|
||||||
|
|
||||||
|
const durationTimer = new Timer(elapsed => {
|
||||||
|
callDuration.value = callsStore.callTimerOffset + elapsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCall = computed(() => callsStore.activeCall);
|
||||||
|
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||||
|
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||||
|
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
|
||||||
|
const firstIncomingCall = computed(() => callsStore.firstIncomingCall);
|
||||||
|
|
||||||
|
const isOutboundRinging = computed(
|
||||||
|
() =>
|
||||||
|
activeCall.value?.direction === 'outbound' &&
|
||||||
|
activeCall.value?.status === 'ringing'
|
||||||
|
);
|
||||||
|
|
||||||
|
const formattedCallDuration = computed(() => {
|
||||||
|
const minutes = Math.floor(callDuration.value / 60);
|
||||||
|
const seconds = callDuration.value % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register cleanup so external call-end events can teardown WebRTC
|
||||||
|
callsStore.registerCleanupCallback(() => {
|
||||||
|
// Only do recording cleanup in legacy mode
|
||||||
|
if (!callsStore.isMediaServerEnabled) {
|
||||||
|
stopAndUploadRecording();
|
||||||
|
}
|
||||||
|
cleanupInboundWebRTC();
|
||||||
|
durationTimer.stop();
|
||||||
|
callDuration.value = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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) 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
|
||||||
|
// Start timer when outbound call becomes connected
|
||||||
|
watch(activeCall, call => {
|
||||||
|
if (
|
||||||
|
call?.direction === 'outbound' &&
|
||||||
|
call?.status === 'connected' &&
|
||||||
|
!durationTimer.intervalId
|
||||||
|
) {
|
||||||
|
durationTimer.start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an incoming call — used by the floating widget buttons.
|
||||||
|
*/
|
||||||
|
const acceptCall = async call => {
|
||||||
|
if (isAccepting.value) return;
|
||||||
|
isAccepting.value = true;
|
||||||
|
callError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await doAcceptCall(call);
|
||||||
|
callsStore.removeIncomingCall(call.callId);
|
||||||
|
|
||||||
|
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'
|
||||||
|
? t('WHATSAPP_CALL.MIC_DENIED')
|
||||||
|
: t('WHATSAPP_CALL.CALL_FAILED');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[WhatsApp Call] acceptCall error:', err);
|
||||||
|
// Note: doAcceptCall already cleans up WebRTC resources on error
|
||||||
|
} finally {
|
||||||
|
isAccepting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectCall = async call => {
|
||||||
|
try {
|
||||||
|
await WhatsappCallsAPI.reject(call.id);
|
||||||
|
} catch {
|
||||||
|
// Best effort
|
||||||
|
} finally {
|
||||||
|
callsStore.removeIncomingCall(call.callId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endActiveCall = async () => {
|
||||||
|
const call = activeCall.value;
|
||||||
|
if (!call) return;
|
||||||
|
|
||||||
|
// Only upload recording in legacy mode
|
||||||
|
if (!call.serverRelay) {
|
||||||
|
stopAndUploadRecording(call.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await WhatsappCallsAPI.terminate(call.id);
|
||||||
|
} catch {
|
||||||
|
// Best effort
|
||||||
|
} finally {
|
||||||
|
cleanupInboundWebRTC();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const stream = inboundStream || getOutboundCallState().stream;
|
||||||
|
if (!stream) return;
|
||||||
|
const audioTrack = stream.getAudioTracks()[0];
|
||||||
|
if (!audioTrack) return;
|
||||||
|
audioTrack.enabled = !audioTrack.enabled;
|
||||||
|
isMuted.value = !audioTrack.enabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissIncomingCall = call => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCall,
|
||||||
|
incomingCalls,
|
||||||
|
hasActiveCall,
|
||||||
|
hasIncomingCall,
|
||||||
|
firstIncomingCall,
|
||||||
|
isAccepting,
|
||||||
|
isMuted,
|
||||||
|
isOutboundRinging,
|
||||||
|
isReconnecting,
|
||||||
|
callError,
|
||||||
|
formattedCallDuration,
|
||||||
|
acceptCall,
|
||||||
|
rejectCall,
|
||||||
|
endActiveCall,
|
||||||
|
toggleMute,
|
||||||
|
dismissIncomingCall,
|
||||||
|
startDurationTimer,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ export const FEATURE_FLAGS = {
|
|||||||
COMPANIES: 'companies',
|
COMPANIES: 'companies',
|
||||||
ADVANCED_SEARCH: 'advanced_search',
|
ADVANCED_SEARCH: 'advanced_search',
|
||||||
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
||||||
|
WHATSAPP_CALL: 'whatsapp_call',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PREMIUM_FEATURES = [
|
export const PREMIUM_FEATURES = [
|
||||||
@@ -57,4 +58,5 @@ export const PREMIUM_FEATURES = [
|
|||||||
FEATURE_FLAGS.SAML,
|
FEATURE_FLAGS.SAML,
|
||||||
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
|
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
|
||||||
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||||
|
FEATURE_FLAGS.WHATSAPP_CALL,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
|
|||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||||
|
import {
|
||||||
|
useWhatsappCallsStore,
|
||||||
|
getOutboundCallState,
|
||||||
|
} from 'dashboard/stores/whatsappCalls';
|
||||||
|
import { handleAgentOffer } from 'dashboard/composables/useWhatsappCallSession';
|
||||||
|
|
||||||
const { isImpersonating } = useImpersonation();
|
const { isImpersonating } = useImpersonation();
|
||||||
|
|
||||||
@@ -34,6 +39,12 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
'conversation.updated': this.onConversationUpdated,
|
'conversation.updated': this.onConversationUpdated,
|
||||||
'account.cache_invalidated': this.onCacheInvalidate,
|
'account.cache_invalidated': this.onCacheInvalidate,
|
||||||
'copilot.message.created': this.onCopilotMessageCreated,
|
'copilot.message.created': this.onCopilotMessageCreated,
|
||||||
|
'whatsapp_call.incoming': this.onWhatsappCallIncoming,
|
||||||
|
'whatsapp_call.accepted': this.onWhatsappCallAccepted,
|
||||||
|
'whatsapp_call.ended': this.onWhatsappCallEnded,
|
||||||
|
'whatsapp_call.outbound_connected': this.onWhatsappCallOutboundConnected,
|
||||||
|
'whatsapp_call.permission_granted': this.onWhatsappCallPermissionGranted,
|
||||||
|
'whatsapp_call.agent_offer': this.onWhatsappCallAgentOffer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +211,111 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
direction: data.direction,
|
||||||
|
inboxId: data.inbox_id,
|
||||||
|
conversationId: data.conversation_id,
|
||||||
|
caller: data.caller,
|
||||||
|
sdpOffer: data.sdp_offer || null,
|
||||||
|
iceServers: data.ice_servers || null,
|
||||||
|
mediaServerEnabled: data.media_server_enabled,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onWhatsappCallAccepted = data => {
|
||||||
|
const whatsappCallsStore = useWhatsappCallsStore();
|
||||||
|
const currentUserId = this.app.$store.getters.getCurrentUserID;
|
||||||
|
// If accepted by a different agent, remove from incoming list for this agent
|
||||||
|
if (data.accepted_by_agent_id !== currentUserId) {
|
||||||
|
whatsappCallsStore.handleCallAcceptedByOther(data.call_id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
onWhatsappCallEnded = data => {
|
||||||
|
const whatsappCallsStore = useWhatsappCallsStore();
|
||||||
|
whatsappCallsStore.handleCallEnded(data.call_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
err => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(
|
||||||
|
'[WhatsApp Call] Failed to set remote SDP answer:',
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
onWhatsappCallPermissionGranted = data => {
|
||||||
|
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 {
|
export default {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const isVoiceCallMessage = message => {
|
|||||||
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
|
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isWhatsappCall = message => {
|
||||||
|
return message?.content_attributes?.data?.call_source === 'whatsapp';
|
||||||
|
};
|
||||||
|
|
||||||
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||||
return callDirection === 'outbound' && senderId !== currentUserId;
|
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||||
};
|
};
|
||||||
@@ -36,6 +40,10 @@ function extractCallData(message) {
|
|||||||
export function handleVoiceCallCreated(message, currentUserId) {
|
export function handleVoiceCallCreated(message, currentUserId) {
|
||||||
if (!isVoiceCallMessage(message)) return;
|
if (!isVoiceCallMessage(message)) return;
|
||||||
|
|
||||||
|
// WhatsApp calls are managed by their own store (whatsappCalls),
|
||||||
|
// don't add them to the Twilio calls store.
|
||||||
|
if (isWhatsappCall(message)) return;
|
||||||
|
|
||||||
const { callSid, callDirection, conversationId, senderId } =
|
const { callSid, callDirection, conversationId, senderId } =
|
||||||
extractCallData(message);
|
extractCallData(message);
|
||||||
|
|
||||||
@@ -56,14 +64,18 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
|||||||
const { callSid, status, callDirection, conversationId, senderId } =
|
const { callSid, status, callDirection, conversationId, senderId } =
|
||||||
extractCallData(message);
|
extractCallData(message);
|
||||||
|
|
||||||
const callsStore = useCallsStore();
|
// Vuex message/conversation status updates apply to all call sources
|
||||||
|
|
||||||
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
|
||||||
|
|
||||||
const callInfo = { conversationId, callStatus: status };
|
const callInfo = { conversationId, callStatus: status };
|
||||||
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||||
|
|
||||||
|
// Twilio-specific store interactions — skip for WhatsApp calls
|
||||||
|
if (isWhatsappCall(message)) return;
|
||||||
|
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
|
||||||
|
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||||
|
|
||||||
const isNewCall =
|
const isNewCall =
|
||||||
status === 'ringing' &&
|
status === 'ringing' &&
|
||||||
!shouldSkipCall(callDirection, senderId, currentUserId);
|
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||||
|
|||||||
@@ -83,7 +83,13 @@
|
|||||||
"CALL_ENDED": "Call ended",
|
"CALL_ENDED": "Call ended",
|
||||||
"NOT_ANSWERED_YET": "Not answered yet",
|
"NOT_ANSWERED_YET": "Not answered yet",
|
||||||
"THEY_ANSWERED": "They answered",
|
"THEY_ANSWERED": "They answered",
|
||||||
"YOU_ANSWERED": "You answered"
|
"YOU_ANSWERED": "You answered",
|
||||||
|
"ANSWERED_BY": "Answered by {name}",
|
||||||
|
"DURATION": "{duration}",
|
||||||
|
"ACCEPT_CALL": "Accept",
|
||||||
|
"JOIN_CALL": "Join",
|
||||||
|
"TRANSCRIPT": "Transcript",
|
||||||
|
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback."
|
||||||
},
|
},
|
||||||
"HEADER": {
|
"HEADER": {
|
||||||
"RESOLVE_ACTION": "Resolve",
|
"RESOLVE_ACTION": "Resolve",
|
||||||
|
|||||||
@@ -790,6 +790,9 @@
|
|||||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||||
|
"WHATSAPP_CALLING_TITLE": "WhatsApp Calling",
|
||||||
|
"WHATSAPP_CALLING_SUBHEADER": "Enable agents to make and receive WhatsApp voice calls directly from Chatwoot. Requires your WhatsApp number to be approved for the Calling API by Meta.",
|
||||||
|
"WHATSAPP_CALLING_LABEL": "Enable WhatsApp Calling",
|
||||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||||
},
|
},
|
||||||
"HELP_CENTER": {
|
"HELP_CENTER": {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import sla from './sla.json';
|
|||||||
import snooze from './snooze.json';
|
import snooze from './snooze.json';
|
||||||
import teamsSettings from './teamsSettings.json';
|
import teamsSettings from './teamsSettings.json';
|
||||||
import whatsappTemplates from './whatsappTemplates.json';
|
import whatsappTemplates from './whatsappTemplates.json';
|
||||||
|
import whatsappCall from './whatsappCall.json';
|
||||||
import contentTemplates from './contentTemplates.json';
|
import contentTemplates from './contentTemplates.json';
|
||||||
import mfa from './mfa.json';
|
import mfa from './mfa.json';
|
||||||
import yearInReview from './yearInReview.json';
|
import yearInReview from './yearInReview.json';
|
||||||
@@ -81,6 +82,7 @@ export default {
|
|||||||
...snooze,
|
...snooze,
|
||||||
...teamsSettings,
|
...teamsSettings,
|
||||||
...whatsappTemplates,
|
...whatsappTemplates,
|
||||||
|
...whatsappCall,
|
||||||
...contentTemplates,
|
...contentTemplates,
|
||||||
...mfa,
|
...mfa,
|
||||||
...yearInReview,
|
...yearInReview,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"WHATSAPP_CALL": {
|
||||||
|
"INCOMING_WHATSAPP_CALL": "Incoming WhatsApp Call",
|
||||||
|
"OUTGOING_WHATSAPP_CALL": "Outgoing WhatsApp Call",
|
||||||
|
"ACCEPT": "Accept",
|
||||||
|
"REJECT": "Reject",
|
||||||
|
"HANG_UP": "Hang Up",
|
||||||
|
"MUTE": "Mute",
|
||||||
|
"UNMUTE": "Unmute",
|
||||||
|
"INITIATE_CALL": "Call via WhatsApp",
|
||||||
|
"CALLING": "Calling…",
|
||||||
|
"RINGING": "Ringing…",
|
||||||
|
"CALL_FAILED": "Call failed. Please try again.",
|
||||||
|
"PERMISSION_REQUESTED": "Call permission request sent to the contact. You can call once they approve.",
|
||||||
|
"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",
|
||||||
|
"RECONNECTING": "Reconnecting…",
|
||||||
|
"PERMISSION_GRANTED": "{contactName} approved the call permission request. You can now call them."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,16 @@ const FloatingCallWidget = defineAsyncComponent(
|
|||||||
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
|
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const WhatsappCallWidget = defineAsyncComponent(
|
||||||
|
() => import('dashboard/components/widgets/WhatsappCallWidget.vue')
|
||||||
|
);
|
||||||
|
|
||||||
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
||||||
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
||||||
|
|
||||||
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
|
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
|
||||||
import { useCallsStore } from 'dashboard/stores/calls';
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
import { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -36,6 +41,7 @@ export default {
|
|||||||
CopilotLauncher,
|
CopilotLauncher,
|
||||||
CopilotContainer,
|
CopilotContainer,
|
||||||
FloatingCallWidget,
|
FloatingCallWidget,
|
||||||
|
WhatsappCallWidget,
|
||||||
MobileSidebarLauncher,
|
MobileSidebarLauncher,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
@@ -44,6 +50,7 @@ export default {
|
|||||||
const { accountId } = useAccount();
|
const { accountId } = useAccount();
|
||||||
const { width: windowWidth } = useWindowSize();
|
const { width: windowWidth } = useWindowSize();
|
||||||
const callsStore = useCallsStore();
|
const callsStore = useCallsStore();
|
||||||
|
const whatsappCallsStore = useWhatsappCallsStore();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
@@ -53,6 +60,10 @@ export default {
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
hasActiveCall: computed(() => callsStore.hasActiveCall),
|
hasActiveCall: computed(() => callsStore.hasActiveCall),
|
||||||
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
|
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
|
||||||
|
hasWhatsappCall: computed(
|
||||||
|
() =>
|
||||||
|
whatsappCallsStore.hasActiveCall || whatsappCallsStore.hasIncomingCall
|
||||||
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -163,6 +174,7 @@ export default {
|
|||||||
/>
|
/>
|
||||||
<CopilotContainer />
|
<CopilotContainer />
|
||||||
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
|
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
|
||||||
|
<WhatsappCallWidget v-if="hasWhatsappCall" />
|
||||||
</template>
|
</template>
|
||||||
<AddAccountModal
|
<AddAccountModal
|
||||||
:show="showCreateAccountModal"
|
:show="showCreateAccountModal"
|
||||||
|
|||||||
+36
@@ -43,6 +43,7 @@ export default {
|
|||||||
isSyncingTemplates: false,
|
isSyncingTemplates: false,
|
||||||
allowedDomains: '',
|
allowedDomains: '',
|
||||||
isUpdatingAllowedDomains: false,
|
isUpdatingAllowedDomains: false,
|
||||||
|
callingEnabled: false,
|
||||||
isSettingDefaults: false,
|
isSettingDefaults: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -83,6 +84,8 @@ export default {
|
|||||||
this.inbox.selected_feature_flags || []
|
this.inbox.selected_feature_flags || []
|
||||||
).includes('allow_mobile_webview');
|
).includes('allow_mobile_webview');
|
||||||
this.allowedDomains = this.inbox.allowed_domains || '';
|
this.allowedDomains = this.inbox.allowed_domains || '';
|
||||||
|
this.callingEnabled =
|
||||||
|
this.inbox.provider_config?.calling_enabled || false;
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.isSettingDefaults = false;
|
this.isSettingDefaults = false;
|
||||||
});
|
});
|
||||||
@@ -171,6 +174,23 @@ export default {
|
|||||||
await this.$refs.whatsappReauth.requestAuthorization();
|
await this.$refs.whatsappReauth.requestAuthorization();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async updateCallingEnabled() {
|
||||||
|
try {
|
||||||
|
await this.$store.dispatch('inboxes/updateInbox', {
|
||||||
|
id: this.inbox.id,
|
||||||
|
formData: false,
|
||||||
|
channel: {
|
||||||
|
provider_config: {
|
||||||
|
...this.inbox.provider_config,
|
||||||
|
calling_enabled: this.callingEnabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||||
|
}
|
||||||
|
},
|
||||||
async syncTemplates() {
|
async syncTemplates() {
|
||||||
this.isSyncingTemplates = true;
|
this.isSyncingTemplates = true;
|
||||||
try {
|
try {
|
||||||
@@ -452,6 +472,22 @@ export default {
|
|||||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
|
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
|
||||||
</NextButton>
|
</NextButton>
|
||||||
</SettingsFieldSection>
|
</SettingsFieldSection>
|
||||||
|
<SettingsFieldSection
|
||||||
|
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_TITLE')"
|
||||||
|
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_SUBHEADER')"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
id="callingEnabled"
|
||||||
|
v-model="callingEnabled"
|
||||||
|
type="checkbox"
|
||||||
|
@change="updateCallingEnabled"
|
||||||
|
/>
|
||||||
|
<label for="callingEnabled" class="text-body-main text-n-slate-12">
|
||||||
|
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_LABEL') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</SettingsFieldSection>
|
||||||
</div>
|
</div>
|
||||||
<WhatsappReauthorize
|
<WhatsappReauthorize
|
||||||
v-if="isEmbeddedSignupWhatsApp"
|
v-if="isEmbeddedSignupWhatsApp"
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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() {
|
||||||
|
return outboundCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setOutboundCallProperty(key, value) {
|
||||||
|
outboundCall[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupOutboundCall() {
|
||||||
|
if (outboundCall.pc) outboundCall.pc.close();
|
||||||
|
if (outboundCall.stream) {
|
||||||
|
outboundCall.stream.getTracks().forEach(t => t.stop());
|
||||||
|
}
|
||||||
|
if (outboundCall.audio) {
|
||||||
|
outboundCall.audio.srcObject = null;
|
||||||
|
outboundCall.audio.remove();
|
||||||
|
}
|
||||||
|
outboundCall.pc = null;
|
||||||
|
outboundCall.stream = null;
|
||||||
|
outboundCall.audio = null;
|
||||||
|
outboundCall.callId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWhatsappCallsStore = defineStore('whatsappCalls', {
|
||||||
|
state: () => ({
|
||||||
|
// Incoming ringing calls waiting for agent action
|
||||||
|
incomingCalls: [],
|
||||||
|
// The single active call (accepted + audio connected)
|
||||||
|
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: {
|
||||||
|
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,
|
||||||
|
|
||||||
|
// 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: {
|
||||||
|
addIncomingCall(callData) {
|
||||||
|
const exists = this.incomingCalls.some(c => c.callId === callData.callId);
|
||||||
|
if (exists) return;
|
||||||
|
this.incomingCalls.push(callData);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeIncomingCall(callId) {
|
||||||
|
this.incomingCalls = this.incomingCalls.filter(c => c.callId !== callId);
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveCall(callData) {
|
||||||
|
this.activeCall = callData;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearActiveCall() {
|
||||||
|
this.activeCall = null;
|
||||||
|
this.callTimerOffset = 0;
|
||||||
|
this.isReconnecting = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
markActiveCallConnected() {
|
||||||
|
if (this.activeCall) {
|
||||||
|
this.activeCall = { ...this.activeCall, status: 'connected' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
registerCleanupCallback(callback) {
|
||||||
|
this.cleanupCallback = callback;
|
||||||
|
},
|
||||||
|
|
||||||
|
setReconnecting(value) {
|
||||||
|
this.isReconnecting = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
setTimerOffset(seconds) {
|
||||||
|
this.callTimerOffset = seconds;
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCallAcceptedByOther(callId) {
|
||||||
|
this.removeIncomingCall(callId);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCallEnded(callId) {
|
||||||
|
this.removeIncomingCall(callId);
|
||||||
|
if (this.activeCall?.callId === callId) {
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -100,3 +100,5 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
|||||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Webhooks::WhatsappEventsJob.prepend_mod_with('Webhooks::WhatsappEventsJob')
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class Whatsapp::FacebookApiClient
|
|||||||
body: {
|
body: {
|
||||||
override_callback_uri: callback_url,
|
override_callback_uri: callback_url,
|
||||||
verify_token: verify_token,
|
verify_token: verify_token,
|
||||||
subscribed_fields: %w[messages smb_message_echoes]
|
subscribed_fields: webhook_subscribed_fields
|
||||||
}.to_json
|
}.to_json
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,6 +102,10 @@ class Whatsapp::FacebookApiClient
|
|||||||
handle_response(response, 'Webhook unsubscription failed')
|
handle_response(response, 'Webhook unsubscription failed')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def webhook_subscribed_fields
|
||||||
|
%w[messages smb_message_echoes]
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def request_headers
|
def request_headers
|
||||||
@@ -123,3 +127,5 @@ class Whatsapp::FacebookApiClient
|
|||||||
response.parsed_response
|
response.parsed_response
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Whatsapp::FacebookApiClient.prepend_mod_with('Whatsapp::FacebookApiClient')
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
|||||||
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
|
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO: See if we can unify the API versions and for both paths and make it consistent with out facebook app API versions
|
|
||||||
def phone_id_path
|
def phone_id_path
|
||||||
"#{api_base_path}/v13.0/#{whatsapp_channel.provider_config['phone_number_id']}"
|
api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
|
||||||
|
"#{api_base_path}/#{api_version}/#{whatsapp_channel.provider_config['phone_number_id']}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def business_account_path
|
def business_account_path
|
||||||
@@ -205,3 +205,5 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
|||||||
process_response(response, message)
|
process_response(response, message)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Whatsapp::Providers::WhatsappCloudService.include_mod_with('Whatsapp::Providers::WhatsappCloudService')
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ if resource.whatsapp?
|
|||||||
json.message_templates resource.channel.try(:message_templates)
|
json.message_templates resource.channel.try(:message_templates)
|
||||||
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
||||||
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
||||||
|
json.calling_enabled resource.channel.try(:provider_config)&.dig('calling_enabled') || false
|
||||||
|
json.media_server_enabled Call.media_server_enabled?
|
||||||
end
|
end
|
||||||
|
|
||||||
## Voice Channel Attributes
|
## Voice Channel Attributes
|
||||||
|
|||||||
@@ -104,6 +104,10 @@
|
|||||||
display_name: Audit Logs
|
display_name: Audit Logs
|
||||||
enabled: false
|
enabled: false
|
||||||
premium: true
|
premium: true
|
||||||
|
- name: whatsapp_call
|
||||||
|
display_name: WhatsApp Calling
|
||||||
|
enabled: false
|
||||||
|
premium: true
|
||||||
- name: custom_tools
|
- name: custom_tools
|
||||||
display_name: Custom Tools
|
display_name: Custom Tools
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -303,6 +303,23 @@ Rails.application.routes.draw do
|
|||||||
resource :authorization, only: [:create]
|
resource :authorization, only: [:create]
|
||||||
end
|
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
|
||||||
|
end
|
||||||
|
|
||||||
resources :webhooks, only: [:index, :create, :update, :destroy]
|
resources :webhooks, only: [:index, :create, :update, :destroy]
|
||||||
namespace :integrations do
|
namespace :integrations do
|
||||||
resources :apps, only: [:index, :show]
|
resources :apps, only: [:index, :show]
|
||||||
@@ -606,6 +623,16 @@ Rails.application.routes.draw do
|
|||||||
get 'instagram/callback', to: 'instagram/callbacks#show'
|
get 'instagram/callback', to: 'instagram/callbacks#show'
|
||||||
get 'tiktok/callback', to: 'tiktok/callbacks#show'
|
get 'tiktok/callback', to: 'tiktok/callbacks#show'
|
||||||
get 'notion/callback', to: 'notion/callbacks#show'
|
get 'notion/callback', to: 'notion/callbacks#show'
|
||||||
|
|
||||||
|
# Media server callbacks — authenticated by shared MEDIA_SERVER_AUTH_TOKEN,
|
||||||
|
# not a user session. Intentionally top-level and not account-scoped.
|
||||||
|
namespace :callbacks do
|
||||||
|
namespace :media_server do
|
||||||
|
post :agent_disconnected, to: '/media_server/callbacks#agent_disconnected'
|
||||||
|
post :recording_ready, to: '/media_server/callbacks#recording_ready'
|
||||||
|
post :session_terminated, to: '/media_server/callbacks#session_terminated'
|
||||||
|
end
|
||||||
|
end
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Routes for external service verifications
|
# Routes for external service verifications
|
||||||
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
|
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
|
||||||
|
|||||||
@@ -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
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2026_04_10_092753) do
|
ActiveRecord::Schema[7.1].define(version: 2026_04_21_042235) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@@ -279,8 +279,11 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_10_092753) do
|
|||||||
t.text "transcript"
|
t.text "transcript"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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", "contact_id"], name: "index_calls_on_account_id_and_contact_id"
|
t.index ["account_id", "contact_id"], name: "index_calls_on_account_id_and_contact_id"
|
||||||
t.index ["account_id", "conversation_id"], name: "index_calls_on_account_id_and_conversation_id"
|
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 ["message_id"], name: "index_calls_on_message_id"
|
||||||
t.index ["provider", "provider_call_id"], name: "index_calls_on_provider_and_provider_call_id", unique: true
|
t.index ["provider", "provider_call_id"], name: "index_calls_on_provider_and_provider_call_id", unique: true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -57,7 +57,34 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- '127.0.0.1:6379:6379'
|
- '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:
|
volumes:
|
||||||
storage_data:
|
storage_data:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
# media_recordings:
|
||||||
|
|||||||
@@ -110,6 +110,32 @@ services:
|
|||||||
- 1025:1025
|
- 1025:1025
|
||||||
- 8025:8025
|
- 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:
|
volumes:
|
||||||
postgres:
|
postgres:
|
||||||
redis:
|
redis:
|
||||||
@@ -117,3 +143,4 @@ volumes:
|
|||||||
node_modules:
|
node_modules:
|
||||||
cache:
|
cache:
|
||||||
bundle:
|
bundle:
|
||||||
|
media_recordings:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
module Enterprise::Messages::MessageBuilder
|
module Enterprise::Messages::MessageBuilder
|
||||||
private
|
private
|
||||||
|
|
||||||
|
INCOMING_ALLOWED_CHANNEL_TYPES = %w[Channel::Voice Channel::Whatsapp].freeze
|
||||||
|
|
||||||
def message_type
|
def message_type
|
||||||
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
|
return @message_type if @message_type == 'incoming' && INCOMING_ALLOWED_CHANNEL_TYPES.include?(@conversation.inbox.channel_type)
|
||||||
|
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
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, :agent_answer, :reconnect, :join, :play_audio]
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: {
|
||||||
|
id: @call.id,
|
||||||
|
call_id: @call.provider_call_id,
|
||||||
|
status: @call.status,
|
||||||
|
direction: @call.direction_label,
|
||||||
|
conversation_id: @call.conversation_id,
|
||||||
|
inbox_id: @call.inbox_id,
|
||||||
|
message_id: @call.message_id,
|
||||||
|
# In server-relay mode the browser must not talk WebRTC to Meta directly
|
||||||
|
# — the media server owns that peer connection. Omitting sdp_offer forces
|
||||||
|
# the FE's isServerRelayCall() check to return true so the accept flow
|
||||||
|
# waits for the whatsapp_call.agent_offer broadcast from Rails instead of
|
||||||
|
# negotiating straight with Meta.
|
||||||
|
sdp_offer: @call.ringing? && !Call.media_server_enabled? ? @call.sdp_offer : nil,
|
||||||
|
ice_servers: Call.media_server_enabled? ? [] : @call.ice_servers,
|
||||||
|
media_server_enabled: Call.media_server_enabled?,
|
||||||
|
caller: caller_info
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def accept
|
||||||
|
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 }
|
||||||
|
end
|
||||||
|
rescue Whatsapp::CallErrors::NotRinging, Whatsapp::CallErrors::AlreadyAccepted => e
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] accept failed: #{e.message}"
|
||||||
|
render json: { error: 'Failed to accept call' }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject
|
||||||
|
call = Whatsapp::CallService.new(call: @call, agent: current_user).reject
|
||||||
|
render json: { id: call.id, status: call.status }
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] reject failed: #{e.message}"
|
||||||
|
render json: { error: 'Failed to reject call' }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminate
|
||||||
|
call = Whatsapp::CallService.new(call: @call, agent: current_user).terminate
|
||||||
|
render json: { id: call.id, status: call.status }
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] terminate failed: #{e.message}"
|
||||||
|
render json: { error: 'Failed to terminate call' }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def upload_recording
|
||||||
|
return render json: { error: 'No recording file provided' }, status: :unprocessable_entity if params[:recording].blank?
|
||||||
|
return render json: { error: 'Call is not ended' }, status: :unprocessable_entity unless @call.terminal?
|
||||||
|
|
||||||
|
attach_recording_and_enqueue_transcription
|
||||||
|
render json: { id: @call.id, status: 'uploaded' }
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] upload_recording failed: #{e.message}"
|
||||||
|
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_by!(display_id: params[:conversation_id])
|
||||||
|
authorize conversation, :show?
|
||||||
|
error = validate_whatsapp_calling(conversation)
|
||||||
|
return render json: { error: error }, status: :unprocessable_entity if error
|
||||||
|
|
||||||
|
call = create_outbound_call(conversation)
|
||||||
|
message = Whatsapp::CallMessageBuilder.create!(conversation: conversation, call: call, user: current_user)
|
||||||
|
call.update!(message_id: message.id)
|
||||||
|
render json: { status: 'calling', call_id: call.provider_call_id, id: call.id, message_id: message.id }
|
||||||
|
rescue Whatsapp::CallErrors::NoCallPermission
|
||||||
|
handle_no_call_permission(conversation)
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: 'Conversation not found' }, status: :not_found
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] initiate failed: #{e.message}"
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
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? && !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']
|
||||||
|
|
||||||
|
current_account.calls.create!(
|
||||||
|
provider: :whatsapp,
|
||||||
|
inbox: conversation.inbox, conversation: conversation, contact: conversation.contact,
|
||||||
|
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
|
||||||
|
meta: { sdp_offer: params[:sdp_offer] }
|
||||||
|
)
|
||||||
|
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)}",
|
||||||
|
direction: 'outgoing',
|
||||||
|
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_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, contact: conversation.contact,
|
||||||
|
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')
|
||||||
|
|
||||||
|
return render json: { status: 'permission_pending' } if last_requested.present? && Time.zone.parse(last_requested) > 5.minutes.ago
|
||||||
|
|
||||||
|
contact_phone = conversation.contact.phone_number.delete('+')
|
||||||
|
result = conversation.inbox.channel.provider_service.send_call_permission_request(contact_phone)
|
||||||
|
return render json: { error: 'Failed to send call permission request' }, status: :unprocessable_entity unless result
|
||||||
|
|
||||||
|
attrs = (conversation.additional_attributes || {}).merge('call_permission_requested_at' => Time.current.iso8601)
|
||||||
|
conversation.update!(additional_attributes: attrs)
|
||||||
|
render json: { status: 'permission_requested' }
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_whatsapp_calling(conversation)
|
||||||
|
channel = conversation.inbox.channel
|
||||||
|
return 'Calling is only supported on WhatsApp Cloud inboxes' unless channel.is_a?(Channel::Whatsapp) && channel.provider == 'whatsapp_cloud'
|
||||||
|
return 'Calling is not enabled for this inbox' unless channel.provider_config['calling_enabled']
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_whatsapp_call_enabled
|
||||||
|
render_payment_required('WhatsApp calling is not enabled for this account') unless current_account.feature_enabled?('whatsapp_call')
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_call
|
||||||
|
@call = current_account.calls.whatsapp.find(params[:id])
|
||||||
|
authorize @call.conversation, :show?
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: 'Call not found' }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_recording_and_enqueue_transcription
|
||||||
|
@call.recording.attach(params[:recording])
|
||||||
|
Whatsapp::CallMessageBuilder.update_recording_url!(call: @call)
|
||||||
|
Whatsapp::CallTranscriptionJob.perform_later(@call.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def caller_info
|
||||||
|
contact = @call.conversation&.contact
|
||||||
|
return {} unless contact
|
||||||
|
|
||||||
|
{ name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
class MediaServer::CallbacksController < ApplicationController
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
call.inbox.channel.provider_service.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
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
module Enterprise::Webhooks::WhatsappEventsJob
|
||||||
|
def handle_message_events(channel, params)
|
||||||
|
if call_event?(params)
|
||||||
|
handle_call_events(channel, params)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if call_permission_reply?(params)
|
||||||
|
handle_call_permission_reply(channel, params)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def call_event?(params)
|
||||||
|
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
|
||||||
|
end
|
||||||
|
|
||||||
|
def call_permission_reply?(params)
|
||||||
|
message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
|
||||||
|
message&.dig(:type) == 'interactive' && message&.dig(:interactive, :type) == 'call_permission_reply'
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call_events(channel, params)
|
||||||
|
Whatsapp::IncomingCallService.new(
|
||||||
|
inbox: channel.inbox,
|
||||||
|
params: extract_call_params(params)
|
||||||
|
).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call_permission_reply(channel, params)
|
||||||
|
Whatsapp::CallPermissionReplyService.new(inbox: channel.inbox, params: params).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_call_params(params)
|
||||||
|
params.dig(:entry, 0, :changes, 0, :value) || {}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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,42 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
# combined.ogg is only produced when the media-server session terminates
|
||||||
|
# (ffmpeg mix runs in Recorder.Finalize). For calls that ended via Meta's
|
||||||
|
# terminate webhook — not agent hang-up — nothing has triggered Finalize
|
||||||
|
# yet. Call terminate first; it's idempotent on the server and returns
|
||||||
|
# only after Finalize has written combined.ogg.
|
||||||
|
safe_terminate(client, call.media_session_id)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def safe_terminate(client, session_id)
|
||||||
|
client.terminate_session(session_id)
|
||||||
|
rescue Whatsapp::MediaServerClient::SessionError, Whatsapp::MediaServerClient::ConnectionError => e
|
||||||
|
Rails.logger.info "[WHATSAPP CALL] terminate_session during fetch (#{session_id}): #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class Whatsapp::CallTranscriptionJob < ApplicationJob
|
||||||
|
queue_as :low
|
||||||
|
|
||||||
|
retry_on ActiveStorage::FileNotFoundError, wait: 2.seconds, attempts: 3
|
||||||
|
discard_on Faraday::BadRequestError do |job, error|
|
||||||
|
Rails.logger.warn("[WHATSAPP CALL] Discarding transcription job: call_id=#{job.arguments.first}, status=#{error.response&.dig(:status)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform(call_id)
|
||||||
|
call = Call.whatsapp.find_by(id: call_id)
|
||||||
|
return if call.blank? || !call.recording.attached?
|
||||||
|
|
||||||
|
Whatsapp::CallTranscriptionService.new(call).perform
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
# direction :integer not null
|
# direction :integer not null
|
||||||
# duration_seconds :integer
|
# duration_seconds :integer
|
||||||
# end_reason :string
|
# end_reason :string
|
||||||
|
# media_session_id :string
|
||||||
# meta :jsonb
|
# meta :jsonb
|
||||||
# provider :integer default("twilio"), not null
|
# provider :integer default("twilio"), not null
|
||||||
# started_at :datetime
|
# started_at :datetime
|
||||||
@@ -23,10 +24,12 @@
|
|||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
|
# index_calls_on_accepted_by_agent_id_and_status (accepted_by_agent_id,status)
|
||||||
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
|
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
|
||||||
# index_calls_on_message_id (message_id)
|
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
|
||||||
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
|
# index_calls_on_media_session_id (media_session_id) UNIQUE
|
||||||
|
# index_calls_on_message_id (message_id)
|
||||||
|
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
|
||||||
#
|
#
|
||||||
class Call < ApplicationRecord
|
class Call < ApplicationRecord
|
||||||
# All valid call statuses
|
# All valid call statuses
|
||||||
@@ -52,4 +55,41 @@ class Call < ApplicationRecord
|
|||||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||||
|
|
||||||
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
|
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'
|
||||||
|
end
|
||||||
|
|
||||||
|
def in_progress?
|
||||||
|
status == 'in_progress'
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminal?
|
||||||
|
TERMINAL_STATUSES.include?(status)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Frontend-facing direction label: incoming→inbound, outgoing→outbound
|
||||||
|
def direction_label
|
||||||
|
incoming? ? 'inbound' : 'outbound'
|
||||||
|
end
|
||||||
|
|
||||||
|
def sdp_offer
|
||||||
|
meta&.dig('sdp_offer')
|
||||||
|
end
|
||||||
|
|
||||||
|
def ice_servers
|
||||||
|
meta&.dig('ice_servers') || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def recording_url
|
||||||
|
return unless recording.attached?
|
||||||
|
|
||||||
|
Rails.application.routes.url_helpers.rails_blob_path(recording, only_path: true)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module Enterprise::Whatsapp::FacebookApiClient
|
||||||
|
def webhook_subscribed_fields
|
||||||
|
super + %w[calls]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module Enterprise::Whatsapp::Providers::WhatsappCloudService
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
include ::Whatsapp::Providers::WhatsappCloudCallMethods
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
class Whatsapp::CallMessageBuilder
|
||||||
|
# Maps Call model statuses to voice_call display statuses (hyphenated for frontend)
|
||||||
|
CALL_TO_VOICE_STATUS = {
|
||||||
|
'ringing' => 'ringing',
|
||||||
|
'in_progress' => 'in-progress',
|
||||||
|
'failed' => 'failed',
|
||||||
|
'no_answer' => 'no-answer',
|
||||||
|
'completed' => 'completed'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def self.create!(conversation:, call:, user: nil)
|
||||||
|
new(conversation: conversation, call: call, user: user).create!
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.update_status!(call:, status: nil, agent: nil, duration_seconds: nil)
|
||||||
|
new(conversation: call.conversation, call: call).update_status!(
|
||||||
|
status: status, agent: agent, duration_seconds: duration_seconds
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.update_recording_url!(call:)
|
||||||
|
message = call.message
|
||||||
|
return unless message
|
||||||
|
|
||||||
|
data = (message.content_attributes || {}).dup
|
||||||
|
data['data'] ||= {}
|
||||||
|
data['data']['recording_url'] = call.recording_url
|
||||||
|
message.update!(content_attributes: data)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(conversation:, call:, user: nil)
|
||||||
|
@conversation = conversation
|
||||||
|
@call = call
|
||||||
|
@user = user
|
||||||
|
end
|
||||||
|
|
||||||
|
def create!
|
||||||
|
params = {
|
||||||
|
content: 'WhatsApp Call',
|
||||||
|
message_type: message_type,
|
||||||
|
content_type: 'voice_call',
|
||||||
|
content_attributes: { 'data' => build_data_payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
Messages::MessageBuilder.new(sender, conversation, params).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_status!(status:, agent: nil, duration_seconds: nil)
|
||||||
|
message = call.message
|
||||||
|
return unless message
|
||||||
|
|
||||||
|
data = (message.content_attributes || {}).dup
|
||||||
|
data['data'] ||= {}
|
||||||
|
data['data']['status'] = map_status(status) if status
|
||||||
|
data['data']['accepted_by'] = { 'id' => agent.id, 'name' => agent.name } if agent
|
||||||
|
data['data']['duration_seconds'] = duration_seconds if duration_seconds
|
||||||
|
|
||||||
|
message.update!(content_attributes: data)
|
||||||
|
message
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :conversation, :call, :user
|
||||||
|
|
||||||
|
def build_data_payload
|
||||||
|
{
|
||||||
|
'call_sid' => call.provider_call_id,
|
||||||
|
'status' => map_status(call.status),
|
||||||
|
'call_direction' => call.direction_label,
|
||||||
|
'call_source' => 'whatsapp',
|
||||||
|
'call_id' => call.id,
|
||||||
|
'from_number' => from_number,
|
||||||
|
'to_number' => to_number,
|
||||||
|
'meta' => { 'created_at' => Time.zone.now.to_i }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_type
|
||||||
|
call.outgoing? ? 'outgoing' : 'incoming'
|
||||||
|
end
|
||||||
|
|
||||||
|
def sender
|
||||||
|
return user if call.outgoing? && user
|
||||||
|
|
||||||
|
conversation.contact
|
||||||
|
end
|
||||||
|
|
||||||
|
def from_number
|
||||||
|
if call.incoming?
|
||||||
|
conversation.contact&.phone_number
|
||||||
|
else
|
||||||
|
conversation.inbox.channel&.phone_number
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_number
|
||||||
|
if call.incoming?
|
||||||
|
conversation.inbox.channel&.phone_number
|
||||||
|
else
|
||||||
|
conversation.contact&.phone_number
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def map_status(status)
|
||||||
|
CALL_TO_VOICE_STATUS[status] || status
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
class Whatsapp::CallPermissionReplyService
|
||||||
|
pattr_initialize [:inbox!, :params!]
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return unless inbox.account.feature_enabled?('whatsapp_call')
|
||||||
|
|
||||||
|
reply_data = extract_reply_data
|
||||||
|
return unless reply_data&.dig(:accepted)
|
||||||
|
|
||||||
|
contact = find_contact(reply_data[:from_number])
|
||||||
|
return unless contact
|
||||||
|
|
||||||
|
conversation = find_active_conversation(contact)
|
||||||
|
return unless conversation
|
||||||
|
|
||||||
|
clear_permission_flag(conversation)
|
||||||
|
broadcast_permission_granted(contact, conversation)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def extract_reply_data
|
||||||
|
value = params.dig(:entry, 0, :changes, 0, :value)
|
||||||
|
message = value&.dig(:messages, 0)
|
||||||
|
reply = message&.dig(:interactive, :call_permission_reply)
|
||||||
|
return unless reply
|
||||||
|
|
||||||
|
accepted = reply[:response] == 'accept'
|
||||||
|
Rails.logger.info "[WHATSAPP CALL] call_permission_reply from=#{message[:from]} accepted=#{accepted} permanent=#{reply[:is_permanent]}"
|
||||||
|
|
||||||
|
{ from_number: message[:from], accepted: accepted }
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_contact(from_number)
|
||||||
|
inbox.contact_inboxes.joins(:contact)
|
||||||
|
.where(contacts: { phone_number: "+#{from_number}" })
|
||||||
|
.first&.contact
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_active_conversation(contact)
|
||||||
|
inbox.conversations.where(contact: contact).where.not(status: :resolved).last
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_permission_flag(conversation)
|
||||||
|
attrs = conversation.additional_attributes || {}
|
||||||
|
attrs.delete('call_permission_requested_at')
|
||||||
|
conversation.update!(additional_attributes: attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_permission_granted(contact, conversation)
|
||||||
|
ActionCable.server.broadcast("account_#{inbox.account_id}", {
|
||||||
|
event: 'whatsapp_call.permission_granted',
|
||||||
|
data: {
|
||||||
|
account_id: inbox.account_id,
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
contact_name: contact.name,
|
||||||
|
contact_phone: contact.phone_number
|
||||||
|
}
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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!
|
||||||
|
ensure_not_already_taken!
|
||||||
|
|
||||||
|
provider = call.inbox.channel.provider_service
|
||||||
|
fixed_sdp = fix_sdp_setup(sdp_answer)
|
||||||
|
|
||||||
|
# Step 1: pre_accept (with SDP answer - required by Meta)
|
||||||
|
pre_response = provider.pre_accept_call(call.provider_call_id, fixed_sdp)
|
||||||
|
raise Whatsapp::CallErrors::NotRinging, 'Meta pre_accept failed' unless pre_response
|
||||||
|
|
||||||
|
# Step 2: accept with same SDP answer
|
||||||
|
accept_response = provider.accept_call(call.provider_call_id, fixed_sdp)
|
||||||
|
raise Whatsapp::CallErrors::NotRinging, 'Meta accept failed' unless accept_response
|
||||||
|
|
||||||
|
call.update!(
|
||||||
|
status: 'in_progress',
|
||||||
|
accepted_by_agent_id: agent.id,
|
||||||
|
started_at: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
Whatsapp::CallMessageBuilder.update_status!(call: call, status: 'in_progress', agent: agent)
|
||||||
|
update_conversation_call_status('in-progress')
|
||||||
|
broadcast_accepted
|
||||||
|
call
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject
|
||||||
|
call.reload
|
||||||
|
return call if call.terminal? || call.in_progress?
|
||||||
|
|
||||||
|
provider = call.inbox.channel.provider_service
|
||||||
|
success = provider.reject_call(call.provider_call_id)
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] reject_call API returned false for call #{call.provider_call_id}" unless success
|
||||||
|
|
||||||
|
call.update!(status: 'failed')
|
||||||
|
Whatsapp::CallMessageBuilder.update_status!(call: call, status: 'failed')
|
||||||
|
update_conversation_call_status('failed')
|
||||||
|
broadcast_call_ended
|
||||||
|
call
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminate
|
||||||
|
return call if call.terminal?
|
||||||
|
|
||||||
|
terminate_media_session if call.media_session_id.present?
|
||||||
|
terminate_on_provider
|
||||||
|
|
||||||
|
call.update!(status: 'completed')
|
||||||
|
Whatsapp::CallMessageBuilder.update_status!(call: call, status: 'completed')
|
||||||
|
update_conversation_call_status('completed')
|
||||||
|
broadcast_call_ended
|
||||||
|
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,
|
||||||
|
direction: 'incoming',
|
||||||
|
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
|
||||||
|
|
||||||
|
def ensure_not_already_taken!
|
||||||
|
raise Whatsapp::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if call.in_progress?
|
||||||
|
end
|
||||||
|
|
||||||
|
def fix_sdp_setup(sdp)
|
||||||
|
sdp.gsub('a=setup:actpass', 'a=setup:active')
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_conversation_call_status(mapped_status)
|
||||||
|
conversation = call.conversation
|
||||||
|
attrs = (conversation.additional_attributes || {}).merge('call_status' => mapped_status)
|
||||||
|
conversation.update!(additional_attributes: attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_accepted
|
||||||
|
payload = {
|
||||||
|
event: 'whatsapp_call.accepted',
|
||||||
|
data: {
|
||||||
|
account_id: call.account_id,
|
||||||
|
id: call.id,
|
||||||
|
call_id: call.provider_call_id,
|
||||||
|
accepted_by_agent_id: agent.id,
|
||||||
|
conversation_id: call.conversation_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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',
|
||||||
|
data: {
|
||||||
|
account_id: call.account_id,
|
||||||
|
id: call.id,
|
||||||
|
call_id: call.provider_call_id,
|
||||||
|
status: call.status,
|
||||||
|
conversation_id: call.conversation_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ActionCable.server.broadcast("account_#{call.account_id}", payload)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
class Whatsapp::CallTranscriptionService < Llm::LegacyBaseOpenAiService
|
||||||
|
WHISPER_MODEL = 'whisper-1'.freeze
|
||||||
|
|
||||||
|
attr_reader :call, :account
|
||||||
|
|
||||||
|
def initialize(call)
|
||||||
|
super()
|
||||||
|
@call = call
|
||||||
|
@account = call.account
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return { error: 'Transcription not available' } unless can_transcribe?
|
||||||
|
return { error: 'No recording attached' } unless call.recording.attached?
|
||||||
|
|
||||||
|
transcribed_text = transcribe_audio
|
||||||
|
update_call_and_message(transcribed_text)
|
||||||
|
{ success: true, transcript: transcribed_text }
|
||||||
|
rescue Faraday::UnauthorizedError
|
||||||
|
Rails.logger.warn('[WHATSAPP CALL] Skipping transcription: OpenAI configuration is invalid (401)')
|
||||||
|
{ error: 'OpenAI configuration is invalid' }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def can_transcribe?
|
||||||
|
account.feature_enabled?('captain_integration') &&
|
||||||
|
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transcribe per-direction recordings separately when possible so lines can
|
||||||
|
# be attributed to Customer vs Agent. Falls back to the combined recording
|
||||||
|
# if the media server isn't available or per-side files are missing.
|
||||||
|
def transcribe_audio
|
||||||
|
if call.media_session_id.present?
|
||||||
|
diarized = diarized_transcript
|
||||||
|
return diarized if diarized.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
transcribe_combined
|
||||||
|
end
|
||||||
|
|
||||||
|
def diarized_transcript
|
||||||
|
temp_dir = Rails.root.join('tmp/uploads/call-transcriptions')
|
||||||
|
FileUtils.mkdir_p(temp_dir)
|
||||||
|
customer_path = File.join(temp_dir, "#{call.media_session_id}_customer.ogg")
|
||||||
|
agent_path = File.join(temp_dir, "#{call.media_session_id}_agent.ogg")
|
||||||
|
|
||||||
|
client = Whatsapp::MediaServerClient.new
|
||||||
|
File.binwrite(customer_path, client.download_recording(call.media_session_id, side: 'customer'))
|
||||||
|
File.binwrite(agent_path, client.download_recording(call.media_session_id, side: 'agent'))
|
||||||
|
|
||||||
|
segments = transcribe_segments(customer_path, 'Customer') +
|
||||||
|
transcribe_segments(agent_path, 'Agent')
|
||||||
|
return nil if segments.empty?
|
||||||
|
|
||||||
|
segments.sort_by { |s| s[:start] }
|
||||||
|
.map { |s| "[#{format_ts(s[:start])}] #{s[:speaker]}: #{s[:text].strip}" }
|
||||||
|
.reject { |line| line.end_with?(': ') }
|
||||||
|
.join("\n")
|
||||||
|
rescue Whatsapp::MediaServerClient::ConnectionError, Whatsapp::MediaServerClient::SessionError => e
|
||||||
|
Rails.logger.warn "[WHATSAPP CALL] Per-side recording unavailable, falling back to combined transcription: #{e.message}"
|
||||||
|
nil
|
||||||
|
ensure
|
||||||
|
FileUtils.rm_f(customer_path) if defined?(customer_path) && customer_path
|
||||||
|
FileUtils.rm_f(agent_path) if defined?(agent_path) && agent_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def transcribe_segments(file_path, speaker)
|
||||||
|
return [] unless File.exist?(file_path) && File.size(file_path).positive?
|
||||||
|
|
||||||
|
File.open(file_path, 'rb') do |file|
|
||||||
|
response = @client.audio.transcribe(
|
||||||
|
parameters: {
|
||||||
|
model: WHISPER_MODEL,
|
||||||
|
file: file,
|
||||||
|
temperature: 0.2,
|
||||||
|
response_format: 'verbose_json',
|
||||||
|
timestamp_granularities: ['segment']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
(response['segments'] || []).map do |seg|
|
||||||
|
{ speaker: speaker, start: seg['start'].to_f, text: seg['text'].to_s }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def transcribe_combined
|
||||||
|
temp_file_path = fetch_combined_recording
|
||||||
|
File.open(temp_file_path, 'rb') do |file|
|
||||||
|
response = @client.audio.transcribe(
|
||||||
|
parameters: { model: WHISPER_MODEL, file: file, temperature: 0.2 }
|
||||||
|
)
|
||||||
|
return response['text']
|
||||||
|
end
|
||||||
|
ensure
|
||||||
|
FileUtils.rm_f(temp_file_path) if defined?(temp_file_path) && temp_file_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_combined_recording
|
||||||
|
blob = call.recording.blob
|
||||||
|
temp_dir = Rails.root.join('tmp/uploads/call-transcriptions')
|
||||||
|
FileUtils.mkdir_p(temp_dir)
|
||||||
|
|
||||||
|
extension = extension_from_content_type(blob.content_type)
|
||||||
|
temp_file_path = File.join(temp_dir, "#{blob.key}.#{extension}")
|
||||||
|
|
||||||
|
File.open(temp_file_path, 'wb') do |file|
|
||||||
|
blob.open { |blob_file| IO.copy_stream(blob_file, file) }
|
||||||
|
end
|
||||||
|
|
||||||
|
temp_file_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_ts(seconds)
|
||||||
|
total = seconds.to_i
|
||||||
|
mins = total / 60
|
||||||
|
secs = total % 60
|
||||||
|
format('%<mins>02d:%<secs>02d', mins: mins, secs: secs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_call_and_message(transcribed_text)
|
||||||
|
return if transcribed_text.blank?
|
||||||
|
|
||||||
|
call.update!(transcript: transcribed_text)
|
||||||
|
account.increment_response_usage
|
||||||
|
|
||||||
|
message = call.message
|
||||||
|
return unless message
|
||||||
|
|
||||||
|
data = (message.content_attributes || {}).dup
|
||||||
|
data['data'] ||= {}
|
||||||
|
data['data']['transcript'] = transcribed_text
|
||||||
|
data['data']['recording_url'] = call.recording_url
|
||||||
|
message.update!(content_attributes: data)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extension_from_content_type(content_type)
|
||||||
|
subtype = content_type.to_s.downcase.split(';').first.to_s.split('/').last.to_s
|
||||||
|
{ 'webm' => 'webm', 'ogg' => 'ogg', 'x-m4a' => 'm4a', 'x-wav' => 'wav', 'mpeg' => 'mp3' }.fetch(subtype, 'webm')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
class Whatsapp::IncomingCallService
|
||||||
|
pattr_initialize [:inbox!, :params!]
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return unless inbox.account.feature_enabled?('whatsapp_call')
|
||||||
|
|
||||||
|
calls = params[:calls]
|
||||||
|
return if calls.blank?
|
||||||
|
|
||||||
|
calls.each do |call_payload|
|
||||||
|
process_call_event(call_payload.with_indifferent_access)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_call_event(call_payload)
|
||||||
|
case call_payload[:event]
|
||||||
|
when 'connect' then handle_call_connect(call_payload)
|
||||||
|
when 'terminate' then handle_call_terminate(call_payload)
|
||||||
|
else Rails.logger.warn "[WHATSAPP CALL] Unknown call event: #{call_payload[:event]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call_connect(call_payload)
|
||||||
|
provider_call_id = call_payload[:id]
|
||||||
|
direction = map_direction(call_payload[:direction])
|
||||||
|
|
||||||
|
# For outbound calls, a Call record already exists from initiate.
|
||||||
|
# Update it instead of creating a duplicate.
|
||||||
|
existing_call = Call.whatsapp.find_by(provider_call_id: provider_call_id)
|
||||||
|
if existing_call
|
||||||
|
Rails.logger.info "[WHATSAPP CALL] call_connect for existing call #{provider_call_id} (direction=#{direction})"
|
||||||
|
# Guard against race condition: skip if already in_progress (agent accepted via CallService)
|
||||||
|
return if existing_call.in_progress?
|
||||||
|
|
||||||
|
sdp_answer = fix_sdp_setup(call_payload.dig(:session, :sdp))
|
||||||
|
existing_call.update!(status: 'in_progress', started_at: Time.current, meta: (existing_call.meta || {}).merge('sdp_answer' => sdp_answer))
|
||||||
|
Whatsapp::CallMessageBuilder.update_status!(call: existing_call, status: 'in_progress')
|
||||||
|
update_conversation_call_status(existing_call.conversation, 'in-progress', existing_call.direction_label)
|
||||||
|
|
||||||
|
if existing_call.media_session_id.present?
|
||||||
|
finalize_outbound_server_relay(existing_call, sdp_answer)
|
||||||
|
else
|
||||||
|
broadcast_outbound_call_connected(existing_call, sdp_answer)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
contact = find_or_create_contact("+#{call_payload[:from]}")
|
||||||
|
return unless contact
|
||||||
|
|
||||||
|
conversation = find_or_create_conversation(contact)
|
||||||
|
return unless conversation
|
||||||
|
|
||||||
|
call = create_call_record(call_payload, conversation, contact, direction)
|
||||||
|
create_voice_call_message(conversation, call)
|
||||||
|
update_conversation_call_status(conversation, 'ringing', call.direction_label)
|
||||||
|
broadcast_incoming_call(call, contact, call_payload.dig(:session, :sdp))
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
|
Rails.logger.warn "[WHATSAPP CALL] Duplicate provider_call_id received: #{provider_call_id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_voice_call_message(conversation, call, user: nil)
|
||||||
|
message = Whatsapp::CallMessageBuilder.create!(conversation: conversation, call: call, user: user)
|
||||||
|
call.update!(message_id: message.id)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] Failed to create voice_call message: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_call_record(call_payload, conversation, contact, direction)
|
||||||
|
Call.create!(
|
||||||
|
provider: :whatsapp,
|
||||||
|
account: inbox.account,
|
||||||
|
inbox: inbox,
|
||||||
|
conversation: conversation,
|
||||||
|
contact: contact,
|
||||||
|
provider_call_id: call_payload[:id],
|
||||||
|
direction: direction,
|
||||||
|
status: 'ringing',
|
||||||
|
meta: { sdp_offer: call_payload.dig(:session, :sdp), ice_servers: default_ice_servers }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call_terminate(call_payload)
|
||||||
|
provider_call_id = call_payload[:id]
|
||||||
|
duration = call_payload[:duration]&.to_i
|
||||||
|
end_reason = call_payload[:terminate_reason]
|
||||||
|
|
||||||
|
call = Call.whatsapp.find_by(provider_call_id: provider_call_id)
|
||||||
|
return unless call
|
||||||
|
|
||||||
|
# Determine if the call was answered: check in_progress status, duration > 0,
|
||||||
|
# or accepted_by_agent_id presence (handles webhook race conditions)
|
||||||
|
was_answered = call.in_progress? || duration.to_i.positive? || call.accepted_by_agent_id.present?
|
||||||
|
final_status = was_answered ? 'completed' : 'no_answer'
|
||||||
|
call.update!(
|
||||||
|
status: final_status,
|
||||||
|
duration_seconds: duration,
|
||||||
|
end_reason: end_reason
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = call.accepted_by_agent if call.accepted_by_agent_id.present?
|
||||||
|
Whatsapp::CallMessageBuilder.update_status!(call: call, status: final_status, agent: agent, duration_seconds: duration)
|
||||||
|
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)
|
||||||
|
waid = phone_number.delete('+')
|
||||||
|
|
||||||
|
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||||
|
source_id: waid,
|
||||||
|
inbox: inbox,
|
||||||
|
contact_attributes: {
|
||||||
|
name: phone_number,
|
||||||
|
phone_number: phone_number
|
||||||
|
}
|
||||||
|
).perform
|
||||||
|
|
||||||
|
contact_inbox&.contact
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_conversation(contact)
|
||||||
|
contact_inbox = contact.contact_inboxes.find_by(inbox: inbox)
|
||||||
|
return unless contact_inbox
|
||||||
|
|
||||||
|
conversation = contact_inbox.conversations.where.not(status: :resolved).last
|
||||||
|
return conversation if conversation
|
||||||
|
|
||||||
|
::Conversation.create!(
|
||||||
|
account_id: inbox.account_id,
|
||||||
|
inbox: inbox,
|
||||||
|
contact: contact,
|
||||||
|
contact_inbox: contact_inbox,
|
||||||
|
additional_attributes: { channel: 'whatsapp' }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_conversation_call_status(conversation, call_status, direction)
|
||||||
|
attrs = (conversation.additional_attributes || {}).merge(
|
||||||
|
'call_status' => call_status,
|
||||||
|
'call_direction' => direction
|
||||||
|
)
|
||||||
|
conversation.update!(additional_attributes: attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_incoming_call(call, contact, sdp_offer)
|
||||||
|
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,
|
||||||
|
media_server_enabled: Call.media_server_enabled?,
|
||||||
|
caller: {
|
||||||
|
name: contact.name,
|
||||||
|
phone: contact.phone_number,
|
||||||
|
avatar: contact.avatar_url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
payload = {
|
||||||
|
event: 'whatsapp_call.ended',
|
||||||
|
data: {
|
||||||
|
account_id: inbox.account_id,
|
||||||
|
id: call.id,
|
||||||
|
call_id: call.provider_call_id,
|
||||||
|
status: call.status,
|
||||||
|
duration_seconds: call.duration_seconds,
|
||||||
|
conversation_id: call.conversation_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionCable.server.broadcast("account_#{inbox.account_id}", payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_outbound_call_connected(call, sdp_answer)
|
||||||
|
payload = {
|
||||||
|
event: 'whatsapp_call.outbound_connected',
|
||||||
|
data: {
|
||||||
|
account_id: inbox.account_id,
|
||||||
|
id: call.id,
|
||||||
|
call_id: call.provider_call_id,
|
||||||
|
conversation_id: call.conversation_id,
|
||||||
|
sdp_answer: sdp_answer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionCable.server.broadcast("account_#{inbox.account_id}", payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Server-relay outbound: deliver Meta's SDP answer to the media server so it
|
||||||
|
# completes Peer A, then ask it for an SDP offer to send to the agent (Peer B).
|
||||||
|
# Broadcast that offer so the agent browser can negotiate directly with the
|
||||||
|
# media server.
|
||||||
|
def finalize_outbound_server_relay(call, sdp_answer)
|
||||||
|
client = Whatsapp::MediaServerClient.new
|
||||||
|
client.set_meta_answer(call.media_session_id, sdp_answer: sdp_answer)
|
||||||
|
agent_offer = client.generate_agent_offer(call.media_session_id)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
event: 'whatsapp_call.outbound_connected',
|
||||||
|
data: {
|
||||||
|
account_id: inbox.account_id,
|
||||||
|
id: call.id,
|
||||||
|
call_id: call.provider_call_id,
|
||||||
|
conversation_id: call.conversation_id,
|
||||||
|
sdp_offer: agent_offer['sdp_offer'],
|
||||||
|
ice_servers: agent_offer['ice_servers']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ActionCable.server.broadcast("account_#{inbox.account_id}", payload)
|
||||||
|
rescue Whatsapp::MediaServerClient::ConnectionError, Whatsapp::MediaServerClient::SessionError => e
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] Failed to finalize outbound server-relay: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Meta sends "USER_INITIATED" / "BUSINESS_INITIATED", map to Call enum values
|
||||||
|
def map_direction(raw_direction)
|
||||||
|
return :outgoing if raw_direction&.upcase == 'BUSINESS_INITIATED'
|
||||||
|
|
||||||
|
:incoming
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_ice_servers
|
||||||
|
[{ urls: ['stun:stun.l.google.com:19302'] }]
|
||||||
|
end
|
||||||
|
|
||||||
|
def fix_sdp_setup(sdp)
|
||||||
|
sdp.present? ? sdp.gsub('a=setup:actpass', 'a=setup:active') : sdp
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
class Whatsapp::MediaServerClient
|
||||||
|
class ConnectionError < StandardError; end
|
||||||
|
class SessionError < StandardError; end
|
||||||
|
|
||||||
|
TIMEOUT = 10
|
||||||
|
|
||||||
|
def create_session(call_id:, direction:, sdp_offer:, ice_servers:, account_id: nil)
|
||||||
|
body = { call_id: call_id, direction: direction, meta_sdp_offer: sdp_offer,
|
||||||
|
ice_servers: normalize_ice_servers(ice_servers), account_id: account_id&.to_s }.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 set_meta_answer(session_id, sdp_answer:)
|
||||||
|
post("/sessions/#{session_id}/meta-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, side: nil)
|
||||||
|
path = "/sessions/#{session_id}/recording"
|
||||||
|
path = "#{path}?side=#{side}" if side
|
||||||
|
response = execute_request(:get, path)
|
||||||
|
unless response.success?
|
||||||
|
Rails.logger.error "[MEDIA SERVER] Recording download failed: side=#{side.inspect} 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
|
||||||
|
|
||||||
|
# Go media server expects `urls` to always be an array of strings.
|
||||||
|
# Accept legacy data that may have `urls` as a single string.
|
||||||
|
def normalize_ice_servers(servers)
|
||||||
|
return [] if servers.blank?
|
||||||
|
|
||||||
|
Array(servers).map do |srv|
|
||||||
|
s = srv.respond_to?(:to_h) ? srv.to_h.transform_keys(&:to_s) : srv.stringify_keys
|
||||||
|
urls = s['urls']
|
||||||
|
s['urls'] = urls.is_a?(Array) ? urls : Array(urls).compact
|
||||||
|
s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
module Whatsapp::Providers::WhatsappCloudCallMethods
|
||||||
|
def pre_accept_call(call_id, sdp_answer)
|
||||||
|
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
|
||||||
|
end
|
||||||
|
|
||||||
|
def accept_call(call_id, sdp_answer)
|
||||||
|
call_api('accept_call', call_action_body(call_id, 'accept', sdp_answer))
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject_call(call_id)
|
||||||
|
call_api('reject_call', { messaging_product: 'whatsapp', call_id: call_id, action: 'reject' })
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminate_call(call_id)
|
||||||
|
call_api('terminate_call', { messaging_product: 'whatsapp', call_id: call_id, action: 'terminate' })
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_call_permission_request(to_phone_number, body_text = 'We would like to call you regarding your conversation.')
|
||||||
|
response = HTTParty.post(
|
||||||
|
"#{phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text).to_json
|
||||||
|
)
|
||||||
|
|
||||||
|
unless response.success?
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] send_call_permission_request failed: status=#{response.code} body=#{response.body}"
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
response.parsed_response
|
||||||
|
end
|
||||||
|
|
||||||
|
def initiate_call(to_phone_number, sdp_offer)
|
||||||
|
response = HTTParty.post(
|
||||||
|
"#{phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer).to_json
|
||||||
|
)
|
||||||
|
process_initiate_call_response(response)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def call_action_body(call_id, action, sdp_answer = nil)
|
||||||
|
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
|
||||||
|
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
|
||||||
|
body
|
||||||
|
end
|
||||||
|
|
||||||
|
def call_api(action_name, body)
|
||||||
|
url = "#{phone_id_path}/calls"
|
||||||
|
Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}"
|
||||||
|
response = HTTParty.post(url, headers: api_headers, body: body.to_json)
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success?
|
||||||
|
response.success?
|
||||||
|
end
|
||||||
|
|
||||||
|
def permission_request_body(to_phone_number, body_text)
|
||||||
|
{
|
||||||
|
messaging_product: 'whatsapp', recipient_type: 'individual', to: to_phone_number,
|
||||||
|
type: 'interactive',
|
||||||
|
interactive: {
|
||||||
|
type: 'call_permission_request',
|
||||||
|
action: { name: 'call_permission_request' },
|
||||||
|
body: { text: body_text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def initiate_call_body(to_phone_number, sdp_offer)
|
||||||
|
{
|
||||||
|
messaging_product: 'whatsapp', to: to_phone_number, type: 'audio',
|
||||||
|
session: { sdp: sdp_offer, sdp_type: 'offer' }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_initiate_call_response(response)
|
||||||
|
return response.parsed_response if response.success?
|
||||||
|
|
||||||
|
parsed = response.parsed_response
|
||||||
|
error_code = parsed&.dig('error', 'code')
|
||||||
|
error_msg = parsed&.dig('error', 'error_user_msg') || 'Failed to initiate call'
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] initiate_call failed: status=#{response.code} body=#{response.body}"
|
||||||
|
|
||||||
|
raise Whatsapp::CallErrors::NoCallPermission, error_msg if error_code == 138_006
|
||||||
|
|
||||||
|
raise StandardError, error_msg
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module Whatsapp::CallErrors
|
||||||
|
class NotRinging < StandardError; end
|
||||||
|
class AlreadyAccepted < StandardError; end
|
||||||
|
class CallFailed < StandardError; end
|
||||||
|
class NoCallPermission < StandardError; 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.35 // 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,61 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||||
|
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||||
|
github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U=
|
||||||
|
github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg=
|
||||||
|
github.com/pion/ice/v4 v4.0.3 h1:9s5rI1WKzF5DRqhJ+Id8bls/8PzM7mau0mj1WZb4IXE=
|
||||||
|
github.com/pion/ice/v4 v4.0.3/go.mod h1:VfHy0beAZ5loDT7BmJ2LtMtC4dbawIkkkejHPRZNB3Y=
|
||||||
|
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
|
||||||
|
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
|
||||||
|
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||||
|
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||||
|
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||||
|
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||||
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
|
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
|
||||||
|
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||||
|
github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk=
|
||||||
|
github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||||
|
github.com/pion/sctp v1.8.35 h1:qwtKvNK1Wc5tHMIYgTDJhfZk7vATGVHhXbUDfHbYwzA=
|
||||||
|
github.com/pion/sctp v1.8.35/go.mod h1:EcXP8zCYVTRy3W9xtOF7wJm1L1aXfKRQzaM33SjQlzg=
|
||||||
|
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
|
||||||
|
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
|
||||||
|
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
|
||||||
|
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
|
||||||
|
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||||
|
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||||
|
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||||
|
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||||
|
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||||
|
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||||
|
github.com/pion/webrtc/v4 v4.0.5 h1:8cVPojcv3cQTwVga2vF1rzCNvkiEimnYdCCG7yF317I=
|
||||||
|
github.com/pion/webrtc/v4 v4.0.5/go.mod h1:LvP8Np5b/sM0uyJIcUPvJcCvhtjHxJwzh2H2PYzE6cQ=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
|
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
|
||||||
|
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||||
|
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
|
||||||
|
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
|
||||||
|
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||||
|
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -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,297 @@
|
|||||||
|
// Package media provides audio bridging, recording, and injection capabilities
|
||||||
|
// for the media server's call sessions.
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
"github.com/pion/webrtc/v4/pkg/media/oggwriter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recorder writes incoming Opus RTP packets to two per-direction OGG files
|
||||||
|
// (customer and agent). At finalize time the two files are mixed into a single
|
||||||
|
// stereo combined.ogg via ffmpeg so playback and Whisper transcription receive
|
||||||
|
// a file with coherent OGG pages and correct duration.
|
||||||
|
//
|
||||||
|
// Writing both streams to a single oggwriter produces a file with non-monotonic
|
||||||
|
// granule positions — the two RTP streams have unrelated clocks and sequence
|
||||||
|
// numbers — which breaks both the reported audio duration in browsers and the
|
||||||
|
// transcription output.
|
||||||
|
type Recorder struct {
|
||||||
|
sessionID string
|
||||||
|
dir string
|
||||||
|
|
||||||
|
// combinedFile is produced by ffmpeg at finalize; never written to directly.
|
||||||
|
combinedFile string
|
||||||
|
|
||||||
|
// customerWriter writes only customer audio (Meta-side track).
|
||||||
|
customerWriter *oggwriter.OggWriter
|
||||||
|
customerFile string
|
||||||
|
|
||||||
|
// agentWriter writes only agent audio (browser-side track).
|
||||||
|
agentWriter *oggwriter.OggWriter
|
||||||
|
agentFile string
|
||||||
|
|
||||||
|
// bothActive gates the first RTP write on either side until the *other*
|
||||||
|
// side has produced at least one packet. This clips the pre-answer ringing
|
||||||
|
// period — browsers send mic RTP as soon as Peer B connects, ~3-5 s before
|
||||||
|
// the contact picks up, which would otherwise inflate the agent OGG and
|
||||||
|
// produce a recording much longer than the real conversation.
|
||||||
|
customerSeen bool
|
||||||
|
agentSeen bool
|
||||||
|
|
||||||
|
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 tracked:
|
||||||
|
// - {sessionID}_customer.ogg (customer channel only, written live)
|
||||||
|
// - {sessionID}_agent.ogg (agent channel only, written live)
|
||||||
|
// - {sessionID}.ogg (combined stereo, produced at Finalize via ffmpeg)
|
||||||
|
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")
|
||||||
|
|
||||||
|
customerWriter, err := oggwriter.New(customerFile, 48000, 1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create customer OGG writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentWriter, err := oggwriter.New(agentFile, 48000, 1)
|
||||||
|
if err != nil {
|
||||||
|
customerWriter.Close()
|
||||||
|
return nil, fmt.Errorf("create agent OGG writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("recorder: started",
|
||||||
|
"session_id", sessionID,
|
||||||
|
"customer_file", customerFile,
|
||||||
|
"agent_file", agentFile,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &Recorder{
|
||||||
|
sessionID: sessionID,
|
||||||
|
dir: dir,
|
||||||
|
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 customer-only OGG file. Drops packets until the
|
||||||
|
// agent side has also produced RTP, so the recording spans only the actual
|
||||||
|
// conversation — not the pre-answer window.
|
||||||
|
func (r *Recorder) WriteCustomerRTP(pkt *rtp.Packet) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if r.finalized {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r.customerSeen = true
|
||||||
|
if !r.agentSeen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.customerWriter.WriteRTP(pkt); err != nil {
|
||||||
|
return fmt.Errorf("write customer RTP: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAgentRTP writes an RTP packet from the agent's audio stream
|
||||||
|
// (browser-side, Peer B) to the agent-only OGG file. Drops packets until the
|
||||||
|
// customer side has also produced RTP so the two streams cover the same
|
||||||
|
// wall-clock window and the combined ffmpeg mix has a sensible duration.
|
||||||
|
func (r *Recorder) WriteAgentRTP(pkt *rtp.Packet) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if r.finalized {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r.agentSeen = true
|
||||||
|
if !r.customerSeen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.agentWriter.WriteRTP(pkt); err != nil {
|
||||||
|
return fmt.Errorf("write agent RTP: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize closes the per-direction writers, then merges them into a single
|
||||||
|
// stereo combined.ogg via ffmpeg. If ffmpeg isn't on PATH the combined file
|
||||||
|
// is produced by copying the customer-side file as a fallback so that at
|
||||||
|
// least one side is playable. Transcription quality degrades in the fallback
|
||||||
|
// but the pipeline does not break.
|
||||||
|
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.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 err := r.buildCombinedFile(); err != nil {
|
||||||
|
errs = append(errs, 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),
|
||||||
|
"combined_file", r.combinedFile,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCombinedFile mixes the two per-direction OGG files into a single
|
||||||
|
// stereo combined.ogg so playback and transcription receive a coherent file
|
||||||
|
// with correct duration metadata.
|
||||||
|
func (r *Recorder) buildCombinedFile() error {
|
||||||
|
customerExists := fileNonEmpty(r.customerFile)
|
||||||
|
agentExists := fileNonEmpty(r.agentFile)
|
||||||
|
if !customerExists && !agentExists {
|
||||||
|
return errors.New("build combined file: no per-direction recordings to merge")
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpegPath, err := exec.LookPath("ffmpeg")
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("recorder: ffmpeg not found, falling back to single-side combined file",
|
||||||
|
"session_id", r.sessionID,
|
||||||
|
)
|
||||||
|
src := r.customerFile
|
||||||
|
if !customerExists {
|
||||||
|
src = r.agentFile
|
||||||
|
}
|
||||||
|
return copyFile(src, r.combinedFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"-y", "-loglevel", "error"}
|
||||||
|
if customerExists {
|
||||||
|
args = append(args, "-i", r.customerFile)
|
||||||
|
}
|
||||||
|
if agentExists {
|
||||||
|
args = append(args, "-i", r.agentFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case customerExists && agentExists:
|
||||||
|
// Mix two mono streams into one mono track. amix pads the shorter input
|
||||||
|
// with silence so the output duration matches the longer input, and
|
||||||
|
// uses the longer input as the reference for timing so the OGG pages
|
||||||
|
// carry correct granule positions.
|
||||||
|
args = append(args,
|
||||||
|
"-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest:dropout_transition=0[aout]",
|
||||||
|
"-map", "[aout]",
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
// Only one side — just remux to produce correct OGG duration headers.
|
||||||
|
args = append(args, "-map", "0:a")
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "-c:a", "libopus", "-b:a", "48000", "-ar", "48000", "-ac", "1", r.combinedFile)
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegPath, args...)
|
||||||
|
out, cmdErr := cmd.CombinedOutput()
|
||||||
|
if cmdErr != nil {
|
||||||
|
return fmt.Errorf("ffmpeg mix failed: %w (%s)", cmdErr, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileNonEmpty(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && info.Size() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", src, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
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,247 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind the local track to a sendrecv transceiver so the offer advertises
|
||||||
|
// both directions: we send customer audio to the browser, and we expect
|
||||||
|
// the browser's microphone audio back. AddTrack alone yields a sendonly
|
||||||
|
// m=audio, leaving the browser with nowhere to send mic audio.
|
||||||
|
transceiver, err := pc.AddTransceiverFromTrack(localTrack, webrtc.RTPTransceiverInit{
|
||||||
|
Direction: webrtc.RTPTransceiverDirectionSendrecv,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pc.Close()
|
||||||
|
return nil, "", fmt.Errorf("add audio transceiver: %w", err)
|
||||||
|
}
|
||||||
|
sender := transceiver.Sender()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
slog.Info("agent peer: connection state changed", "peer_id", id, "state", state.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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,297 @@
|
|||||||
|
// 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 (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"runtime/debug"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For inbound calls Meta doesn't know our DTLS fingerprint until Rails
|
||||||
|
// delivers the SDP answer via pre_accept_call / accept_call — which runs
|
||||||
|
// *after* create_session returns. If we're DTLS client (pion default when
|
||||||
|
// remote is actpass) we'd send ClientHello before Meta is listening for
|
||||||
|
// us and Meta would respond with a fatal alert. Answer as server so Meta
|
||||||
|
// becomes the client and only starts ClientHello after it has our
|
||||||
|
// fingerprint.
|
||||||
|
if err := se.SetAnsweringDTLSRole(webrtc.DTLSRoleServer); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("set answering DTLS role: %w", err)
|
||||||
|
}
|
||||||
|
se.SetDTLSConnectContextMaker(func() (context.Context, func()) {
|
||||||
|
return context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
})
|
||||||
|
se.SetDTLSRetransmissionInterval(200 * time.Millisecond)
|
||||||
|
// Skip the HelloVerify round-trip on the server side. Meta's WhatsApp
|
||||||
|
// calling stack sometimes uses stateless retry which loses the pion
|
||||||
|
// cookie between retransmits; skipping it makes the server accept the
|
||||||
|
// ClientHello in one go.
|
||||||
|
se.SetDTLSInsecureSkipHelloVerify(true)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind the local track to a single sendrecv transceiver. Using AddTrack +
|
||||||
|
// a separate recvonly transceiver yields two m=audio sections, which Meta
|
||||||
|
// rejects with error 100 "Invalid parameter".
|
||||||
|
transceiver, err := pc.AddTransceiverFromTrack(localTrack, webrtc.RTPTransceiverInit{
|
||||||
|
Direction: webrtc.RTPTransceiverDirectionSendrecv,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pc.Close()
|
||||||
|
return nil, "", fmt.Errorf("add audio transceiver: %w", err)
|
||||||
|
}
|
||||||
|
sender := transceiver.Sender()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log overall peer connection state (covers DTLS + ICE + signaling).
|
||||||
|
// This catches failures that don't surface via ICEConnectionState alone,
|
||||||
|
// such as DTLS handshake errors.
|
||||||
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||||
|
slog.Info("meta peer: connection state changed", "state", state.String())
|
||||||
|
})
|
||||||
|
pc.OnSignalingStateChange(func(state webrtc.SignalingState) {
|
||||||
|
slog.Debug("meta peer: signaling state changed", "state", state.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also log DTLS transport state — failure here is the likely cause of the
|
||||||
|
// inbound "closed immediately after ICE connected" symptom.
|
||||||
|
if t := pc.SCTP().Transport(); t != nil {
|
||||||
|
t.OnStateChange(func(state webrtc.DTLSTransportState) {
|
||||||
|
slog.Info("meta peer: DTLS state changed", "state", state.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. The sendrecv
|
||||||
|
// transceiver registered above already advertises both directions.
|
||||||
|
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 (explicit)", "stack", string(debug.Stack()))
|
||||||
|
return mp.pc.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,753 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaAnswerRequest is the JSON body for POST /sessions/:id/meta-answer.
|
||||||
|
// Used for outgoing calls to deliver Meta's SDP answer to the media server
|
||||||
|
// so it can complete the Peer A (Meta-side) WebRTC handshake.
|
||||||
|
type MetaAnswerRequest struct {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// When peer_id is omitted (single-agent sessions), fall back to the only
|
||||||
|
// agent peer attached to the session. Rails doesn't currently surface
|
||||||
|
// peer_id through ActionCable, so browsers just send the SDP answer.
|
||||||
|
if req.PeerID == "" {
|
||||||
|
if only, ok := sess.SoleAgentPeerID(); ok {
|
||||||
|
req.PeerID = only
|
||||||
|
} else {
|
||||||
|
writeError(w, http.StatusBadRequest, "peer_id is required (session has multiple agent peers)")
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaAnswer handles POST /sessions/{id}/meta-answer. It sets Meta's SDP
|
||||||
|
// answer on the Meta-side peer connection for outbound calls.
|
||||||
|
func (h *Handlers) MetaAnswer(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 MetaAnswerRequest
|
||||||
|
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 err := sess.SetMetaAnswer(req.SDPAnswer); err != nil {
|
||||||
|
slog.Error("handler: failed to set meta answer",
|
||||||
|
"session_id", sessionID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to set meta answer: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("handler: meta answer set", "session_id", sessionID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. An optional ?side=customer|agent
|
||||||
|
// query parameter returns the per-direction recording instead, which Rails
|
||||||
|
// uses to produce speaker-separated transcripts. Falls back to looking the
|
||||||
|
// files up on disk when the session has already been terminated and removed
|
||||||
|
// from the in-memory manager — Rails typically fetches per-side recordings
|
||||||
|
// shortly after calling terminate.
|
||||||
|
func (h *Handlers) GetRecording(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := r.PathValue("id")
|
||||||
|
side := r.URL.Query().Get("side")
|
||||||
|
|
||||||
|
filePath, filename := h.resolveRecordingPath(sessionID, side)
|
||||||
|
|
||||||
|
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"`, filename))
|
||||||
|
http.ServeContent(w, r, filePath, stat.ModTime(), f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRecordingPath returns the filesystem path + download filename for a
|
||||||
|
// session's recording. It prefers the live session's recorder (for sessions
|
||||||
|
// still in memory) and falls back to deterministic on-disk paths so Rails can
|
||||||
|
// download recordings even after the session was terminated and evicted from
|
||||||
|
// the manager.
|
||||||
|
func (h *Handlers) resolveRecordingPath(sessionID, side string) (string, string) {
|
||||||
|
var suffix, filename string
|
||||||
|
switch side {
|
||||||
|
case "customer":
|
||||||
|
suffix = "_customer.ogg"
|
||||||
|
case "agent":
|
||||||
|
suffix = "_agent.ogg"
|
||||||
|
default:
|
||||||
|
suffix = ".ogg"
|
||||||
|
}
|
||||||
|
filename = sessionID + suffix
|
||||||
|
|
||||||
|
if sess := h.manager.GetSession(sessionID); sess != nil {
|
||||||
|
switch side {
|
||||||
|
case "customer":
|
||||||
|
return sess.RecorderCustomerPath(), filename
|
||||||
|
case "agent":
|
||||||
|
return sess.RecorderAgentPath(), filename
|
||||||
|
default:
|
||||||
|
return sess.RecordingFilePath(), filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diskPath := filepath.Join(h.cfg.RecordingsDir, filename)
|
||||||
|
if _, err := os.Stat(diskPath); err == nil {
|
||||||
|
return diskPath, filename
|
||||||
|
}
|
||||||
|
return "", filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
err := json.NewDecoder(limited).Decode(v)
|
||||||
|
// Tolerate empty bodies — handlers with all-optional fields treat this
|
||||||
|
// as "use defaults" rather than failing.
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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,117 @@
|
|||||||
|
// 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}/meta-answer", authMw(http.HandlerFunc(rt.handler.MetaAnswer)))
|
||||||
|
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,595 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoleAgentPeerID returns the peer_id when the session has exactly one agent
|
||||||
|
// peer. Used by the agent-answer handler as a fallback for clients that don't
|
||||||
|
// track peer ids.
|
||||||
|
func (s *Session) SoleAgentPeerID() (string, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if len(s.AgentPeers) != 1 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for id := range s.AgentPeers {
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecorderCustomerPath returns the path to the customer-only recording.
|
||||||
|
func (s *Session) RecorderCustomerPath() string {
|
||||||
|
if s.Recorder == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.Recorder.CustomerFilePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecorderAgentPath returns the path to the agent-only recording.
|
||||||
|
func (s *Session) RecorderAgentPath() string {
|
||||||
|
if s.Recorder == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.Recorder.AgentFilePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ RSpec.describe Webhooks::WhatsappEventsJob do
|
|||||||
it 'enqueues the job' do
|
it 'enqueues the job' do
|
||||||
expect { job.perform_later(params) }.to have_enqueued_job(described_class)
|
expect { job.perform_later(params) }.to have_enqueued_job(described_class)
|
||||||
.with(params)
|
.with(params)
|
||||||
.on_queue('low')
|
.on_queue('default')
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when whatsapp_cloud provider' do
|
context 'when whatsapp_cloud provider' do
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
|||||||
describe '#send_message' do
|
describe '#send_message' do
|
||||||
context 'when called' do
|
context 'when called' do
|
||||||
it 'calls message endpoints for normal messages' do
|
it 'calls message endpoints for normal messages' do
|
||||||
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
|
stub_request(:post, 'https://graph.facebook.com/v22.0/123456789/messages')
|
||||||
.with(
|
.with(
|
||||||
body: {
|
body: {
|
||||||
messaging_product: 'whatsapp',
|
messaging_product: 'whatsapp',
|
||||||
@@ -40,7 +40,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'calls message endpoints for a reply to messages' do
|
it 'calls message endpoints for a reply to messages' do
|
||||||
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
|
stub_request(:post, 'https://graph.facebook.com/v22.0/123456789/messages')
|
||||||
.with(
|
.with(
|
||||||
body: {
|
body: {
|
||||||
messaging_product: 'whatsapp',
|
messaging_product: 'whatsapp',
|
||||||
@@ -60,7 +60,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
|||||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||||
|
|
||||||
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
|
stub_request(:post, 'https://graph.facebook.com/v22.0/123456789/messages')
|
||||||
.with(
|
.with(
|
||||||
body: hash_including({
|
body: hash_including({
|
||||||
messaging_product: 'whatsapp',
|
messaging_product: 'whatsapp',
|
||||||
@@ -79,7 +79,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
|||||||
|
|
||||||
# ref: https://github.com/bblimke/webmock/issues/900
|
# ref: https://github.com/bblimke/webmock/issues/900
|
||||||
# reason for Webmock::API.hash_including
|
# reason for Webmock::API.hash_including
|
||||||
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
|
stub_request(:post, 'https://graph.facebook.com/v22.0/123456789/messages')
|
||||||
.with(
|
.with(
|
||||||
body: hash_including({
|
body: hash_including({
|
||||||
messaging_product: 'whatsapp',
|
messaging_product: 'whatsapp',
|
||||||
@@ -106,7 +106,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
|||||||
{ title: 'Sushi', value: 'Sushi' }
|
{ title: 'Sushi', value: 'Sushi' }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
|
stub_request(:post, 'https://graph.facebook.com/v22.0/123456789/messages')
|
||||||
.with(
|
.with(
|
||||||
body: {
|
body: {
|
||||||
messaging_product: 'whatsapp', to: '+123456789',
|
messaging_product: 'whatsapp', to: '+123456789',
|
||||||
@@ -133,7 +133,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
|||||||
sections: [{ rows: %w[Burito Pasta Sushi Salad].map { |i| { id: i, title: i } } }]
|
sections: [{ rows: %w[Burito Pasta Sushi Salad].map { |i| { id: i, title: i } } }]
|
||||||
}.to_json
|
}.to_json
|
||||||
|
|
||||||
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
|
stub_request(:post, 'https://graph.facebook.com/v22.0/123456789/messages')
|
||||||
.with(
|
.with(
|
||||||
body: {
|
body: {
|
||||||
messaging_product: 'whatsapp', to: '+123456789',
|
messaging_product: 'whatsapp', to: '+123456789',
|
||||||
@@ -181,7 +181,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
|||||||
|
|
||||||
context 'when called' do
|
context 'when called' do
|
||||||
it 'calls message endpoints with template params for template messages' do
|
it 'calls message endpoints with template params for template messages' do
|
||||||
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
|
stub_request(:post, 'https://graph.facebook.com/v22.0/123456789/messages')
|
||||||
.with(
|
.with(
|
||||||
body: template_body.to_json
|
body: template_body.to_json
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ describe Whatsapp::SendOnWhatsappService do
|
|||||||
processed_params: { 'body' => { 'last_name' => 'Dale', 'ticket_id' => '2332' } }
|
processed_params: { 'body' => { 'last_name' => 'Dale', 'ticket_id' => '2332' } }
|
||||||
}
|
}
|
||||||
|
|
||||||
stub_request(:post, "https://graph.facebook.com/v13.0/#{whatsapp_cloud_channel.provider_config['phone_number_id']}/messages")
|
stub_request(:post, "https://graph.facebook.com/v22.0/#{whatsapp_cloud_channel.provider_config['phone_number_id']}/messages")
|
||||||
.with(
|
.with(
|
||||||
:headers => {
|
:headers => {
|
||||||
'Accept' => '*/*',
|
'Accept' => '*/*',
|
||||||
|
|||||||
Reference in New Issue
Block a user