feat: select calling medium while calling and set defaulr medium

This commit is contained in:
Shariq Ansari 2025-01-15 18:07:32 +05:30
parent 49de7c92a5
commit 8a912b21e0
10 changed files with 725 additions and 600 deletions

View File

@ -12,7 +12,9 @@
"brand_logo",
"favicon",
"dropdown_items_tab",
"dropdown_items"
"dropdown_items",
"calling_tab",
"default_calling_medium"
],
"fields": [
{
@ -56,12 +58,23 @@
"fieldname": "favicon",
"fieldtype": "Attach",
"label": "Favicon"
},
{
"fieldname": "calling_tab",
"fieldtype": "Tab Break",
"label": "Calling"
},
{
"fieldname": "default_calling_medium",
"fieldtype": "Select",
"label": "Default calling medium",
"options": "\nTwilio\nExotel"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-12-30 19:21:30.847343",
"modified": "2025-01-15 17:40:32.784762",
"modified_by": "Administrator",
"module": "FCRM",
"name": "FCRM Settings",

View File

19
crm/integrations/api.py Normal file
View File

@ -0,0 +1,19 @@
import frappe
@frappe.whitelist()
def is_call_integration_enabled():
twilio_enabled = frappe.db.get_single_value("Twilio Settings", "enabled")
exotel_enabled = frappe.db.get_single_value("CRM Exotel Settings", "enabled")
default_calling_medium = frappe.db.get_single_value("FCRM Settings", "default_calling_medium")
return {
"twilio_enabled": twilio_enabled,
"exotel_enabled": exotel_enabled,
"default_calling_medium": default_calling_medium,
}
@frappe.whitelist()
def set_default_calling_medium(medium):
return frappe.db.set_value("FCRM Settings", "FCRM Settings", "default_calling_medium", medium)

View File

@ -2,13 +2,11 @@
<div class="flex border-b pr-5">
<div id="app-header" class="flex-1"></div>
<div class="flex items-center justify-center">
<ExotelCallUI />
<CallUI />
</div>
</div>
</template>
<script setup>
import ExotelCallUI from '@/components/Telephony/ExotelCallUI.vue'
import CallUI from '@/components/Telephony/CallUI.vue'
</script>

View File

@ -12,12 +12,10 @@
<div id="app-header" class="flex-1" />
</div>
<CallUI class="mr-3 mt-2" />
<ExotelCallUI class="mr-3 mt-2" />
</template>
<script setup>
import MenuIcon from '@/components/Icons/MenuIcon.vue'
import ExotelCallUI from '@/components/Telephony/ExotelCallUI.vue'
import CallUI from '@/components/Telephony/CallUI.vue'
import { mobileSidebarOpened as sidebarOpened } from '@/composables/settings'
</script>

View File

@ -1,549 +1,135 @@
<template>
<div v-show="showCallPopup" v-bind="$attrs">
<div
ref="callPopup"
class="fixed z-20 flex w-60 cursor-move select-none flex-col rounded-lg bg-surface-gray-7 p-4 text-ink-gray-2 shadow-2xl"
:style="style"
>
<div class="flex flex-row-reverse items-center gap-1">
<MinimizeIcon
class="h-4 w-4 cursor-pointer"
@click="toggleCallWindow"
/>
</div>
<div class="flex flex-col items-center justify-center gap-3">
<Avatar
:image="contact.image"
:label="contact.full_name"
class="relative flex !h-24 !w-24 items-center justify-center [&>div]:text-[30px]"
:class="onCall || calling ? '' : 'pulse'"
/>
<div class="flex flex-col items-center justify-center gap-1">
<div class="text-xl font-medium">
{{ contact.full_name }}
</div>
<div class="text-sm text-ink-gray-5">{{ contact.mobile_no }}</div>
</div>
<CountUpTimer ref="counterUp">
<div v-if="onCall" class="my-1 text-base">
{{ counterUp?.updatedTime }}
</div>
</CountUpTimer>
<div v-if="!onCall" class="my-1 text-base">
{{
callStatus == 'initiating'
? __('Initiating call...')
: callStatus == 'ringing'
? __('Ringing...')
: calling
? __('Calling...')
: __('Incoming call...')
}}
</div>
<div v-if="onCall" class="flex gap-2">
<Button
:icon="muted ? 'mic-off' : 'mic'"
class="rounded-full"
@click="toggleMute"
/>
<!-- <Button class="rounded-full">
<template #icon>
<DialpadIcon class="cursor-pointer rounded-full" />
</template>
</Button> -->
<Button class="rounded-full">
<template #icon>
<NoteIcon
class="h-4 w-4 cursor-pointer rounded-full text-ink-gray-9"
@click="showNoteModal = true"
/>
</template>
</Button>
<Button class="rounded-full bg-surface-red-5 hover:bg-surface-red-6">
<template #icon>
<PhoneIcon
class="h-4 w-4 rotate-[135deg] fill-white text-ink-white"
@click="hangUpCall"
/>
</template>
</Button>
</div>
<div v-else-if="calling || callStatus == 'initiating'">
<Button
size="md"
variant="solid"
theme="red"
:label="__('Cancel')"
@click="cancelCall"
class="rounded-lg"
:disabled="callStatus == 'initiating'"
>
<template #prefix>
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
</template>
</Button>
</div>
<div v-else class="flex gap-2">
<Button
size="md"
variant="solid"
theme="green"
:label="__('Accept')"
class="rounded-lg"
@click="acceptIncomingCall"
>
<template #prefix>
<PhoneIcon class="h-4 w-4 fill-white" />
</template>
</Button>
<Button
size="md"
variant="solid"
theme="red"
:label="__('Reject')"
class="rounded-lg"
@click="rejectIncomingCall"
>
<template #prefix>
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
</template>
</Button>
</div>
</div>
</div>
</div>
<div
v-show="showSmallCallWindow"
class="ml-2 flex cursor-pointer select-none items-center justify-between gap-3 rounded-lg bg-surface-gray-7 px-2 py-[7px] text-base text-ink-gray-2"
@click="toggleCallWindow"
v-bind="$attrs"
<TwilioCallUI ref="twilio" />
<ExotelCallUI ref="exotel" />
<Dialog
v-model="show"
:options="{
title: __('Make call'),
actions: [
{
label: __('Call using {0}', [callMedium]),
variant: 'solid',
onClick: makeCallUsing,
},
],
}"
>
<div class="flex items-center gap-2">
<Avatar
:image="contact.image"
:label="contact.full_name"
class="relative flex !h-5 !w-5 items-center justify-center"
/>
<div class="max-w-[120px] truncate">
{{ contact.full_name }}
</div>
</div>
<div v-if="onCall" class="flex items-center gap-2">
<div class="my-1 min-w-[40px] text-center">
{{ counterUp?.updatedTime }}
</div>
<Button variant="solid" theme="red" class="!h-6 !w-6 rounded-full">
<template #icon>
<PhoneIcon
class="h-4 w-4 rotate-[135deg] fill-white"
@click.stop="hangUpCall"
<template #body-content>
<div class="flex flex-col gap-4">
<FormControl
type="text"
v-model="mobileNumber"
:label="__('Mobile Number')"
/>
<FormControl
type="select"
v-model="callMedium"
:label="__('Call Medium')"
:options="['Twilio', 'Exotel']"
/>
<div class="flex flex-col gap-1">
<FormControl
type="checkbox"
v-model="isDefaultMedium"
:label="__('Make {0} as default call medium', [callMedium])"
/>
</template>
</Button>
</div>
<div v-else-if="calling" class="flex items-center gap-3">
<div class="my-1">
{{ callStatus == 'ringing' ? __('Ringing...') : __('Calling...') }}
<div v-if="isDefaultMedium" class="text-sm text-ink-gray-4">
{{ __('You can change the default call medium from the settings') }}
</div>
</div>
</div>
<Button
variant="solid"
theme="red"
class="!h-6 !w-6 rounded-full"
@click.stop="cancelCall"
>
<template #icon>
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
</template>
</Button>
</div>
<div v-else class="flex items-center gap-2">
<Button
variant="solid"
theme="green"
class="pulse relative !h-6 !w-6 rounded-full"
@click.stop="acceptIncomingCall"
>
<template #icon>
<PhoneIcon class="h-4 w-4 animate-pulse fill-white" />
</template>
</Button>
<Button
variant="solid"
theme="red"
class="!h-6 !w-6 rounded-full"
@click.stop="rejectIncomingCall"
>
<template #icon>
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
</template>
</Button>
</div>
</div>
<NoteModal
v-model="showNoteModal"
:note="note"
doctype="CRM Call Log"
@after="updateNote"
/>
</template>
</Dialog>
</template>
<script setup>
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import MinimizeIcon from '@/components/Icons/MinimizeIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import CountUpTimer from '@/components/CountUpTimer.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import { Device } from '@twilio/voice-sdk'
import { useDraggable, useWindowSize } from '@vueuse/core'
import TwilioCallUI from '@/components/Telephony/TwilioCallUI.vue'
import ExotelCallUI from '@/components/Telephony/ExotelCallUI.vue'
import {
twilioEnabled,
exotelEnabled,
defaultCallingMedium,
} from '@/composables/settings'
import { globalStore } from '@/stores/global'
import { contactsStore } from '@/stores/contacts'
import { capture } from '@/telemetry'
import { Avatar, call } from 'frappe-ui'
import { onMounted, ref, watch } from 'vue'
import { createToast } from '@/utils'
import { FormControl, call } from 'frappe-ui'
import { ref, watch } from 'vue'
const { getContact, getLeadContact } = contactsStore()
const { setMakeCall, setTwilioEnabled } = globalStore()
const { setMakeCall } = globalStore()
let device = ''
let log = ref('Connecting...')
let _call = null
const contact = ref({
full_name: '',
mobile_no: '',
const twilio = ref(null)
const exotel = ref(null)
const callMedium = ref('Twilio')
const isDefaultMedium = ref(false)
const show = ref(false)
const mobileNumber = ref('')
function makeCall(number) {
if (
twilioEnabled.value &&
exotelEnabled.value &&
!defaultCallingMedium.value
) {
mobileNumber.value = number
show.value = true
return
}
callMedium.value = twilioEnabled.value ? 'Twilio' : 'Exotel'
if (defaultCallingMedium.value) {
callMedium.value = defaultCallingMedium.value
}
mobileNumber.value = number
makeCallUsing()
}
function makeCallUsing() {
if (isDefaultMedium.value && callMedium.value) {
setDefaultCallingMedium()
}
if (callMedium.value === 'Twilio') {
twilio.value.makeOutgoingCall(mobileNumber.value)
}
if (callMedium.value === 'Exotel') {
exotel.value.makeOutgoingCall(mobileNumber.value)
}
show.value = false
}
async function setDefaultCallingMedium() {
await call('crm.integrations.api.set_default_calling_medium', {
medium: callMedium.value,
})
defaultCallingMedium.value = callMedium.value
createToast({
title: __('Default calling medium set successfully to {0}', [
callMedium.value,
]),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
}
watch([twilioEnabled, exotelEnabled], ([twilioValue, exotelValue]) => {
if (twilioValue) {
twilio.value.setup()
callMedium.value = 'Twilio'
}
if (exotelValue) {
exotel.value.setup()
callMedium.value = 'Exotel'
}
if (twilioValue || exotelValue) {
callMedium.value = 'Twilio'
setMakeCall(makeCall)
}
})
let showCallPopup = ref(false)
let showSmallCallWindow = ref(false)
let onCall = ref(false)
let calling = ref(false)
let muted = ref(false)
let callPopup = ref(null)
let counterUp = ref(null)
let callStatus = ref('')
const showNoteModal = ref(false)
const note = ref({
title: '',
content: '',
})
async function updateNote(_note, insert_mode = false) {
note.value = _note
if (insert_mode && _note.name) {
await call('crm.integrations.twilio.api.add_note_to_call_log', {
call_sid: _call.parameters.CallSid,
note: _note.name,
})
}
}
const { width, height } = useWindowSize()
let { style } = useDraggable(callPopup, {
initialValue: { x: width.value - 280, y: height.value - 310 },
preventDefault: true,
})
async function is_twilio_enabled() {
return await call('crm.integrations.twilio.api.is_enabled')
}
async function startupClient() {
log.value = 'Requesting Access Token...'
try {
const data = await call('crm.integrations.twilio.api.generate_access_token')
log.value = 'Got a token.'
intitializeDevice(data.token)
} catch (err) {
log.value = 'An error occurred. ' + err.message
}
}
function intitializeDevice(token) {
device = new Device(token, {
codecPreferences: ['opus', 'pcmu'],
fakeLocalDTMF: true,
enableRingingState: true,
})
addDeviceListeners()
device.register()
}
function addDeviceListeners() {
device.on('registered', () => {
log.value = 'Ready to make and receive calls!'
})
device.on('unregistered', (device) => {
log.value = 'Logged out'
})
device.on('error', (error) => {
log.value = 'Twilio.Device Error: ' + error.message
})
device.on('incoming', handleIncomingCall)
device.on('tokenWillExpire', async () => {
const data = await call('crm.integrations.twilio.api.generate_access_token')
device.updateToken(data.token)
})
}
function toggleMute() {
if (_call.isMuted()) {
_call.mute(false)
muted.value = false
} else {
_call.mute()
muted.value = true
}
}
function handleIncomingCall(call) {
log.value = `Incoming call from ${call.parameters.From}`
// get name of the caller from the phone number
contact.value = getContact(call.parameters.From)
if (!contact.value) {
contact.value = getLeadContact(call.parameters.From)
}
if (!contact.value) {
contact.value = {
full_name: __('Unknown'),
mobile_no: call.parameters.From,
}
}
showCallPopup.value = true
_call = call
_call.on('accept', (conn) => {
console.log('conn', conn)
})
// add event listener to call object
call.on('cancel', handleDisconnectedIncomingCall)
call.on('disconnect', handleDisconnectedIncomingCall)
call.on('reject', handleDisconnectedIncomingCall)
}
async function acceptIncomingCall() {
log.value = 'Accepted incoming call.'
onCall.value = true
await _call.accept()
counterUp.value.start()
}
function rejectIncomingCall() {
_call.reject()
log.value = 'Rejected incoming call'
showCallPopup.value = false
if (showSmallCallWindow.value == undefined) {
showSmallCallWindow = false
} else {
showSmallCallWindow.value = false
}
callStatus.value = ''
muted.value = false
}
function hangUpCall() {
_call.disconnect()
log.value = 'Hanging up incoming call'
onCall.value = false
callStatus.value = ''
muted.value = false
note.value = {
title: '',
content: '',
}
counterUp.value.stop()
}
function handleDisconnectedIncomingCall() {
log.value = `Call ended from handle disconnected Incoming call.`
showCallPopup.value = false
if (showSmallCallWindow.value == undefined) {
showSmallCallWindow = false
} else {
showSmallCallWindow.value = false
}
_call = null
muted.value = false
onCall.value = false
counterUp.value.stop()
}
async function makeOutgoingCall(number) {
// check if number has a country code
// if (number?.replace(/[^0-9+]/g, '').length == 10) {
// $dialog({
// title: 'Invalid Mobile Number',
// message: `${number} is not a valid mobile number. Either add a country code or check the number again.`,
// })
// return
// }
contact.value = getContact(number)
if (!contact.value) {
contact.value = getLeadContact(number)
}
if (device) {
log.value = `Attempting to call ${number} ...`
try {
_call = await device.connect({
params: { To: number },
})
showCallPopup.value = true
callStatus.value = 'initiating'
capture('make_outgoing_call')
_call.on('messageReceived', (message) => {
let info = message.content
callStatus.value = info.CallStatus
log.value = `Call status: ${info.CallStatus}`
if (info.CallStatus == 'in-progress') {
log.value = `Call in progress.`
calling.value = false
onCall.value = true
counterUp.value.start()
}
})
_call.on('accept', () => {
log.value = `Initiated call!`
showCallPopup.value = true
calling.value = true
onCall.value = false
})
_call.on('disconnect', (conn) => {
log.value = `Call ended from makeOutgoing call disconnect.`
calling.value = false
onCall.value = false
showCallPopup.value = false
showSmallCallWindow = false
_call = null
callStatus.value = ''
muted.value = false
counterUp.value.stop()
note.value = {
title: '',
content: '',
}
})
_call.on('cancel', () => {
log.value = `Call ended from makeOutgoing call cancel.`
calling.value = false
onCall.value = false
showCallPopup.value = false
showSmallCallWindow = false
_call = null
callStatus.value = ''
muted.value = false
note.value = {
title: '',
content: '',
}
counterUp.value.stop()
})
} catch (error) {
log.value = `Could not connect call: ${error.message}`
}
} else {
log.value = 'Unable to make call.'
}
}
function cancelCall() {
_call.disconnect()
showCallPopup.value = false
if (showSmallCallWindow.value == undefined) {
showSmallCallWindow = false
} else {
showSmallCallWindow.value = false
}
calling.value = false
onCall.value = false
callStatus.value = ''
muted.value = false
note.value = {
title: '',
content: '',
}
}
function toggleCallWindow() {
showCallPopup.value = !showCallPopup.value
if (showSmallCallWindow.value == undefined) {
showSmallCallWindow = !showSmallCallWindow
} else {
showSmallCallWindow.value = !showSmallCallWindow.value
}
}
onMounted(async () => {
let enabled = await is_twilio_enabled()
setTwilioEnabled(enabled)
enabled && startupClient()
setMakeCall(makeOutgoingCall)
})
watch(
() => log.value,
(value) => {
console.log(value)
},
{ immediate: true }
)
</script>
<style scoped>
.pulse::before {
content: '';
position: absolute;
border: 1px solid green;
width: calc(100% + 20px);
height: calc(100% + 20px);
border-radius: 50%;
animation: pulse 1s linear infinite;
}
.pulse::after {
content: '';
position: absolute;
border: 1px solid green;
width: calc(100% + 20px);
height: calc(100% + 20px);
border-radius: 50%;
animation: pulse 1s linear infinite;
animation-delay: 0.3s;
}
@keyframes pulse {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1.3);
opacity: 0;
}
}
</style>

View File

@ -25,7 +25,7 @@
"
class="font-normal text-ink-gray-4 mr-1"
>
{{ callStatus }}
{{ __(callStatus) }}
</span>
<span>{{ phoneNumber }}</span>
<span
@ -43,35 +43,11 @@
}"
>
<span class="text-ink-gray-4"> · </span>
<span>{{ callStatus }}</span>
<span>{{ __(callStatus) }}</span>
<span v-if="callStatus == 'Call Ended'"> <span> · </span>00:38 </span>
</span>
</div>
</div>
<Dialog
v-model="showCallModal"
:options="{
title: 'Make a call',
actions: [
{
label: 'Make a Call',
variant: 'solid',
onClick: makeCall,
},
],
}"
>
<template #body-content>
<div>
<FormControl
v-model="phoneNumber"
label="Phone Number"
placeholder="+917666980887"
/>
</div>
</template>
</Dialog>
<div
v-show="showCallPopup"
ref="callPopup"
@ -163,14 +139,14 @@ import AvatarIcon from '@/components/Icons/AvatarIcon.vue'
import MinimizeIcon from '@/components/Icons/MinimizeIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import { Button, Dialog, FormControl, call, Avatar } from 'frappe-ui'
import { Avatar, Button, call } from 'frappe-ui'
import { globalStore } from '@/stores/global'
import { contactsStore } from '@/stores/contacts'
import { useDraggable, useWindowSize } from '@vueuse/core'
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { ref, computed, onBeforeUnmount } from 'vue'
const { getContact, getLeadContact } = contactsStore()
const { $socket, setMakeCall } = globalStore()
const { $socket } = globalStore()
const callPopup = ref(null)
const showCallPopup = ref(false)
@ -192,7 +168,6 @@ let { style } = useDraggable(callPopup, {
preventDefault: true,
})
const showCallModal = ref(false)
const callStatus = ref('')
const phoneNumber = ref('')
const callData = ref(null)
@ -211,9 +186,7 @@ const contact = computed(() => {
return _contact
})
const note = ref(
'This is a note for the call. This is a note for the call. This is a note for the call. This is a note for the call.',
)
const note = ref('')
const showNote = ref(false)
@ -227,7 +200,7 @@ function showNoteWindow() {
}
}
const task = ref('This is a task for the call. This is a task for the call.')
const task = ref('')
const showTask = ref(false)
@ -254,26 +227,18 @@ function updateWindowHeight(condition) {
callPopup.value.style.top = updatedTop + 'px'
}
function showMakeCallModal(number) {
showCallModal.value = true
function makeOutgoingCall(number) {
phoneNumber.value = number
}
function makeCall() {
showCallModal.value = false
callStatus.value = 'Calling...'
showCallPopup.value = true
showSmallCallPopup.value = false
call('crm.integrations.exotel.handler.make_a_call', {
to_number: phoneNumber.value,
})
}
onBeforeUnmount(() => {
$socket.off('exotel_call')
})
onMounted(() => {
function setup() {
$socket.on('exotel_call', (data) => {
callData.value = data
console.log(data)
@ -284,8 +249,10 @@ onMounted(() => {
showCallPopup.value = true
}
})
}
setMakeCall(showMakeCallModal)
onBeforeUnmount(() => {
$socket.off('exotel_call')
})
function closeCallPopup() {
@ -318,6 +285,12 @@ function updateStatus(data) {
(data.Status == 'completed' || data.Status == 'no-answer')
) {
return data.Status == 'no-answer' ? 'No Answer' : 'Call Ended'
} else if (
data.EventType == 'terminal' &&
data.Direction == 'outbound-api' &&
data.Status == 'busy'
) {
return 'No Answer'
}
// incoming call
@ -335,4 +308,6 @@ function updateStatus(data) {
return 'Call Ended'
}
}
defineExpose({ makeOutgoingCall, setup })
</script>

View File

@ -0,0 +1,537 @@
<template>
<div v-show="showCallPopup" v-bind="$attrs">
<div
ref="callPopup"
class="fixed z-20 flex w-60 cursor-move select-none flex-col rounded-lg bg-surface-gray-7 p-4 text-ink-gray-2 shadow-2xl"
:style="style"
>
<div class="flex flex-row-reverse items-center gap-1">
<MinimizeIcon
class="h-4 w-4 cursor-pointer"
@click="toggleCallWindow"
/>
</div>
<div class="flex flex-col items-center justify-center gap-3">
<Avatar
:image="contact.image"
:label="contact.full_name"
class="relative flex !h-24 !w-24 items-center justify-center [&>div]:text-[30px]"
:class="onCall || calling ? '' : 'pulse'"
/>
<div class="flex flex-col items-center justify-center gap-1">
<div class="text-xl font-medium">
{{ contact.full_name }}
</div>
<div class="text-sm text-ink-gray-5">{{ contact.mobile_no }}</div>
</div>
<CountUpTimer ref="counterUp">
<div v-if="onCall" class="my-1 text-base">
{{ counterUp?.updatedTime }}
</div>
</CountUpTimer>
<div v-if="!onCall" class="my-1 text-base">
{{
callStatus == 'initiating'
? __('Initiating call...')
: callStatus == 'ringing'
? __('Ringing...')
: calling
? __('Calling...')
: __('Incoming call...')
}}
</div>
<div v-if="onCall" class="flex gap-2">
<Button
:icon="muted ? 'mic-off' : 'mic'"
class="rounded-full"
@click="toggleMute"
/>
<!-- <Button class="rounded-full">
<template #icon>
<DialpadIcon class="cursor-pointer rounded-full" />
</template>
</Button> -->
<Button class="rounded-full">
<template #icon>
<NoteIcon
class="h-4 w-4 cursor-pointer rounded-full text-ink-gray-9"
@click="showNoteModal = true"
/>
</template>
</Button>
<Button class="rounded-full bg-surface-red-5 hover:bg-surface-red-6">
<template #icon>
<PhoneIcon
class="h-4 w-4 rotate-[135deg] fill-white text-ink-white"
@click="hangUpCall"
/>
</template>
</Button>
</div>
<div v-else-if="calling || callStatus == 'initiating'">
<Button
size="md"
variant="solid"
theme="red"
:label="__('Cancel')"
@click="cancelCall"
class="rounded-lg"
:disabled="callStatus == 'initiating'"
>
<template #prefix>
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
</template>
</Button>
</div>
<div v-else class="flex gap-2">
<Button
size="md"
variant="solid"
theme="green"
:label="__('Accept')"
class="rounded-lg"
@click="acceptIncomingCall"
>
<template #prefix>
<PhoneIcon class="h-4 w-4 fill-white" />
</template>
</Button>
<Button
size="md"
variant="solid"
theme="red"
:label="__('Reject')"
class="rounded-lg"
@click="rejectIncomingCall"
>
<template #prefix>
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
</template>
</Button>
</div>
</div>
</div>
</div>
<div
v-show="showSmallCallWindow"
class="ml-2 flex cursor-pointer select-none items-center justify-between gap-3 rounded-lg bg-surface-gray-7 px-2 py-[7px] text-base text-ink-gray-2"
@click="toggleCallWindow"
v-bind="$attrs"
>
<div class="flex items-center gap-2">
<Avatar
:image="contact.image"
:label="contact.full_name"
class="relative flex !h-5 !w-5 items-center justify-center"
/>
<div class="max-w-[120px] truncate">
{{ contact.full_name }}
</div>
</div>
<div v-if="onCall" class="flex items-center gap-2">
<div class="my-1 min-w-[40px] text-center">
{{ counterUp?.updatedTime }}
</div>
<Button variant="solid" theme="red" class="!h-6 !w-6 rounded-full">
<template #icon>
<PhoneIcon
class="h-4 w-4 rotate-[135deg] fill-white"
@click.stop="hangUpCall"
/>
</template>
</Button>
</div>
<div v-else-if="calling" class="flex items-center gap-3">
<div class="my-1">
{{ callStatus == 'ringing' ? __('Ringing...') : __('Calling...') }}
</div>
<Button
variant="solid"
theme="red"
class="!h-6 !w-6 rounded-full"
@click.stop="cancelCall"
>
<template #icon>
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
</template>
</Button>
</div>
<div v-else class="flex items-center gap-2">
<Button
variant="solid"
theme="green"
class="pulse relative !h-6 !w-6 rounded-full"
@click.stop="acceptIncomingCall"
>
<template #icon>
<PhoneIcon class="h-4 w-4 animate-pulse fill-white" />
</template>
</Button>
<Button
variant="solid"
theme="red"
class="!h-6 !w-6 rounded-full"
@click.stop="rejectIncomingCall"
>
<template #icon>
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
</template>
</Button>
</div>
</div>
<NoteModal
v-model="showNoteModal"
:note="note"
doctype="CRM Call Log"
@after="updateNote"
/>
</template>
<script setup>
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import MinimizeIcon from '@/components/Icons/MinimizeIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import CountUpTimer from '@/components/CountUpTimer.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import { Device } from '@twilio/voice-sdk'
import { useDraggable, useWindowSize } from '@vueuse/core'
import { contactsStore } from '@/stores/contacts'
import { capture } from '@/telemetry'
import { Avatar, call } from 'frappe-ui'
import { ref, watch } from 'vue'
const { getContact, getLeadContact } = contactsStore()
let device = ''
let log = ref('Connecting...')
let _call = null
const contact = ref({
full_name: '',
mobile_no: '',
})
let showCallPopup = ref(false)
let showSmallCallWindow = ref(false)
let onCall = ref(false)
let calling = ref(false)
let muted = ref(false)
let callPopup = ref(null)
let counterUp = ref(null)
let callStatus = ref('')
const showNoteModal = ref(false)
const note = ref({
title: '',
content: '',
})
async function updateNote(_note, insert_mode = false) {
note.value = _note
if (insert_mode && _note.name) {
await call('crm.integrations.twilio.api.add_note_to_call_log', {
call_sid: _call.parameters.CallSid,
note: _note.name,
})
}
}
const { width, height } = useWindowSize()
let { style } = useDraggable(callPopup, {
initialValue: { x: width.value - 280, y: height.value - 310 },
preventDefault: true,
})
async function startupClient() {
log.value = 'Requesting Access Token...'
try {
const data = await call('crm.integrations.twilio.api.generate_access_token')
log.value = 'Got a token.'
intitializeDevice(data.token)
} catch (err) {
log.value = 'An error occurred. ' + err.message
}
}
function intitializeDevice(token) {
device = new Device(token, {
codecPreferences: ['opus', 'pcmu'],
fakeLocalDTMF: true,
enableRingingState: true,
})
addDeviceListeners()
device.register()
}
function addDeviceListeners() {
device.on('registered', () => {
log.value = 'Ready to make and receive calls!'
})
device.on('unregistered', (device) => {
log.value = 'Logged out'
})
device.on('error', (error) => {
log.value = 'Twilio.Device Error: ' + error.message
})
device.on('incoming', handleIncomingCall)
device.on('tokenWillExpire', async () => {
const data = await call('crm.integrations.twilio.api.generate_access_token')
device.updateToken(data.token)
})
}
function toggleMute() {
if (_call.isMuted()) {
_call.mute(false)
muted.value = false
} else {
_call.mute()
muted.value = true
}
}
function handleIncomingCall(call) {
log.value = `Incoming call from ${call.parameters.From}`
// get name of the caller from the phone number
contact.value = getContact(call.parameters.From)
if (!contact.value) {
contact.value = getLeadContact(call.parameters.From)
}
if (!contact.value) {
contact.value = {
full_name: __('Unknown'),
mobile_no: call.parameters.From,
}
}
showCallPopup.value = true
_call = call
_call.on('accept', (conn) => {
console.log('conn', conn)
})
// add event listener to call object
call.on('cancel', handleDisconnectedIncomingCall)
call.on('disconnect', handleDisconnectedIncomingCall)
call.on('reject', handleDisconnectedIncomingCall)
}
async function acceptIncomingCall() {
log.value = 'Accepted incoming call.'
onCall.value = true
await _call.accept()
counterUp.value.start()
}
function rejectIncomingCall() {
_call.reject()
log.value = 'Rejected incoming call'
showCallPopup.value = false
if (showSmallCallWindow.value == undefined) {
showSmallCallWindow = false
} else {
showSmallCallWindow.value = false
}
callStatus.value = ''
muted.value = false
}
function hangUpCall() {
_call.disconnect()
log.value = 'Hanging up incoming call'
onCall.value = false
callStatus.value = ''
muted.value = false
note.value = {
title: '',
content: '',
}
counterUp.value.stop()
}
function handleDisconnectedIncomingCall() {
log.value = `Call ended from handle disconnected Incoming call.`
showCallPopup.value = false
if (showSmallCallWindow.value == undefined) {
showSmallCallWindow = false
} else {
showSmallCallWindow.value = false
}
_call = null
muted.value = false
onCall.value = false
counterUp.value.stop()
}
async function makeOutgoingCall(number) {
// check if number has a country code
// if (number?.replace(/[^0-9+]/g, '').length == 10) {
// $dialog({
// title: 'Invalid Mobile Number',
// message: `${number} is not a valid mobile number. Either add a country code or check the number again.`,
// })
// return
// }
contact.value = getContact(number)
if (!contact.value) {
contact.value = getLeadContact(number)
}
if (device) {
log.value = `Attempting to call ${number} ...`
try {
_call = await device.connect({
params: { To: number },
})
showCallPopup.value = true
callStatus.value = 'initiating'
capture('make_outgoing_call')
_call.on('messageReceived', (message) => {
let info = message.content
callStatus.value = info.CallStatus
log.value = `Call status: ${info.CallStatus}`
if (info.CallStatus == 'in-progress') {
log.value = `Call in progress.`
calling.value = false
onCall.value = true
counterUp.value.start()
}
})
_call.on('accept', () => {
log.value = `Initiated call!`
showCallPopup.value = true
calling.value = true
onCall.value = false
})
_call.on('disconnect', (conn) => {
log.value = `Call ended from makeOutgoing call disconnect.`
calling.value = false
onCall.value = false
showCallPopup.value = false
showSmallCallWindow = false
_call = null
callStatus.value = ''
muted.value = false
counterUp.value.stop()
note.value = {
title: '',
content: '',
}
})
_call.on('cancel', () => {
log.value = `Call ended from makeOutgoing call cancel.`
calling.value = false
onCall.value = false
showCallPopup.value = false
showSmallCallWindow = false
_call = null
callStatus.value = ''
muted.value = false
note.value = {
title: '',
content: '',
}
counterUp.value.stop()
})
} catch (error) {
log.value = `Could not connect call: ${error.message}`
}
} else {
log.value = 'Unable to make call.'
}
}
function cancelCall() {
_call.disconnect()
showCallPopup.value = false
if (showSmallCallWindow.value == undefined) {
showSmallCallWindow = false
} else {
showSmallCallWindow.value = false
}
calling.value = false
onCall.value = false
callStatus.value = ''
muted.value = false
note.value = {
title: '',
content: '',
}
}
function toggleCallWindow() {
showCallPopup.value = !showCallPopup.value
if (showSmallCallWindow.value == undefined) {
showSmallCallWindow = !showSmallCallWindow
} else {
showSmallCallWindow.value = !showSmallCallWindow.value
}
}
watch(
() => log.value,
(value) => {
console.log(value)
},
{ immediate: true },
)
defineExpose({ makeOutgoingCall, setup: startupClient })
</script>
<style scoped>
.pulse::before {
content: '';
position: absolute;
border: 1px solid green;
width: calc(100% + 20px);
height: calc(100% + 20px);
border-radius: 50%;
animation: pulse 1s linear infinite;
}
.pulse::after {
content: '';
position: absolute;
border: 1px solid green;
width: calc(100% + 20px);
height: calc(100% + 20px);
border-radius: 50%;
animation: pulse 1s linear infinite;
animation-delay: 0.3s;
}
@keyframes pulse {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1.3);
opacity: 0;
}
}
</style>

View File

@ -21,12 +21,18 @@ createResource({
})
export const callEnabled = ref(false)
export const twilioEnabled = ref(false)
export const exotelEnabled = ref(false)
export const defaultCallingMedium = ref('')
createResource({
url: 'crm.integrations.twilio.api.is_enabled',
cache: 'Is Twilio Enabled',
url: 'crm.integrations.api.is_call_integration_enabled',
cache: 'Is Call Integration Enabled',
auto: true,
onSuccess: (data) => {
callEnabled.value = Boolean(data)
twilioEnabled.value = Boolean(data.twilio_enabled)
exotelEnabled.value = Boolean(data.exotel_enabled)
defaultCallingMedium.value = data.default_calling_medium
callEnabled.value = twilioEnabled.value || exotelEnabled.value
},
})

View File

@ -5,13 +5,8 @@ export const globalStore = defineStore('crm-global', () => {
const app = getCurrentInstance()
const { $dialog, $socket } = app.appContext.config.globalProperties
let twilioEnabled = ref(false)
let callMethod = () => {}
function setTwilioEnabled(value) {
twilioEnabled.value = value
}
function setMakeCall(value) {
callMethod = value
}
@ -23,9 +18,7 @@ export const globalStore = defineStore('crm-global', () => {
return {
$dialog,
$socket,
twilioEnabled,
makeCall,
setTwilioEnabled,
setMakeCall,
}
})