fix: fix twilio contact viewing, note creation & linking code

This commit is contained in:
Shariq Ansari 2025-01-18 20:34:04 +05:30
parent e6d72146b5
commit cfae3101c5
5 changed files with 190 additions and 158 deletions

View File

@ -24,34 +24,51 @@ def set_default_calling_medium(medium):
@frappe.whitelist() @frappe.whitelist()
def create_and_add_note_to_call_log(call_sid, content): def add_note_to_call_log(call_sid, note):
"""Add note to call log based on call sid.""" """Add/Update note to call log based on call sid."""
note = frappe.get_doc( _note = None
{ if not note.get("name"):
"doctype": "FCRM Note", _note = frappe.get_doc(
"content": content, {
} "doctype": "FCRM Note",
).insert(ignore_permissions=True) "content": note.get("content"),
}
).insert(ignore_permissions=True)
call_log = frappe.get_cached_doc("CRM Call Log", call_sid)
call_log.link_with_reference_doc("FCRM Note", _note.name)
call_log.save(ignore_permissions=True)
else:
_note = frappe.set_value("FCRM Note", note.get("name"), "content", note.get("content"))
call_log = frappe.get_doc("CRM Call Log", call_sid) return _note
call_log.link_with_reference_doc("FCRM Note", note.name)
call_log.save(ignore_permissions=True)
@frappe.whitelist() @frappe.whitelist()
def create_and_add_task_to_call_log(call_sid, task): def add_task_to_call_log(call_sid, task):
"""Add task to call log based on call sid.""" """Add/Update task to call log based on call sid."""
_task = frappe.get_doc( _task = None
{ if not task.get("name"):
"doctype": "CRM Task", _task = frappe.get_doc(
"title": task.get("title"), {
"description": task.get("description"), "doctype": "CRM Task",
} "title": task.get("title"),
).insert(ignore_permissions=True) "description": task.get("description"),
}
).insert(ignore_permissions=True)
call_log = frappe.get_doc("CRM Call Log", call_sid)
call_log.link_with_reference_doc("CRM Task", _task.name)
call_log.save(ignore_permissions=True)
else:
_task = frappe.get_doc("CRM Task", task.get("name"))
_task.update(
{
"title": task.get("title"),
"description": task.get("description"),
}
)
_task.save(ignore_permissions=True)
call_log = frappe.get_doc("CRM Call Log", call_sid) return _task
call_log.link_with_reference_doc("CRM Task", _task.name)
call_log.save(ignore_permissions=True)
@frappe.whitelist() @frappe.whitelist()

View File

@ -4,8 +4,9 @@ import frappe
from frappe import _ from frappe import _
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from crm.integrations.api import get_contact_by_phone_number
from .twilio_handler import IncomingCall, Twilio, TwilioCallDetails from .twilio_handler import IncomingCall, Twilio, TwilioCallDetails
from .utils import parse_mobile_no
@frappe.whitelist() @frappe.whitelist()
@ -69,13 +70,31 @@ def twilio_incoming_call_handler(**kwargs):
def create_call_log(call_details: TwilioCallDetails): def create_call_log(call_details: TwilioCallDetails):
call_log = frappe.get_doc( details = call_details.to_dict()
{**call_details.to_dict(), "doctype": "CRM Call Log", "telephony_medium": "Twilio"}
) call_log = frappe.get_doc({**details, "doctype": "CRM Call Log", "telephony_medium": "Twilio"})
call_log.reference_docname, call_log.reference_doctype = get_lead_or_deal_from_number(call_log)
call_log.flags.ignore_permissions = True # link call log with lead/deal
call_log.save() contact_number = details.get("from") if details.get("type") == "Incoming" else details.get("to")
link(contact_number, call_log)
call_log.save(ignore_permissions=True)
frappe.db.commit() frappe.db.commit()
return call_log
def link(contact_number, call_log):
contact = get_contact_by_phone_number(contact_number)
if contact.get("name"):
doctype = "Contact"
docname = contact.get("name")
if contact.get("lead"):
doctype = "CRM Lead"
docname = contact.get("lead")
elif contact.get("deal"):
doctype = "CRM Deal"
docname = contact.get("deal")
call_log.link_with_reference_doc(doctype, docname)
def update_call_log(call_sid, status=None): def update_call_log(call_sid, status=None):
@ -84,18 +103,19 @@ def update_call_log(call_sid, status=None):
if not (twilio and frappe.db.exists("CRM Call Log", call_sid)): if not (twilio and frappe.db.exists("CRM Call Log", call_sid)):
return return
call_details = twilio.get_call_info(call_sid) try:
call_log = frappe.get_doc("CRM Call Log", call_sid) call_details = twilio.get_call_info(call_sid)
call_log.status = TwilioCallDetails.get_call_status(status or call_details.status) call_log = frappe.get_doc("CRM Call Log", call_sid)
call_log.duration = call_details.duration call_log.status = TwilioCallDetails.get_call_status(status or call_details.status)
call_log.start_time = get_datetime_from_timestamp(call_details.start_time) call_log.duration = call_details.duration
call_log.end_time = get_datetime_from_timestamp(call_details.end_time) call_log.start_time = get_datetime_from_timestamp(call_details.start_time)
if call_log.note and call_log.reference_docname: call_log.end_time = get_datetime_from_timestamp(call_details.end_time)
frappe.db.set_value("FCRM Note", call_log.note, "reference_doctype", call_log.reference_doctype) call_log.save(ignore_permissions=True)
frappe.db.set_value("FCRM Note", call_log.note, "reference_docname", call_log.reference_docname) frappe.db.commit()
call_log.flags.ignore_permissions = True return call_log
call_log.save() except Exception:
frappe.db.commit() frappe.log_error(title="Error while updating call record")
frappe.db.commit()
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@ -106,7 +126,7 @@ def update_recording_info(**kwargs):
call_sid = args.CallSid call_sid = args.CallSid
update_call_log(call_sid) update_call_log(call_sid)
frappe.db.set_value("CRM Call Log", call_sid, "recording_url", recording_url) frappe.db.set_value("CRM Call Log", call_sid, "recording_url", recording_url)
except: except Exception:
frappe.log_error(title=_("Failed to capture Twilio recording")) frappe.log_error(title=_("Failed to capture Twilio recording"))
@ -128,7 +148,7 @@ def update_call_status_info(**kwargs):
client = Twilio.get_twilio_client() client = Twilio.get_twilio_client()
client.calls(args.ParentCallSid).user_defined_messages.create(content=json.dumps(call_info)) client.calls(args.ParentCallSid).user_defined_messages.create(content=json.dumps(call_info))
except: except Exception:
frappe.log_error(title=_("Failed to update Twilio call status")) frappe.log_error(title=_("Failed to update Twilio call status"))
@ -144,45 +164,3 @@ def get_datetime_from_timestamp(timestamp):
system_timezone = frappe.utils.get_system_timezone() system_timezone = frappe.utils.get_system_timezone()
converted_datetime = datetime_utc_tz.astimezone(ZoneInfo(system_timezone)) converted_datetime = datetime_utc_tz.astimezone(ZoneInfo(system_timezone))
return frappe.utils.format_datetime(converted_datetime, "yyyy-MM-dd HH:mm:ss") return frappe.utils.format_datetime(converted_datetime, "yyyy-MM-dd HH:mm:ss")
@frappe.whitelist()
def add_note_to_call_log(call_sid, note):
"""Add note to call log. based on child call sid."""
twilio = Twilio.connect()
if not twilio:
return
call_details = twilio.get_call_info(call_sid)
sid = call_sid if call_details.direction == "inbound" else call_details.parent_call_sid
frappe.db.set_value("CRM Call Log", sid, "note", note)
frappe.db.commit()
def get_lead_or_deal_from_number(call):
"""Get lead/deal from the given number."""
def find_record(doctype, mobile_no, where=""):
mobile_no = parse_mobile_no(mobile_no)
query = f"""
SELECT name, mobile_no
FROM `tab{doctype}`
WHERE CONCAT('+', REGEXP_REPLACE(mobile_no, '[^0-9]', '')) = {mobile_no}
"""
data = frappe.db.sql(query + where, as_dict=True)
return data[0].name if data else None
doctype = "CRM Deal"
number = call.get("to") if call.type == "Outgoing" else call.get("from")
doc = find_record(doctype, number) or None
if not doc:
doctype = "CRM Lead"
doc = find_record(doctype, number, "AND converted is not True")
if not doc:
doc = find_record(doctype, number)
return doc, doctype

View File

@ -15,10 +15,3 @@ def merge_dicts(d1: dict, d2: dict):
""" """
return {k: {**v, **d2.get(k, {})} for k, v in d1.items()} return {k: {**v, **d2.get(k, {})} for k, v in d1.items()}
def parse_mobile_no(mobile_no: str):
"""Parse mobile number to remove spaces, brackets, etc.
>>> parse_mobile_no("+91 (766) 667 6666")
... "+917666676666"
"""
return "".join([c for c in mobile_no if c.isdigit() or c == "+"])

View File

@ -146,8 +146,8 @@
ref="content" ref="content"
editor-class="prose-sm h-[290px] text-ink-white overflow-auto mt-1" editor-class="prose-sm h-[290px] text-ink-white overflow-auto mt-1"
:bubbleMenu="true" :bubbleMenu="true"
:content="note" :content="note.content"
@change="(val) => (note = val)" @change="(val) => (note.content = val)"
:placeholder="__('Take a note...')" :placeholder="__('Take a note...')"
/> />
</div> </div>
@ -210,8 +210,21 @@
</template> </template>
</Button> </Button>
</div> </div>
<Button <Button
v-if="(note && note != '<p></p>') || task.title" v-if="(note.name || task.name) && dirty"
@click="update"
class="bg-surface-white !text-ink-gray-9 hover:!bg-surface-gray-3"
variant="solid"
:label="__('Update')"
size="md"
/>
<Button
v-else-if="
((note?.content && note.content != '<p></p>') || task.title) &&
!note.name &&
!task.name
"
@click="save" @click="save"
class="bg-surface-white !text-ink-gray-9 hover:!bg-surface-gray-3" class="bg-surface-white !text-ink-gray-9 hover:!bg-surface-gray-3"
variant="solid" variant="solid"
@ -234,8 +247,8 @@ import CountUpTimer from '@/components/CountUpTimer.vue'
import { createToast } from '@/utils' import { createToast } from '@/utils'
import { globalStore } from '@/stores/global' import { globalStore } from '@/stores/global'
import { useDraggable, useWindowSize } from '@vueuse/core' import { useDraggable, useWindowSize } from '@vueuse/core'
import { TextEditor, Avatar, Button, call, createResource } from 'frappe-ui' import { TextEditor, Avatar, Button, createResource } from 'frappe-ui'
import { ref, onBeforeUnmount, watch } from 'vue' import { ref, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const { $socket } = globalStore() const { $socket } = globalStore()
@ -289,7 +302,12 @@ const getContact = createResource({
}, },
}) })
const note = ref('') const dirty = ref(false)
const note = ref({
name: '',
content: '',
})
const showNote = ref(false) const showNote = ref(false)
@ -303,15 +321,25 @@ function showNoteWindow() {
} }
} }
function createNote() { function createUpdateNote() {
call('crm.integrations.api.create_and_add_note_to_call_log', { createResource({
call_sid: callData.value.CallSid, url: 'crm.integrations.api.add_note_to_call_log',
content: note.value, params: {
call_sid: callData.value.CallSid,
note: note.value,
},
auto: true,
onSuccess(_note) {
note.value['name'] = _note.name
nextTick(() => {
dirty.value = false
})
},
}) })
note.value = ''
} }
const task = ref({ const task = ref({
name: '',
title: '', title: '',
description: '', description: '',
assigned_to: '', assigned_to: '',
@ -332,21 +360,25 @@ function showTaskWindow() {
} }
} }
function createTask() { function createUpdateTask() {
call('crm.integrations.api.create_and_add_task_to_call_log', { createResource({
call_sid: callData.value.CallSid, url: 'crm.integrations.api.add_task_to_call_log',
task: task.value, params: {
call_sid: callData.value.CallSid,
task: task.value,
},
auto: true,
onSuccess(_task) {
task.value['name'] = _task.name
nextTick(() => {
dirty.value = false
})
},
}) })
task.value = {
title: '',
description: '',
assigned_to: '',
due_date: '',
status: 'Backlog',
priority: 'Low',
}
} }
watch([note, task], () => (dirty.value = true), { deep: true })
function updateWindowHeight(condition) { function updateWindowHeight(condition) {
let callPopup = callPopupHeader.value.parentElement let callPopup = callPopupHeader.value.parentElement
let top = parseInt(callPopup.style.top) let top = parseInt(callPopup.style.top)
@ -420,8 +452,12 @@ function openDealOrLead() {
function closeCallPopup() { function closeCallPopup() {
showCallPopup.value = false showCallPopup.value = false
showSmallCallPopup.value = false showSmallCallPopup.value = false
note.value = '' note.value = {
name: '',
content: '',
}
task.value = { task.value = {
name: '',
title: '', title: '',
description: '', description: '',
assigned_to: '', assigned_to: '',
@ -432,8 +468,13 @@ function closeCallPopup() {
} }
function save() { function save() {
if (note.value) createNote() if (note.value.content) createUpdateNote()
if (task.value.title) createTask() if (task.value.title) createUpdateTask()
}
function update() {
if (note.value.content) createUpdateNote()
if (task.value.title) createUpdateTask()
} }
const callDuration = ref('00:00') const callDuration = ref('00:00')

View File

@ -13,6 +13,7 @@
</div> </div>
<div class="flex flex-col items-center justify-center gap-3"> <div class="flex flex-col items-center justify-center gap-3">
<Avatar <Avatar
v-if="contact?.image"
:image="contact.image" :image="contact.image"
:label="contact.full_name" :label="contact.full_name"
class="relative flex !h-24 !w-24 items-center justify-center [&>div]:text-[30px]" class="relative flex !h-24 !w-24 items-center justify-center [&>div]:text-[30px]"
@ -20,9 +21,9 @@
/> />
<div class="flex flex-col items-center justify-center gap-1"> <div class="flex flex-col items-center justify-center gap-1">
<div class="text-xl font-medium"> <div class="text-xl font-medium">
{{ contact.full_name }} {{ contact?.full_name ?? __('Unknown') }}
</div> </div>
<div class="text-sm text-ink-gray-5">{{ contact.mobile_no }}</div> <div class="text-sm text-ink-gray-5">{{ contact?.mobile_no }}</div>
</div> </div>
<CountUpTimer ref="counterUp"> <CountUpTimer ref="counterUp">
<div v-if="onCall" class="my-1 text-base"> <div v-if="onCall" class="my-1 text-base">
@ -120,12 +121,13 @@
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Avatar <Avatar
v-if="contact?.image"
:image="contact.image" :image="contact.image"
:label="contact.full_name" :label="contact.full_name"
class="relative flex !h-5 !w-5 items-center justify-center" class="relative flex !h-5 !w-5 items-center justify-center"
/> />
<div class="max-w-[120px] truncate"> <div class="max-w-[120px] truncate">
{{ contact.full_name }} {{ contact?.full_name ?? __('Unknown') }}
</div> </div>
</div> </div>
<div v-if="onCall" class="flex items-center gap-2"> <div v-if="onCall" class="flex items-center gap-2">
@ -195,20 +197,13 @@ import CountUpTimer from '@/components/CountUpTimer.vue'
import NoteModal from '@/components/Modals/NoteModal.vue' import NoteModal from '@/components/Modals/NoteModal.vue'
import { Device } from '@twilio/voice-sdk' import { Device } from '@twilio/voice-sdk'
import { useDraggable, useWindowSize } from '@vueuse/core' import { useDraggable, useWindowSize } from '@vueuse/core'
import { contactsStore } from '@/stores/contacts'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { Avatar, call } from 'frappe-ui' import { Avatar, call, createResource } from 'frappe-ui'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
const { getContact, getLeadContact } = contactsStore()
let device = '' let device = ''
let log = ref('Connecting...') let log = ref('Connecting...')
let _call = null let _call = null
const contact = ref({
full_name: '',
mobile_no: '',
})
let showCallPopup = ref(false) let showCallPopup = ref(false)
let showSmallCallWindow = ref(false) let showSmallCallWindow = ref(false)
@ -218,8 +213,36 @@ let muted = ref(false)
let callPopup = ref(null) let callPopup = ref(null)
let counterUp = ref(null) let counterUp = ref(null)
let callStatus = ref('') let callStatus = ref('')
const phoneNumber = ref('')
const contact = ref({
full_name: '',
image: '',
mobile_no: '',
})
watch(phoneNumber, (value) => {
if (!value) return
getContact.fetch()
})
const getContact = createResource({
url: 'crm.integrations.api.get_contact_by_phone_number',
makeParams() {
return {
phone_number: phoneNumber.value,
}
},
cache: ['contact', phoneNumber.value],
onSuccess(data) {
contact.value = data
},
})
const showNoteModal = ref(false) const showNoteModal = ref(false)
const note = ref({ const note = ref({
name: '',
title: '', title: '',
content: '', content: '',
}) })
@ -227,9 +250,9 @@ const note = ref({
async function updateNote(_note, insert_mode = false) { async function updateNote(_note, insert_mode = false) {
note.value = _note note.value = _note
if (insert_mode && _note.name) { if (insert_mode && _note.name) {
await call('crm.integrations.twilio.api.add_note_to_call_log', { await call('crm.integrations.api.add_note_to_call_log', {
call_sid: _call.parameters.CallSid, call_sid: _call.parameters.CallSid,
note: _note.name, note: _note,
}) })
} }
} }
@ -298,19 +321,7 @@ function toggleMute() {
function handleIncomingCall(call) { function handleIncomingCall(call) {
log.value = `Incoming call from ${call.parameters.From}` log.value = `Incoming call from ${call.parameters.From}`
phoneNumber.value = 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 showCallPopup.value = true
_call = call _call = call
@ -352,6 +363,7 @@ function hangUpCall() {
callStatus.value = '' callStatus.value = ''
muted.value = false muted.value = false
note.value = { note.value = {
name: '',
title: '', title: '',
content: '', content: '',
} }
@ -373,19 +385,7 @@ function handleDisconnectedIncomingCall() {
} }
async function makeOutgoingCall(number) { async function makeOutgoingCall(number) {
// check if number has a country code phoneNumber.value = number
// 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) { if (device) {
log.value = `Attempting to call ${number} ...` log.value = `Attempting to call ${number} ...`
@ -431,6 +431,7 @@ async function makeOutgoingCall(number) {
muted.value = false muted.value = false
counterUp.value.stop() counterUp.value.stop()
note.value = { note.value = {
name: '',
title: '', title: '',
content: '', content: '',
} }
@ -445,6 +446,7 @@ async function makeOutgoingCall(number) {
callStatus.value = '' callStatus.value = ''
muted.value = false muted.value = false
note.value = { note.value = {
name: '',
title: '', title: '',
content: '', content: '',
} }
@ -471,6 +473,7 @@ function cancelCall() {
callStatus.value = '' callStatus.value = ''
muted.value = false muted.value = false
note.value = { note.value = {
name: '',
title: '', title: '',
content: '', content: '',
} }