Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41a7c88aef | ||
|
|
19b5496c56 | ||
|
|
d693d468d8 | ||
|
|
0b1ad1a854 | ||
|
|
6179adac22 | ||
|
|
3c959f0604 | ||
|
|
772734840d | ||
|
|
77c57acffa | ||
|
|
499ac053ee | ||
|
|
5aa1df81a8 | ||
|
|
81464a9087 | ||
|
|
6ed9500792 | ||
|
|
6835f9c85b | ||
|
|
442631102c | ||
|
|
78c615fe42 | ||
|
|
907011e280 | ||
|
|
a766ebf642 | ||
|
|
0e0e0868d7 | ||
|
|
d9077c64d3 | ||
|
|
1abb51992b | ||
|
|
0282156821 | ||
|
|
257a9129d7 | ||
|
|
1ce316efc7 | ||
|
|
ba94d8c2b0 | ||
|
|
6e11bebeae | ||
|
|
069f25f88a | ||
|
|
63c193358c | ||
|
|
7663e2ac30 | ||
|
|
0567655840 | ||
|
|
c25ed094fa | ||
|
|
112a8f9e9e | ||
|
|
723d416ce3 | ||
|
|
f3df486ce6 | ||
|
|
c7b6a87e77 | ||
|
|
19d91e47f1 | ||
|
|
1c945899c7 | ||
|
|
2c025755e2 | ||
|
|
0618e6e229 | ||
|
|
cbef2d4e66 | ||
|
|
cb93aba55c | ||
|
|
beef43f2bf | ||
|
|
cbd3f759b5 | ||
|
|
a7fdc45977 | ||
|
|
41e3ea0d87 | ||
|
|
fd22017292 | ||
|
|
555894793e | ||
|
|
7c87d3d150 | ||
|
|
c37d1b1d6b | ||
|
|
e5a03d08ea | ||
|
|
bc80985f55 | ||
|
|
83cd568ca2 | ||
|
|
f32a7b3f77 | ||
|
|
ba0fcbfc7f | ||
|
|
546193de2a | ||
|
|
67b522c433 | ||
|
|
f5eca9d1d0 | ||
|
|
5c4db3a6bc | ||
|
|
792dc359d4 | ||
|
|
2b0aaa237f | ||
|
|
48022833ea | ||
|
|
e2b3965a08 | ||
|
|
17e25fa3c4 | ||
|
|
37963cc654 | ||
|
|
c7f5277277 |
@@ -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 { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
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 { useAlert } from 'dashboard/composables';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import { useCallsStore } from 'dashboard/stores/calls';
|
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 Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
@@ -38,7 +44,6 @@ const voiceInboxes = computed(() =>
|
|||||||
);
|
);
|
||||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||||
|
|
||||||
// Unified behavior: hide when no phone
|
|
||||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||||
|
|
||||||
const isInitiatingCall = computed(() => {
|
const isInitiatingCall = computed(() => {
|
||||||
@@ -58,9 +63,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 => {
|
const startCall = async inboxId => {
|
||||||
if (isInitiatingCall.value) return;
|
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 {
|
try {
|
||||||
const response = await store.dispatch('contacts/initiateCall', {
|
const response = await store.dispatch('contacts/initiateCall', {
|
||||||
contactId: props.contactId,
|
contactId: props.contactId,
|
||||||
@@ -68,7 +127,6 @@ const startCall = async inboxId => {
|
|||||||
});
|
});
|
||||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||||
|
|
||||||
// Add call to store immediately so widget shows
|
|
||||||
const callsStore = useCallsStore();
|
const callsStore = useCallsStore();
|
||||||
callsStore.addCall({
|
callsStore.addCall({
|
||||||
callSid,
|
callSid,
|
||||||
|
|||||||
@@ -32,7 +32,14 @@ const BG_COLOR_MAP = {
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
|
const {
|
||||||
|
call,
|
||||||
|
attachments,
|
||||||
|
contentAttributes,
|
||||||
|
conversationId,
|
||||||
|
currentUserId,
|
||||||
|
inboxId,
|
||||||
|
} = useMessageContext();
|
||||||
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
||||||
useCallSession();
|
useCallSession();
|
||||||
|
|
||||||
@@ -66,6 +73,23 @@ const displayAgentName = computed(() => {
|
|||||||
return conversationAssignee.value?.name || null;
|
return conversationAssignee.value?.name || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const audioAttachment = computed(() =>
|
||||||
|
(attachments?.value || []).find(a => a.fileType === 'audio')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Duration may arrive on call.duration_seconds (push_event_data) or
|
||||||
|
// content_attributes.data.duration_seconds — and either may be camelCased
|
||||||
|
// upstream, so 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;
|
||||||
|
return data?.durationSeconds || data?.duration_seconds;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
|
||||||
|
|
||||||
const labelKey = computed(() => {
|
const labelKey = computed(() => {
|
||||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||||
@@ -78,10 +102,6 @@ const labelKey = computed(() => {
|
|||||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
});
|
});
|
||||||
|
|
||||||
const formattedDuration = computed(() =>
|
|
||||||
formatDuration(call.value?.durationSeconds)
|
|
||||||
);
|
|
||||||
|
|
||||||
const subtext = computed(() => {
|
const subtext = computed(() => {
|
||||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||||
@@ -135,6 +155,7 @@ const canJoinCall = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const recordingAttachment = computed(() => {
|
const recordingAttachment = computed(() => {
|
||||||
|
if (audioAttachment.value) return audioAttachment.value;
|
||||||
const url = call.value?.recordingUrl;
|
const url = call.value?.recordingUrl;
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
return {
|
return {
|
||||||
@@ -201,8 +222,8 @@ const handleJoinCall = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="recordingAttachment" class="px-3 pb-3">
|
<div v-if="recordingAttachment" class="px-3 pb-3 w-full">
|
||||||
<AudioChip :attachment="recordingAttachment" />
|
<AudioChip :attachment="recordingAttachment" class="text-n-slate-12" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseBubble>
|
</BaseBubble>
|
||||||
|
|||||||
@@ -41,8 +41,33 @@ const playbackSpeed = ref(1);
|
|||||||
|
|
||||||
const { uid } = getCurrentInstance();
|
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 = () => {
|
const onLoadedMetadata = () => {
|
||||||
duration.value = audioPlayer.value?.duration;
|
const d = audioPlayer.value?.duration;
|
||||||
|
if (!Number.isFinite(d)) {
|
||||||
|
resolveStreamingDuration();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
duration.value = d;
|
||||||
};
|
};
|
||||||
|
|
||||||
const playbackSpeedLabel = computed(() => {
|
const playbackSpeedLabel = computed(() => {
|
||||||
@@ -53,7 +78,8 @@ const playbackSpeedLabel = computed(() => {
|
|||||||
// When the onLoadMetadata is called, so we need to set the duration
|
// When the onLoadMetadata is called, so we need to set the duration
|
||||||
// value when the component is mounted
|
// value when the component is mounted
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
duration.value = audioPlayer.value?.duration;
|
const d = audioPlayer.value?.duration;
|
||||||
|
if (Number.isFinite(d)) duration.value = d;
|
||||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||||
|
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||||
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
|
||||||
@@ -21,16 +22,40 @@ const {
|
|||||||
formattedCallDuration,
|
formattedCallDuration,
|
||||||
} = useCallSession();
|
} = 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 getCallInfo = call => {
|
||||||
const conversation = store.getters.getConversationById(call?.conversationId);
|
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||||
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
||||||
const sender = conversation?.meta?.sender;
|
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 {
|
return {
|
||||||
conversation,
|
conversation,
|
||||||
inbox,
|
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',
|
inboxName: inbox?.name || 'Customer support',
|
||||||
avatar: sender?.avatar || sender?.thumbnail,
|
avatar: sender?.avatar || sender?.thumbnail || caller?.avatar,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,21 +74,22 @@ const handleEndCall = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleJoinCall = async call => {
|
const handleJoinCall = async call => {
|
||||||
|
if (!call || isJoining.value) return;
|
||||||
const { conversation } = getCallInfo(call);
|
const { conversation } = getCallInfo(call);
|
||||||
if (!call || !conversation || isJoining.value) return;
|
|
||||||
|
|
||||||
// End current active call before joining new one
|
|
||||||
if (hasActiveCall.value) {
|
if (hasActiveCall.value) {
|
||||||
await handleEndCall();
|
await handleEndCall();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The conversation may not be hydrated yet (post-refresh seeding path);
|
||||||
|
// call.inboxId already carries what joinCall needs.
|
||||||
const result = await joinCall({
|
const result = await joinCall({
|
||||||
conversationId: call.conversationId,
|
conversationId: call.conversationId,
|
||||||
inboxId: conversation.inbox_id,
|
inboxId: call.inboxId || conversation?.inbox_id,
|
||||||
callSid: call.callSid,
|
callSid: call.callSid,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result && conversation) {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'inbox_conversation',
|
name: 'inbox_conversation',
|
||||||
params: { conversation_id: call.conversationId },
|
params: { conversation_id: call.conversationId },
|
||||||
@@ -162,6 +188,30 @@ watch(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 gap-2">
|
<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
|
<button
|
||||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||||
@click="
|
@click="
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
|
|||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
import { useElementSize } from '@vueuse/core';
|
import { useElementSize } from '@vueuse/core';
|
||||||
import BackButton from '../BackButton.vue';
|
import BackButton from '../BackButton.vue';
|
||||||
|
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
|
||||||
import InboxName from '../InboxName.vue';
|
import InboxName from '../InboxName.vue';
|
||||||
import MoreActions from './MoreActions.vue';
|
import MoreActions from './MoreActions.vue';
|
||||||
import Avatar from 'next/avatar/Avatar.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 { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||||
import { useInbox } from 'dashboard/composables/useInbox';
|
import { useInbox } from 'dashboard/composables/useInbox';
|
||||||
|
import {
|
||||||
|
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';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -91,6 +99,45 @@ const hasMultipleInboxes = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -152,6 +199,18 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
|||||||
:parent-width="width"
|
:parent-width="width"
|
||||||
class="hidden md:flex"
|
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" />
|
<MoreActions :conversation-id="currentChat.id" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||||
import { useCallsStore } from 'dashboard/stores/calls';
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import {
|
||||||
|
useWhatsappCallSession,
|
||||||
|
sendWhatsappTerminateBeacon,
|
||||||
|
cleanupWhatsappSession,
|
||||||
|
} from 'dashboard/composables/useWhatsappCallSession';
|
||||||
|
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
|
||||||
import Timer from 'dashboard/helper/Timer';
|
import Timer from 'dashboard/helper/Timer';
|
||||||
|
|
||||||
|
const isWhatsappCall = call => call?.provider === 'whatsapp';
|
||||||
|
|
||||||
export function useCallSession() {
|
export function useCallSession() {
|
||||||
|
const store = useStore();
|
||||||
const callsStore = useCallsStore();
|
const callsStore = useCallsStore();
|
||||||
|
const whatsappSession = useWhatsappCallSession();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isJoining = ref(false);
|
const isJoining = ref(false);
|
||||||
const callDuration = ref(0);
|
const callDuration = ref(0);
|
||||||
@@ -18,6 +29,7 @@ export function useCallSession() {
|
|||||||
const activeCall = computed(() => callsStore.activeCall);
|
const activeCall = computed(() => callsStore.activeCall);
|
||||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||||
|
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
hasActiveCall,
|
hasActiveCall,
|
||||||
@@ -32,20 +44,75 @@ export function useCallSession() {
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Warn before a refresh/close drops a live or ringing call. Cable events
|
||||||
|
// aren't replayed on reconnect, so a confirmed refresh during ringing would
|
||||||
|
// leave the agent unable to accept; for active calls the WebRTC session
|
||||||
|
// dies outright (no rejoin path).
|
||||||
|
const handleBeforeUnload = event => {
|
||||||
|
if (!hasActiveCall.value && !hasIncomingCall.value) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cable broadcasts (voice_call.incoming / message.created) are one-shot, so
|
||||||
|
// on a hard refresh they leave the calls store empty. Seed it from any
|
||||||
|
// ringing voice_call message in the conversation cache.
|
||||||
|
const seedCallsFromHydratedMessages = () => {
|
||||||
|
const conversations = store.getters.getAllConversations || [];
|
||||||
|
const currentUserId = store.getters.getCurrentUserID;
|
||||||
|
conversations.forEach(conv => {
|
||||||
|
(conv.messages || []).forEach(msg => {
|
||||||
|
if (msg.content_type !== 'voice_call') return;
|
||||||
|
if (msg.call?.status !== 'ringing') return;
|
||||||
|
handleVoiceCallCreated(msg, currentUserId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Terminate only the active call — ringing calls stay alive on Meta so the
|
||||||
|
// agent can pick them up after reload (seeded back via the watcher above).
|
||||||
|
const handlePageHide = () => {
|
||||||
|
sendWhatsappTerminateBeacon();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTwilioDisconnected = () => callsStore.clearActiveCall();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
TwilioVoiceClient.addEventListener(
|
||||||
callsStore.clearActiveCall()
|
'call:disconnected',
|
||||||
|
handleTwilioDisconnected
|
||||||
);
|
);
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
window.addEventListener('pagehide', handlePageHide);
|
||||||
|
seedCallsFromHydratedMessages();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-seed when conversations stream in after mount; addCall merges by callSid.
|
||||||
|
watch(
|
||||||
|
() => store.getters.getAllConversations?.length,
|
||||||
|
() => seedCallsFromHydratedMessages()
|
||||||
|
);
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
durationTimer.stop();
|
durationTimer.stop();
|
||||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
TwilioVoiceClient.removeEventListener(
|
||||||
callsStore.clearActiveCall()
|
'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 }) => {
|
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||||
|
if (isWhatsappCall(findCall(callSid))) {
|
||||||
|
await whatsappSession.endActiveCall();
|
||||||
|
durationTimer.stop();
|
||||||
|
callsStore.clearActiveCall();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||||
TwilioVoiceClient.endClientCall();
|
TwilioVoiceClient.endClientCall();
|
||||||
durationTimer.stop();
|
durationTimer.stop();
|
||||||
@@ -57,6 +124,18 @@ export function useCallSession() {
|
|||||||
|
|
||||||
isJoining.value = true;
|
isJoining.value = true;
|
||||||
try {
|
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);
|
const device = await TwilioVoiceClient.initializeDevice(inboxId);
|
||||||
if (!device) return null;
|
if (!device) return null;
|
||||||
|
|
||||||
@@ -84,6 +163,8 @@ export function useCallSession() {
|
|||||||
}
|
}
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('Failed to join call:', error);
|
console.error('Failed to join call:', error);
|
||||||
|
// Drop any half-built WebRTC state so the next click starts fresh.
|
||||||
|
cleanupWhatsappSession();
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
isJoining.value = false;
|
isJoining.value = false;
|
||||||
@@ -91,7 +172,12 @@ export function useCallSession() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rejectIncomingCall = callSid => {
|
const rejectIncomingCall = callSid => {
|
||||||
TwilioVoiceClient.endClientCall();
|
const call = findCall(callSid);
|
||||||
|
if (isWhatsappCall(call) && call?.callId) {
|
||||||
|
whatsappSession.rejectIncomingCall(call.callId);
|
||||||
|
} else {
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
}
|
||||||
callsStore.dismissCall(callSid);
|
callsStore.dismissCall(callSid);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
|
||||||
|
|
||||||
|
// Module-level state lets the cable handlers and unload listeners reach the
|
||||||
|
// live PeerConnection without prop-drilling refs through every composable.
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
el.play().catch(err => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[WhatsApp Call] remote audio play() failed:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1s timeslice keeps a recent recording chunk in memory so a remote hangup
|
||||||
|
// that races cleanup still has data 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 have no backend-supplied ice_servers (the call doesn't exist
|
||||||
|
// at offer time). Without STUN the browser only sends host candidates and
|
||||||
|
// browser→Meta media silently drops through any non-trivial NAT.
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupRecorder = () => {
|
||||||
|
if (!localStream || !remoteStream || mediaRecorder) return;
|
||||||
|
// createMediaStreamSource on a stream with no audio tracks wires up to
|
||||||
|
// nothing — the recorded mix would be silence. Wait until ontrack fires.
|
||||||
|
if (remoteStream.getAudioTracks().length === 0) return;
|
||||||
|
|
||||||
|
audioContext = new AudioContext({ sampleRate: 48000 });
|
||||||
|
// AudioContext starts suspended under most autoplay policies; without
|
||||||
|
// resume() 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 => {
|
||||||
|
// Reuse the same MediaStream object — the recorder's audioContext source
|
||||||
|
// taps it once, so reassigning would orphan the recorder.
|
||||||
|
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);
|
||||||
|
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 });
|
||||||
|
// Best-effort — the controller's idempotency guard handles a retry.
|
||||||
|
try {
|
||||||
|
await WhatsappCallsAPI.uploadRecording(callId, blob);
|
||||||
|
} catch (_) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// devise-token-auth requires access-token / client / uid headers on every
|
||||||
|
// request — navigator.sendBeacon can't set custom headers, so we rehydrate
|
||||||
|
// the auth payload from the cw_d_session_info cookie that the dashboard sets
|
||||||
|
// at login. Used by the page-close terminate path below.
|
||||||
|
const getDeviseAuthHeaders = () => {
|
||||||
|
try {
|
||||||
|
const raw = Cookies.get('cw_d_session_info');
|
||||||
|
if (!raw) return null;
|
||||||
|
const session = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
'access-token': session['access-token'] || '',
|
||||||
|
client: session.client || '',
|
||||||
|
uid: session.uid || '',
|
||||||
|
expiry: session.expiry || '',
|
||||||
|
'token-type': session['token-type'] || 'Bearer',
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const beaconTerminate = callId => {
|
||||||
|
if (!callId) return;
|
||||||
|
const accountId = window.location.pathname.split('/')[3];
|
||||||
|
if (!accountId) return;
|
||||||
|
const headers = getDeviseAuthHeaders();
|
||||||
|
if (!headers) return;
|
||||||
|
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
|
||||||
|
// fetch+keepalive (instead of navigator.sendBeacon) so we can attach auth
|
||||||
|
// headers — without them devise-token-auth 401s and the call stays open on
|
||||||
|
// Meta until its carrier-side timeout (~60s).
|
||||||
|
try {
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
keepalive: true,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
|
body: '{}',
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch (_) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWhatsappCallSession() {
|
||||||
|
const isInitiating = ref(false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
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 (the cable broadcast can race the
|
||||||
|
// click). Fall back to GET /whatsapp_calls/:id which exposes it.
|
||||||
|
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;
|
||||||
|
try {
|
||||||
|
const sdpOffer = await prepareOutboundOffer();
|
||||||
|
const response = await WhatsappCallsAPI.initiate(
|
||||||
|
conversationId,
|
||||||
|
sdpOffer
|
||||||
|
);
|
||||||
|
// The permission-request branch returns no call id; let the caller render the banner.
|
||||||
|
activeCallId = response?.id || null;
|
||||||
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
cleanup();
|
||||||
|
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,
|
||||||
|
prepareInboundAnswer,
|
||||||
|
prepareOutboundOffer,
|
||||||
|
acceptIncomingCall,
|
||||||
|
rejectIncomingCall,
|
||||||
|
initiateOutboundCall,
|
||||||
|
endActiveCall,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cable handlers fire outside any composable instance, so the shared session
|
||||||
|
// surface is exposed as module-level functions for them.
|
||||||
|
|
||||||
|
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
|
||||||
|
if (!pc) return;
|
||||||
|
activeCallId = callId;
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cleanupWhatsappSession = () => cleanup();
|
||||||
|
|
||||||
|
export const handleWhatsappRemoteEnd = async callId => {
|
||||||
|
// Snapshot before cleanup nulls activeCallId.
|
||||||
|
const id = callId || activeCallId;
|
||||||
|
if (!id) {
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await stopRecorderAndUpload(id);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setWhatsappCallMuted = muted => {
|
||||||
|
if (!localStream) return false;
|
||||||
|
localStream.getAudioTracks().forEach(track => {
|
||||||
|
track.enabled = !muted;
|
||||||
|
});
|
||||||
|
return muted;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendWhatsappTerminateBeacon = () => {
|
||||||
|
if (!activeCallId || intentionallyClosing) return;
|
||||||
|
beaconTerminate(activeCallId);
|
||||||
|
};
|
||||||
@@ -4,6 +4,11 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
|
|||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
import {
|
||||||
|
applyOutboundAnswer,
|
||||||
|
handleWhatsappRemoteEnd,
|
||||||
|
} from 'dashboard/composables/useWhatsappCallSession';
|
||||||
|
|
||||||
const { isImpersonating } = useImpersonation();
|
const { isImpersonating } = useImpersonation();
|
||||||
|
|
||||||
@@ -35,6 +40,9 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
'account.cache_invalidated': this.onCacheInvalidate,
|
'account.cache_invalidated': this.onCacheInvalidate,
|
||||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||||
'copilot.message.created': this.onCopilotMessageCreated,
|
'copilot.message.created': this.onCopilotMessageCreated,
|
||||||
|
'voice_call.incoming': this.onVoiceCallIncoming,
|
||||||
|
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
|
||||||
|
'voice_call.ended': this.onVoiceCallEnded,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +213,41 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
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: 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;
|
||||||
|
// Await upload before removeCall — the store's sync teardown would otherwise
|
||||||
|
// wipe the recorder chunks before they reach the server.
|
||||||
|
try {
|
||||||
|
await handleWhatsappRemoteEnd(data.id);
|
||||||
|
} catch (_) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
useCallsStore().removeCall(data.call_id);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const INBOX_TYPES = {
|
|||||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||||
export const VOICE_CALL_PROVIDERS = {
|
export const VOICE_CALL_PROVIDERS = {
|
||||||
TWILIO: 'twilio',
|
TWILIO: 'twilio',
|
||||||
|
WHATSAPP: 'whatsapp',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getVoiceCallProvider = inbox => {
|
export const getVoiceCallProvider = inbox => {
|
||||||
@@ -25,9 +26,11 @@ export const getVoiceCallProvider = inbox => {
|
|||||||
const channelType = inbox.channel_type || inbox.channelType;
|
const channelType = inbox.channel_type || inbox.channelType;
|
||||||
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
|
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
|
||||||
|
|
||||||
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
|
if (!voiceEnabled) return null;
|
||||||
return VOICE_CALL_PROVIDERS.TWILIO;
|
|
||||||
}
|
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
|
||||||
|
if (channelType === INBOX_TYPES.WHATSAPP)
|
||||||
|
return VOICE_CALL_PROVIDERS.WHATSAPP;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ function extractCallData(message) {
|
|||||||
const call = message?.call || {};
|
const call = message?.call || {};
|
||||||
return {
|
return {
|
||||||
callSid: call.provider_call_id,
|
callSid: call.provider_call_id,
|
||||||
|
callId: call.id,
|
||||||
|
provider: call.provider,
|
||||||
status: call.status,
|
status: call.status,
|
||||||
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||||
conversationId: message?.conversation_id,
|
conversationId: message?.conversation_id,
|
||||||
@@ -60,8 +62,15 @@ function extractCallData(message) {
|
|||||||
export function handleVoiceCallCreated(message, currentUserId) {
|
export function handleVoiceCallCreated(message, currentUserId) {
|
||||||
if (!isVoiceCallMessage(message)) return;
|
if (!isVoiceCallMessage(message)) return;
|
||||||
|
|
||||||
const { callSid, callDirection, conversationId, assigneeId, senderId } =
|
const {
|
||||||
extractCallData(message);
|
callSid,
|
||||||
|
callId,
|
||||||
|
provider,
|
||||||
|
callDirection,
|
||||||
|
conversationId,
|
||||||
|
assigneeId,
|
||||||
|
senderId,
|
||||||
|
} = extractCallData(message);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!shouldShowCall({
|
!shouldShowCall({
|
||||||
@@ -77,6 +86,8 @@ export function handleVoiceCallCreated(message, currentUserId) {
|
|||||||
const callsStore = useCallsStore();
|
const callsStore = useCallsStore();
|
||||||
callsStore.addCall({
|
callsStore.addCall({
|
||||||
callSid,
|
callSid,
|
||||||
|
callId,
|
||||||
|
provider,
|
||||||
conversationId,
|
conversationId,
|
||||||
callDirection,
|
callDirection,
|
||||||
senderId,
|
senderId,
|
||||||
|
|||||||
@@ -99,6 +99,10 @@
|
|||||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||||
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
|
"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": {
|
"SLA_STATUS": {
|
||||||
"FRT": "FRT {status}",
|
"FRT": "FRT {status}",
|
||||||
"NRT": "NRT {status}",
|
"NRT": "NRT {status}",
|
||||||
@@ -299,7 +303,9 @@
|
|||||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||||
"REJECT_CALL": "Reject",
|
"REJECT_CALL": "Reject",
|
||||||
"JOIN_CALL": "Join call",
|
"JOIN_CALL": "Join call",
|
||||||
"END_CALL": "End call"
|
"END_CALL": "End call",
|
||||||
|
"MUTE": "Mute mic",
|
||||||
|
"UNMUTE": "Unmute mic"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EMAIL_TRANSCRIPT": {
|
"EMAIL_TRANSCRIPT": {
|
||||||
|
|||||||
@@ -637,7 +637,8 @@
|
|||||||
"BOT_CONFIGURATION": "Bot Configuration",
|
"BOT_CONFIGURATION": "Bot Configuration",
|
||||||
"ACCOUNT_HEALTH": "Account Health",
|
"ACCOUNT_HEALTH": "Account Health",
|
||||||
"CSAT": "CSAT",
|
"CSAT": "CSAT",
|
||||||
"VOICE": "Voice"
|
"VOICE": "Voice",
|
||||||
|
"CALLS": "Calls"
|
||||||
},
|
},
|
||||||
"VOICE_CONFIGURATION": {
|
"VOICE_CONFIGURATION": {
|
||||||
"ENABLE_VOICE": {
|
"ENABLE_VOICE": {
|
||||||
@@ -648,6 +649,20 @@
|
|||||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"WHATSAPP_CALLING": {
|
||||||
|
"ENABLE": {
|
||||||
|
"LABEL": "Enable WhatsApp Calling",
|
||||||
|
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
|
||||||
|
},
|
||||||
|
"PHONE_NUMBER": {
|
||||||
|
"LABEL": "Business phone number",
|
||||||
|
"HELP_TEXT": "WhatsApp number that customers will call."
|
||||||
|
},
|
||||||
|
"HOW_IT_WORKS": {
|
||||||
|
"LABEL": "How it works",
|
||||||
|
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
|
||||||
|
}
|
||||||
|
},
|
||||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||||
"WIDGET_FEATURES": "Widget features",
|
"WIDGET_FEATURES": "Widget features",
|
||||||
"ACCOUNT_HEALTH": {
|
"ACCOUNT_HEALTH": {
|
||||||
@@ -800,6 +815,10 @@
|
|||||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||||
|
"WHATSAPP_CALLING_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"
|
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||||
},
|
},
|
||||||
"HELP_CENTER": {
|
"HELP_CENTER": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
|||||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||||
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
|
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
|
||||||
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
|
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
|
||||||
|
import WhatsappCallingPage from './settingsPage/WhatsappCallingPage.vue';
|
||||||
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
|
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
|
||||||
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
||||||
import BotConfiguration from './components/BotConfiguration.vue';
|
import BotConfiguration from './components/BotConfiguration.vue';
|
||||||
@@ -48,6 +49,7 @@ export default {
|
|||||||
CollaboratorsPage,
|
CollaboratorsPage,
|
||||||
ConfigurationPage,
|
ConfigurationPage,
|
||||||
VoiceConfigurationPage,
|
VoiceConfigurationPage,
|
||||||
|
WhatsappCallingPage,
|
||||||
CustomerSatisfactionPage,
|
CustomerSatisfactionPage,
|
||||||
FacebookReauthorize,
|
FacebookReauthorize,
|
||||||
GreetingsEditor,
|
GreetingsEditor,
|
||||||
@@ -249,6 +251,16 @@ export default {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isAWhatsAppCloudChannel && this.isEmbeddedSignupWhatsApp) {
|
||||||
|
visibleToAllChannelTabs = [
|
||||||
|
...visibleToAllChannelTabs,
|
||||||
|
{
|
||||||
|
key: 'calls-configuration',
|
||||||
|
name: this.$t('INBOX_MGMT.TABS.CALLS'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return visibleToAllChannelTabs;
|
return visibleToAllChannelTabs;
|
||||||
},
|
},
|
||||||
currentInboxId() {
|
currentInboxId() {
|
||||||
@@ -1262,6 +1274,12 @@ export default {
|
|||||||
>
|
>
|
||||||
<VoiceConfigurationPage :inbox="inbox" />
|
<VoiceConfigurationPage :inbox="inbox" />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="selectedTabKey === 'calls-configuration'"
|
||||||
|
class="mx-6 max-w-4xl"
|
||||||
|
>
|
||||||
|
<WhatsappCallingPage :inbox="inbox" />
|
||||||
|
</div>
|
||||||
<div v-if="selectedTabKey === 'csat'">
|
<div v-if="selectedTabKey === 'csat'">
|
||||||
<CustomerSatisfactionPage :inbox="inbox" />
|
<CustomerSatisfactionPage :inbox="inbox" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
<script>
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||||
|
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
SettingsFieldSection,
|
||||||
|
SettingsToggleSection,
|
||||||
|
NextButton,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
inbox: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
|
||||||
|
isUpdating: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
phoneNumber() {
|
||||||
|
return (
|
||||||
|
this.inbox.provider_config?.phone_number || this.inbox.phone_number
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'inbox.provider_config.calling_enabled'(val) {
|
||||||
|
this.callingEnabled = val || false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async updateCallingSettings() {
|
||||||
|
this.isUpdating = true;
|
||||||
|
try {
|
||||||
|
await this.$store.dispatch('inboxes/updateInbox', {
|
||||||
|
id: this.inbox.id,
|
||||||
|
formData: false,
|
||||||
|
channel: {
|
||||||
|
provider_config: {
|
||||||
|
...this.inbox.provider_config,
|
||||||
|
calling_enabled: this.callingEnabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||||
|
} finally {
|
||||||
|
this.isUpdating = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<SettingsToggleSection
|
||||||
|
v-model="callingEnabled"
|
||||||
|
:header="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.LABEL')"
|
||||||
|
:description="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.DESCRIPTION')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsFieldSection
|
||||||
|
v-if="phoneNumber"
|
||||||
|
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
|
||||||
|
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.HELP_TEXT')"
|
||||||
|
>
|
||||||
|
<woot-code :script="phoneNumber" lang="html" />
|
||||||
|
</SettingsFieldSection>
|
||||||
|
|
||||||
|
<SettingsFieldSection
|
||||||
|
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.LABEL')"
|
||||||
|
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.DESCRIPTION')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NextButton
|
||||||
|
:is-loading="isUpdating"
|
||||||
|
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||||
|
@click="updateCallingSettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||||
|
import { cleanupWhatsappSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||||
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
|
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
|
||||||
|
|
||||||
|
const teardownByProvider = call => {
|
||||||
|
if (call?.provider === 'whatsapp') {
|
||||||
|
cleanupWhatsappSession();
|
||||||
|
} else {
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const useCallsStore = defineStore('calls', {
|
export const useCallsStore = defineStore('calls', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
calls: [],
|
calls: [],
|
||||||
@@ -16,15 +25,28 @@ export const useCallsStore = defineStore('calls', {
|
|||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
handleCallStatusChanged({ callSid, status }) {
|
handleCallStatusChanged({ callSid, status }) {
|
||||||
if (TERMINAL_STATUSES.includes(status)) {
|
if (!TERMINAL_STATUSES.includes(status)) return;
|
||||||
this.removeCall(callSid);
|
|
||||||
|
const call = this.calls.find(c => c.callSid === callSid);
|
||||||
|
// WhatsApp recordings live in the in-memory recorder until voice_call.ended
|
||||||
|
// uploads them; tearing down here would race-wipe those chunks.
|
||||||
|
if (call?.provider === 'whatsapp') {
|
||||||
|
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.removeCall(callSid);
|
||||||
},
|
},
|
||||||
|
|
||||||
addCall(callData) {
|
addCall(callData) {
|
||||||
if (!callData?.callSid) return;
|
if (!callData?.callSid) return;
|
||||||
const exists = this.calls.some(call => call.callSid === callData.callSid);
|
const existing = this.calls.find(c => c.callSid === callData.callSid);
|
||||||
if (exists) return;
|
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({
|
this.calls.push({
|
||||||
...callData,
|
...callData,
|
||||||
@@ -35,7 +57,7 @@ export const useCallsStore = defineStore('calls', {
|
|||||||
removeCall(callSid) {
|
removeCall(callSid) {
|
||||||
const callToRemove = this.calls.find(c => c.callSid === callSid);
|
const callToRemove = this.calls.find(c => c.callSid === callSid);
|
||||||
if (callToRemove?.isActive) {
|
if (callToRemove?.isActive) {
|
||||||
TwilioVoiceClient.endClientCall();
|
teardownByProvider(callToRemove);
|
||||||
}
|
}
|
||||||
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
||||||
},
|
},
|
||||||
@@ -48,7 +70,8 @@ export const useCallsStore = defineStore('calls', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearActiveCall() {
|
clearActiveCall() {
|
||||||
TwilioVoiceClient.endClientCall();
|
const active = this.calls.find(c => c.isActive);
|
||||||
|
teardownByProvider(active);
|
||||||
this.calls = this.calls.filter(call => !call.isActive);
|
this.calls = this.calls.filter(call => !call.isActive);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -128,3 +128,5 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
|||||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Webhooks::WhatsappEventsJob.prepend_mod_with('Webhooks::WhatsappEventsJob')
|
||||||
|
|||||||
@@ -104,11 +104,23 @@ class Attachment < ApplicationRecord
|
|||||||
audio_file_data = base_data.merge(file_metadata)
|
audio_file_data = base_data.merge(file_metadata)
|
||||||
audio_file_data.merge(
|
audio_file_data.merge(
|
||||||
{
|
{
|
||||||
|
# ActiveStorage's redirect endpoint defaults to Content-Disposition: attachment,
|
||||||
|
# which makes <audio> elements download instead of play. Force inline so the
|
||||||
|
# call-recording chip (and any other audio bubble) can stream directly.
|
||||||
|
data_url: inline_audio_url,
|
||||||
transcribed_text: meta&.[]('transcribed_text') || ''
|
transcribed_text: meta&.[]('transcribed_text') || ''
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def inline_audio_url
|
||||||
|
return '' unless file.attached?
|
||||||
|
|
||||||
|
# Proxy endpoint streams through Rails and honours `disposition: 'inline'`,
|
||||||
|
# unlike the redirect endpoint which always sends Content-Disposition: attachment.
|
||||||
|
Rails.application.routes.url_helpers.rails_storage_proxy_url(file, disposition: 'inline')
|
||||||
|
end
|
||||||
|
|
||||||
def file_metadata
|
def file_metadata
|
||||||
metadata = {
|
metadata = {
|
||||||
extension: extension,
|
extension: extension,
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ class Channel::Whatsapp < ApplicationRecord
|
|||||||
'Whatsapp'
|
'Whatsapp'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
|
||||||
|
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
|
||||||
|
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
|
||||||
|
def voice_enabled?
|
||||||
|
provider == 'whatsapp_cloud' &&
|
||||||
|
provider_config['source'] == 'embedded_signup' &&
|
||||||
|
provider_config['calling_enabled'].present?
|
||||||
|
end
|
||||||
|
|
||||||
def provider_service
|
def provider_service
|
||||||
if provider == 'whatsapp_cloud'
|
if provider == 'whatsapp_cloud'
|
||||||
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
|
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ class Base::SendOnChannelService
|
|||||||
def invalid_message?
|
def invalid_message?
|
||||||
# private notes aren't send to the channels
|
# private notes aren't send to the channels
|
||||||
# we should also avoid the case of message loops, when outgoing messages are created from channel
|
# 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 call status indicators, not deliverable messages — skip the send pipeline
|
||||||
|
# otherwise it falls through to "Template not found" / 24-hour-window errors
|
||||||
|
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_target_channel
|
def validate_target_channel
|
||||||
|
|||||||
@@ -142,3 +142,6 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
|
|||||||
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
|
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
|
||||||
|
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Allow audio attachments (call recordings, voice notes) to serve inline so the
|
||||||
|
# in-app <audio> player can stream them. Without this, ActiveStorage's blob model
|
||||||
|
# forces Content-Disposition: attachment for any MIME outside the default allowlist
|
||||||
|
# (images + PDF), which makes the browser download instead of play.
|
||||||
|
Rails.application.config.active_storage.content_types_allowed_inline += %w[
|
||||||
|
audio/webm
|
||||||
|
audio/ogg
|
||||||
|
audio/mpeg
|
||||||
|
audio/mp4
|
||||||
|
audio/x-m4a
|
||||||
|
audio/wav
|
||||||
|
audio/x-wav
|
||||||
|
]
|
||||||
@@ -116,6 +116,13 @@ en:
|
|||||||
reauthorization:
|
reauthorization:
|
||||||
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
||||||
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
||||||
|
calls:
|
||||||
|
not_enabled: 'Calling is not enabled for this inbox'
|
||||||
|
no_recording: 'No recording file provided'
|
||||||
|
no_message: 'Call has no associated message'
|
||||||
|
sdp_offer_required: 'sdp_offer is required'
|
||||||
|
contact_phone_required: 'Contact phone number is required'
|
||||||
|
permission_request_failed: 'Failed to send call permission request'
|
||||||
inboxes:
|
inboxes:
|
||||||
imap:
|
imap:
|
||||||
socket_error: Please check the network connection, IMAP address and try again.
|
socket_error: Please check the network connection, IMAP address and try again.
|
||||||
@@ -246,6 +253,9 @@ en:
|
|||||||
whatsapp:
|
whatsapp:
|
||||||
list_button_label: 'Choose an item'
|
list_button_label: 'Choose an item'
|
||||||
call_permission_request_body: 'We would like to call you regarding your conversation.'
|
call_permission_request_body: 'We would like to call you regarding your conversation.'
|
||||||
|
voice_call:
|
||||||
|
twilio: 'Voice Call'
|
||||||
|
whatsapp: 'WhatsApp Call'
|
||||||
delivery_status:
|
delivery_status:
|
||||||
error_code: 'Error code: %{error_code}'
|
error_code: 'Error code: %{error_code}'
|
||||||
activity:
|
activity:
|
||||||
|
|||||||
@@ -215,6 +215,21 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
|
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
|
||||||
|
|
||||||
|
if ChatwootApp.enterprise?
|
||||||
|
resources :whatsapp_calls, only: [:show] do
|
||||||
|
member do
|
||||||
|
post :accept
|
||||||
|
post :reject
|
||||||
|
post :terminate
|
||||||
|
post :upload_recording
|
||||||
|
end
|
||||||
|
collection do
|
||||||
|
post :initiate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
|
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
|
||||||
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
|
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
|
||||||
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
|
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ module Enterprise::Messages::MessageBuilder
|
|||||||
private
|
private
|
||||||
|
|
||||||
def message_type
|
def message_type
|
||||||
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
|
return @message_type if @message_type == 'incoming' && voice_call_inbox? && @params[:content_type] == 'voice_call'
|
||||||
|
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
def twilio_voice_inbox?
|
def voice_call_inbox?
|
||||||
inbox = @conversation.inbox
|
@conversation.inbox.channel.try(:voice_enabled?)
|
||||||
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
|
||||||
|
PERMISSION_REQUEST_THROTTLE = 5.minutes
|
||||||
|
|
||||||
|
before_action :set_call, only: %i[show accept reject terminate upload_recording]
|
||||||
|
before_action :set_conversation, only: :initiate
|
||||||
|
before_action :ensure_calling_enabled, only: :initiate
|
||||||
|
before_action :ensure_sdp_offer, only: :initiate
|
||||||
|
before_action :ensure_contact_phone, only: :initiate
|
||||||
|
before_action :ensure_recording_present, only: :upload_recording
|
||||||
|
before_action :ensure_call_message, only: :upload_recording
|
||||||
|
|
||||||
|
rescue_from Voice::CallErrors::NotRinging,
|
||||||
|
Voice::CallErrors::AlreadyAccepted,
|
||||||
|
Voice::CallErrors::CallFailed,
|
||||||
|
with: :render_call_error
|
||||||
|
rescue_from Voice::CallErrors::NoCallPermission, with: :render_permission_request
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
|
def accept
|
||||||
|
call_service.accept
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject
|
||||||
|
call_service.reject
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminate
|
||||||
|
call_service.terminate
|
||||||
|
end
|
||||||
|
|
||||||
|
def upload_recording
|
||||||
|
@upload_status = @call.message.with_lock { attach_recording_idempotently }
|
||||||
|
end
|
||||||
|
|
||||||
|
def initiate
|
||||||
|
@call = create_outbound_call
|
||||||
|
@message = Voice::CallMessageBuilder.new(@call).perform!
|
||||||
|
@call.update!(message_id: @message.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def call_service
|
||||||
|
@call_service ||= Whatsapp::CallService.new(call: @call, agent: Current.user, sdp_answer: params[:sdp_answer])
|
||||||
|
end
|
||||||
|
|
||||||
|
def provider_service
|
||||||
|
@provider_service ||= @conversation.inbox.channel.provider_service
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_call
|
||||||
|
@call = Current.account.calls.whatsapp.find(params[:id])
|
||||||
|
authorize @call.conversation, :show?
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_conversation
|
||||||
|
@conversation = Current.account.conversations.find_by!(display_id: params[:conversation_id])
|
||||||
|
authorize @conversation, :show?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Twilio voice also exposes voice_enabled? but uses a different initiation path.
|
||||||
|
def ensure_calling_enabled
|
||||||
|
channel = @conversation.inbox.channel
|
||||||
|
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
|
||||||
|
|
||||||
|
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_sdp_offer
|
||||||
|
return if params[:sdp_offer].present?
|
||||||
|
|
||||||
|
render_could_not_create_error(I18n.t('errors.whatsapp.calls.sdp_offer_required'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_contact_phone
|
||||||
|
return if @conversation.contact&.phone_number.present?
|
||||||
|
|
||||||
|
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_recording_present
|
||||||
|
return if params[:recording].present?
|
||||||
|
|
||||||
|
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_recording'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_call_message
|
||||||
|
return if @call.message.present?
|
||||||
|
|
||||||
|
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_message'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_recording_idempotently
|
||||||
|
return 'already_uploaded' if @call.message.attachments.exists?(file_type: :audio)
|
||||||
|
|
||||||
|
@call.message.attachments.create!(account_id: @call.account_id, file_type: :audio, file: params[:recording])
|
||||||
|
'uploaded'
|
||||||
|
end
|
||||||
|
|
||||||
|
# Browser-built SDP offer is forwarded to Meta; the connect webhook later delivers Meta's answer.
|
||||||
|
def create_outbound_call
|
||||||
|
contact_phone = @conversation.contact.phone_number.delete('+')
|
||||||
|
result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
|
||||||
|
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
|
||||||
|
|
||||||
|
Current.account.calls.create!(
|
||||||
|
provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact,
|
||||||
|
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
|
||||||
|
accepted_by_agent_id: Current.user.id,
|
||||||
|
meta: { 'sdp_offer' => params[:sdp_offer], 'ice_servers' => Call.default_ice_servers }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Meta error 138006 means the contact hasn't opted in yet; send the opt-in
|
||||||
|
# template (throttled, behind a conversation lock to prevent double-send).
|
||||||
|
def render_permission_request
|
||||||
|
status = nil
|
||||||
|
@conversation.with_lock do
|
||||||
|
if permission_request_throttled?
|
||||||
|
status = 'permission_pending'
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
sent = send_permission_request_safely
|
||||||
|
if sent
|
||||||
|
record_permission_request_wamid(sent)
|
||||||
|
status = 'permission_requested'
|
||||||
|
else
|
||||||
|
status = 'failed'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) if status == 'failed'
|
||||||
|
|
||||||
|
render json: { status: status }
|
||||||
|
end
|
||||||
|
|
||||||
|
def permission_request_throttled?
|
||||||
|
last_requested = @conversation.additional_attributes&.dig('call_permission_requested_at')
|
||||||
|
last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago
|
||||||
|
end
|
||||||
|
|
||||||
|
# Treat transport errors as a falsy return so we render 422 rather than 500.
|
||||||
|
def send_permission_request_safely
|
||||||
|
provider_service.send_call_permission_request(@conversation.contact.phone_number.delete('+'))
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Stash the outbound wamid so the reply webhook can match context.id back here.
|
||||||
|
def record_permission_request_wamid(sent)
|
||||||
|
attrs = (@conversation.additional_attributes || {}).merge(
|
||||||
|
'call_permission_requested_at' => Time.current.iso8601,
|
||||||
|
'call_permission_request_message_id' => sent.dig('messages', 0, 'id')
|
||||||
|
)
|
||||||
|
@conversation.update!(additional_attributes: attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_call_error(error)
|
||||||
|
render_could_not_create_error(error.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -94,7 +94,6 @@ class Twilio::VoiceController < ApplicationController
|
|||||||
case twilio_direction
|
case twilio_direction
|
||||||
when 'inbound'
|
when 'inbound'
|
||||||
Voice::InboundCallBuilder.perform!(
|
Voice::InboundCallBuilder.perform!(
|
||||||
account: current_account,
|
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
from_number: twilio_from,
|
from_number: twilio_from,
|
||||||
call_sid: twilio_call_sid
|
call_sid: twilio_call_sid
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
module Enterprise::Webhooks::WhatsappEventsJob
|
||||||
|
def handle_message_events(channel, params)
|
||||||
|
return handle_call_events(channel, params) if call_event?(params)
|
||||||
|
return handle_call_permission_reply(channel, params) if call_permission_reply?(params)
|
||||||
|
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Lock per-call_id inside handle_call_events instead of the parent's per-sender mutex.
|
||||||
|
def contact_sender_id(params)
|
||||||
|
return nil if call_event?(params)
|
||||||
|
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
def call_event?(params)
|
||||||
|
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
|
||||||
|
end
|
||||||
|
|
||||||
|
def call_permission_reply?(params)
|
||||||
|
params.dig(:entry, 0, :changes, 0, :value, :messages, 0, :interactive, :type) == 'call_permission_reply'
|
||||||
|
end
|
||||||
|
|
||||||
|
# Per-call_id mutex so connect/terminate for the same call serialize across batches.
|
||||||
|
def handle_call_events(channel, params)
|
||||||
|
calls = params.dig(:entry, 0, :changes, 0, :value, :calls) || []
|
||||||
|
calls.each do |call_payload|
|
||||||
|
lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX,
|
||||||
|
inbox_id: channel.inbox.id, sender_id: "call:#{call_payload[:id]}")
|
||||||
|
with_lock(lock_key, 30.seconds) do
|
||||||
|
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call_permission_reply(channel, params)
|
||||||
|
Whatsapp::CallPermissionReplyService.new(inbox: channel.inbox, params: params).perform
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -29,13 +29,16 @@
|
|||||||
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
|
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
|
||||||
#
|
#
|
||||||
class Call < ApplicationRecord
|
class Call < ApplicationRecord
|
||||||
# All valid call statuses
|
|
||||||
STATUSES = %w[ringing in_progress completed no_answer failed].freeze
|
STATUSES = %w[ringing in_progress completed no_answer failed].freeze
|
||||||
# Statuses where the call is finished and won't change again
|
|
||||||
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
|
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
|
||||||
|
|
||||||
store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
|
store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
|
||||||
|
|
||||||
|
# Frontend voice bubbles/stores expect inbound/outbound string values
|
||||||
|
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
|
||||||
|
|
||||||
|
DEFAULT_STUN_URL = 'stun:stun.l.google.com:19302'.freeze
|
||||||
|
|
||||||
enum :provider, { twilio: 0, whatsapp: 1 }
|
enum :provider, { twilio: 0, whatsapp: 1 }
|
||||||
enum :direction, { incoming: 0, outgoing: 1 }
|
enum :direction, { incoming: 0, outgoing: 1 }
|
||||||
|
|
||||||
@@ -65,6 +68,28 @@ class Call < ApplicationRecord
|
|||||||
"conf_account_#{account_id}_call_#{id}"
|
"conf_account_#{account_id}_call_#{id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its public srflx candidate.
|
||||||
|
def self.default_ice_servers
|
||||||
|
urls = ENV.fetch('VOICE_CALL_STUN_URLS', DEFAULT_STUN_URL).split(',').filter_map { |u| u.strip.presence }
|
||||||
|
[{ urls: urls }]
|
||||||
|
end
|
||||||
|
|
||||||
|
def direction_label
|
||||||
|
DISPLAY_DIRECTION[direction]
|
||||||
|
end
|
||||||
|
|
||||||
|
def ringing?
|
||||||
|
status == 'ringing'
|
||||||
|
end
|
||||||
|
|
||||||
|
def in_progress?
|
||||||
|
status == 'in_progress'
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminal?
|
||||||
|
TERMINAL_STATUSES.include?(status)
|
||||||
|
end
|
||||||
|
|
||||||
def display_status
|
def display_status
|
||||||
status.to_s.tr('_', '-')
|
status.to_s.tr('_', '-')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Stub for legacy inboxes whose channel_type is still 'Channel::Voice' after
|
||||||
|
# the upstream DropChannelVoice migration moved voice fields onto
|
||||||
|
# Channel::TwilioSms. Pointing at the new table lets Rails resolve
|
||||||
|
# `belongs_to :channel, polymorphic: true` lookups to nil (no matching row),
|
||||||
|
# so jbuilder `.try` chains short-circuit instead of raising.
|
||||||
|
class Channel::Voice < ApplicationRecord
|
||||||
|
self.table_name = 'channel_twilio_sms'
|
||||||
|
end
|
||||||
@@ -3,13 +3,30 @@ module Enterprise::Concerns::Attachment
|
|||||||
|
|
||||||
included do
|
included do
|
||||||
after_create_commit :enqueue_audio_transcription
|
after_create_commit :enqueue_audio_transcription
|
||||||
|
# Broadcast the message update so the FE bubble picks up the new audio
|
||||||
|
# attachment immediately. Without this, the FE has to wait until Whisper
|
||||||
|
# finishes (or fall back to a page refresh) — and if Whisper returns blank,
|
||||||
|
# the bubble never gets the audio at all.
|
||||||
|
after_create_commit :broadcast_message_update_for_audio
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def enqueue_audio_transcription
|
def enqueue_audio_transcription
|
||||||
return unless file_type.to_sym == :audio
|
return unless file_type.to_sym == :audio
|
||||||
|
return unless file.attached?
|
||||||
|
|
||||||
Messages::AudioTranscriptionJob.perform_later(id)
|
Messages::AudioTranscriptionJob.perform_later(id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def broadcast_message_update_for_audio
|
||||||
|
return unless file_type.to_sym == :audio
|
||||||
|
return unless message
|
||||||
|
# Without an attached file, the message serializer's audio_metadata path
|
||||||
|
# dereferences `file.metadata[:width]` on nil and raises. The pre-attach
|
||||||
|
# broadcast wouldn't carry useful audio info anyway — skip until upload completes.
|
||||||
|
return unless file.attached?
|
||||||
|
|
||||||
|
message.reload.send_update_event
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ module Enterprise::Conversation
|
|||||||
super + %w[sla_policy_id]
|
super + %w[sla_policy_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Surface call lifecycle changes to the FE: writes to additional_attributes
|
||||||
|
# call_status/call_direction should rebroadcast conversation_updated.
|
||||||
|
def allowed_keys?
|
||||||
|
super || call_attributes_changed?
|
||||||
|
end
|
||||||
|
|
||||||
def with_captain_activity_context(reason:, reason_type:)
|
def with_captain_activity_context(reason:, reason_type:)
|
||||||
previous_reason = captain_activity_reason
|
previous_reason = captain_activity_reason
|
||||||
previous_reason_type = captain_activity_reason_type
|
previous_reason_type = captain_activity_reason_type
|
||||||
@@ -30,4 +36,13 @@ module Enterprise::Conversation
|
|||||||
def dispatch_captain_inference_event(event_name)
|
def dispatch_captain_inference_event(event_name)
|
||||||
dispatcher_dispatch(event_name)
|
dispatcher_dispatch(event_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def call_attributes_changed?
|
||||||
|
return false if previous_changes['additional_attributes'].blank?
|
||||||
|
|
||||||
|
# Compare before/after values for call keys — checking key presence alone
|
||||||
|
# rebroadcasts on any unrelated additional_attributes write once the keys exist.
|
||||||
|
before, after = previous_changes['additional_attributes']
|
||||||
|
%w[call_status call_direction].any? { |key| (before || {})[key] != (after || {})[key] }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
module Enterprise::Whatsapp::Providers::WhatsappCloudService
|
module Enterprise::Whatsapp::Providers::WhatsappCloudService
|
||||||
|
# Calls API + the call_permission_request interactive message both require Graph
|
||||||
|
# API v17+; OSS phone_id_path is locked at v13.0 for legacy /messages compatibility.
|
||||||
|
# Use the configured global version (defaulting to v22.0) for call-flow endpoints.
|
||||||
|
WHATSAPP_CALLING_API_VERSION_FALLBACK = 'v22.0'.freeze
|
||||||
|
|
||||||
def pre_accept_call(call_id, sdp_answer)
|
def pre_accept_call(call_id, sdp_answer)
|
||||||
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
|
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
|
||||||
end
|
end
|
||||||
@@ -17,7 +22,7 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
|
|||||||
|
|
||||||
def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body'))
|
def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body'))
|
||||||
response = HTTParty.post(
|
response = HTTParty.post(
|
||||||
"#{phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
|
"#{calls_phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
|
||||||
)
|
)
|
||||||
|
|
||||||
unless response.success?
|
unless response.success?
|
||||||
@@ -30,13 +35,19 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
|
|||||||
|
|
||||||
def initiate_call(to_phone_number, sdp_offer)
|
def initiate_call(to_phone_number, sdp_offer)
|
||||||
response = HTTParty.post(
|
response = HTTParty.post(
|
||||||
"#{phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
|
"#{calls_phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
|
||||||
)
|
)
|
||||||
process_initiate_call_response(response)
|
process_initiate_call_response(response)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def calls_phone_id_path
|
||||||
|
base = ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
|
||||||
|
version = GlobalConfigService.load('WHATSAPP_API_VERSION', WHATSAPP_CALLING_API_VERSION_FALLBACK)
|
||||||
|
"#{base}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}"
|
||||||
|
end
|
||||||
|
|
||||||
def call_action_body(call_id, action, sdp_answer = nil)
|
def call_action_body(call_id, action, sdp_answer = nil)
|
||||||
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
|
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
|
||||||
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
|
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
|
||||||
@@ -44,7 +55,7 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call_api(action_name, body)
|
def call_api(action_name, body)
|
||||||
url = "#{phone_id_path}/calls"
|
url = "#{calls_phone_id_path}/calls"
|
||||||
Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}"
|
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)
|
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?
|
Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success?
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
|||||||
parameters: {
|
parameters: {
|
||||||
model: WHISPER_MODEL,
|
model: WHISPER_MODEL,
|
||||||
file: file,
|
file: file,
|
||||||
temperature: 0.4
|
# 0.0 minimises Whisper's hallucinations on silence / sub-vocal segments —
|
||||||
|
# higher temperatures produce phantom phrases that look like real transcripts.
|
||||||
|
temperature: 0.0
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
transcribed_text = response['text']
|
transcribed_text = response['text']
|
||||||
|
|||||||
@@ -7,15 +7,30 @@ class Voice::CallMessageBuilder
|
|||||||
call.message || create_message!
|
call.message || create_message!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_status!(status:, agent: nil, duration_seconds: nil)
|
||||||
|
message = call.message
|
||||||
|
return unless message
|
||||||
|
|
||||||
|
patch = {
|
||||||
|
'status' => status&.to_s&.tr('_', '-'),
|
||||||
|
'accepted_by' => agent && { 'id' => agent.id, 'name' => agent.name },
|
||||||
|
'duration_seconds' => duration_seconds
|
||||||
|
}.compact
|
||||||
|
|
||||||
|
message.update!(content_attributes: (message.content_attributes || {}).deep_merge('data' => patch))
|
||||||
|
message
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :call
|
attr_reader :call
|
||||||
|
|
||||||
def create_message!
|
def create_message!
|
||||||
params = {
|
params = {
|
||||||
content: 'Voice Call',
|
content: I18n.t("conversations.messages.voice_call.#{call.provider}"),
|
||||||
message_type: call.outgoing? ? 'outgoing' : 'incoming',
|
message_type: call.outgoing? ? 'outgoing' : 'incoming',
|
||||||
content_type: 'voice_call'
|
content_type: 'voice_call',
|
||||||
|
content_attributes: { 'data' => build_data_payload }
|
||||||
}
|
}
|
||||||
Messages::MessageBuilder.new(sender, call.conversation, params).perform
|
Messages::MessageBuilder.new(sender, call.conversation, params).perform
|
||||||
end
|
end
|
||||||
@@ -23,4 +38,15 @@ class Voice::CallMessageBuilder
|
|||||||
def sender
|
def sender
|
||||||
call.outgoing? ? call.accepted_by_agent : call.contact
|
call.outgoing? ? call.accepted_by_agent : call.contact
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# call_source lets the FE disambiguate WhatsApp vs Twilio without re-fetching the Call.
|
||||||
|
def build_data_payload
|
||||||
|
{
|
||||||
|
'call_id' => call.id,
|
||||||
|
'call_sid' => call.provider_call_id,
|
||||||
|
'call_source' => call.provider,
|
||||||
|
'call_direction' => call.direction_label,
|
||||||
|
'status' => call.display_status
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
class Voice::InboundCallBuilder
|
class Voice::InboundCallBuilder
|
||||||
attr_reader :account, :inbox, :from_number, :call_sid
|
attr_reader :inbox, :from_number, :call_sid, :provider, :extra_meta
|
||||||
|
|
||||||
def self.perform!(account:, inbox:, from_number:, call_sid:)
|
def self.perform!(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
|
||||||
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform!
|
new(inbox: inbox, from_number: from_number, call_sid: call_sid,
|
||||||
|
provider: provider, extra_meta: extra_meta).perform!
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(account:, inbox:, from_number:, call_sid:)
|
def initialize(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
|
||||||
@account = account
|
|
||||||
@inbox = inbox
|
@inbox = inbox
|
||||||
@from_number = from_number
|
@from_number = from_number
|
||||||
@call_sid = call_sid
|
@call_sid = call_sid
|
||||||
|
@provider = provider.to_sym
|
||||||
|
@extra_meta = extra_meta || {}
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform!
|
def perform!
|
||||||
@@ -17,8 +19,8 @@ class Voice::InboundCallBuilder
|
|||||||
return existing if existing
|
return existing if existing
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
contact = ensure_contact!
|
contact_inbox = ensure_contact_inbox!
|
||||||
contact_inbox = ensure_contact_inbox!(contact)
|
contact = contact_inbox.contact
|
||||||
conversation = resolve_conversation!(contact, contact_inbox)
|
conversation = resolve_conversation!(contact, contact_inbox)
|
||||||
call = create_call!(contact, conversation)
|
call = create_call!(contact, conversation)
|
||||||
message = Voice::CallMessageBuilder.new(call).perform!
|
message = Voice::CallMessageBuilder.new(call).perform!
|
||||||
@@ -26,15 +28,30 @@ class Voice::InboundCallBuilder
|
|||||||
call
|
call
|
||||||
end
|
end
|
||||||
rescue ActiveRecord::RecordNotUnique
|
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
|
find_existing_call || raise
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def account
|
||||||
|
inbox.account
|
||||||
|
end
|
||||||
|
|
||||||
def find_existing_call
|
def find_existing_call
|
||||||
Call.where(account_id: account.id, inbox_id: inbox.id)
|
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
|
||||||
|
|
||||||
|
# Always look up by (inbox, source_id) first — that pair has a UNIQUE index, so
|
||||||
|
# creating with a colliding source_id under a different contact would raise
|
||||||
|
# RecordNotUnique. Reuse the existing ContactInbox (and its contact) when found.
|
||||||
|
def ensure_contact_inbox!
|
||||||
|
sid = source_id_for_provider
|
||||||
|
existing = inbox.contact_inboxes.find_by(source_id: sid)
|
||||||
|
return existing if existing
|
||||||
|
|
||||||
|
ContactInbox.create!(contact: ensure_contact!, inbox: inbox, source_id: sid)
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_contact!
|
def ensure_contact!
|
||||||
@@ -43,13 +60,9 @@ class Voice::InboundCallBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_contact_inbox!(contact)
|
# WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +.
|
||||||
ContactInbox.find_or_create_by!(
|
def source_id_for_provider
|
||||||
contact_id: contact.id,
|
provider == :whatsapp ? from_number.to_s.delete_prefix('+') : from_number
|
||||||
inbox_id: inbox.id
|
|
||||||
) do |record|
|
|
||||||
record.source_id = from_number
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_conversation!(contact, contact_inbox)
|
def resolve_conversation!(contact, contact_inbox)
|
||||||
@@ -76,13 +89,14 @@ class Voice::InboundCallBuilder
|
|||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
provider: :twilio,
|
provider: provider,
|
||||||
direction: :incoming,
|
direction: :incoming,
|
||||||
status: 'ringing',
|
status: 'ringing',
|
||||||
provider_call_id: call_sid,
|
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: call.default_conference_sid)
|
# `conference_sid` is a Twilio bridging concept; WhatsApp goes browser↔Meta.
|
||||||
|
call.update!(conference_sid: call.default_conference_sid) if call.twilio?
|
||||||
call
|
call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
class Whatsapp::CallPermissionReplyService
|
||||||
|
pattr_initialize [:inbox!, :params!]
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return unless inbox.channel.voice_enabled?
|
||||||
|
|
||||||
|
reply_data = extract_reply_data
|
||||||
|
return unless reply_data&.dig(:accepted)
|
||||||
|
|
||||||
|
conversation = find_requesting_conversation(reply_data[:context_id])
|
||||||
|
return unless conversation
|
||||||
|
|
||||||
|
clear_permission_flag(conversation)
|
||||||
|
broadcast_permission_granted(conversation.contact, conversation)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def extract_reply_data
|
||||||
|
message = params.dig(:entry, 0, :changes, 0, :value, :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, context_id: message.dig(:context, :id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Match the reply to the conversation whose request message it actually points
|
||||||
|
# at (interactive replies carry context.id = our outbound wamid). Recency-based
|
||||||
|
# lookup would broadcast to the wrong thread when a contact has multiple
|
||||||
|
# parallel pending requests.
|
||||||
|
def find_requesting_conversation(context_id)
|
||||||
|
return if context_id.blank?
|
||||||
|
|
||||||
|
inbox.conversations
|
||||||
|
.where.not(status: :resolved)
|
||||||
|
.where("additional_attributes ->> 'call_permission_request_message_id' = ?", context_id)
|
||||||
|
.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_permission_flag(conversation)
|
||||||
|
attrs = (conversation.additional_attributes || {}).except(
|
||||||
|
'call_permission_requested_at', 'call_permission_request_message_id'
|
||||||
|
)
|
||||||
|
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,102 @@
|
|||||||
|
class Whatsapp::CallService
|
||||||
|
pattr_initialize [:call!, :agent!, :sdp_answer]
|
||||||
|
|
||||||
|
def accept
|
||||||
|
raise Voice::CallErrors::CallFailed, 'sdp_answer is required' if sdp_answer.blank?
|
||||||
|
|
||||||
|
# All side effects under the lock so a concurrent terminate cannot finalize
|
||||||
|
# the call between status update and the message/conversation/broadcast writes.
|
||||||
|
call.with_lock do
|
||||||
|
transition_to_in_progress!
|
||||||
|
update_message_status('in_progress')
|
||||||
|
update_conversation_call_status(call.display_status)
|
||||||
|
broadcast(:accepted, accepted_by_agent_id: agent.id)
|
||||||
|
end
|
||||||
|
call
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject
|
||||||
|
call.with_lock do
|
||||||
|
next if call.terminal? || call.in_progress?
|
||||||
|
|
||||||
|
invoke_provider!(:reject_call)
|
||||||
|
finalize_call('failed')
|
||||||
|
end
|
||||||
|
call
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminate
|
||||||
|
call.with_lock do
|
||||||
|
next if call.terminal?
|
||||||
|
|
||||||
|
invoke_provider!(:terminate_call)
|
||||||
|
# Agent hangs up before contact picks up → no_answer; mirrors the webhook terminate path.
|
||||||
|
finalize_call(call.in_progress? ? 'completed' : 'no_answer')
|
||||||
|
end
|
||||||
|
call
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def transition_to_in_progress!
|
||||||
|
# Order matters: in_progress and terminal both make ringing? false, so we have to
|
||||||
|
# branch on in_progress? first to surface the distinct AlreadyAccepted state.
|
||||||
|
raise Voice::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if call.in_progress?
|
||||||
|
raise Voice::CallErrors::NotRinging, 'Call is not in ringing state' unless call.ringing?
|
||||||
|
|
||||||
|
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!
|
||||||
|
invoke_provider!(:pre_accept_call, sdp_answer)
|
||||||
|
invoke_provider!(:accept_call, sdp_answer)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI).
|
||||||
|
def claim_conversation_for_agent
|
||||||
|
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Raise on Meta failure (bool false or transport error) so callers bail before
|
||||||
|
# finalizing local state — otherwise we'd mark a still-active call as ended
|
||||||
|
# and broadcast voice_call.ended while Meta thinks it's live.
|
||||||
|
def invoke_provider!(method, *)
|
||||||
|
success = call.inbox.channel.provider_service.public_send(method, call.provider_call_id, *)
|
||||||
|
raise Voice::CallErrors::CallFailed, "Meta #{method} failed" unless success
|
||||||
|
rescue Voice::CallErrors::CallFailed
|
||||||
|
raise
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[WHATSAPP CALL] #{method} failed: #{e.class} #{e.message}"
|
||||||
|
raise Voice::CallErrors::CallFailed, "Meta #{method} failed"
|
||||||
|
end
|
||||||
|
|
||||||
|
def finalize_call(status)
|
||||||
|
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
|
||||||
|
call.update!(status: status, meta: meta)
|
||||||
|
update_message_status(status)
|
||||||
|
update_conversation_call_status(call.display_status)
|
||||||
|
broadcast(:ended, status: call.display_status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_message_status(status)
|
||||||
|
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: agent)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_conversation_call_status(status)
|
||||||
|
call.conversation.update!(
|
||||||
|
additional_attributes: (call.conversation.additional_attributes || {}).merge('call_status' => 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
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
class Whatsapp::IncomingCallService
|
||||||
|
pattr_initialize [:inbox!, :params!]
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return unless inbox.channel.voice_enabled?
|
||||||
|
|
||||||
|
Array(params[:calls]).each { |c| handle_event(c.with_indifferent_access) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def handle_event(payload)
|
||||||
|
case payload[:event]
|
||||||
|
when 'connect' then handle_connect(payload)
|
||||||
|
when 'terminate' then handle_terminate(payload)
|
||||||
|
else Rails.logger.warn "[WHATSAPP CALL] Unknown call event: #{payload[:event]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_connect(payload)
|
||||||
|
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
|
||||||
|
return create_inbound_call(payload) if call.nil?
|
||||||
|
return accept_outbound_call(call, payload) if call.outgoing?
|
||||||
|
|
||||||
|
Rails.logger.info "[WHATSAPP CALL] Duplicate inbound connect for #{payload[:id]}; ignoring"
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
|
Rails.logger.warn "[WHATSAPP CALL] Duplicate provider_call_id received: #{payload[:id]}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_inbound_call(payload)
|
||||||
|
sdp_offer = payload.dig(:session, :sdp)
|
||||||
|
call = Voice::InboundCallBuilder.perform!(
|
||||||
|
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
|
||||||
|
provider: :whatsapp,
|
||||||
|
extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
|
||||||
|
)
|
||||||
|
update_conversation(call)
|
||||||
|
broadcast_incoming(call, sdp_offer)
|
||||||
|
end
|
||||||
|
|
||||||
|
def accept_outbound_call(call, payload)
|
||||||
|
return if call.in_progress? || call.terminal?
|
||||||
|
|
||||||
|
# Pin setup:active so browsers don't renegotiate when Meta echoes actpass.
|
||||||
|
sdp_answer = payload.dig(:session, :sdp)&.gsub('a=setup:actpass', 'a=setup:active')
|
||||||
|
update_call!(call, 'in_progress',
|
||||||
|
started_at: Time.current,
|
||||||
|
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
|
||||||
|
broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_terminate(payload)
|
||||||
|
# Webhooks can arrive out of order (terminate before connect under tunnel/network
|
||||||
|
# delays). Materialise a missed-call record so the contact's "called and hung up"
|
||||||
|
# still surfaces in the dashboard instead of being silently dropped.
|
||||||
|
call = Call.whatsapp.find_by(provider_call_id: payload[:id]) || build_missed_inbound_call(payload)
|
||||||
|
return unless call
|
||||||
|
|
||||||
|
duration = payload[:duration]&.to_i
|
||||||
|
status = answered?(call, duration) ? 'completed' : 'no_answer'
|
||||||
|
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
|
||||||
|
update_call!(call, status, duration_seconds: duration, end_reason: payload[:terminate_reason], meta: meta)
|
||||||
|
broadcast(call, 'voice_call.ended', status: call.display_status, duration_seconds: call.duration_seconds)
|
||||||
|
end
|
||||||
|
|
||||||
|
# No connect was ever processed (webhook reordering or never delivered) — build a
|
||||||
|
# bare Call+Message for the contact so the UI shows the missed-call bubble.
|
||||||
|
def build_missed_inbound_call(payload)
|
||||||
|
Voice::InboundCallBuilder.perform!(
|
||||||
|
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
|
||||||
|
provider: :whatsapp,
|
||||||
|
extra_meta: { 'ice_servers' => Call.default_ice_servers }
|
||||||
|
)
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
|
Call.whatsapp.find_by(provider_call_id: payload[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
# accepted_by_agent_id is the initiating agent on outbound calls, so it only signals "answered" for inbound.
|
||||||
|
def answered?(call, duration)
|
||||||
|
call.in_progress? || duration.to_i.positive? || (call.incoming? && call.accepted_by_agent_id.present?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_call!(call, status, **attrs)
|
||||||
|
call.update!(status: status, **attrs)
|
||||||
|
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: call.accepted_by_agent,
|
||||||
|
duration_seconds: attrs[:duration_seconds])
|
||||||
|
update_conversation(call)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_conversation(call)
|
||||||
|
call.conversation.update!(
|
||||||
|
additional_attributes: (call.conversation.additional_attributes || {}).merge(
|
||||||
|
'call_status' => call.display_status, 'call_direction' => call.direction_label
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ring the assignee if assigned; otherwise account-wide so any agent can pick up.
|
||||||
|
def broadcast_incoming(call, sdp_offer)
|
||||||
|
contact = call.contact
|
||||||
|
token = call.conversation.assignee&.pubsub_token
|
||||||
|
broadcast(call, 'voice_call.incoming',
|
||||||
|
streams: token ? [token] : account_streams,
|
||||||
|
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 })
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast(call, event, streams: account_streams, **extra)
|
||||||
|
payload = { event: event, data: base_payload(call).merge(extra) }
|
||||||
|
streams.each { |s| ActionCable.server.broadcast(s, payload) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_streams
|
||||||
|
["account_#{inbox.account_id}"]
|
||||||
|
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
|
||||||
|
end
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
json.partial! 'api/v1/models/whatsapp_call', call: @call
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
json.status 'calling'
|
||||||
|
json.call_id @call.provider_call_id
|
||||||
|
json.id @call.id
|
||||||
|
json.message_id @message.id
|
||||||
|
json.provider 'whatsapp'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
json.id @call.id
|
||||||
|
json.status @call.display_status
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
json.partial! 'api/v1/models/whatsapp_call', call: @call
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
json.id @call.id
|
||||||
|
json.status @call.display_status
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
json.id @call.id
|
||||||
|
json.status @upload_status
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
contact = call.conversation&.contact
|
||||||
|
|
||||||
|
json.id call.id
|
||||||
|
json.call_id call.provider_call_id
|
||||||
|
json.provider call.provider
|
||||||
|
json.status call.display_status
|
||||||
|
json.direction call.direction_label
|
||||||
|
json.conversation_id call.conversation_id
|
||||||
|
json.inbox_id call.inbox_id
|
||||||
|
json.message_id call.message_id
|
||||||
|
json.accepted_by_agent_id call.accepted_by_agent_id
|
||||||
|
json.elapsed_seconds(call.started_at ? (Time.current - call.started_at).to_i : 0)
|
||||||
|
json.sdp_offer call.meta&.dig('sdp_offer')
|
||||||
|
json.ice_servers(call.meta&.dig('ice_servers') || Call.default_ice_servers)
|
||||||
|
|
||||||
|
if contact
|
||||||
|
json.caller do
|
||||||
|
json.name contact.name
|
||||||
|
json.phone contact.phone_number
|
||||||
|
json.avatar contact.avatar_url
|
||||||
|
end
|
||||||
|
else
|
||||||
|
json.caller({})
|
||||||
|
end
|
||||||
@@ -32,7 +32,6 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
|||||||
call.update!(conference_sid: call.default_conference_sid)
|
call.update!(conference_sid: call.default_conference_sid)
|
||||||
|
|
||||||
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
|
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
|
||||||
account: account,
|
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
from_number: from_number,
|
from_number: from_number,
|
||||||
call_sid: call_sid
|
call_sid: call_sid
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ RSpec.describe Voice::InboundCallBuilder do
|
|||||||
|
|
||||||
def perform_builder
|
def perform_builder
|
||||||
described_class.perform!(
|
described_class.perform!(
|
||||||
account: account,
|
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
from_number: from_number,
|
from_number: from_number,
|
||||||
call_sid: call_sid
|
call_sid: call_sid
|
||||||
@@ -87,6 +86,20 @@ RSpec.describe Voice::InboundCallBuilder do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when a ContactInbox already exists for the source_id (different contact)' do
|
||||||
|
let!(:original_contact) { create(:contact, account: account, phone_number: '+15550009999') }
|
||||||
|
let!(:original_contact_inbox) do
|
||||||
|
create(:contact_inbox, contact: original_contact, inbox: inbox, source_id: from_number)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reuses the existing ContactInbox instead of raising RecordNotUnique' do
|
||||||
|
call = perform_builder
|
||||||
|
|
||||||
|
expect(call.contact).to eq(original_contact)
|
||||||
|
expect(call.conversation.contact_inbox).to eq(original_contact_inbox)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when the inbox has lock_to_single_conversation enabled' do
|
context 'when the inbox has lock_to_single_conversation enabled' do
|
||||||
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
|
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Whatsapp::CallPermissionReplyService do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:channel) do
|
||||||
|
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
|
||||||
|
validate_provider_config: false, sync_templates: false)
|
||||||
|
end
|
||||||
|
let(:inbox) { channel.inbox }
|
||||||
|
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
|
||||||
|
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '15550001111') }
|
||||||
|
let(:request_wamid) { 'wamid.permission_request_abc' }
|
||||||
|
let!(:conversation) do
|
||||||
|
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open,
|
||||||
|
additional_attributes: {
|
||||||
|
'call_permission_requested_at' => Time.current.iso8601,
|
||||||
|
'call_permission_request_message_id' => request_wamid
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
|
||||||
|
channel.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def reply_params(response:, context_id: request_wamid)
|
||||||
|
interactive = { type: 'call_permission_reply',
|
||||||
|
call_permission_reply: { response: response, is_permanent: false } }
|
||||||
|
message = { from: '15550001111', type: 'interactive', interactive: interactive }
|
||||||
|
message[:context] = { id: context_id } if context_id
|
||||||
|
{ entry: [{ changes: [{ value: { messages: [message] } }] }] }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'clears both permission flags and broadcasts voice_call.permission_granted on accept' do
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform
|
||||||
|
|
||||||
|
attrs = conversation.reload.additional_attributes
|
||||||
|
expect(attrs).not_to include('call_permission_requested_at')
|
||||||
|
expect(attrs).not_to include('call_permission_request_message_id')
|
||||||
|
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||||
|
"account_#{account.id}",
|
||||||
|
hash_including(event: 'voice_call.permission_granted',
|
||||||
|
data: hash_including(conversation_id: conversation.id))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is a no-op when the contact rejected the request' do
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: reply_params(response: 'reject')).perform
|
||||||
|
|
||||||
|
expect(conversation.reload.additional_attributes).to include('call_permission_requested_at')
|
||||||
|
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is a no-op when calling is disabled on the channel' do
|
||||||
|
channel.provider_config = channel.provider_config.merge('calling_enabled' => false)
|
||||||
|
channel.save!
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform
|
||||||
|
|
||||||
|
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'matches the originating conversation by context.id when the contact has multiple pending requests' do
|
||||||
|
other_request_wamid = 'wamid.permission_request_xyz'
|
||||||
|
other_open = create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox,
|
||||||
|
status: :open,
|
||||||
|
additional_attributes: {
|
||||||
|
'call_permission_requested_at' => Time.current.iso8601,
|
||||||
|
'call_permission_request_message_id' => other_request_wamid
|
||||||
|
})
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: reply_params(response: 'accept', context_id: other_request_wamid)).perform
|
||||||
|
|
||||||
|
# The reply pointed at other_open's request — it should be the cleared one, not `conversation`
|
||||||
|
expect(other_open.reload.additional_attributes).not_to include('call_permission_request_message_id')
|
||||||
|
expect(conversation.reload.additional_attributes).to include('call_permission_request_message_id')
|
||||||
|
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||||
|
"account_#{account.id}",
|
||||||
|
hash_including(data: hash_including(conversation_id: other_open.id))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is a no-op when the reply has no context.id' do
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: reply_params(response: 'accept', context_id: nil)).perform
|
||||||
|
|
||||||
|
expect(conversation.reload.additional_attributes).to include('call_permission_request_message_id')
|
||||||
|
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Whatsapp::IncomingCallService do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:channel) do
|
||||||
|
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
|
||||||
|
validate_provider_config: false, sync_templates: false)
|
||||||
|
end
|
||||||
|
let(:inbox) { channel.inbox }
|
||||||
|
let(:from_number) { '15550001111' }
|
||||||
|
let(:provider_call_id) { 'wacid_abc' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
|
||||||
|
channel.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def call_payload(event:, **extra)
|
||||||
|
{ calls: [{ id: provider_call_id, from: from_number, event: event, **extra }] }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when calling is disabled on the channel' do
|
||||||
|
it 'is a no-op' do
|
||||||
|
channel.provider_config = channel.provider_config.merge('calling_enabled' => false)
|
||||||
|
channel.save!
|
||||||
|
|
||||||
|
expect { described_class.new(inbox: inbox, params: call_payload(event: 'connect')).perform }
|
||||||
|
.not_to change(Call, :count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'inbound connect' do
|
||||||
|
let(:sdp_offer) { "v=0\r\n...sdp..." }
|
||||||
|
|
||||||
|
it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
|
||||||
|
params = call_payload(event: 'connect', session: { sdp: sdp_offer, sdp_type: 'offer' })
|
||||||
|
expect { described_class.new(inbox: inbox, params: params).perform }
|
||||||
|
.to change(Call, :count).by(1).and change(Conversation, :count).by(1)
|
||||||
|
|
||||||
|
call = Call.last
|
||||||
|
expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing',
|
||||||
|
provider_call_id: provider_call_id)
|
||||||
|
expect(call.meta['sdp_offer']).to eq(sdp_offer)
|
||||||
|
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||||
|
"account_#{account.id}",
|
||||||
|
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'outbound connect (existing call)' do
|
||||||
|
let!(:call) do
|
||||||
|
conversation = create(:conversation, account: account, inbox: inbox)
|
||||||
|
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||||
|
provider: :whatsapp, direction: :outgoing, status: 'ringing', provider_call_id: provider_call_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'transitions the call to in_progress and broadcasts voice_call.outbound_connected with the SDP answer' do
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
sdp_answer = "v=0\r\na=setup:actpass\r\n"
|
||||||
|
|
||||||
|
params = call_payload(event: 'connect', session: { sdp: sdp_answer, sdp_type: 'answer' })
|
||||||
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
|
expect(call.reload).to have_attributes(status: 'in_progress', started_at: be_present)
|
||||||
|
expect(call.meta['sdp_answer']).to include('a=setup:active')
|
||||||
|
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||||
|
"account_#{account.id}",
|
||||||
|
hash_including(event: 'voice_call.outbound_connected')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'terminate' do
|
||||||
|
let!(:call) do
|
||||||
|
conversation = create(:conversation, account: account, inbox: inbox)
|
||||||
|
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||||
|
provider: :whatsapp, direction: :incoming, status: 'in_progress', provider_call_id: provider_call_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'marks the call completed when the call had been answered' do
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
|
||||||
|
params = call_payload(event: 'terminate', duration: 42, terminate_reason: 'completed_normally')
|
||||||
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
|
expect(call.reload).to have_attributes(status: 'completed', duration_seconds: 42, end_reason: 'completed_normally')
|
||||||
|
expect(call.ended_at).to be_present
|
||||||
|
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||||
|
"account_#{account.id}",
|
||||||
|
hash_including(event: 'voice_call.ended', data: hash_including(status: 'completed'))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'marks unanswered ringing calls as no_answer' do
|
||||||
|
call.update!(status: 'ringing')
|
||||||
|
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
expect(call.reload.status).to eq('no_answer')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'duplicate inbound connect' do
|
||||||
|
let!(:call) do
|
||||||
|
conversation = create(:conversation, account: account, inbox: inbox)
|
||||||
|
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||||
|
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: provider_call_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs and ignores rather than treating it as outbound' do
|
||||||
|
allow(Rails.logger).to receive(:info)
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
params = call_payload(event: 'connect', session: { sdp: 'sdp_x', sdp_type: 'offer' })
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
|
expect(call.reload).to have_attributes(status: 'ringing')
|
||||||
|
expect(Rails.logger).to have_received(:info).with(/Duplicate inbound connect/)
|
||||||
|
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'connect arriving after terminal status' do
|
||||||
|
let!(:call) do
|
||||||
|
conversation = create(:conversation, account: account, inbox: inbox)
|
||||||
|
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||||
|
provider: :whatsapp, direction: :outgoing, status: 'completed', provider_call_id: provider_call_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not reopen a completed outbound call' do
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
params = call_payload(event: 'connect', session: { sdp: 'late_sdp', sdp_type: 'answer' })
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
|
expect(call.reload.status).to eq('completed')
|
||||||
|
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'unanswered outbound call terminate' do
|
||||||
|
let!(:agent) { create(:user, account: account) }
|
||||||
|
let!(:call) do
|
||||||
|
conversation = create(:conversation, account: account, inbox: inbox)
|
||||||
|
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
|
||||||
|
provider: :whatsapp, direction: :outgoing, status: 'ringing',
|
||||||
|
accepted_by_agent: agent, provider_call_id: provider_call_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'marks the call as no_answer even though accepted_by_agent_id is set' do
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
|
||||||
|
|
||||||
|
described_class.new(inbox: inbox, params: params).perform
|
||||||
|
|
||||||
|
expect(call.reload.status).to eq('no_answer')
|
||||||
|
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||||
|
"account_#{account.id}",
|
||||||
|
hash_including(event: 'voice_call.ended', data: hash_including(status: 'no-answer'))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'unknown event' do
|
||||||
|
it 'logs a warning and does not raise' do
|
||||||
|
allow(Rails.logger).to receive(:warn)
|
||||||
|
params = call_payload(event: 'mystery')
|
||||||
|
|
||||||
|
expect { described_class.new(inbox: inbox, params: params).perform }.not_to raise_error
|
||||||
|
expect(Rails.logger).to have_received(:warn).with(/Unknown call event: mystery/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'multiple calls in one webhook payload' do
|
||||||
|
it 'processes every call in the array' do
|
||||||
|
allow(ActionCable.server).to receive(:broadcast)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
calls: [
|
||||||
|
{ id: 'wacid_a', from: from_number, event: 'connect', session: { sdp: 'sdp_a', sdp_type: 'offer' } },
|
||||||
|
{ id: 'wacid_b', from: '15550002222', event: 'connect', session: { sdp: 'sdp_b', sdp_type: 'offer' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect { described_class.new(inbox: inbox, params: params).perform }.to change(Call, :count).by(2)
|
||||||
|
expect(Call.where(provider_call_id: %w[wacid_a wacid_b]).pluck(:provider_call_id)).to contain_exactly('wacid_a', 'wacid_b')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user