feat(whatsapp-call): direct browser-Meta WebRTC, no media server
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class VoiceCallsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('voice_calls', { accountScoped: true });
|
||||
}
|
||||
|
||||
show(callId) {
|
||||
return axios.get(`${this.url}/${callId}`);
|
||||
}
|
||||
|
||||
// The browser does WebRTC locally and ships the SDP answer up. Rails forwards
|
||||
// it to Meta via pre_accept_call+accept_call.
|
||||
accept(callId, { sdpAnswer } = {}) {
|
||||
return axios.post(`${this.url}/${callId}/accept`, {
|
||||
sdp_answer: sdpAnswer,
|
||||
});
|
||||
}
|
||||
|
||||
reject(callId) {
|
||||
return axios.post(`${this.url}/${callId}/reject`);
|
||||
}
|
||||
|
||||
terminate(callId) {
|
||||
return axios.post(`${this.url}/${callId}/terminate`);
|
||||
}
|
||||
|
||||
// Outbound: browser builds the offer first; Rails ships it to Meta and
|
||||
// creates the Call record. Meta delivers its SDP answer later via the
|
||||
// connect webhook (broadcast over ActionCable as voice_call.outbound_connected).
|
||||
initiate(conversationId, provider, { sdpOffer } = {}) {
|
||||
return axios.post(`${this.url}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
provider,
|
||||
sdp_offer: sdpOffer,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the current agent's active call (if any). Used on page load to detect
|
||||
// a stale active session so we can terminate it cleanly.
|
||||
active() {
|
||||
return axios.get(`${this.url}/active`);
|
||||
}
|
||||
|
||||
// Multipart upload of the in-browser MediaRecorder Blob to Rails. The
|
||||
// controller attaches it to the call's voice_call message; the after-create
|
||||
// hook on Attachment fires Messages::AudioTranscriptionJob automatically.
|
||||
uploadRecording(callId, blob, filename) {
|
||||
const fd = new FormData();
|
||||
fd.append('recording', blob, filename);
|
||||
return axios.post(`${this.url}/${callId}/upload_recording`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
// URL helpers for navigator.sendBeacon (which can't carry custom headers and
|
||||
// needs the absolute account-scoped path).
|
||||
uploadRecordingUrl(callId) {
|
||||
return `${this.url}/${callId}/upload_recording`;
|
||||
}
|
||||
|
||||
terminateUrl(callId) {
|
||||
return `${this.url}/${callId}/terminate`;
|
||||
}
|
||||
}
|
||||
|
||||
export default new VoiceCallsAPI();
|
||||
@@ -3,10 +3,10 @@ import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -34,11 +34,12 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(isVoiceCallEnabled)
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const isInitiatingCall = computed(() => {
|
||||
@@ -66,15 +67,16 @@ const startCall = async inboxId => {
|
||||
contactId: props.contactId,
|
||||
inboxId,
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
// Add call to store immediately so widget shows
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: 'outbound',
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
voiceCallsStore.setActiveCall({
|
||||
id: response.call_id,
|
||||
callId: response.call_sid,
|
||||
provider: response.provider || 'twilio',
|
||||
direction: 'outbound',
|
||||
status: 'ringing',
|
||||
conversationId: response.conversation_id,
|
||||
caller: null,
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
import { acceptVoiceCallById } from 'dashboard/composables/useVoiceCallSession';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -30,14 +33,65 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { call } = useMessageContext();
|
||||
const router = useRouter();
|
||||
const { contentAttributes, messageType, attachments } = useMessageContext();
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
// NOTE: contentAttributes.data keys are camelCase because MessageList.vue
|
||||
// applies useCamelCase(messages, { deep: true }) before rendering.
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
// Call source and metadata — all camelCase due to deep transform
|
||||
const callSource = computed(() => data.value?.callSource);
|
||||
const isVoiceCall = computed(() =>
|
||||
['whatsapp', 'twilio'].includes(callSource.value)
|
||||
);
|
||||
const callId = computed(() => data.value?.callId);
|
||||
const acceptedBy = computed(() => data.value?.acceptedBy);
|
||||
const durationSeconds = computed(() => data.value?.durationSeconds);
|
||||
|
||||
// Recording and transcript live on the first audio attachment. After the
|
||||
// call ends, CallRecordingFetchJob creates an Attachment on this message;
|
||||
// Messages::AudioTranscriptionService fills in `transcribedText` when ready.
|
||||
const audioAttachment = computed(
|
||||
() => attachments.value?.find(a => a.fileType === 'audio') || null
|
||||
);
|
||||
const recordingUrl = computed(() => audioAttachment.value?.dataUrl);
|
||||
const transcript = computed(() => audioAttachment.value?.transcribedText);
|
||||
const isJoining = ref(false);
|
||||
const isRejecting = ref(false);
|
||||
const showTranscript = ref(false);
|
||||
|
||||
// Reject is shown only for ringing inbound calls — outbound calls and
|
||||
// in-progress calls don't need a reject button (initiator hangs up via the
|
||||
// floating widget).
|
||||
const showRejectButton = computed(
|
||||
() =>
|
||||
isVoiceCall.value &&
|
||||
!isOutbound.value &&
|
||||
status.value === VOICE_CALL_STATUS.RINGING
|
||||
);
|
||||
|
||||
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
|
||||
// Direct browser↔Meta WebRTC has no rejoin path (Meta caches the prior
|
||||
// DTLS fingerprint), so only ringing calls show the Accept button.
|
||||
const showJoinButton = computed(
|
||||
() => isVoiceCall.value && status.value === VOICE_CALL_STATUS.RINGING
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
@@ -51,6 +105,19 @@ const labelKey = computed(() => {
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
// acceptedBy on outbound calls is the initiator, not the contact — so keep
|
||||
// "They answered" instead of "Answered by <agent>". Only inbound bubbles
|
||||
// should suppress the subtext in favor of the acceptedBy line.
|
||||
if (
|
||||
!isOutbound.value &&
|
||||
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 (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
@@ -62,12 +129,50 @@ const subtextKey = computed(() => {
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const answeredByText = computed(() => {
|
||||
if (isOutbound.value) return '';
|
||||
if (!acceptedBy.value?.name) return '';
|
||||
return acceptedBy.value.name;
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
const handleJoinCall = async () => {
|
||||
if (isJoining.value || !isVoiceCall.value) return;
|
||||
isJoining.value = true;
|
||||
try {
|
||||
const result = await acceptVoiceCallById(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('[Voice Call] Accept from bubble failed:', err);
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectCall = async () => {
|
||||
if (isRejecting.value || !callId.value) return;
|
||||
isRejecting.value = true;
|
||||
try {
|
||||
await VoiceCallsAPI.reject(callId.value);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] Reject from bubble failed:', err);
|
||||
} finally {
|
||||
isRejecting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,14 +193,90 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
/>
|
||||
</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">
|
||||
{{ $t(labelKey) }}
|
||||
</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) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="formattedDuration && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="text-xs text-n-slate-10"
|
||||
>
|
||||
{{ formattedDuration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showRejectButton"
|
||||
:disabled="isRejecting"
|
||||
:title="$t('WHATSAPP_CALL.REJECT')"
|
||||
class="flex justify-center items-center w-8 h-8 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shrink-0"
|
||||
:class="{ 'opacity-75 cursor-wait': isRejecting }"
|
||||
@click="handleRejectCall"
|
||||
>
|
||||
<i
|
||||
v-if="isRejecting"
|
||||
class="i-ph-circle-notch-bold text-sm text-white animate-spin"
|
||||
/>
|
||||
<i v-else class="i-ph-phone-x-bold text-sm text-white" />
|
||||
</button>
|
||||
|
||||
<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('CONVERSATION.VOICE_CALL.ACCEPT_CALL') }}
|
||||
</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>
|
||||
</BaseBubble>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<script setup>
|
||||
import { watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useVoiceCallSession } from 'dashboard/composables/useVoiceCallSession';
|
||||
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,
|
||||
callError,
|
||||
formattedCallDuration,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endActiveCall,
|
||||
toggleMute,
|
||||
dismissIncomingCall,
|
||||
startDurationTimer,
|
||||
} = useVoiceCallSession();
|
||||
|
||||
// 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('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
|
||||
emitter.on('voice_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('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
|
||||
emitter.off('voice_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">
|
||||
{{
|
||||
call.provider === 'twilio'
|
||||
? 'Incoming voice call'
|
||||
: 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 ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
|
||||
"
|
||||
>
|
||||
<template v-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 { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import {
|
||||
prepareOutboundOffer,
|
||||
cleanupInboundWebRTC,
|
||||
} from 'dashboard/composables/useVoiceCallSession';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
@@ -30,7 +38,10 @@ const store = useStore();
|
||||
const route = useRoute();
|
||||
const conversationHeader = ref(null);
|
||||
const { width } = useElementSize(conversationHeader);
|
||||
const { isAWebWidgetInbox } = useInbox();
|
||||
const { isAWebWidgetInbox, isAWhatsAppCloudChannel, voiceCallEnabled } =
|
||||
useInbox();
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
const isInitiatingCall = ref(false);
|
||||
|
||||
const currentChat = computed(() => store.getters.getSelectedChat);
|
||||
const accountId = computed(() => store.getters.getCurrentAccountId);
|
||||
@@ -91,6 +102,99 @@ const hasMultipleInboxes = computed(
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
// Returns the provider to use for an outbound call, based on the inbox
|
||||
// channel type. WhatsApp Cloud inboxes use :whatsapp; Twilio Voice inboxes
|
||||
// use :twilio. Returns null if outbound calling isn't supported on this
|
||||
// channel.
|
||||
const callProvider = computed(() => {
|
||||
if (isAWhatsAppCloudChannel.value && inbox.value?.calling_enabled) {
|
||||
return 'whatsapp';
|
||||
}
|
||||
if (voiceCallEnabled.value) {
|
||||
return 'twilio';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const canInitiateVoiceCall = computed(() => {
|
||||
if (!callProvider.value) return false;
|
||||
if (voiceCallsStore.hasVoiceCall) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Outbound call initiation. WhatsApp now runs direct browser ↔ Meta WebRTC:
|
||||
// the browser builds the SDP offer locally before any network call, so Meta
|
||||
// gets the agent's real fingerprint and the eventual answer applies cleanly
|
||||
// in the cable handler. Twilio still POSTs first and waits for the
|
||||
// synchronous agent_offer in the response (its OutboundCallBuilder produces
|
||||
// the SDP server-side). On 138006 (no call permission) we tear down the local
|
||||
// PC immediately so the mic light goes off while the contact's phone shows
|
||||
// the consent prompt.
|
||||
const initiateWhatsappCall = async () => {
|
||||
if (isInitiatingCall.value || !currentChat.value?.id || !callProvider.value)
|
||||
return;
|
||||
isInitiatingCall.value = true;
|
||||
const isWhatsApp = callProvider.value === 'whatsapp';
|
||||
|
||||
try {
|
||||
let sdpOffer = null;
|
||||
if (isWhatsApp) {
|
||||
sdpOffer = await prepareOutboundOffer({});
|
||||
}
|
||||
|
||||
const response = await VoiceCallsAPI.initiate(
|
||||
currentChat.value.id,
|
||||
callProvider.value,
|
||||
isWhatsApp ? { sdpOffer } : {}
|
||||
);
|
||||
|
||||
const callStatus = response.data?.status;
|
||||
if (
|
||||
callStatus === 'permission_requested' ||
|
||||
callStatus === 'permission_pending'
|
||||
) {
|
||||
// Tear down the local PC; we'll rebuild from scratch on retry.
|
||||
cleanupInboundWebRTC();
|
||||
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 callData = response.data || {};
|
||||
voiceCallsStore.setActiveCall({
|
||||
id: callData.id,
|
||||
callId: callData.call_id,
|
||||
provider: callData.provider || callProvider.value,
|
||||
direction: 'outbound',
|
||||
status: 'ringing',
|
||||
conversationId: currentChat.value.id,
|
||||
caller: {
|
||||
name: currentContact.value?.name,
|
||||
phone: currentContact.value?.phone_number,
|
||||
avatar: currentContact.value?.thumbnail,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
cleanupInboundWebRTC();
|
||||
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;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -152,6 +256,19 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<button
|
||||
v-if="canInitiateVoiceCall"
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
/**
|
||||
* Detects a stale active call on page load and cleans it up. Direct
|
||||
* browser ↔ Meta WebRTC has no rejoin path — Meta caches the prior DTLS
|
||||
* fingerprint, so a fresh PC can never re-bind to the existing call leg.
|
||||
* Best we can do is terminate the stranded Call so the agent isn't shown
|
||||
* "in a call" forever.
|
||||
*/
|
||||
export function useCallReconnection() {
|
||||
const callsStore = useVoiceCallsStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
onMounted(async () => {
|
||||
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return;
|
||||
|
||||
try {
|
||||
const { data } = await VoiceCallsAPI.active();
|
||||
if (!data?.call && !data?.id) return;
|
||||
|
||||
const activeCallData = data.call || data;
|
||||
try {
|
||||
await VoiceCallsAPI.terminate(activeCallData.id);
|
||||
} catch {
|
||||
// best-effort — Rails-side cleanup
|
||||
}
|
||||
useAlert(t('WHATSAPP_CALL.RELOAD_ENDED_CALL'));
|
||||
} catch {
|
||||
callsStore.clearActiveCall();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Module-level WebRTC + recorder state. One active peer per browser tab.
|
||||
// `endActiveCall` and `pagehide` race for finalize duty; `intentionallyClosing`
|
||||
// disambiguates so beforeunload doesn't warn during a clean hangup.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
let inboundPc = null;
|
||||
let inboundStream = null;
|
||||
let inboundAudio = null;
|
||||
|
||||
let audioContext = null;
|
||||
let mediaRecorder = null;
|
||||
let recordingMime = null;
|
||||
let recordingChunks = [];
|
||||
let recordingFilename = null;
|
||||
let intentionallyClosing = false;
|
||||
|
||||
const RECORDER_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
];
|
||||
|
||||
const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
function pickRecorderMime() {
|
||||
if (typeof MediaRecorder === 'undefined') return null;
|
||||
return (
|
||||
RECORDER_MIME_CANDIDATES.find(t => MediaRecorder.isTypeSupported(t)) || null
|
||||
);
|
||||
}
|
||||
|
||||
function teardownRecorder() {
|
||||
mediaRecorder = null;
|
||||
recordingChunks = [];
|
||||
recordingMime = null;
|
||||
recordingFilename = null;
|
||||
if (audioContext) {
|
||||
audioContext.close().catch(() => {});
|
||||
audioContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
function extensionFromMime(mime) {
|
||||
if (!mime) return 'webm';
|
||||
if (mime.startsWith('audio/webm')) return 'webm';
|
||||
if (mime.startsWith('audio/ogg')) return 'ogg';
|
||||
return 'webm';
|
||||
}
|
||||
|
||||
// Mix the agent's mic and Meta's remote audio into a single stream so the
|
||||
// resulting Blob is a real conversation rather than two interleaved channels.
|
||||
// Web Audio handles the mix; we never apply gain (raw levels matter for
|
||||
// Whisper's speech-detection floor).
|
||||
function setupRecorder(localStream, remoteStream, callId, providerCallId) {
|
||||
if (mediaRecorder || !localStream || !remoteStream) return;
|
||||
const mime = pickRecorderMime();
|
||||
if (!mime) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[Voice Call] No supported MediaRecorder MIME — skipping recording'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: 48000,
|
||||
});
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().catch(() => {});
|
||||
}
|
||||
const localSource = audioContext.createMediaStreamSource(localStream);
|
||||
const remoteSource = audioContext.createMediaStreamSource(remoteStream);
|
||||
const mixDest = audioContext.createMediaStreamDestination();
|
||||
localSource.connect(mixDest);
|
||||
remoteSource.connect(mixDest);
|
||||
|
||||
recordingMime = mime;
|
||||
recordingChunks = [];
|
||||
recordingFilename = `call_${callId}_${providerCallId || callId}.${extensionFromMime(mime)}`;
|
||||
|
||||
mediaRecorder = new MediaRecorder(mixDest.stream, { mimeType: mime });
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
if (event.data && event.data.size) recordingChunks.push(event.data);
|
||||
};
|
||||
// 5s timeslice keeps the in-memory blob fresh enough for the pagehide
|
||||
// beacon to capture most of the call if the user closes the tab.
|
||||
mediaRecorder.start(5000);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Voice Call] Recorder setup failed:', err);
|
||||
teardownRecorder();
|
||||
}
|
||||
}
|
||||
|
||||
// stop() returns nothing useful; the final dataavailable fires on the next
|
||||
// task and onstop fires after that. Wait for onstop, then assemble the blob.
|
||||
async function stopRecorderAndGetBlob() {
|
||||
if (!mediaRecorder) return null;
|
||||
const mr = mediaRecorder;
|
||||
if (mr.state === 'inactive') {
|
||||
const blob = new Blob(recordingChunks, {
|
||||
type: recordingMime || 'audio/webm',
|
||||
});
|
||||
teardownRecorder();
|
||||
return { blob, filename: recordingFilename };
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
const cleanup = () => {
|
||||
const blob = new Blob(recordingChunks, {
|
||||
type: recordingMime || 'audio/webm',
|
||||
});
|
||||
const filename = recordingFilename;
|
||||
teardownRecorder();
|
||||
resolve({ blob, filename });
|
||||
};
|
||||
mr.onstop = cleanup;
|
||||
try {
|
||||
mr.stop();
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupInboundWebRTC() {
|
||||
// Stop recorder first so the source nodes still see live tracks during
|
||||
// flush. Any awaited blob is the caller's problem (endActiveCall does it).
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
teardownRecorder();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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('[Voice 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'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function attachRemoteTrackHandler(pc, callId, providerCallId) {
|
||||
pc.ontrack = event => {
|
||||
let remoteStream = event.streams && event.streams[0];
|
||||
if (!remoteStream) {
|
||||
if (!event.track) return;
|
||||
remoteStream = new MediaStream([event.track]);
|
||||
}
|
||||
if (!inboundAudio) {
|
||||
const audio = document.createElement('audio');
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
inboundAudio = audio;
|
||||
}
|
||||
inboundAudio.srcObject = remoteStream;
|
||||
inboundAudio.play().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Voice Call] audio.play() rejected:', err);
|
||||
});
|
||||
if (inboundStream) {
|
||||
setupRecorder(inboundStream, remoteStream, callId, providerCallId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Inbound: browser receives Meta's SDP offer via the voice_call.incoming
|
||||
// cable event, opens its mic, builds the answer, and ships it back so Rails
|
||||
// can hand it to Meta. After this returns, the agent and Meta are mid-DTLS.
|
||||
async function prepareInboundAnswer({
|
||||
callId,
|
||||
providerCallId,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
}) {
|
||||
cleanupInboundWebRTC();
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
|
||||
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
|
||||
const pc = new RTCPeerConnection({ iceServers: servers });
|
||||
inboundPc = pc;
|
||||
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
attachRemoteTrackHandler(pc, callId, providerCallId);
|
||||
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
|
||||
return pc.localDescription.sdp;
|
||||
}
|
||||
|
||||
// Outbound: browser builds the offer locally, before any network call. Rails
|
||||
// hands the offer to Meta via initiate_call; we receive Meta's SDP answer
|
||||
// later via the voice_call.outbound_connected cable event.
|
||||
async function prepareOutboundOffer({
|
||||
callId,
|
||||
providerCallId,
|
||||
iceServers,
|
||||
} = {}) {
|
||||
cleanupInboundWebRTC();
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
|
||||
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
|
||||
const pc = new RTCPeerConnection({ iceServers: servers });
|
||||
inboundPc = pc;
|
||||
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
attachRemoteTrackHandler(pc, callId, providerCallId);
|
||||
|
||||
const offer = await pc.createOffer({ offerToReceiveAudio: true });
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
|
||||
return pc.localDescription.sdp;
|
||||
}
|
||||
|
||||
// Apply Meta's SDP answer to the existing outbound RTCPeerConnection. Called
|
||||
// from the actionCable handler when voice_call.outbound_connected lands.
|
||||
async function applyOutboundAnswer(sdpAnswer) {
|
||||
if (!inboundPc) throw new Error('No active outbound RTCPeerConnection');
|
||||
if (inboundPc.signalingState !== 'have-local-offer') {
|
||||
// Already applied — duplicate cable delivery. No-op rather than detonate.
|
||||
return;
|
||||
}
|
||||
await inboundPc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||
// Some browsers fire ontrack synchronously inside setRemoteDescription;
|
||||
// others delay it. If we already have a remote stream, kick the recorder.
|
||||
if (inboundAudio?.srcObject && inboundStream) {
|
||||
setupRecorder(inboundStream, inboundAudio.srcObject, undefined, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export { prepareOutboundOffer, applyOutboundAnswer, cleanupInboundWebRTC };
|
||||
|
||||
// Standalone for the message bubble's Accept button. Branches on whether the
|
||||
// store has an SDP offer (WhatsApp direct mode = always yes).
|
||||
export async function acceptVoiceCallById(callId) {
|
||||
const callsStore = useVoiceCallsStore();
|
||||
|
||||
if (callsStore.hasActiveCall) {
|
||||
return { success: false, error: 'active_call_exists' };
|
||||
}
|
||||
|
||||
let call = callsStore.incomingCalls.find(
|
||||
c => c.id === callId || c.callId === String(callId)
|
||||
);
|
||||
|
||||
let sdpOffer = call?.sdpOffer;
|
||||
let iceServers = call?.iceServers;
|
||||
let providerCallId = call?.callId;
|
||||
|
||||
if (!sdpOffer) {
|
||||
const { data } = await VoiceCallsAPI.show(callId);
|
||||
if (data.status !== 'ringing') {
|
||||
return { success: false, error: 'not_ringing' };
|
||||
}
|
||||
sdpOffer = data.sdp_offer;
|
||||
iceServers = data.ice_servers;
|
||||
providerCallId = data.call_id;
|
||||
call = {
|
||||
id: data.id,
|
||||
callId: data.call_id,
|
||||
provider: data.provider,
|
||||
direction: data.direction,
|
||||
inboxId: data.inbox_id,
|
||||
conversationId: data.conversation_id,
|
||||
caller: data.caller,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
};
|
||||
callsStore.addIncomingCall(call);
|
||||
}
|
||||
|
||||
if (!sdpOffer) return { success: false, error: 'missing_sdp_offer' };
|
||||
|
||||
// ORDER-SENSITIVE: setActiveCall must happen synchronously before the
|
||||
// network call so the cable handler doesn't drop a frame on a null guard.
|
||||
const activeCallData = { ...call };
|
||||
callsStore.setActiveCall(activeCallData);
|
||||
|
||||
try {
|
||||
const sdpAnswer = await prepareInboundAnswer({
|
||||
callId: call.id,
|
||||
providerCallId,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
});
|
||||
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.markActiveCallConnected();
|
||||
emitter.emit('voice_call:agent_webrtc_connected');
|
||||
return { success: true, call: activeCallData };
|
||||
} catch (err) {
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.clearActiveCall?.();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort upload. Errors are swallowed because the call has already
|
||||
// completed by the time we get here; surfacing failures isn't actionable.
|
||||
async function uploadRecordingBlob(callId) {
|
||||
if (!mediaRecorder && recordingChunks.length === 0) return;
|
||||
try {
|
||||
const result = await stopRecorderAndGetBlob();
|
||||
if (!result?.blob || result.blob.size === 0) return;
|
||||
await VoiceCallsAPI.uploadRecording(callId, result.blob, result.filename);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Voice Call] Recording upload failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composable (used by VoiceCallWidget for floating UI + timer) ──
|
||||
export function useVoiceCallSession() {
|
||||
const { t } = useI18n();
|
||||
const callsStore = useVoiceCallsStore();
|
||||
const getters = useStoreGetters();
|
||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
||||
|
||||
const isAccepting = ref(false);
|
||||
const isMuted = ref(false);
|
||||
const callError = ref(null);
|
||||
const callDuration = ref(0);
|
||||
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
callDuration.value = 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')}`;
|
||||
});
|
||||
|
||||
// Triggered when handleCallEnded fires — i.e. the OTHER side hung up
|
||||
// (Meta terminate webhook → voice_call.ended cable). Upload whatever we
|
||||
// recorded BEFORE tearing down the PC, otherwise the agent-hangup path is
|
||||
// the only one that ships audio to the server. uploadRecordingBlob
|
||||
// gathers the blob via the recorder's onstop listener and then frees the
|
||||
// recorder — only AFTER that completes do we close the PC and stop tracks.
|
||||
callsStore.registerCleanupCallback(async callId => {
|
||||
const idForUpload = callId || activeCall.value?.id;
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
try {
|
||||
if (idForUpload) await uploadRecordingBlob(idForUpload);
|
||||
} finally {
|
||||
cleanupInboundWebRTC();
|
||||
}
|
||||
});
|
||||
|
||||
// Warn the user if they try to navigate away mid-call. The actual cleanup
|
||||
// happens in the pagehide handler — beforeunload is purely the warning.
|
||||
const handleBeforeUnload = e => {
|
||||
if (!callsStore.activeCall || intentionallyClosing) return undefined;
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
// Some browsers (older Safari/Firefox) only honour a returned string.
|
||||
// eslint-disable-next-line consistent-return
|
||||
return '';
|
||||
};
|
||||
|
||||
// Fires after the user confirms close (or on tab switch). We use fetch
|
||||
// with `keepalive: true` rather than navigator.sendBeacon because Chatwoot
|
||||
// authenticates via devise-token-auth headers (`access-token`, `client`,
|
||||
// `uid`) which sendBeacon cannot attach — beacon would 401 silently.
|
||||
// fetch+keepalive sends both cookies AND custom headers and the request
|
||||
// survives page unload (capped at ~64KB body, fine for terminate).
|
||||
const handlePageHide = () => {
|
||||
const call = callsStore.activeCall;
|
||||
if (!call || intentionallyClosing) return;
|
||||
|
||||
// Stop the recorder synchronously; final dataavailable already fired via
|
||||
// the 5s timeslice, so chunks[] is approximately current.
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const sessionHeaders = (() => {
|
||||
try {
|
||||
return JSON.parse(
|
||||
decodeURIComponent(
|
||||
(document.cookie.match(/(^|;\s*)cw_d_session_info=([^;]+)/) ||
|
||||
[])[2] || '%7B%7D'
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
const authHeaders = {
|
||||
'access-token': sessionHeaders['access-token'] || '',
|
||||
client: sessionHeaders.client || '',
|
||||
uid: sessionHeaders.uid || '',
|
||||
expiry: sessionHeaders.expiry || '',
|
||||
'token-type': sessionHeaders['token-type'] || 'Bearer',
|
||||
};
|
||||
|
||||
if (recordingChunks.length > 0 && accountId.value) {
|
||||
try {
|
||||
const blob = new Blob(recordingChunks, {
|
||||
type: recordingMime || 'audio/webm',
|
||||
});
|
||||
const fd = new FormData();
|
||||
fd.append(
|
||||
'recording',
|
||||
blob,
|
||||
recordingFilename || `call_${call.id}.webm`
|
||||
);
|
||||
fetch(
|
||||
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/upload_recording`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: authHeaders,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (accountId.value) {
|
||||
try {
|
||||
fetch(
|
||||
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/terminate`,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: authHeaders,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
cleanupInboundWebRTC();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
window.addEventListener('pagehide', handlePageHide);
|
||||
|
||||
// Start timer when an outbound call transitions to connected.
|
||||
watch(activeCall, call => {
|
||||
if (
|
||||
call?.direction === 'outbound' &&
|
||||
call?.status === 'connected' &&
|
||||
!durationTimer.intervalId
|
||||
) {
|
||||
durationTimer.start();
|
||||
}
|
||||
});
|
||||
|
||||
// Floating-widget Accept button. Same WhatsApp-direct flow as
|
||||
// acceptVoiceCallById — see comments there.
|
||||
const acceptCall = async call => {
|
||||
if (isAccepting.value) return;
|
||||
isAccepting.value = true;
|
||||
callError.value = null;
|
||||
|
||||
const sdpOffer = call.sdpOffer;
|
||||
const iceServers = call.iceServers;
|
||||
if (!sdpOffer) {
|
||||
callError.value = t('WHATSAPP_CALL.CALL_FAILED');
|
||||
isAccepting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const activeCallData = { ...call };
|
||||
callsStore.setActiveCall(activeCallData);
|
||||
|
||||
try {
|
||||
const sdpAnswer = await prepareInboundAnswer({
|
||||
callId: call.id,
|
||||
providerCallId: call.callId,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
});
|
||||
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.markActiveCallConnected();
|
||||
durationTimer.start();
|
||||
emitter.emit('voice_call:agent_webrtc_connected');
|
||||
} 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('[Voice Call] acceptCall error:', err);
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.clearActiveCall();
|
||||
} finally {
|
||||
isAccepting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectCall = async call => {
|
||||
try {
|
||||
await VoiceCallsAPI.reject(call.id);
|
||||
} catch {
|
||||
// Best effort
|
||||
} finally {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
}
|
||||
};
|
||||
|
||||
const endActiveCall = async () => {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
intentionallyClosing = true;
|
||||
|
||||
try {
|
||||
await uploadRecordingBlob(call.id);
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
|
||||
try {
|
||||
await VoiceCallsAPI.terminate(call.id);
|
||||
} catch {
|
||||
// Best effort
|
||||
} finally {
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.clearActiveCall();
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
intentionallyClosing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!inboundStream) return;
|
||||
const audioTrack = inboundStream.getAudioTracks()[0];
|
||||
if (!audioTrack) return;
|
||||
audioTrack.enabled = !audioTrack.enabled;
|
||||
isMuted.value = !audioTrack.enabled;
|
||||
};
|
||||
|
||||
const dismissIncomingCall = call => {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
};
|
||||
|
||||
const startDurationTimer = () => {
|
||||
durationTimer.start();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('pagehide', handlePageHide);
|
||||
durationTimer.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
hasIncomingCall,
|
||||
firstIncomingCall,
|
||||
isAccepting,
|
||||
isMuted,
|
||||
isOutboundRinging,
|
||||
callError,
|
||||
formattedCallDuration,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endActiveCall,
|
||||
toggleMute,
|
||||
dismissIncomingCall,
|
||||
startDurationTimer,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import { applyOutboundAnswer } from 'dashboard/composables/useVoiceCallSession';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
@@ -33,8 +35,12 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
'voice_call.incoming': this.onVoiceCallIncoming,
|
||||
'voice_call.accepted': this.onVoiceCallAccepted,
|
||||
'voice_call.ended': this.onVoiceCallEnded,
|
||||
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
|
||||
'voice_call.permission_granted': this.onVoiceCallPermissionGranted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,16 +201,75 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||
};
|
||||
|
||||
onEnrichmentCompleted = () => {
|
||||
this.app.$store.dispatch('accounts/get', { silent: true });
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallIncoming = data => {
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
voiceCallsStore.addIncomingCall({
|
||||
id: data.id,
|
||||
callId: data.call_id,
|
||||
provider: data.provider,
|
||||
direction: data.direction,
|
||||
inboxId: data.inbox_id,
|
||||
conversationId: data.conversation_id,
|
||||
caller: data.caller,
|
||||
// Stash Meta's SDP offer + ICE servers on the incoming-call entry so
|
||||
// the bubble's Accept button can build the WebRTC answer locally
|
||||
// without re-fetching from /show.
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
});
|
||||
};
|
||||
|
||||
onVoiceCallAccepted = data => {
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
const currentUserId = this.app.$store.getters.getCurrentUserID;
|
||||
if (data.accepted_by_agent_id !== currentUserId) {
|
||||
voiceCallsStore.handleCallAcceptedByOther(data.call_id);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallEnded = data => {
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
voiceCallsStore.handleCallEnded(data.call_id);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallOutboundConnected = data => {
|
||||
// Outbound WhatsApp direct mode: Meta delivers its SDP answer when the
|
||||
// contact picks up. Apply it to the existing local RTCPeerConnection so
|
||||
// DTLS can complete browser ↔ Meta directly. The PC was created earlier
|
||||
// by prepareOutboundOffer when the agent clicked the call button.
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
const activeCall = voiceCallsStore.activeCall;
|
||||
if (!activeCall || activeCall.callId !== data.call_id) return;
|
||||
|
||||
if (!data.sdp_answer) return;
|
||||
|
||||
applyOutboundAnswer(data.sdp_answer)
|
||||
.then(() => {
|
||||
voiceCallsStore.markActiveCallConnected();
|
||||
emitter.emit('voice_call:agent_webrtc_connected');
|
||||
})
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] Failed to apply outbound SDP answer:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallPermissionGranted = data => {
|
||||
emitter.emit('voice_call:permission_granted', {
|
||||
contactName: data.contact_name,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -83,7 +83,11 @@
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered"
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"ANSWERED_BY": "Answered by {name}",
|
||||
"ACCEPT_CALL": "Accept",
|
||||
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback.",
|
||||
"TRANSCRIPT": "Transcript"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
|
||||
@@ -36,6 +36,7 @@ import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappCall from './whatsappCall.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
@@ -81,6 +82,7 @@ export default {
|
||||
...sla,
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...whatsappCall,
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"WHATSAPP_CALL": {
|
||||
"INCOMING_WHATSAPP_CALL": "Incoming 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.",
|
||||
"PERMISSION_GRANTED": "{contactName} approved the call permission request. You can now call them.",
|
||||
"RELOAD_ENDED_CALL": "The call ended because the page was refreshed."
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,17 @@ const FloatingCallWidget = defineAsyncComponent(
|
||||
() => 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 CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
||||
|
||||
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import { useCallReconnection } from 'dashboard/composables/useCallReconnection';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -36,6 +42,7 @@ export default {
|
||||
CopilotLauncher,
|
||||
CopilotContainer,
|
||||
FloatingCallWidget,
|
||||
WhatsappCallWidget,
|
||||
MobileSidebarLauncher,
|
||||
},
|
||||
setup() {
|
||||
@@ -44,6 +51,13 @@ export default {
|
||||
const { accountId } = useAccount();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const callsStore = useCallsStore();
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
|
||||
// Detect a stale active WhatsApp call left over from a previous tab /
|
||||
// refresh and terminate it. Direct browser↔Meta WebRTC has no rejoin
|
||||
// path because Meta caches our DTLS fingerprint; the only safe action
|
||||
// is to clean up server-side state and tell the agent the call dropped.
|
||||
useCallReconnection();
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
@@ -51,8 +65,15 @@ export default {
|
||||
accountId,
|
||||
upgradePageRef,
|
||||
windowWidth,
|
||||
// Twilio voice still flows through useCallSession + the calls store.
|
||||
hasActiveCall: computed(() => callsStore.hasActiveCall),
|
||||
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
|
||||
// WhatsApp direct-browser calls flow through useVoiceCallSession + the
|
||||
// voiceCalls store. Both widgets can mount simultaneously; only one
|
||||
// ever has state at a time because they listen to disjoint cable
|
||||
// events.
|
||||
hasActiveVoiceCall: computed(() => voiceCallsStore.hasActiveCall),
|
||||
hasIncomingVoiceCall: computed(() => voiceCallsStore.hasIncomingCall),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -163,6 +184,7 @@ export default {
|
||||
/>
|
||||
<CopilotContainer />
|
||||
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
|
||||
<WhatsappCallWidget v-if="hasActiveVoiceCall || hasIncomingVoiceCall" />
|
||||
</template>
|
||||
<AddAccountModal
|
||||
:show="showCreateAccountModal"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useVoiceCallsStore = defineStore('voiceCalls', {
|
||||
state: () => ({
|
||||
// Incoming ringing calls waiting for agent action (per account)
|
||||
incomingCalls: [],
|
||||
// The single active call (accepted + audio connected)
|
||||
activeCall: null,
|
||||
// Cleanup callback registered by the composable — called when a call ends externally
|
||||
cleanupCallback: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
hasIncomingCall: state => state.incomingCalls.length > 0,
|
||||
hasActiveCall: state => state.activeCall !== null,
|
||||
hasVoiceCall: state =>
|
||||
state.incomingCalls.length > 0 || state.activeCall !== null,
|
||||
firstIncomingCall: state => state.incomingCalls[0] || null,
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
markActiveCallConnected() {
|
||||
if (this.activeCall) {
|
||||
this.activeCall = { ...this.activeCall, status: 'connected' };
|
||||
}
|
||||
},
|
||||
|
||||
registerCleanupCallback(callback) {
|
||||
this.cleanupCallback = callback;
|
||||
},
|
||||
|
||||
handleCallAcceptedByOther(callId) {
|
||||
this.removeIncomingCall(callId);
|
||||
},
|
||||
|
||||
handleCallEnded(callId) {
|
||||
this.removeIncomingCall(callId);
|
||||
if (this.activeCall?.callId === callId) {
|
||||
// Snapshot the DB id before nulling activeCall so the composable can
|
||||
// POST upload_recording with the right path even after we clear state.
|
||||
const dbCallId = this.activeCall?.id;
|
||||
if (this.cleanupCallback) this.cleanupCallback(dbCallId);
|
||||
this.activeCall = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -100,3 +100,5 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
end
|
||||
end
|
||||
|
||||
Webhooks::WhatsappEventsJob.prepend_mod_with('Webhooks::WhatsappEventsJob')
|
||||
|
||||
@@ -46,7 +46,10 @@ class Base::SendOnChannelService
|
||||
def invalid_message?
|
||||
# private notes aren't send to the channels
|
||||
# we should also avoid the case of message loops, when outgoing messages are created from channel
|
||||
message.private? || outgoing_message_originated_from_channel?
|
||||
# voice_call bubbles are in-app system events for the call lifecycle —
|
||||
# dispatching them to the channel would deliver "WhatsApp Call" as a
|
||||
# text message to the contact every time the agent placed a call.
|
||||
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
|
||||
end
|
||||
|
||||
def validate_target_channel
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseService
|
||||
prepend Whatsapp::Providers::WhatsappCloudCallMethods if defined?(Whatsapp::Providers::WhatsappCloudCallMethods)
|
||||
|
||||
def send_message(phone_number, message)
|
||||
@message = message
|
||||
|
||||
|
||||
@@ -130,6 +130,9 @@ if resource.whatsapp?
|
||||
json.message_templates resource.channel.try(:message_templates)
|
||||
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
||||
# `calling_enabled` is needed by every agent (not just admins) so the
|
||||
# ConversationHeader can decide whether to render the outbound call button.
|
||||
json.calling_enabled resource.channel.try(:provider_config)&.fetch('calling_enabled', false) || false
|
||||
end
|
||||
|
||||
## Voice attributes for TwilioSms
|
||||
|
||||
@@ -303,6 +303,19 @@ Rails.application.routes.draw do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
resources :voice_calls, only: [:show] do
|
||||
collection do
|
||||
get :active
|
||||
post :initiate
|
||||
end
|
||||
member do
|
||||
post :accept
|
||||
post :reject
|
||||
post :terminate
|
||||
post :upload_recording
|
||||
end
|
||||
end
|
||||
|
||||
resources :webhooks, only: [:index, :create, :update, :destroy]
|
||||
namespace :integrations do
|
||||
resources :apps, only: [:index, :show]
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
class Api::V1::Accounts::VoiceCallsController < Api::V1::Accounts::BaseController
|
||||
before_action :set_call, only: %i[show accept reject terminate upload_recording]
|
||||
|
||||
def show
|
||||
render json: call_payload(@call)
|
||||
end
|
||||
|
||||
def accept
|
||||
call = Voice::CallService.new(
|
||||
call: @call,
|
||||
agent: current_user,
|
||||
sdp_answer: params[:sdp_answer]
|
||||
).accept
|
||||
render json: call_payload(call)
|
||||
rescue Voice::CallErrors::NotRinging, Voice::CallErrors::AlreadyAccepted, Voice::CallErrors::CallFailed => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[VOICE CALL] accept failed: #{e.class} #{e.message}"
|
||||
render json: { error: 'Failed to accept call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def reject
|
||||
call = Voice::CallService.new(call: @call, agent: current_user).reject
|
||||
render json: { id: call.id, status: call.status }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[VOICE CALL] reject failed: #{e.class} #{e.message}"
|
||||
render json: { error: 'Failed to reject call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def terminate
|
||||
call = Voice::CallService.new(call: @call, agent: current_user).terminate
|
||||
render json: { id: call.id, status: call.status }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[VOICE CALL] terminate failed: #{e.class} #{e.message}"
|
||||
render json: { error: 'Failed to terminate call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
# Browser-supplied recording, captured via MediaRecorder during the call.
|
||||
# Idempotent: subsequent uploads after the first audio attachment exists
|
||||
# silently no-op so the hangup-vs-pagehide race can't double-attach.
|
||||
def upload_recording
|
||||
return render json: { error: 'No recording file provided' }, status: :unprocessable_entity if params[:recording].blank?
|
||||
return render json: { id: @call.id, status: 'no_message' }, status: :unprocessable_entity if @call.message.blank?
|
||||
return render json: { id: @call.id, status: 'already_uploaded' } if @call.message.attachments.exists?(file_type: :audio)
|
||||
|
||||
attach_recording!
|
||||
render json: { id: @call.id, status: 'uploaded' }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[VOICE CALL] upload_recording failed: #{e.class} #{e.message}"
|
||||
render json: { error: 'Failed to upload recording' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def active
|
||||
call = current_account.calls.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,
|
||||
provider: call.provider,
|
||||
conversation_id: call.conversation_id,
|
||||
status: call.status,
|
||||
elapsed_seconds: elapsed
|
||||
}
|
||||
else
|
||||
render json: { call: nil }
|
||||
end
|
||||
end
|
||||
|
||||
def initiate
|
||||
conversation = current_account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
authorize conversation, :show?
|
||||
|
||||
initiate_whatsapp(conversation)
|
||||
rescue Voice::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 "[VOICE CALL] initiate failed: #{e.class} #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initiate_whatsapp(conversation)
|
||||
error = validate_whatsapp_calling(conversation)
|
||||
return render json: { error: error }, status: :unprocessable_entity if error
|
||||
return render json: { error: 'sdp_offer is required' }, status: :unprocessable_entity if params[:sdp_offer].blank?
|
||||
|
||||
contact_phone = conversation.contact&.phone_number
|
||||
raise ArgumentError, 'Contact phone number not available' if contact_phone.blank?
|
||||
|
||||
call = create_whatsapp_outbound_call(conversation, contact_phone, params[:sdp_offer])
|
||||
message = Voice::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, provider: 'whatsapp' }
|
||||
end
|
||||
|
||||
# Browser → Rails → Meta. The browser already opened its mic, built an
|
||||
# RTCPeerConnection, generated the offer, and waited for ICE gathering.
|
||||
# We just hand that offer to Meta. Meta returns the call_id immediately
|
||||
# (no SDP yet); the contact's phone rings; on pickup the connect webhook
|
||||
# delivers Meta's SDP answer and we relay it back via ActionCable.
|
||||
def create_whatsapp_outbound_call(conversation, contact_phone, 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',
|
||||
accepted_by_agent_id: current_user.id,
|
||||
meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
|
||||
)
|
||||
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 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 call_payload(call)
|
||||
{
|
||||
id: call.id,
|
||||
call_id: call.provider_call_id,
|
||||
provider: call.provider,
|
||||
status: call.status,
|
||||
direction: call.direction_label,
|
||||
conversation_id: call.conversation_id,
|
||||
inbox_id: call.inbox_id,
|
||||
message_id: call.message_id,
|
||||
accepted_by_agent_id: call.accepted_by_agent_id,
|
||||
elapsed_seconds: call.started_at ? (Time.current - call.started_at).to_i : 0,
|
||||
sdp_offer: call.meta&.dig('sdp_offer'),
|
||||
ice_servers: call.meta&.dig('ice_servers') || Call.default_ice_servers,
|
||||
caller: caller_info(call)
|
||||
}
|
||||
end
|
||||
|
||||
def caller_info(call)
|
||||
contact = call.conversation&.contact
|
||||
return {} unless contact
|
||||
|
||||
{ name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url }
|
||||
end
|
||||
|
||||
def set_call
|
||||
@call = current_account.calls.find(params[:id])
|
||||
authorize @call.conversation, :show?
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Call not found' }, status: :not_found
|
||||
end
|
||||
|
||||
def attach_recording!
|
||||
@call.message.attachments.create!(
|
||||
account_id: @call.account_id,
|
||||
file_type: :audio,
|
||||
file: params[:recording]
|
||||
)
|
||||
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
|
||||
@@ -18,15 +18,18 @@
|
||||
# contact_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
# media_session_id :string
|
||||
# message_id :bigint
|
||||
# provider_call_id :string not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
|
||||
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
|
||||
# index_calls_on_message_id (message_id)
|
||||
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
|
||||
# index_calls_on_accepted_by_agent_id_and_status (accepted_by_agent_id,status)
|
||||
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
|
||||
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
|
||||
# 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
|
||||
# All valid call statuses
|
||||
@@ -56,6 +59,7 @@ class Call < ApplicationRecord
|
||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||
|
||||
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
|
||||
scope :active_for_agent, ->(agent_id) { active.where(accepted_by_agent_id: agent_id) }
|
||||
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
|
||||
|
||||
META_ACCESSORS.each do |key|
|
||||
@@ -67,10 +71,33 @@ class Call < ApplicationRecord
|
||||
find_by(provider: provider, provider_call_id: sid)
|
||||
end
|
||||
|
||||
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its
|
||||
# public srflx candidate. Configurable via VOICE_CALL_STUN_URLS (comma-
|
||||
# separated). TURN can be added by appending turn:user@host?credential=...
|
||||
# entries to the same env var.
|
||||
def self.default_ice_servers
|
||||
urls = ENV.fetch('VOICE_CALL_STUN_URLS', 'stun:stun.l.google.com:19302').split(',').map(&:strip).reject(&:blank?)
|
||||
[{ urls: urls }]
|
||||
end
|
||||
|
||||
def display_direction
|
||||
DISPLAY_DIRECTION[direction]
|
||||
end
|
||||
|
||||
alias direction_label display_direction
|
||||
|
||||
def ringing?
|
||||
status == 'ringing'
|
||||
end
|
||||
|
||||
def in_progress?
|
||||
status == 'in_progress'
|
||||
end
|
||||
|
||||
def terminal?
|
||||
TERMINAL_STATUSES.include?(status)
|
||||
end
|
||||
|
||||
def display_status
|
||||
status.to_s.tr('_', '-')
|
||||
end
|
||||
|
||||
@@ -2,6 +2,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
WHISPER_MODEL = 'whisper-1'.freeze
|
||||
# Whisper API rejects files larger than 25 MB. At Opus 48 kbps that is ~70
|
||||
# minutes of audio — longer recordings keep the audio attachment but skip
|
||||
# transcription rather than failing with an OpenAI 413.
|
||||
WHISPER_BYTE_LIMIT = 25.megabytes
|
||||
|
||||
attr_reader :attachment, :message, :account
|
||||
|
||||
@@ -15,6 +19,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
def perform
|
||||
return { error: 'Transcription limit exceeded' } unless can_transcribe?
|
||||
return { error: 'Message not found' } if message.blank?
|
||||
return { error: 'Audio too large for Whisper' } if audio_too_large?
|
||||
|
||||
transcriptions = transcribe_audio
|
||||
Rails.logger.info "Audio transcription successful: #{transcriptions}"
|
||||
@@ -33,6 +38,13 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||
end
|
||||
|
||||
def audio_too_large?
|
||||
blob = attachment.file&.blob
|
||||
return false unless blob
|
||||
|
||||
blob.byte_size > WHISPER_BYTE_LIMIT
|
||||
end
|
||||
|
||||
def fetch_audio_file
|
||||
blob = attachment.file.blob
|
||||
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
|
||||
@@ -63,11 +75,17 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
transcribed_text = nil
|
||||
|
||||
File.open(temp_file_path, 'rb') do |file|
|
||||
# temperature: 0.0 minimises Whisper's hallucinations on ambiguous or
|
||||
# low-amplitude segments. The previous value (0.4) triggered spiraling
|
||||
# repetitions like "Oh, dear. Oh, dear. Oh, dear." on silences and
|
||||
# "No. No. No." / "Hello. Hello. Hello." on near-silent audio —
|
||||
# well-documented Whisper behaviour at non-zero temperatures. 0.0
|
||||
# matches OpenAI's own default recommendation.
|
||||
response = @client.audio.transcribe(
|
||||
parameters: {
|
||||
model: WHISPER_MODEL,
|
||||
file: file,
|
||||
temperature: 0.4
|
||||
temperature: 0.0
|
||||
}
|
||||
)
|
||||
transcribed_text = response['text']
|
||||
|
||||
@@ -1,30 +1,119 @@
|
||||
class Voice::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
|
||||
|
||||
# Backwards-compatible entry used by Voice::OutboundCallBuilder (Twilio).
|
||||
def self.perform!(call:)
|
||||
new(call: call).perform!
|
||||
create!(conversation: call.conversation, call: call, user: call.accepted_by_agent)
|
||||
end
|
||||
|
||||
def initialize(call:)
|
||||
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 initialize(conversation:, call:, user: nil)
|
||||
@conversation = conversation
|
||||
@call = call
|
||||
@user = user
|
||||
end
|
||||
|
||||
def perform!
|
||||
call.message || create_message!
|
||||
def create!
|
||||
# Direct create rather than Messages::MessageBuilder because the latter
|
||||
# rejects `incoming` messages on non-Api inboxes — that gate is meant for
|
||||
# human messages flowing through chatbot-style integrations, but a voice
|
||||
# call bubble is a system event that needs to live on the WhatsApp inbox.
|
||||
Message.create!(
|
||||
conversation: conversation,
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
content: message_content,
|
||||
message_type: message_type,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { 'data' => build_data_payload },
|
||||
sender: sender
|
||||
)
|
||||
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 :call
|
||||
attr_reader :conversation, :call, :user
|
||||
|
||||
def create_message!
|
||||
params = {
|
||||
content: 'Voice Call',
|
||||
message_type: call.outgoing? ? 'outgoing' : 'incoming',
|
||||
content_type: 'voice_call'
|
||||
# `call_source` lets the FE disambiguate WhatsApp vs Twilio for UI copy
|
||||
# and event routing without loading the whole Call record client-side.
|
||||
# `media_server_enabled` is hard-coded to false because WhatsApp now runs
|
||||
# browser↔Meta WebRTC directly (no media server) and Twilio's bubble
|
||||
# never gates on this flag. Rejoin is unsupported across the board.
|
||||
def build_data_payload
|
||||
{
|
||||
'call_sid' => call.provider_call_id,
|
||||
'status' => map_status(call.status),
|
||||
'call_direction' => call.direction_label,
|
||||
'call_source' => call.provider,
|
||||
'call_id' => call.id,
|
||||
'from_number' => from_number,
|
||||
'to_number' => to_number,
|
||||
'media_server_enabled' => false,
|
||||
'meta' => { 'created_at' => Time.zone.now.to_i }
|
||||
}
|
||||
Messages::MessageBuilder.new(sender, call.conversation, params).perform
|
||||
end
|
||||
|
||||
def message_content
|
||||
call.twilio? ? 'Voice Call' : 'WhatsApp Call'
|
||||
end
|
||||
|
||||
def message_type
|
||||
call.outgoing? ? 'outgoing' : 'incoming'
|
||||
end
|
||||
|
||||
def sender
|
||||
call.outgoing? ? call.accepted_by_agent : call.contact
|
||||
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,89 @@
|
||||
class Voice::CallService
|
||||
pattr_initialize [:call!, :agent!, :sdp_answer]
|
||||
|
||||
# WhatsApp accept: forward the browser-built SDP answer to Meta. Twilio
|
||||
# voice does not flow through this service.
|
||||
def accept
|
||||
raise ArgumentError, "Unsupported provider: #{call.provider}" unless call.whatsapp?
|
||||
raise Voice::CallErrors::CallFailed, 'sdp_answer is required' if sdp_answer.blank?
|
||||
|
||||
call.with_lock { transition_to_in_progress! }
|
||||
Voice::CallMessageBuilder.update_status!(call: call, status: 'in_progress', agent: agent)
|
||||
update_conversation_call_status('in-progress')
|
||||
broadcast(:accepted, accepted_by_agent_id: agent.id)
|
||||
call
|
||||
end
|
||||
|
||||
def reject
|
||||
call.reload
|
||||
return call if call.terminal? || call.in_progress?
|
||||
|
||||
invoke_provider(:reject_call)
|
||||
finalize_call('failed')
|
||||
call
|
||||
end
|
||||
|
||||
def terminate
|
||||
return call if call.terminal?
|
||||
|
||||
invoke_provider(:terminate_call)
|
||||
finalize_call('completed')
|
||||
call
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transition_to_in_progress!
|
||||
raise Voice::CallErrors::NotRinging, 'Call is not in ringing state' unless call.ringing?
|
||||
raise Voice::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if call.in_progress?
|
||||
|
||||
forward_answer_to_meta!
|
||||
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
|
||||
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
|
||||
claim_conversation_for_agent
|
||||
end
|
||||
|
||||
def forward_answer_to_meta!
|
||||
svc = call.inbox.channel.provider_service
|
||||
raise Voice::CallErrors::CallFailed, 'Meta pre_accept failed' unless svc.pre_accept_call(call.provider_call_id, sdp_answer)
|
||||
raise Voice::CallErrors::CallFailed, 'Meta accept failed' unless svc.accept_call(call.provider_call_id, sdp_answer)
|
||||
end
|
||||
|
||||
# Auto-assignment on accept: take ownership of the conversation if it has
|
||||
# no assignee. If someone else already holds it, leave it (transfer via UI).
|
||||
def claim_conversation_for_agent
|
||||
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
|
||||
end
|
||||
|
||||
def invoke_provider(method)
|
||||
return unless call.whatsapp?
|
||||
|
||||
success = call.inbox.channel.provider_service.public_send(method, call.provider_call_id)
|
||||
Rails.logger.error "[VOICE CALL] #{method} returned false for #{call.provider_call_id}" unless success
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[VOICE CALL] #{method} failed: #{e.message}"
|
||||
end
|
||||
|
||||
def finalize_call(status)
|
||||
call.update!(status: status)
|
||||
Voice::CallMessageBuilder.update_status!(call: call, status: status)
|
||||
update_conversation_call_status(status)
|
||||
broadcast(:ended, status: status)
|
||||
end
|
||||
|
||||
def update_conversation_call_status(mapped_status)
|
||||
conversation = call.conversation
|
||||
conversation.update!(
|
||||
additional_attributes: (conversation.additional_attributes || {}).merge('call_status' => mapped_status)
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast(event, extra = {})
|
||||
payload = {
|
||||
event: "voice_call.#{event}",
|
||||
data: { id: call.id, call_id: call.provider_call_id, provider: call.provider,
|
||||
conversation_id: call.conversation_id, account_id: call.account_id }.merge(extra)
|
||||
}
|
||||
ActionCable.server.broadcast("account_#{call.account_id}", payload)
|
||||
end
|
||||
end
|
||||
@@ -1,16 +1,24 @@
|
||||
class Voice::InboundCallBuilder
|
||||
attr_reader :account, :inbox, :from_number, :call_sid
|
||||
attr_reader :account, :inbox, :from_number, :call_sid, :provider, :extra_meta
|
||||
|
||||
def self.perform!(account:, inbox:, from_number:, call_sid:)
|
||||
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform!
|
||||
# `provider` defaults to :twilio for backward compatibility with the original
|
||||
# Twilio-only call sites; WhatsApp pass :whatsapp + extra_meta carrying the
|
||||
# SDP offer + ICE servers.
|
||||
# rubocop:disable Metrics/ParameterLists
|
||||
def self.perform!(account:, inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
|
||||
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid,
|
||||
provider: provider, extra_meta: extra_meta).perform!
|
||||
end
|
||||
|
||||
def initialize(account:, inbox:, from_number:, call_sid:)
|
||||
def initialize(account:, inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
|
||||
@account = account
|
||||
@inbox = inbox
|
||||
@from_number = from_number
|
||||
@call_sid = call_sid
|
||||
@provider = provider.to_sym
|
||||
@extra_meta = extra_meta || {}
|
||||
end
|
||||
# rubocop:enable Metrics/ParameterLists
|
||||
|
||||
def perform!
|
||||
existing = find_existing_call
|
||||
@@ -26,7 +34,7 @@ class Voice::InboundCallBuilder
|
||||
call
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# A concurrent Twilio retry won the create race; return what now exists.
|
||||
# A concurrent provider retry won the create race; return what now exists.
|
||||
find_existing_call || raise
|
||||
end
|
||||
|
||||
@@ -34,7 +42,7 @@ class Voice::InboundCallBuilder
|
||||
|
||||
def find_existing_call
|
||||
Call.where(account_id: account.id, inbox_id: inbox.id)
|
||||
.find_by(provider: :twilio, provider_call_id: call_sid)
|
||||
.find_by(provider: provider, provider_call_id: call_sid)
|
||||
end
|
||||
|
||||
def ensure_contact!
|
||||
@@ -76,13 +84,14 @@ class Voice::InboundCallBuilder
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: contact,
|
||||
provider: :twilio,
|
||||
provider: provider,
|
||||
direction: :incoming,
|
||||
status: 'ringing',
|
||||
provider_call_id: call_sid,
|
||||
meta: { 'initiated_at' => Time.zone.now.to_i }
|
||||
meta: { 'initiated_at' => Time.zone.now.to_i }.merge(extra_meta.stringify_keys)
|
||||
)
|
||||
call.update!(conference_sid: Voice::Conference::Name.for(call))
|
||||
# `conference_sid` is a Twilio bridging concept; WhatsApp goes browser↔Meta.
|
||||
call.update!(conference_sid: Voice::Conference::Name.for(call)) if call.twilio?
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
class Whatsapp::CallPermissionReplyService
|
||||
pattr_initialize [:inbox!, :params!]
|
||||
|
||||
def perform
|
||||
return unless inbox.channel.provider_config['calling_enabled']
|
||||
|
||||
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: 'voice_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,113 @@
|
||||
class Whatsapp::IncomingCallService
|
||||
pattr_initialize [:inbox!, :params!]
|
||||
|
||||
def perform
|
||||
return unless inbox.channel.provider_config['calling_enabled']
|
||||
|
||||
Array(params[: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)
|
||||
existing = Call.whatsapp.find_by(provider_call_id: call_payload[:id])
|
||||
existing ? handle_outbound_connect(existing, call_payload) : handle_inbound_connect(call_payload)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
Rails.logger.warn "[WHATSAPP CALL] Duplicate provider_call_id received: #{call_payload[:id]}"
|
||||
end
|
||||
|
||||
def handle_outbound_connect(call, call_payload)
|
||||
return if call.in_progress?
|
||||
|
||||
sdp_answer = fix_sdp_setup(call_payload.dig(:session, :sdp))
|
||||
call.update!(status: 'in_progress', started_at: Time.current,
|
||||
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
|
||||
Voice::CallMessageBuilder.update_status!(call: call, status: 'in_progress', agent: call.accepted_by_agent)
|
||||
update_conversation_call_status(call.conversation, 'in-progress', call.direction_label)
|
||||
broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer)
|
||||
end
|
||||
|
||||
# Inbound delegates to Voice::InboundCallBuilder (Twilio's path) for contact +
|
||||
# conversation + call + message creation; auto-assignment falls out of the
|
||||
# standard Conversation lifecycle. We just stash Meta's SDP offer in meta.
|
||||
def handle_inbound_connect(call_payload)
|
||||
sdp_offer = call_payload.dig(:session, :sdp)
|
||||
call = Voice::InboundCallBuilder.perform!(
|
||||
account: inbox.account, inbox: inbox,
|
||||
from_number: "+#{call_payload[:from]}", call_sid: call_payload[:id],
|
||||
provider: :whatsapp,
|
||||
extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
|
||||
)
|
||||
update_conversation_call_status(call.conversation, 'ringing', call.direction_label)
|
||||
broadcast_incoming_call(call, sdp_offer)
|
||||
end
|
||||
|
||||
def handle_call_terminate(call_payload)
|
||||
call = Call.whatsapp.find_by(provider_call_id: call_payload[:id])
|
||||
return unless call
|
||||
|
||||
duration = call_payload[:duration]&.to_i
|
||||
final_status = answered?(call, duration) ? 'completed' : 'no_answer'
|
||||
call.update!(status: final_status, duration_seconds: duration, end_reason: call_payload[:terminate_reason])
|
||||
Voice::CallMessageBuilder.update_status!(call: call, status: final_status, agent: call.accepted_by_agent,
|
||||
duration_seconds: duration)
|
||||
mapped = Voice::CallMessageBuilder::CALL_TO_VOICE_STATUS[final_status] || final_status
|
||||
update_conversation_call_status(call.conversation, mapped, call.direction_label)
|
||||
broadcast(call, 'voice_call.ended', status: call.status, duration_seconds: call.duration_seconds)
|
||||
end
|
||||
|
||||
def answered?(call, duration)
|
||||
call.in_progress? || duration.to_i.positive? || call.accepted_by_agent_id.present?
|
||||
end
|
||||
|
||||
def update_conversation_call_status(conversation, call_status, direction)
|
||||
conversation.update!(
|
||||
additional_attributes: (conversation.additional_attributes || {}).merge(
|
||||
'call_status' => call_status, 'call_direction' => direction
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# PLA-98: ring only the conversation's assignee when assigned, account-wide
|
||||
# otherwise so any eligible agent can pick up.
|
||||
def broadcast_incoming_call(call, sdp_offer)
|
||||
contact = call.contact
|
||||
data = base_payload(call).merge(
|
||||
direction: call.direction_label, inbox_id: call.inbox_id,
|
||||
sdp_offer: sdp_offer, ice_servers: Call.default_ice_servers,
|
||||
caller: { name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url }
|
||||
)
|
||||
streams = call.conversation.assignee&.pubsub_token ? [call.conversation.assignee.pubsub_token] : ["account_#{inbox.account_id}"]
|
||||
streams.each { |s| ActionCable.server.broadcast(s, event: 'voice_call.incoming', data: data) }
|
||||
end
|
||||
|
||||
def broadcast(call, event, extra = {})
|
||||
ActionCable.server.broadcast(
|
||||
"account_#{inbox.account_id}",
|
||||
event: event, data: base_payload(call).merge(extra)
|
||||
)
|
||||
end
|
||||
|
||||
def base_payload(call)
|
||||
{
|
||||
account_id: inbox.account_id, id: call.id, call_id: call.provider_call_id,
|
||||
provider: 'whatsapp', conversation_id: call.conversation_id
|
||||
}
|
||||
end
|
||||
|
||||
# Browsers always emit a=setup:active in answers, but Meta sometimes echoes
|
||||
# actpass in its outbound answer; pin it to active so peers don't renegotiate.
|
||||
def fix_sdp_setup(sdp)
|
||||
sdp.present? ? sdp.gsub('a=setup:actpass', 'a=setup:active') : sdp
|
||||
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 Voice::CallErrors::NoCallPermission, error_msg if error_code == 138_006
|
||||
|
||||
raise StandardError, error_msg
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
module Voice::CallErrors
|
||||
class NotRinging < StandardError; end
|
||||
class AlreadyAccepted < StandardError; end
|
||||
class CallFailed < StandardError; end
|
||||
class NoCallPermission < StandardError; end
|
||||
end
|
||||
Reference in New Issue
Block a user