feat(voice): WhatsApp Cloud Calling — UI
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../../ApiClient';
|
||||
|
||||
class WhatsappCallsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('whatsapp_calls', { accountScoped: true });
|
||||
}
|
||||
|
||||
show(callId) {
|
||||
return axios.get(`${this.url}/${callId}`).then(r => r.data);
|
||||
}
|
||||
|
||||
initiate(conversationId, sdpOffer) {
|
||||
return axios
|
||||
.post(`${this.url}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
sdp_offer: sdpOffer,
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
accept(callId, sdpAnswer) {
|
||||
return axios
|
||||
.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
reject(callId) {
|
||||
return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
|
||||
}
|
||||
|
||||
terminate(callId) {
|
||||
return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
|
||||
}
|
||||
|
||||
uploadRecording(callId, blob, filename = 'call-recording.webm') {
|
||||
const formData = new FormData();
|
||||
formData.append('recording', blob, filename);
|
||||
return axios
|
||||
.post(`${this.url}/${callId}/upload_recording`, formData)
|
||||
.then(r => r.data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new WhatsappCallsAPI();
|
||||
@@ -3,10 +3,16 @@ 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 {
|
||||
isVoiceCallEnabled,
|
||||
getVoiceCallProvider,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -58,9 +64,63 @@ const navigateToConversation = conversationId => {
|
||||
}
|
||||
};
|
||||
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
|
||||
// Find the most recent open conversation for this contact in the picked inbox.
|
||||
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
|
||||
const findWhatsappConversationId = async inboxId => {
|
||||
const { data } = await ContactAPI.getConversations(props.contactId);
|
||||
const conversations = data?.payload || [];
|
||||
const match = conversations
|
||||
.filter(c => c.inbox_id === inboxId)
|
||||
.sort((a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0))[0];
|
||||
return match?.id || null;
|
||||
};
|
||||
|
||||
const startWhatsappCall = async inboxId => {
|
||||
const conversationId = await findWhatsappConversationId(inboxId);
|
||||
if (!conversationId) {
|
||||
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
|
||||
return;
|
||||
}
|
||||
|
||||
const response =
|
||||
await whatsappCallSession.initiateOutboundCall(conversationId);
|
||||
if (!response?.id) {
|
||||
// Permission flow returns no id — banner already handled server-side; surface to user.
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
callsStore.setCallActive(response.call_id);
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(conversationId);
|
||||
};
|
||||
|
||||
const startCall = async inboxId => {
|
||||
if (isInitiatingCall.value) return;
|
||||
|
||||
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
|
||||
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
|
||||
try {
|
||||
await startWhatsappCall(inboxId);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('contacts/initiateCall', {
|
||||
contactId: props.contactId,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { VOICE_CALL_STATUS } from '../constants';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import AudioChip from 'dashboard/components-next/message/chips/Audio.vue';
|
||||
|
||||
const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
@@ -30,7 +31,7 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { call } = useMessageContext();
|
||||
const { call, attachments, contentAttributes } = useMessageContext();
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
@@ -38,6 +39,30 @@ const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
const audioAttachment = computed(() =>
|
||||
(attachments?.value || []).find(a => a.fileType === 'audio')
|
||||
);
|
||||
|
||||
// Duration lives in two places depending on which payload the FE got:
|
||||
// - call.duration_seconds / call.durationSeconds (push_event_data shape)
|
||||
// - content_attributes.data.duration_seconds (message-side mirror)
|
||||
// Both can be camelCased by useTransformKeys upstream — check every variant.
|
||||
const durationSeconds = computed(() => {
|
||||
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
|
||||
if (fromCall != null) return fromCall;
|
||||
|
||||
const data = contentAttributes?.value?.data || contentAttributes?.value?.data;
|
||||
return data?.durationSeconds || data?.duration_seconds;
|
||||
});
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
const s = Number(durationSeconds.value);
|
||||
if (!s || Number.isNaN(s)) return '';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
@@ -93,10 +118,19 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
<!-- When the audio chip is rendered it already shows duration in
|
||||
its own player; suppress here to avoid two competing numbers. -->
|
||||
{{
|
||||
audioAttachment
|
||||
? $t(subtextKey)
|
||||
: formattedDuration || $t(subtextKey)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="audioAttachment" class="px-3 pb-3 w-full">
|
||||
<AudioChip :attachment="audioAttachment" class="text-n-slate-12" />
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -41,8 +41,33 @@ const playbackSpeed = ref(1);
|
||||
|
||||
const { uid } = getCurrentInstance();
|
||||
|
||||
// MediaRecorder-produced WebM/Opus blobs lack a Duration header → <audio>.duration
|
||||
// resolves to Infinity until we seek past the end, which forces the engine to
|
||||
// scan the file and compute the real length. Safe no-op for files with a real
|
||||
// duration already (mp3/m4a/etc).
|
||||
const resolveStreamingDuration = () => {
|
||||
const el = audioPlayer.value;
|
||||
if (!el) return;
|
||||
const onTimeUpdate = () => {
|
||||
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||
el.currentTime = 0;
|
||||
duration.value = el.duration;
|
||||
};
|
||||
el.addEventListener('timeupdate', onTimeUpdate);
|
||||
try {
|
||||
el.currentTime = Number.MAX_SAFE_INTEGER;
|
||||
} catch {
|
||||
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
const d = audioPlayer.value?.duration;
|
||||
if (!Number.isFinite(d)) {
|
||||
resolveStreamingDuration();
|
||||
return;
|
||||
}
|
||||
duration.value = d;
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => {
|
||||
@@ -53,7 +78,8 @@ const playbackSpeedLabel = computed(() => {
|
||||
// When the onLoadMetadata is called, so we need to set the duration
|
||||
// value when the component is mounted
|
||||
onMounted(() => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
const d = audioPlayer.value?.duration;
|
||||
if (Number.isFinite(d)) duration.value = d;
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
@@ -21,16 +22,40 @@ const {
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
|
||||
// don't expose a mic track on the browser side.
|
||||
const isMuted = ref(false);
|
||||
const isWhatsappActive = computed(
|
||||
() => activeCall.value?.provider === 'whatsapp'
|
||||
);
|
||||
|
||||
const toggleMute = () => {
|
||||
isMuted.value = !isMuted.value;
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
};
|
||||
|
||||
watch(hasActiveCall, active => {
|
||||
if (!active) isMuted.value = false;
|
||||
});
|
||||
|
||||
const getCallInfo = call => {
|
||||
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
||||
const sender = conversation?.meta?.sender;
|
||||
// Inbound WhatsApp calls stash caller info on the call record (from the cable
|
||||
// payload) so the widget has something to show before the conversation lands.
|
||||
const caller = call?.caller;
|
||||
return {
|
||||
conversation,
|
||||
inbox,
|
||||
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
|
||||
contactName:
|
||||
sender?.name ||
|
||||
sender?.phone_number ||
|
||||
caller?.name ||
|
||||
caller?.phone ||
|
||||
'Unknown caller',
|
||||
inboxName: inbox?.name || 'Customer support',
|
||||
avatar: sender?.avatar || sender?.thumbnail,
|
||||
avatar: sender?.avatar || sender?.thumbnail || caller?.avatar,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -162,6 +187,30 @@ watch(
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
v-if="hasActiveCall && isWhatsappActive"
|
||||
v-tooltip.top="
|
||||
isMuted
|
||||
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
|
||||
"
|
||||
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
|
||||
:class="
|
||||
isMuted
|
||||
? 'bg-n-amber-9 hover:bg-n-amber-10'
|
||||
: 'bg-n-slate-3 hover:bg-n-slate-4'
|
||||
"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<i
|
||||
class="text-lg"
|
||||
:class="
|
||||
isMuted
|
||||
? 'text-white i-ph-microphone-slash-bold'
|
||||
: 'text-n-slate-12 i-ph-microphone-bold'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
@click="
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import BackButton from '../BackButton.vue';
|
||||
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
@@ -12,6 +13,13 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import {
|
||||
getVoiceCallProvider,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -91,6 +99,45 @@ const hasMultipleInboxes = computed(
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappCallSession = useWhatsappCallSession();
|
||||
|
||||
const isWhatsappVoiceInbox = computed(
|
||||
() => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
);
|
||||
|
||||
const startWhatsappCall = async () => {
|
||||
if (whatsappCallSession.isInitiating.value) return;
|
||||
try {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
currentChat.value.id
|
||||
);
|
||||
|
||||
// Permission template path returns no call id — show banner, no widget yet.
|
||||
if (!response?.id) {
|
||||
const status = response?.status;
|
||||
const messageKey =
|
||||
status === 'permission_pending'
|
||||
? 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING'
|
||||
: 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED';
|
||||
useAlert(t(messageKey));
|
||||
return;
|
||||
}
|
||||
|
||||
callsStore.addCall({
|
||||
callSid: response.call_id,
|
||||
callId: response.id,
|
||||
conversationId: currentChat.value.id,
|
||||
inboxId: inbox.value?.id,
|
||||
callDirection: 'outbound',
|
||||
provider: 'whatsapp',
|
||||
});
|
||||
callsStore.setCallActive(response.call_id);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -152,6 +199,18 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<ButtonV4
|
||||
v-if="isWhatsappVoiceInbox"
|
||||
v-tooltip.bottom="$t('CONVERSATION.HEADER.WHATSAPP_CALL')"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
icon="i-lucide-phone"
|
||||
:is-loading="whatsappCallSession.isInitiating.value"
|
||||
:disabled="whatsappCallSession.isInitiating.value"
|
||||
class="rounded-md hover:bg-n-alpha-2"
|
||||
@click="startWhatsappCall"
|
||||
/>
|
||||
<MoreActions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,18 @@ import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import {
|
||||
useWhatsappCallSession,
|
||||
sendWhatsappTerminateBeacon,
|
||||
cleanupWhatsappSession,
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
const isWhatsappCall = call => call?.provider === 'whatsapp';
|
||||
|
||||
export function useCallSession() {
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappSession = useWhatsappCallSession();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
@@ -29,20 +37,52 @@ export function useCallSession() {
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Browser-native confirm prompt when reload/close happens mid-call. Reload
|
||||
// tears down the WebRTC session permanently for WhatsApp (no rejoin) and
|
||||
// drops the agent leg for Twilio, so warn either way.
|
||||
const handleBeforeUnload = event => {
|
||||
if (!hasActiveCall.value) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
|
||||
// pagehide fires after the user confirms the prompt. Let the WhatsApp session
|
||||
// best-effort sendBeacon a terminate so the server doesn't keep the call open.
|
||||
const handlePageHide = () => {
|
||||
sendWhatsappTerminateBeacon();
|
||||
};
|
||||
|
||||
const handleTwilioDisconnected = () => callsStore.clearActiveCall();
|
||||
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
TwilioVoiceClient.addEventListener(
|
||||
'call:disconnected',
|
||||
handleTwilioDisconnected
|
||||
);
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
window.addEventListener('pagehide', handlePageHide);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
TwilioVoiceClient.removeEventListener(
|
||||
'call:disconnected',
|
||||
handleTwilioDisconnected
|
||||
);
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('pagehide', handlePageHide);
|
||||
});
|
||||
|
||||
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
|
||||
|
||||
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
if (isWhatsappCall(findCall(callSid))) {
|
||||
await whatsappSession.endActiveCall();
|
||||
durationTimer.stop();
|
||||
callsStore.clearActiveCall();
|
||||
return;
|
||||
}
|
||||
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
@@ -54,6 +94,18 @@ export function useCallSession() {
|
||||
|
||||
isJoining.value = true;
|
||||
try {
|
||||
const call = findCall(callSid);
|
||||
if (isWhatsappCall(call)) {
|
||||
await whatsappSession.acceptIncomingCall({
|
||||
callId: call.callId,
|
||||
sdpOffer: call.sdpOffer,
|
||||
iceServers: call.iceServers,
|
||||
});
|
||||
callsStore.setCallActive(callSid);
|
||||
durationTimer.start();
|
||||
return { callId: call.callId };
|
||||
}
|
||||
|
||||
const device = await TwilioVoiceClient.initializeDevice(inboxId);
|
||||
if (!device) return null;
|
||||
|
||||
@@ -76,6 +128,10 @@ export function useCallSession() {
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
// Tear down any half-built WebRTC state so the user's next click starts
|
||||
// fresh; otherwise the leftover pc + mic stream survives and confuses
|
||||
// the second-attempt SDP exchange.
|
||||
cleanupWhatsappSession();
|
||||
return null;
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
@@ -83,7 +139,12 @@ export function useCallSession() {
|
||||
};
|
||||
|
||||
const rejectIncomingCall = callSid => {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
const call = findCall(callSid);
|
||||
if (isWhatsappCall(call) && call?.callId) {
|
||||
whatsappSession.rejectIncomingCall(call.callId);
|
||||
} else {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
callsStore.dismissCall(callSid);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { ref } from 'vue';
|
||||
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
|
||||
|
||||
// Browser ↔ Meta WebRTC is a singleton — only one PeerConnection at a time can
|
||||
// hold the user's mic. Module-level state lets cable handlers and the pagehide
|
||||
// listener reach the live session without prop-drilling refs through composables.
|
||||
let pc = null;
|
||||
let localStream = null;
|
||||
let remoteStream = null;
|
||||
let remoteAudioEl = null;
|
||||
let mediaRecorder = null;
|
||||
let recorderChunks = [];
|
||||
let audioContext = null;
|
||||
let activeCallId = null;
|
||||
let intentionallyClosing = false;
|
||||
|
||||
// Lazily attach a hidden <audio autoplay> to the document so Meta's track
|
||||
// actually plays through the speakers — without this, mic flows to Meta but
|
||||
// the user hears nothing back.
|
||||
const ensureRemoteAudioElement = () => {
|
||||
if (remoteAudioEl) return remoteAudioEl;
|
||||
remoteAudioEl = document.createElement('audio');
|
||||
remoteAudioEl.id = 'whatsapp-call-remote-audio';
|
||||
remoteAudioEl.autoplay = true;
|
||||
remoteAudioEl.playsInline = true;
|
||||
remoteAudioEl.style.display = 'none';
|
||||
document.body.appendChild(remoteAudioEl);
|
||||
return remoteAudioEl;
|
||||
};
|
||||
|
||||
const playRemoteStream = stream => {
|
||||
const el = ensureRemoteAudioElement();
|
||||
el.srcObject = stream;
|
||||
// play() may reject under autoplay policies; surface to console but don't crash the call.
|
||||
el.play().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[WhatsApp Call] remote audio play() failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// Smaller timeslice → chunks flush to memory every second so a remote hangup
|
||||
// that races cleanup still leaves data behind to upload.
|
||||
const RECORDING_TIMESLICE_MS = 1000;
|
||||
const ICE_GATHER_TIMEOUT_MS = 10000;
|
||||
const RECORDER_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
];
|
||||
|
||||
// Outbound calls don't get ice_servers from the backend (call doesn't exist yet
|
||||
// at offer time). Without STUN the browser only has host candidates which can't
|
||||
// reach Meta through NAT, so the browser→Meta direction silently drops media.
|
||||
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
const waitForIceGatheringComplete = peer =>
|
||||
new Promise(resolve => {
|
||||
if (peer.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
|
||||
peer.addEventListener('icegatheringstatechange', () => {
|
||||
if (peer.iceGatheringState === 'complete') {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close().catch(() => {});
|
||||
}
|
||||
if (localStream) localStream.getTracks().forEach(t => t.stop());
|
||||
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
|
||||
if (pc) pc.close();
|
||||
if (remoteAudioEl) remoteAudioEl.srcObject = null;
|
||||
|
||||
pc = null;
|
||||
localStream = null;
|
||||
remoteStream = null;
|
||||
mediaRecorder = null;
|
||||
recorderChunks = [];
|
||||
audioContext = null;
|
||||
activeCallId = null;
|
||||
intentionallyClosing = false;
|
||||
};
|
||||
|
||||
// Mix local mic + remote audio via Web Audio so the recording captures both legs.
|
||||
const setupRecorder = () => {
|
||||
if (!localStream || !remoteStream || mediaRecorder) return;
|
||||
// Without at least one remote track, createMediaStreamSource on remoteStream
|
||||
// wires up to nothing — the recorded mix is effectively just silence.
|
||||
if (remoteStream.getAudioTracks().length === 0) return;
|
||||
|
||||
audioContext = new AudioContext({ sampleRate: 48000 });
|
||||
// AudioContext starts suspended under most autoplay policies. Resume so the
|
||||
// graph actually runs; otherwise the destination stream produces silence.
|
||||
audioContext.resume().catch(() => {});
|
||||
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
audioContext.createMediaStreamSource(localStream).connect(destination);
|
||||
audioContext.createMediaStreamSource(remoteStream).connect(destination);
|
||||
|
||||
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
|
||||
MediaRecorder.isTypeSupported(t)
|
||||
);
|
||||
if (!mimeType) return;
|
||||
|
||||
recorderChunks = [];
|
||||
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
|
||||
};
|
||||
mediaRecorder.start(RECORDING_TIMESLICE_MS);
|
||||
};
|
||||
|
||||
const buildPeerConnection = iceServers => {
|
||||
const config = iceServers && iceServers.length ? { iceServers } : {};
|
||||
pc = new RTCPeerConnection(config);
|
||||
remoteStream = new MediaStream();
|
||||
pc.ontrack = event => {
|
||||
// Add to the stable placeholder stream so any sources/recorders referencing
|
||||
// it stay connected — never reassign the variable, since the recorder's
|
||||
// audioContext source taps the original MediaStream object.
|
||||
const tracks =
|
||||
event.streams && event.streams[0]
|
||||
? event.streams[0].getTracks()
|
||||
: [event.track];
|
||||
tracks.forEach(track => {
|
||||
if (!remoteStream.getTracks().includes(track))
|
||||
remoteStream.addTrack(track);
|
||||
});
|
||||
playRemoteStream(remoteStream);
|
||||
// Defer recorder setup until we actually have remote tracks; createMediaStreamSource
|
||||
// on an empty MediaStream is unreliable across browsers.
|
||||
setupRecorder();
|
||||
};
|
||||
return pc;
|
||||
};
|
||||
|
||||
const stopRecorderAndUpload = async callId => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
await new Promise(resolve => {
|
||||
mediaRecorder.addEventListener('stop', resolve, { once: true });
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch (_) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!recorderChunks.length || !callId) return;
|
||||
|
||||
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
|
||||
try {
|
||||
await WhatsappCallsAPI.uploadRecording(callId, blob);
|
||||
} catch (_) {
|
||||
/* best-effort — server-side idempotency guard handles a retry */
|
||||
}
|
||||
};
|
||||
|
||||
export function useWhatsappCallSession() {
|
||||
const isInitiating = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
|
||||
cleanup();
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
buildPeerConnection(iceServers);
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
// Recorder fires from ontrack once remote tracks arrive.
|
||||
return pc.localDescription.sdp;
|
||||
};
|
||||
|
||||
const prepareOutboundOffer = async () => {
|
||||
cleanup();
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
|
||||
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
return pc.localDescription.sdp;
|
||||
};
|
||||
|
||||
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
|
||||
// The store may not have sdpOffer yet (cable's voice_call.incoming raced
|
||||
// the click), so fall back to GET /whatsapp_calls/:id which exposes the
|
||||
// SDP offer + ICE servers from the show jbuilder.
|
||||
let offer = sdpOffer;
|
||||
let ice = iceServers;
|
||||
if (!offer && callId) {
|
||||
try {
|
||||
const fresh = await WhatsappCallsAPI.show(callId);
|
||||
offer = fresh?.sdp_offer || fresh?.sdpOffer;
|
||||
ice = ice || fresh?.ice_servers || fresh?.iceServers;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'[WhatsApp Call] failed to fetch call data for accept:',
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!offer) {
|
||||
throw new Error('Missing sdp_offer for accept — call may have ended.');
|
||||
}
|
||||
|
||||
const sdpAnswer = await prepareInboundAnswer(offer, ice);
|
||||
activeCallId = callId;
|
||||
await WhatsappCallsAPI.accept(callId, sdpAnswer);
|
||||
};
|
||||
|
||||
const rejectIncomingCall = async callId => {
|
||||
intentionallyClosing = true;
|
||||
try {
|
||||
await WhatsappCallsAPI.reject(callId);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
const initiateOutboundCall = async conversationId => {
|
||||
if (isInitiating.value) return null;
|
||||
isInitiating.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const sdpOffer = await prepareOutboundOffer();
|
||||
const response = await WhatsappCallsAPI.initiate(
|
||||
conversationId,
|
||||
sdpOffer
|
||||
);
|
||||
// Permission flow returns no call id — let the caller render the banner.
|
||||
activeCallId = response?.id || null;
|
||||
return response;
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
error.value = e;
|
||||
throw e;
|
||||
} finally {
|
||||
isInitiating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const endActiveCall = async () => {
|
||||
if (!activeCallId) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
intentionallyClosing = true;
|
||||
const callIdSnapshot = activeCallId;
|
||||
try {
|
||||
await stopRecorderAndUpload(callIdSnapshot);
|
||||
await WhatsappCallsAPI.terminate(callIdSnapshot).catch(() => {});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isInitiating,
|
||||
error,
|
||||
prepareInboundAnswer,
|
||||
prepareOutboundOffer,
|
||||
acceptIncomingCall,
|
||||
rejectIncomingCall,
|
||||
initiateOutboundCall,
|
||||
endActiveCall,
|
||||
};
|
||||
}
|
||||
|
||||
// Cable handlers fire outside any composable instance; expose the shared session
|
||||
// surface so they can apply the outbound answer onto the live PeerConnection.
|
||||
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
|
||||
if (!pc) return;
|
||||
activeCallId = callId;
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||
// Recorder + audio playback fire from ontrack as soon as Meta's tracks arrive.
|
||||
};
|
||||
|
||||
export const hasActiveWhatsappCall = () => Boolean(activeCallId);
|
||||
|
||||
// Used by the calls store as a sync teardown safety net.
|
||||
export const cleanupWhatsappSession = () => cleanup();
|
||||
|
||||
// Cable-driven end (contact hung up / call timed out). Flush any in-memory
|
||||
// recorder chunks and upload them so the resulting message bubble shows the
|
||||
// audio + transcript — without this, only agent-initiated hangups upload.
|
||||
export const handleWhatsappRemoteEnd = async callId => {
|
||||
// Snapshot before cleanup nulls activeCallId.
|
||||
const id = callId || activeCallId;
|
||||
if (!id) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await stopRecorderAndUpload(id);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
// Mute helpers — toggle the mic track's enabled flag (instantaneous, no renegotiation).
|
||||
export const setWhatsappCallMuted = muted => {
|
||||
if (!localStream) return false;
|
||||
localStream.getAudioTracks().forEach(track => {
|
||||
track.enabled = !muted;
|
||||
});
|
||||
return muted;
|
||||
};
|
||||
|
||||
export const isWhatsappCallMuted = () => {
|
||||
if (!localStream) return false;
|
||||
const tracks = localStream.getAudioTracks();
|
||||
if (!tracks.length) return false;
|
||||
return !tracks[0].enabled;
|
||||
};
|
||||
|
||||
// Best-effort terminate when the tab actually closes after the beforeunload prompt.
|
||||
export const sendWhatsappTerminateBeacon = () => {
|
||||
if (!activeCallId || intentionallyClosing) return;
|
||||
const accountId = window.location.pathname.split('/')[3];
|
||||
if (!accountId) return;
|
||||
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${activeCallId}/terminate`;
|
||||
try {
|
||||
navigator.sendBeacon(url);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,11 @@ 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 { useCallsStore } from 'dashboard/stores/calls';
|
||||
import {
|
||||
applyOutboundAnswer,
|
||||
handleWhatsappRemoteEnd,
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
@@ -35,6 +40,11 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
// WhatsApp call SDP exchange happens via these events; Twilio-shaped voice_call.*
|
||||
// events also flow through here but are ignored when provider !== 'whatsapp'.
|
||||
'voice_call.incoming': this.onVoiceCallIncoming,
|
||||
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
|
||||
'voice_call.ended': this.onVoiceCallEnded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,6 +215,44 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
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 => {
|
||||
if (data?.provider !== 'whatsapp') return;
|
||||
useCallsStore().addCall({
|
||||
callSid: data.call_id,
|
||||
callId: data.id,
|
||||
conversationId: data.conversation_id,
|
||||
inboxId: data.inbox_id,
|
||||
callDirection: 'inbound',
|
||||
provider: 'whatsapp',
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
// Caller info for the FloatingCallWidget so it doesn't show "Unknown caller"
|
||||
// before the conversation/contact has loaded into the store.
|
||||
caller: data.caller,
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallOutboundConnected = data => {
|
||||
if (data?.provider !== 'whatsapp' || !data.sdp_answer) return;
|
||||
applyOutboundAnswer(data.id, data.sdp_answer).catch(() => {});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallEnded = async data => {
|
||||
if (data?.provider !== 'whatsapp') return;
|
||||
// Must await the upload-and-cleanup BEFORE removeCall, because the store's
|
||||
// sync teardownByProvider -> cleanupWhatsappSession would otherwise wipe
|
||||
// mediaRecorder + recorderChunks before any upload microtask gets to run.
|
||||
try {
|
||||
await handleWhatsappRemoteEnd(data.id);
|
||||
} catch (_) {
|
||||
/* noop — upload is best-effort */
|
||||
}
|
||||
useCallsStore().removeCall(data.call_id);
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const INBOX_TYPES = {
|
||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||
export const VOICE_CALL_PROVIDERS = {
|
||||
TWILIO: 'twilio',
|
||||
WHATSAPP: 'whatsapp',
|
||||
};
|
||||
|
||||
export const getVoiceCallProvider = inbox => {
|
||||
@@ -25,9 +26,11 @@ export const getVoiceCallProvider = inbox => {
|
||||
const channelType = inbox.channel_type || inbox.channelType;
|
||||
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
|
||||
|
||||
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
|
||||
return VOICE_CALL_PROVIDERS.TWILIO;
|
||||
}
|
||||
if (!voiceEnabled) return null;
|
||||
|
||||
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
|
||||
if (channelType === INBOX_TYPES.WHATSAPP)
|
||||
return VOICE_CALL_PROVIDERS.WHATSAPP;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ function extractCallData(message) {
|
||||
const call = message?.call || {};
|
||||
return {
|
||||
callSid: call.provider_call_id,
|
||||
callId: call.id,
|
||||
provider: call.provider,
|
||||
status: call.status,
|
||||
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
conversationId: message?.conversation_id,
|
||||
@@ -36,7 +38,7 @@ function extractCallData(message) {
|
||||
export function handleVoiceCallCreated(message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, callDirection, conversationId, senderId } =
|
||||
const { callSid, callId, provider, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
||||
@@ -44,6 +46,8 @@ export function handleVoiceCallCreated(message, currentUserId) {
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
callId,
|
||||
provider,
|
||||
conversationId,
|
||||
callDirection,
|
||||
senderId,
|
||||
|
||||
@@ -97,6 +97,10 @@
|
||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
|
||||
"WHATSAPP_CALL": "Start WhatsApp call",
|
||||
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
|
||||
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
|
||||
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
|
||||
"SLA_STATUS": {
|
||||
"FRT": "FRT {status}",
|
||||
"NRT": "NRT {status}",
|
||||
@@ -297,7 +301,9 @@
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
"END_CALL": "End call",
|
||||
"MUTE": "Mute mic",
|
||||
"UNMUTE": "Unmute mic"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@@ -800,6 +800,10 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||
"WHATSAPP_CALLING_ENABLED": {
|
||||
"LABEL": "Enable voice calling",
|
||||
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
|
||||
},
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
|
||||
+35
@@ -44,6 +44,7 @@ export default {
|
||||
allowedDomains: '',
|
||||
isUpdatingAllowedDomains: false,
|
||||
isSettingDefaults: false,
|
||||
whatsAppCallingEnabled: false,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -71,6 +72,10 @@ export default {
|
||||
if (!this.isSettingDefaults && this.isAWebWidgetInbox)
|
||||
this.handleHmacFlag();
|
||||
},
|
||||
whatsAppCallingEnabled() {
|
||||
if (!this.isSettingDefaults && this.isEmbeddedSignupWhatsApp)
|
||||
this.updateWhatsAppCallingEnabled();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setDefaults();
|
||||
@@ -83,6 +88,9 @@ export default {
|
||||
this.inbox.selected_feature_flags || []
|
||||
).includes('allow_mobile_webview');
|
||||
this.allowedDomains = this.inbox.allowed_domains || '';
|
||||
this.whatsAppCallingEnabled = Boolean(
|
||||
this.inbox.provider_config?.calling_enabled
|
||||
);
|
||||
this.$nextTick(() => {
|
||||
this.isSettingDefaults = false;
|
||||
});
|
||||
@@ -147,6 +155,24 @@ export default {
|
||||
this.isUpdatingAllowedDomains = false;
|
||||
}
|
||||
},
|
||||
async updateWhatsAppCallingEnabled() {
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
provider_config: {
|
||||
...this.inbox.provider_config,
|
||||
calling_enabled: this.whatsAppCallingEnabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
async updateWhatsAppInboxAPIKey() {
|
||||
try {
|
||||
const payload = {
|
||||
@@ -374,6 +400,15 @@ export default {
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsFieldSection>
|
||||
<SettingsToggleSection
|
||||
v-model="whatsAppCallingEnabled"
|
||||
:header="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_ENABLED.LABEL')
|
||||
"
|
||||
:description="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_ENABLED.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Manual Setup Section -->
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { cleanupWhatsappSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
|
||||
|
||||
const teardownByProvider = call => {
|
||||
if (call?.provider === 'whatsapp') {
|
||||
cleanupWhatsappSession();
|
||||
} else {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
};
|
||||
|
||||
export const useCallsStore = defineStore('calls', {
|
||||
state: () => ({
|
||||
calls: [],
|
||||
@@ -16,15 +25,32 @@ export const useCallsStore = defineStore('calls', {
|
||||
|
||||
actions: {
|
||||
handleCallStatusChanged({ callSid, status }) {
|
||||
if (TERMINAL_STATUSES.includes(status)) {
|
||||
this.removeCall(callSid);
|
||||
if (!TERMINAL_STATUSES.includes(status)) return;
|
||||
|
||||
const call = this.calls.find(c => c.callSid === callSid);
|
||||
// For WhatsApp, the upload-and-cleanup must happen before the recorder
|
||||
// state is wiped — that runs from the voice_call.ended cable handler.
|
||||
// If we tear down here (race-winning the cable end-event), the recorder
|
||||
// chunks are gone before they get uploaded, so the recording is lost.
|
||||
// Just drop the call from the store; voice_call.ended will idempotently
|
||||
// finish cleanup once it arrives.
|
||||
if (call?.provider === 'whatsapp') {
|
||||
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeCall(callSid);
|
||||
},
|
||||
|
||||
addCall(callData) {
|
||||
if (!callData?.callSid) return;
|
||||
const exists = this.calls.some(call => call.callSid === callData.callSid);
|
||||
if (exists) return;
|
||||
const existing = this.calls.find(c => c.callSid === callData.callSid);
|
||||
if (existing) {
|
||||
// Merge so a later cable event with sdp_offer/provider/caller fills in
|
||||
// gaps left by the earlier message.created path (and vice versa).
|
||||
Object.assign(existing, callData, { isActive: existing.isActive });
|
||||
return;
|
||||
}
|
||||
|
||||
this.calls.push({
|
||||
...callData,
|
||||
@@ -35,7 +61,7 @@ export const useCallsStore = defineStore('calls', {
|
||||
removeCall(callSid) {
|
||||
const callToRemove = this.calls.find(c => c.callSid === callSid);
|
||||
if (callToRemove?.isActive) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
teardownByProvider(callToRemove);
|
||||
}
|
||||
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
||||
},
|
||||
@@ -48,7 +74,8 @@ export const useCallsStore = defineStore('calls', {
|
||||
},
|
||||
|
||||
clearActiveCall() {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
const active = this.calls.find(c => c.isActive);
|
||||
teardownByProvider(active);
|
||||
this.calls = this.calls.filter(call => !call.isActive);
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user