1
0
forked from test/crm

fix: initating Call at app level and showing minimized call on sidebar

Handled incoming call UI and outgoing call UI
This commit is contained in:
Shariq Ansari 2023-08-22 16:41:30 +05:30
parent 3868f5dbe5
commit 50e5fae2b7
11 changed files with 621 additions and 280 deletions

View File

@ -1,7 +1,7 @@
from werkzeug.wrappers import Response
import json
import frappe
from twilio.rest import Client
from .twilio_handler import Twilio, IncomingCall
@frappe.whitelist()
@ -58,3 +58,24 @@ def twilio_incoming_call_handler(**kwargs):
resp = IncomingCall(args.From, args.To).process()
return Response(resp.to_xml(), mimetype='text/xml')
@frappe.whitelist(allow_guest=True)
def get_call_info(**kwargs):
"""This is a webhook called when the outgoing call status changes.
E.g. 'initiated' 'ringing', 'in-progress', 'completed' etc.
"""
args = frappe._dict(kwargs)
call_info = {
'ParentCallSid': args.ParentCallSid,
'CallSid': args.CallSid,
'CallStatus': args.CallStatus,
'CallDuration': args.CallDuration,
'From': args.From,
'To': args.To,
}
client = Twilio.get_twilio_client()
client.calls(args.ParentCallSid).user_defined_messages.create(
content=json.dumps(call_info)
)

View File

@ -70,8 +70,8 @@ class Twilio:
"""
return identity.replace('(at)', '@')
def get_recording_status_callback_url(self):
url_path = "/api/method/twilio_integration.twilio_integration.api.update_recording_info"
def get_call_status_callback_url(self):
url_path = "/api/method/crm.twilio.api.get_call_info"
return get_public_url(url_path)
def generate_twilio_dial_response(self, from_number: str, to_number: str):
@ -84,7 +84,12 @@ class Twilio:
# recording_status_callback=self.get_recording_status_callback_url(),
# recording_status_callback_event='completed'
)
dial.number(to_number)
dial.number(
to_number,
status_callback_event='initiated ringing answered completed',
status_callback=self.get_call_status_callback_url(),
status_callback_method='POST'
)
resp.append(dial)
return resp

View File

@ -2,7 +2,7 @@ from frappe.utils import get_url
def get_public_url(path: str=None):
return get_url(path)
return get_url().split(":8", 1)[0] + path
def merge_dicts(d1: dict, d2: dict):

View File

@ -1,11 +1,14 @@
<template>
<router-view v-if="$route.name == 'Login'" />
<DesktopLayout v-else-if="session().isLoggedIn">
<router-view />
</DesktopLayout>
<CallUI v-else-if="session().isLoggedIn">
<DesktopLayout>
<router-view />
</DesktopLayout>
</CallUI>
</template>
<script setup>
import DesktopLayout from '@/components/DesktopLayout.vue'
import { sessionStore as session } from '@/stores/session'
import CallUI from './components/CallUI.vue'
</script>

View File

@ -1,25 +1,33 @@
<template>
<div class="flex p-2">
<UserDropdown />
</div>
<div class="flex-1">
<nav class="space-y-0.5 px-2">
<NavLinks
:links="navigations"
class="flex items-center rounded px-2 py-1 text-gray-800 transition-all duration-300 ease-in-out"
active="bg-white shadow-sm"
inactive="hover:bg-gray-100"
>
<template v-slot="{ link }">
<div class="flex w-full items-center space-x-2">
<span class="grid h-5 w-6 place-items-center">
<component :is="link.icon" class="h-4.5 w-4.5 text-gray-700" />
</span>
<span class="text-base">{{ link.name }}</span>
</div>
</template>
</NavLinks>
</nav>
<div class="flex flex-col h-full justify-between">
<div>
<div class="flex p-2">
<UserDropdown />
</div>
<div class="flex-1">
<nav class="space-y-0.5 px-2">
<NavLinks
:links="navigations"
class="flex items-center rounded px-2 py-1 text-gray-800 transition-all duration-300 ease-in-out"
active="bg-white shadow-sm"
inactive="hover:bg-gray-100"
>
<template v-slot="{ link }">
<div class="flex w-full items-center space-x-2">
<span class="grid h-5 w-6 place-items-center">
<component
:is="link.icon"
class="h-4.5 w-4.5 text-gray-700"
/>
</span>
<span class="text-base">{{ link.name }}</span>
</div>
</template>
</NavLinks>
</nav>
</div>
</div>
<div id="call-area"></div>
</div>
</template>

View File

@ -0,0 +1,435 @@
<template>
<slot />
<Dialog
v-model="showPhoneCall"
:options="{
title: 'Make a call...',
actions: [{ label: 'Make a call...', variant: 'solid' }],
}"
>
<template #body-content>
<div>Make a call to +917666980887</div>
</template>
<template #actions="{ close }">
<div class="flex flex-row-reverse gap-2">
<Button
variant="solid"
label="Make a call..."
@click="makeOutgoingCall(close)"
/>
</div>
</template>
</Dialog>
<div
v-if="showCallPopup"
ref="callPopup"
class="fixed select-none z-10 bg-gray-900 rounded-lg shadow-lg p-4 flex flex-col w-60"
:style="style"
>
<div class="flex items-center flex-row-reverse gap-1">
<DragIcon1 ref="callPopopHandle" class="w-4 h-4 cursor-move text-white" />
<MinimizeIcon
class="w-4 h-4 text-white cursor-pointer"
@click="toggleCallWindow"
/>
</div>
<div class="flex flex-col justify-center items-center gap-2">
<UserAvatar
:user="getUser().name"
class="flex items-center justify-center !h-24 !w-24 relative"
:class="onCall || calling ? '' : 'pulse'"
/>
<div class="text-xl font-medium text-white">
{{ getUser().full_name }}
</div>
<div class="text-sm text-gray-500">+917666980887</div>
<div class="text-white text-base my-1">
{{
onCall
? '0:38'
: callStatus == 'ringing'
? 'Ringing...'
: callStatus == 'initiated' || callStatus == '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="rounded-full cursor-pointer" />
</template>
</Button>
<Button class="rounded-full bg-red-600 hover:bg-red-700">
<template #icon>
<PhoneIcon
class="text-white fill-white h-4 w-4 rotate-[135deg]"
@click="hangUpCall"
/>
</template>
</Button>
</div>
<div v-else-if="calling">
<Button
size="md"
variant="solid"
theme="red"
label="Cancel"
@click="cancelCall"
>
<template #prefix>
<PhoneIcon class="text-white fill-white h-4 w-4 rotate-[135deg]" />
</template>
</Button>
</div>
<div v-else class="flex gap-2 text-sm mt-2">
<Button
size="md"
variant="solid"
theme="green"
label="Accept"
@click="acceptIncomingCall"
>
<template #prefix>
<PhoneIcon class="text-white fill-white h-4 w-4" />
</template>
</Button>
<Button
size="md"
variant="solid"
theme="red"
label="Reject"
@click="rejectIncomingCall"
>
<template #prefix>
<PhoneIcon class="text-white fill-white h-4 w-4 rotate-[135deg]" />
</template>
</Button>
</div>
</div>
</div>
<Teleport v-if="showSmallCallWindow" to="#call-area">
<div
class="flex items-center justify-between p-1.5 gap-2 bg-gray-900 rounded m-2 cursor-pointer select-none"
@click="toggleCallWindow"
>
<div class="inline-flex items-center gap-1.5 truncate">
<UserAvatar
:user="getUser().name"
class="flex items-center justify-center"
/>
<div class="text-base font-medium text-white truncate">
Shariq Ansari
</div>
</div>
<div v-if="onCall" class="flex items-center gap-1.5">
<div class="text-white text-base my-1">0:38</div>
<Button variant="solid" theme="red" class="rounded-full !h-6 !w-6">
<template #icon>
<PhoneIcon
class="text-white fill-white h-3 w-3 rotate-[135deg]"
@click.stop="hangUpCall"
/>
</template>
</Button>
</div>
<div v-else-if="calling" class="flex items-center gap-1.5">
<div class="text-white text-base my-1">
{{ callStatus == 'ringing' ? 'Ringing...' : 'Calling...' }}
</div>
<Button
variant="solid"
theme="red"
class="rounded-full !h-6 !w-6"
@click.stop="cancelCall"
>
<template #icon>
<PhoneIcon class="text-white fill-white h-3 w-3 rotate-[135deg]" />
</template>
</Button>
</div>
<div v-else class="flex gap-1.5 text-sm">
<Button
variant="solid"
theme="green"
class="rounded-full !h-6 !w-6 pulse relative"
@click.stop="acceptIncomingCall"
>
<template #icon>
<PhoneIcon class="text-white fill-white h-3 w-3 animate-pulse" />
</template>
</Button>
<Button
variant="solid"
theme="red"
class="rounded-full !h-6 !w-6"
@click.stop="rejectIncomingCall"
>
<template #icon>
<PhoneIcon class="text-white fill-white h-3 w-3 rotate-[135deg]" />
</template>
</Button>
</div>
</div>
</Teleport>
</template>
<script setup>
import DragIcon1 from '@/components/Icons/DragIcon1.vue'
import MinimizeIcon from '@/components/Icons/MinimizeIcon.vue'
import DialpadIcon from '@/components/Icons/DialpadIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { Device } from '@twilio/voice-sdk'
import { useDraggable, useWindowSize } from '@vueuse/core'
import { usersStore } from '@/stores/users'
import { call } from 'frappe-ui'
import { onMounted, provide, ref, watch } from 'vue'
const { getUser } = usersStore()
let device = ''
let log = ref('Connecting...')
let _call = ref(null)
let showPhoneCall = ref(false)
let showCallPopup = ref(false)
let showSmallCallWindow = ref(false)
let onCall = ref(false)
let muted = ref(false)
let callPopup = ref(null)
let callPopopHandle = ref(null)
let calling = ref(false)
const { width, height } = useWindowSize()
let { style } = useDraggable(callPopup, {
initialValue: { x: width.value - 280, y: height.value - 310 },
handle: callPopopHandle,
preventDefault: true,
})
async function startupClient() {
log.value = 'Requesting Access Token...'
try {
const data = await call('crm.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('connect', (conn) => {
conn
debugger
log.value = 'Successfully established call!'
})
}
function toggleMute() {
if (_call.value.isMuted()) {
_call.value.mute(false)
muted.value = false
} else {
_call.value.mute()
muted.value = true
}
}
function handleIncomingCall(call) {
log.value = `Incoming call from ${call.parameters.From}`
showCallPopup.value = true
_call.value = call
// add event listener to call object
call.on('cancel', handleDisconnectedIncomingCall)
call.on('disconnect', handleDisconnectedIncomingCall)
call.on('reject', handleDisconnectedIncomingCall)
}
function acceptIncomingCall() {
_call.value.accept()
log.value = 'Accepted incoming call.'
onCall.value = true
}
function rejectIncomingCall() {
_call.value.reject()
log.value = 'Rejected incoming call'
showCallPopup.value = false
callStatus.value = ''
}
function hangUpCall() {
_call.value.disconnect()
log.value = 'Hanging up incoming call'
onCall.value = false
callStatus.value = ''
}
function handleDisconnectedIncomingCall() {
log.value = `Call ended.`
showCallPopup.value = false
_call.value = null
}
let callStatus = ref('')
async function makeOutgoingCall(close) {
close()
if (device) {
log.value = `Attempting to call +917666980887 ...`
try {
_call.value = await device.connect({
params: {
To: '+917666980887',
},
})
_call.value.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.`
showCallPopup.value = true
calling.value = false
onCall.value = true
}
})
_call.value.on('accept', () => {
log.value = `Initiated call!`
showCallPopup.value = true
calling.value = true
onCall.value = false
callStatus.value = 'calling'
})
_call.value.on('disconnect', () => {
log.value = `Call ended.`
calling.value = false
onCall.value = false
showCallPopup.value = false
_call.value = null
callStatus.value = ''
})
_call.value.on('cancel', () => {
log.value = `Call ended.`
calling.value = false
onCall.value = false
showCallPopup.value = false
_call.value = null
callStatus.value = ''
})
} catch (error) {
log.value = `Could not connect call: ${error.message}`
}
} else {
log.value = 'Unable to make call.'
}
}
function cancelCall() {
showCallPopup.value = false
calling.value = false
onCall.value = false
callStatus.value = ''
_call.value.disconnect()
}
function toggleCallWindow() {
showCallPopup.value = !showCallPopup.value
showSmallCallWindow.value = !showSmallCallWindow.value
}
onMounted(() => startupClient())
watch(
() => log.value,
(value) => {
console.log(value)
},
{ immediate: true }
)
provide('showPhoneCall', showPhoneCall)
</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

@ -10,7 +10,7 @@
<Tooltip text="Make a call..." class="m-1">
<PhoneIcon
class="bg-gray-900 rounded-full text-white fill-white p-[3px]"
@click.stop="openPhoneCallDialog"
@click.stop="showPhoneCall = true"
/>
</Tooltip>
</button>
@ -47,78 +47,6 @@
/>
</div>
</div>
<Dialog
v-model="showPhoneCall"
:options="{
title: 'Make a call...',
actions: [{ label: 'Make a call...', variant: 'solid' }],
}"
>
<template #body-content>
<div>Make a call to +917666980887</div>
</template>
<template #actions="{ close }">
<div class="flex flex-row-reverse gap-2">
<Button
variant="solid"
label="Make a call..."
@click="makeOutgoingCall"
/>
</div>
</template>
</Dialog>
<div
v-show="showIncomingCall"
ref="incomingCallPopup"
class="fixed select-none z-10 bg-white rounded-lg shadow-lg p-4 flex flex-col gap-4 w-60"
:style="style"
>
<div class="flex items-center justify-between">
<div>Incoming Call...</div>
<DragIcon ref="incomingCallHandle" class="w-4 h-4 cursor-move" />
</div>
<div class="flex flex-col justify-center items-center gap-2">
<UserAvatar
:user="getUser().name"
class="flex items-center justify-center !h-24 !w-24 relative pulse"
/>
<div class="text-xl font-medium">{{ getUser().full_name }}</div>
<div class="text-sm text-gray-500">+917666980887</div>
<div v-if="onCall" class="flex gap-2">
<Button :icon="muted ? 'mic-off' : 'mic'" @click="toggleMute" />
<Button
variant="solid"
theme="red"
icon="phone-off"
@click="rejectIncomingCall"
/>
</div>
<div v-else class="flex gap-2 text-sm mt-2">
<Button
size="md"
variant="solid"
theme="green"
label="Accept"
@click="acceptIncomingCall"
>
<template #prefix>
<PhoneIcon class="text-white fill-white h-4 w-4" />
</template>
</Button>
<Button
size="md"
variant="solid"
theme="red"
label="Reject"
@click="rejectIncomingCall"
>
<template #prefix>
<PhoneIcon class="text-white fill-white h-4 w-4 rotate-[135deg]" />
</template>
</Button>
</div>
</div>
</div>
</template>
<script setup>
@ -126,16 +54,15 @@ import UserAvatar from '@/components/UserAvatar.vue'
import EmailEditor from '@/components/EmailEditor.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import { usersStore } from '@/stores/users'
import { Tooltip, Dialog, call } from 'frappe-ui'
import { ref, watch, computed, defineModel } from 'vue'
import { Device } from '@twilio/voice-sdk'
import { useDraggable, useWindowSize } from '@vueuse/core'
import DragIcon from '@/components/Icons/DragIcon.vue'
import { Tooltip, call, Button } from 'frappe-ui'
import { ref, watch, computed, defineModel, inject } from 'vue'
const modelValue = defineModel()
const { getUser } = usersStore()
let showPhoneCall = inject('showPhoneCall')
const showCommunicationBox = ref(false)
const newEmail = ref('')
const newEmailEditor = ref(null)
@ -180,178 +107,7 @@ async function submitComment() {
modelValue.value.reload()
}
let device = ''
let log = ref('Connecting...')
let incomingCall = ref(null)
let showPhoneCall = ref(false)
let showIncomingCall = ref(false)
let onCall = ref(false)
let muted = ref(false)
let incomingCallPopup = ref(null)
let incomingCallHandle = ref(null)
const { width, height } = useWindowSize()
let { style } = useDraggable(incomingCallPopup, {
initialValue: { x: width.value - 280, y: height.value - 300 },
handle: incomingCallHandle,
preventDefault: true,
})
function openPhoneCallDialog() {
showPhoneCall.value = true
startupClient()
//
}
async function startupClient() {
log.value = 'Requesting Access Token...'
try {
const data = await call('crm.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('connect', (conn) => {
log.value = 'Successfully established call!'
})
}
function toggleMute() {
if (incomingCall.value.isMuted()) {
incomingCall.value.mute(false)
muted.value = false
} else {
incomingCall.value.mute()
muted.value = true
}
}
function handleIncomingCall(call) {
log.value = `Incoming call from ${call.parameters.From}`
showIncomingCall.value = true
incomingCall.value = call
// add event listener to call object
call.on('cancel', handleDisconnectedIncomingCall)
call.on('disconnect', handleDisconnectedIncomingCall)
call.on('reject', handleDisconnectedIncomingCall)
}
function acceptIncomingCall() {
incomingCall.value.accept()
log.value = 'Accepted incoming call.'
onCall.value = true
}
function rejectIncomingCall() {
incomingCall.value.reject()
log.value = 'Rejected incoming call'
showIncomingCall.value = false
}
function handleDisconnectedIncomingCall() {
log.value = `Call ended.`
showIncomingCall.value = false
incomingCall.value = null
}
async function makeOutgoingCall() {
if (device) {
log.value = `Attempting to call +917666980887 ...`
try {
const call = await device.connect({
params: {
To: '+917666980887',
},
})
} catch (error) {
log.value = `Could not connect call: ${error.message}`
}
} else {
log.value = 'Unable to make call.'
}
}
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

@ -0,0 +1,73 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 4.00002C8.36819 4.00002 8.66667 3.70154 8.66667 3.33335C8.66667 2.96516 8.36819 2.66669 8 2.66669C7.63181 2.66669 7.33334 2.96516 7.33334 3.33335C7.33334 3.70154 7.63181 4.00002 8 4.00002Z"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12.6667 4.00002C13.0349 4.00002 13.3333 3.70154 13.3333 3.33335C13.3333 2.96516 13.0349 2.66669 12.6667 2.66669C12.2985 2.66669 12 2.96516 12 3.33335C12 3.70154 12.2985 4.00002 12.6667 4.00002Z"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3.33333 4.00002C3.70152 4.00002 4 3.70154 4 3.33335C4 2.96516 3.70152 2.66669 3.33333 2.66669C2.96514 2.66669 2.66666 2.96516 2.66666 3.33335C2.66666 3.70154 2.96514 4.00002 3.33333 4.00002Z"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8 8.66665C8.36819 8.66665 8.66667 8.36817 8.66667 7.99998C8.66667 7.63179 8.36819 7.33331 8 7.33331C7.63181 7.33331 7.33334 7.63179 7.33334 7.99998C7.33334 8.36817 7.63181 8.66665 8 8.66665Z"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12.6667 8.66665C13.0349 8.66665 13.3333 8.36817 13.3333 7.99998C13.3333 7.63179 13.0349 7.33331 12.6667 7.33331C12.2985 7.33331 12 7.63179 12 7.99998C12 8.36817 12.2985 8.66665 12.6667 8.66665Z"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3.33333 8.66665C3.70152 8.66665 4 8.36817 4 7.99998C4 7.63179 3.70152 7.33331 3.33333 7.33331C2.96514 7.33331 2.66666 7.63179 2.66666 7.99998C2.66666 8.36817 2.96514 8.66665 3.33333 8.66665Z"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8 13.3333C8.36819 13.3333 8.66667 13.0349 8.66667 12.6667C8.66667 12.2985 8.36819 12 8 12C7.63181 12 7.33334 12.2985 7.33334 12.6667C7.33334 13.0349 7.63181 13.3333 8 13.3333Z"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12.6667 13.3333C13.0349 13.3333 13.3333 13.0349 13.3333 12.6667C13.3333 12.2985 13.0349 12 12.6667 12C12.2985 12 12 12.2985 12 12.6667C12 13.0349 12.2985 13.3333 12.6667 13.3333Z"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3.33333 13.3333C3.70152 13.3333 4 13.0349 4 12.6667C4 12.2985 3.70152 12 3.33333 12C2.96514 12 2.66666 12.2985 2.66666 12.6667C2.66666 13.0349 2.96514 13.3333 3.33333 13.3333Z"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.25 13C9.25 12.3096 9.80964 11.75 10.5 11.75C11.1904 11.75 11.75 12.3096 11.75 13C11.75 13.6904 11.1904 14.25 10.5 14.25C9.80964 14.25 9.25 13.6904 9.25 13ZM9.25 3C9.25 2.30964 9.80964 1.75 10.5 1.75C11.1904 1.75 11.75 2.30964 11.75 3C11.75 3.69036 11.1904 4.25 10.5 4.25C9.80964 4.25 9.25 3.69036 9.25 3ZM10.5 6.75C9.80964 6.75 9.25 7.30964 9.25 8C9.25 8.69036 9.80964 9.25 10.5 9.25C11.1904 9.25 11.75 8.69036 11.75 8C11.75 7.30964 11.1904 6.75 10.5 6.75ZM4.25 13C4.25 12.3096 4.8096 11.75 5.5 11.75C6.19036 11.75 6.75 12.3096 6.75 13C6.75 13.6904 6.19036 14.25 5.5 14.25C4.8096 14.25 4.25 13.6904 4.25 13ZM5.5 1.75C4.8096 1.75 4.25 2.30964 4.25 3C4.25 3.69036 4.8096 4.25 5.5 4.25C6.19036 4.25 6.75 3.69036 6.75 3C6.75 2.30964 6.19036 1.75 5.5 1.75ZM4.25 8C4.25 7.30964 4.8096 6.75 5.5 6.75C6.19036 6.75 6.75 7.30964 6.75 8C6.75 8.69036 6.19036 9.25 5.5 9.25C4.8096 9.25 4.25 8.69036 4.25 8Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,24 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.66667 9.33331H6.66667V13.3333"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M13.3333 6.66669H9.33333V2.66669"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>

View File

@ -9,7 +9,7 @@ readme = "README.md"
dynamic = ["version"]
dependencies = [
# "frappe~=15.0.0" # Installed and managed by bench.
"twilio==6.44.2"
"twilio==8.5.0"
]
[build-system]