feat: phone call (testing)
This commit is contained in:
parent
4f4c156ba4
commit
b3abc00e82
0
crm/crm/doctype/twilio_agents/__init__.py
Normal file
0
crm/crm/doctype/twilio_agents/__init__.py
Normal file
9
crm/crm/doctype/twilio_agents/test_twilio_agents.py
Normal file
9
crm/crm/doctype/twilio_agents/test_twilio_agents.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests.utils import FrappeTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestTwilioAgents(FrappeTestCase):
|
||||||
|
pass
|
||||||
8
crm/crm/doctype/twilio_agents/twilio_agents.js
Normal file
8
crm/crm/doctype/twilio_agents/twilio_agents.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("Twilio Agents", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
78
crm/crm/doctype/twilio_agents/twilio_agents.json
Normal file
78
crm/crm/doctype/twilio_agents/twilio_agents.json
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"autoname": "field:user",
|
||||||
|
"creation": "2023-08-17 19:59:56.239729",
|
||||||
|
"default_view": "List",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"user",
|
||||||
|
"user_name",
|
||||||
|
"call_receiving_device",
|
||||||
|
"column_break_ljne",
|
||||||
|
"twilio_number"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "user",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "User",
|
||||||
|
"options": "User",
|
||||||
|
"unique": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_ljne",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "twilio_number",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Twilio Number",
|
||||||
|
"options": "Phone"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "user.full_name",
|
||||||
|
"fieldname": "user_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "User Name",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "Computer",
|
||||||
|
"fieldname": "call_receiving_device",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Device",
|
||||||
|
"options": "Computer\nPhone"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2023-08-17 22:21:00.606384",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "CRM",
|
||||||
|
"name": "Twilio Agents",
|
||||||
|
"naming_rule": "By fieldname",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort_field": "modified",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": [],
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
||||||
9
crm/crm/doctype/twilio_agents/twilio_agents.py
Normal file
9
crm/crm/doctype/twilio_agents/twilio_agents.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class TwilioAgents(Document):
|
||||||
|
pass
|
||||||
0
crm/crm/doctype/twilio_settings/__init__.py
Normal file
0
crm/crm/doctype/twilio_settings/__init__.py
Normal file
9
crm/crm/doctype/twilio_settings/test_twilio_settings.py
Normal file
9
crm/crm/doctype/twilio_settings/test_twilio_settings.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests.utils import FrappeTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestTwilioSettings(FrappeTestCase):
|
||||||
|
pass
|
||||||
8
crm/crm/doctype/twilio_settings/twilio_settings.js
Normal file
8
crm/crm/doctype/twilio_settings/twilio_settings.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("Twilio Settings", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
76
crm/crm/doctype/twilio_settings/twilio_settings.json
Normal file
76
crm/crm/doctype/twilio_settings/twilio_settings.json
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"creation": "2023-08-17 18:38:02.655918",
|
||||||
|
"default_view": "List",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"account_sid",
|
||||||
|
"api_key",
|
||||||
|
"api_secret",
|
||||||
|
"column_break_idds",
|
||||||
|
"auth_token",
|
||||||
|
"twiml_sid"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "account_sid",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Account SID",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "api_key",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "API Key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "api_secret",
|
||||||
|
"fieldtype": "Password",
|
||||||
|
"label": "API Secret"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_idds",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "auth_token",
|
||||||
|
"fieldtype": "Password",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Auth Token",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "twiml_sid",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "TwiML SID"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"issingle": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2023-08-17 18:38:02.655918",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "CRM",
|
||||||
|
"name": "Twilio Settings",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort_field": "modified",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": [],
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
||||||
9
crm/crm/doctype/twilio_settings/twilio_settings.py
Normal file
9
crm/crm/doctype/twilio_settings/twilio_settings.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class TwilioSettings(Document):
|
||||||
|
pass
|
||||||
105
crm/twilio/api.py
Normal file
105
crm/twilio/api.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from twilio.rest import Client
|
||||||
|
from .twilio_handler import Twilio, IncomingCall
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def generate_access_token():
|
||||||
|
"""Returns access token that is required to authenticate Twilio Client SDK.
|
||||||
|
"""
|
||||||
|
twilio = Twilio.connect()
|
||||||
|
if not twilio:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
from_number = frappe.db.get_value('Twilio Agents', frappe.session.user, 'twilio_number')
|
||||||
|
if not from_number:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "caller_phone_identity_missing",
|
||||||
|
"detail": "Phone number is not mapped to the caller"
|
||||||
|
}
|
||||||
|
|
||||||
|
token=twilio.generate_voice_access_token(identity=frappe.session.user)
|
||||||
|
return {
|
||||||
|
'token': frappe.safe_decode(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
@frappe.whitelist(allow_guest=True)
|
||||||
|
def voice(**kwargs):
|
||||||
|
"""This is a webhook called by twilio to get instructions when the voice call request comes to twilio server.
|
||||||
|
"""
|
||||||
|
def _get_caller_number(caller):
|
||||||
|
identity = caller.replace('client:', '').strip()
|
||||||
|
user = Twilio.emailid_from_identity(identity)
|
||||||
|
return frappe.db.get_value('Twilio Agents', user, 'twilio_number')
|
||||||
|
|
||||||
|
args = frappe._dict(kwargs)
|
||||||
|
twilio = Twilio.connect()
|
||||||
|
if not twilio:
|
||||||
|
return
|
||||||
|
|
||||||
|
assert args.AccountSid == twilio.account_sid
|
||||||
|
assert args.ApplicationSid == twilio.application_sid
|
||||||
|
|
||||||
|
# Generate TwiML instructions to make a call
|
||||||
|
from_number = _get_caller_number(args.Caller)
|
||||||
|
resp = twilio.generate_twilio_dial_response(from_number, args.To)
|
||||||
|
|
||||||
|
# call_details = TwilioCallDetails(args, call_from=from_number)
|
||||||
|
# create_call_log(call_details)
|
||||||
|
return Response(resp.to_xml(), mimetype='text/xml')
|
||||||
|
|
||||||
|
@frappe.whitelist(allow_guest=True)
|
||||||
|
def twilio_incoming_call_handler(**kwargs):
|
||||||
|
args = frappe._dict(kwargs)
|
||||||
|
# call_details = TwilioCallDetails(args)
|
||||||
|
# create_call_log(call_details)
|
||||||
|
|
||||||
|
resp = IncomingCall(args.From, args.To).process()
|
||||||
|
return Response(resp.to_xml(), mimetype='text/xml')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# @frappe.whitelist(allow_guest=True)
|
||||||
|
# def twilio_incoming_call_handler(**kwargs):
|
||||||
|
# args = frappe._dict(kwargs)
|
||||||
|
# resp = VoiceResponse()
|
||||||
|
|
||||||
|
# resp.say("Thank you for calling! Have a great day.", voice='Polly.Amy')
|
||||||
|
|
||||||
|
# todo = frappe.get_doc({
|
||||||
|
# "doctype": "ToDo",
|
||||||
|
# "description": "Call from {0} to {1} is {2}".format(args.From, args.To, args.CallStatus),
|
||||||
|
# })
|
||||||
|
# todo.insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def make_call(to, from_='+13134748669'):
|
||||||
|
application_sid = 'APa7a85c103b7477c8eb25e9a8aafae055'
|
||||||
|
account_sid = 'AC1a65d630772fbdb3a9a977c46aacef61'
|
||||||
|
auth_token = '1eb29b621c6a60f4afdde18160bc1e2d'
|
||||||
|
client = Client(account_sid, auth_token)
|
||||||
|
|
||||||
|
call = client.calls.create(
|
||||||
|
url='http://demo.twilio.com/docs/voice.xml',
|
||||||
|
to=to,
|
||||||
|
from_=from_
|
||||||
|
)
|
||||||
|
|
||||||
|
print(call.sid)
|
||||||
184
crm/twilio/twilio_handler.py
Normal file
184
crm/twilio/twilio_handler.py
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import re
|
||||||
|
import json
|
||||||
|
from twilio.rest import Client as TwilioClient
|
||||||
|
from twilio.jwt.access_token import AccessToken
|
||||||
|
from twilio.jwt.access_token.grants import VoiceGrant
|
||||||
|
from twilio.twiml.voice_response import VoiceResponse, Dial
|
||||||
|
from .utils import get_public_url, merge_dicts
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
from frappe.utils.password import get_decrypted_password
|
||||||
|
|
||||||
|
class Twilio:
|
||||||
|
"""Twilio connector over TwilioClient.
|
||||||
|
"""
|
||||||
|
def __init__(self, settings):
|
||||||
|
"""
|
||||||
|
:param settings: `Twilio Settings` doctype
|
||||||
|
"""
|
||||||
|
self.settings = settings
|
||||||
|
self.account_sid = settings.account_sid
|
||||||
|
self.application_sid = settings.twiml_sid
|
||||||
|
self.api_key = settings.api_key
|
||||||
|
self.api_secret = settings.get_password("api_secret")
|
||||||
|
self.twilio_client = self.get_twilio_client()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def connect(self):
|
||||||
|
"""Make a twilio connection.
|
||||||
|
"""
|
||||||
|
settings = frappe.get_doc("Twilio Settings")
|
||||||
|
# if not (settings and settings.enabled):
|
||||||
|
# return
|
||||||
|
return Twilio(settings=settings)
|
||||||
|
|
||||||
|
def get_phone_numbers(self):
|
||||||
|
"""Get account's twilio phone numbers.
|
||||||
|
"""
|
||||||
|
numbers = self.twilio_client.incoming_phone_numbers.list()
|
||||||
|
return [n.phone_number for n in numbers]
|
||||||
|
|
||||||
|
def generate_voice_access_token(self, identity: str, ttl=60*60):
|
||||||
|
"""Generates a token required to make voice calls from the browser.
|
||||||
|
"""
|
||||||
|
# identity is used by twilio to identify the user uniqueness at browser(or any endpoints).
|
||||||
|
identity = self.safe_identity(identity)
|
||||||
|
|
||||||
|
# Create access token with credentials
|
||||||
|
token = AccessToken(self.account_sid, self.api_key, self.api_secret, identity=identity, ttl=ttl)
|
||||||
|
|
||||||
|
# Create a Voice grant and add to token
|
||||||
|
voice_grant = VoiceGrant(
|
||||||
|
outgoing_application_sid=self.application_sid,
|
||||||
|
incoming_allow=True, # Allow incoming calls
|
||||||
|
)
|
||||||
|
token.add_grant(voice_grant)
|
||||||
|
return token.to_jwt()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def safe_identity(cls, identity: str):
|
||||||
|
"""Create a safe identity by replacing unsupported special charaters `@` with (at)).
|
||||||
|
Twilio Client JS fails to make a call connection if identity has special characters like @, [, / etc)
|
||||||
|
https://www.twilio.com/docs/voice/client/errors (#31105)
|
||||||
|
"""
|
||||||
|
return identity.replace('@', '(at)')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def emailid_from_identity(cls, identity: str):
|
||||||
|
"""Convert safe identity string into emailID.
|
||||||
|
"""
|
||||||
|
return identity.replace('(at)', '@')
|
||||||
|
|
||||||
|
def get_recording_status_callback_url(self):
|
||||||
|
url_path = "/api/method/twilio_integration.twilio_integration.api.update_recording_info"
|
||||||
|
return get_public_url(url_path)
|
||||||
|
|
||||||
|
def generate_twilio_dial_response(self, from_number: str, to_number: str):
|
||||||
|
"""Generates voice call instructions to forward the call to agents Phone.
|
||||||
|
"""
|
||||||
|
resp = VoiceResponse()
|
||||||
|
dial = Dial(
|
||||||
|
caller_id=from_number,
|
||||||
|
record=self.settings.record_calls,
|
||||||
|
recording_status_callback=self.get_recording_status_callback_url(),
|
||||||
|
recording_status_callback_event='completed'
|
||||||
|
)
|
||||||
|
dial.number(to_number)
|
||||||
|
resp.append(dial)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def get_call_info(self, call_sid):
|
||||||
|
return self.twilio_client.calls(call_sid).fetch()
|
||||||
|
|
||||||
|
def generate_twilio_client_response(self, client, ring_tone='at'):
|
||||||
|
"""Generates voice call instructions to forward the call to agents computer.
|
||||||
|
"""
|
||||||
|
resp = VoiceResponse()
|
||||||
|
dial = Dial(
|
||||||
|
ring_tone=ring_tone,
|
||||||
|
record=self.settings.record_calls,
|
||||||
|
recording_status_callback=self.get_recording_status_callback_url(),
|
||||||
|
recording_status_callback_event='completed'
|
||||||
|
)
|
||||||
|
dial.client(client)
|
||||||
|
resp.append(dial)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_twilio_client(self):
|
||||||
|
twilio_settings = frappe.get_doc("Twilio Settings")
|
||||||
|
|
||||||
|
auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", 'auth_token')
|
||||||
|
client = TwilioClient(twilio_settings.account_sid, auth_token)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
class IncomingCall:
|
||||||
|
def __init__(self, from_number, to_number, meta=None):
|
||||||
|
self.from_number = from_number
|
||||||
|
self.to_number = to_number
|
||||||
|
self.meta = meta
|
||||||
|
|
||||||
|
def process(self):
|
||||||
|
"""Process the incoming call
|
||||||
|
* Figure out who is going to pick the call (call attender)
|
||||||
|
* Check call attender settings and forward the call to Phone
|
||||||
|
"""
|
||||||
|
twilio = Twilio.connect()
|
||||||
|
owners = get_twilio_number_owners(self.to_number)
|
||||||
|
attender = get_the_call_attender(owners)
|
||||||
|
|
||||||
|
if not attender:
|
||||||
|
resp = VoiceResponse()
|
||||||
|
resp.say(_('Agent is unavailable to take the call, please call after some time.'))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
if attender['call_receiving_device'] == 'Phone':
|
||||||
|
return twilio.generate_twilio_dial_response(self.from_number, attender['mobile_no'])
|
||||||
|
else:
|
||||||
|
return twilio.generate_twilio_client_response(twilio.safe_identity(attender['name']))
|
||||||
|
|
||||||
|
def get_twilio_number_owners(phone_number):
|
||||||
|
"""Get list of users who is using the phone_number.
|
||||||
|
>>> get_twilio_number_owners('+11234567890')
|
||||||
|
{
|
||||||
|
'owner1': {'name': '..', 'mobile_no': '..', 'call_receiving_device': '...'},
|
||||||
|
'owner2': {....}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
user_voice_settings = frappe.get_all(
|
||||||
|
'Twilio Agents',
|
||||||
|
filters={'twilio_number': phone_number},
|
||||||
|
fields=["name", "call_receiving_device"]
|
||||||
|
)
|
||||||
|
user_wise_voice_settings = {user['name']: user for user in user_voice_settings}
|
||||||
|
|
||||||
|
user_general_settings = frappe.get_all(
|
||||||
|
'User',
|
||||||
|
filters = [['name', 'IN', user_wise_voice_settings.keys()]],
|
||||||
|
fields = ['name', 'mobile_no']
|
||||||
|
)
|
||||||
|
user_wise_general_settings = {user['name']: user for user in user_general_settings}
|
||||||
|
|
||||||
|
return merge_dicts(user_wise_general_settings, user_wise_voice_settings)
|
||||||
|
|
||||||
|
def get_active_loggedin_users(users):
|
||||||
|
"""Filter the current loggedin users from the given users list
|
||||||
|
"""
|
||||||
|
rows = frappe.db.sql("""
|
||||||
|
SELECT `user`
|
||||||
|
FROM `tabSessions`
|
||||||
|
WHERE `user` IN %(users)s
|
||||||
|
""", {'users': users})
|
||||||
|
return [row[0] for row in set(rows)]
|
||||||
|
|
||||||
|
def get_the_call_attender(owners):
|
||||||
|
"""Get attender details from list of owners
|
||||||
|
"""
|
||||||
|
if not owners: return
|
||||||
|
current_loggedin_users = get_active_loggedin_users(list(owners.keys()))
|
||||||
|
for name, details in owners.items():
|
||||||
|
if ((details['call_receiving_device'] == 'Phone' and details['mobile_no']) or
|
||||||
|
(details['call_receiving_device'] == 'Computer' and name in current_loggedin_users)):
|
||||||
|
return details
|
||||||
16
crm/twilio/utils.py
Normal file
16
crm/twilio/utils.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from frappe.utils import get_url
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_url(path: str=None):
|
||||||
|
return get_url(path)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_dicts(d1: dict, d2: dict):
|
||||||
|
"""Merge dicts of dictionaries.
|
||||||
|
>>> merge_dicts(
|
||||||
|
{'name1': {'age': 20}, 'name2': {'age': 30}},
|
||||||
|
{'name1': {'phone': '+xxx'}, 'name2': {'phone': '+yyy'}, 'name3': {'phone': '+zzz'}}
|
||||||
|
)
|
||||||
|
... {'name1': {'age': 20, 'phone': '+xxx'}, 'name2': {'age': 30, 'phone': '+yyy'}}
|
||||||
|
"""
|
||||||
|
return {k:{**v, **d2.get(k, {})} for k, v in d1.items()}
|
||||||
@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/vue-3": "^2.0.4",
|
"@tiptap/vue-3": "^2.0.4",
|
||||||
|
"@twilio/voice-sdk": "^2.7.1",
|
||||||
"@vitejs/plugin-vue": "^4.2.3",
|
"@vitejs/plugin-vue": "^4.2.3",
|
||||||
"@vueuse/core": "^10.3.0",
|
"@vueuse/core": "^10.3.0",
|
||||||
"@vueuse/integrations": "^10.3.0",
|
"@vueuse/integrations": "^10.3.0",
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-w-[81.7%] bg-white pl-16 p-4 pt-2 z-20">
|
<div class="max-w-[81.7%] bg-white pl-16 p-4 pt-2 z-20">
|
||||||
<button
|
<button
|
||||||
class="flex w-full items-center rounded-lg bg-gray-100 px-2 py-2 text-left text-base text-gray-600 hover:bg-gray-200"
|
class="flex gap-2 w-full items-center rounded-lg p-1 bg-gray-100 hover:bg-gray-200"
|
||||||
@click="showCommunicationBox = true"
|
@click="showCommunicationBox = true"
|
||||||
v-show="!showCommunicationBox"
|
v-show="!showCommunicationBox"
|
||||||
>
|
>
|
||||||
<UserAvatar class="mr-3" :user="getUser().name" size="sm" />
|
<UserAvatar class="m-1" :user="getUser().name" size="sm" />
|
||||||
Add a reply...
|
<div class="flex-1 text-left text-base text-gray-600">Add a reply...</div>
|
||||||
|
<Tooltip text="Make a call..." class="m-1">
|
||||||
|
<PhoneIcon
|
||||||
|
class="bg-gray-900 rounded-full text-white fill-white p-[3px]"
|
||||||
|
@click.stop="makeOutgoingCall"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-show="showCommunicationBox"
|
v-show="showCommunicationBox"
|
||||||
@ -46,16 +52,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import EmailEditor from '@/components/EmailEditor.vue'
|
import EmailEditor from '@/components/EmailEditor.vue'
|
||||||
|
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { call } from 'frappe-ui'
|
import { Tooltip, call } from 'frappe-ui'
|
||||||
import { ref, watch, computed, defineModel } from 'vue'
|
import { ref, watch, computed, defineModel, onMounted } from 'vue'
|
||||||
|
import { Device } from '@twilio/voice-sdk'
|
||||||
|
|
||||||
const modelValue = defineModel()
|
const modelValue = defineModel()
|
||||||
|
|
||||||
const { getUser } = usersStore()
|
const { getUser } = usersStore()
|
||||||
|
|
||||||
const showCommunicationBox = ref(false)
|
const showCommunicationBox = ref(false)
|
||||||
const newEmail = ref('<p>Hi,<br><br>Gentle reminder!<br>We have a call at 3 - 5 PM today.<br><br>Thanks & Regards<br>Shariq Ansari</p>')
|
const newEmail = ref('')
|
||||||
const newEmailEditor = ref(null)
|
const newEmailEditor = ref(null)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -97,4 +105,85 @@ async function submitComment() {
|
|||||||
newEmail.value = ''
|
newEmail.value = ''
|
||||||
modelValue.value.reload()
|
modelValue.value.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countryCode = '91'
|
||||||
|
let device = ref('')
|
||||||
|
let currentNumber = ref('7666980887')
|
||||||
|
let muted = ref(false)
|
||||||
|
let onPhone = ref(false)
|
||||||
|
let log = ref('Connecting...')
|
||||||
|
let connection = ref(null)
|
||||||
|
|
||||||
|
onMounted(() => 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.value = new Device(token, {
|
||||||
|
codecPreferences: ['opus', 'pcmu'],
|
||||||
|
fakeLocalDTMF: true,
|
||||||
|
enableRingingState: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
addDeviceListeners()
|
||||||
|
|
||||||
|
device.value.register()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDeviceListeners() {
|
||||||
|
device.value.on('registered', () => {
|
||||||
|
log.value = 'Ready to make and receive calls!'
|
||||||
|
})
|
||||||
|
|
||||||
|
device.value.on('unregistered', (device) => {
|
||||||
|
onPhone.value = false
|
||||||
|
connection.value = null
|
||||||
|
log.value = 'Logged out'
|
||||||
|
})
|
||||||
|
|
||||||
|
device.value.on('error', (error) => {
|
||||||
|
log.value = 'Twilio.Device Error: ' + error.message
|
||||||
|
})
|
||||||
|
|
||||||
|
device.value.on('connect', (conn) => {
|
||||||
|
connection.value = conn
|
||||||
|
log.value = 'Successfully established call!'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeOutgoingCall() {
|
||||||
|
if (device.value) {
|
||||||
|
log.value = `Attempting to call +917666980887 ...`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const call = await device.value.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>
|
</script>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ readme = "README.md"
|
|||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
# "frappe~=15.0.0" # Installed and managed by bench.
|
# "frappe~=15.0.0" # Installed and managed by bench.
|
||||||
|
"twilio==6.44.2"
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
68
yarn.lock
68
yarn.lock
@ -2760,6 +2760,23 @@
|
|||||||
"@tiptap/extension-bubble-menu" "^2.0.4"
|
"@tiptap/extension-bubble-menu" "^2.0.4"
|
||||||
"@tiptap/extension-floating-menu" "^2.0.4"
|
"@tiptap/extension-floating-menu" "^2.0.4"
|
||||||
|
|
||||||
|
"@twilio/voice-errors@1.3.1":
|
||||||
|
version "1.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@twilio/voice-errors/-/voice-errors-1.3.1.tgz#24acb5cb1a5d17cde5114b1a9f3e98f3981a4dfe"
|
||||||
|
integrity sha512-CtozqXquzeUqYkYNus3aOEuS6G007UQK6a31QJYKa28j0tZl8ziwTKVSghj6oeRLlQB2btxiiSQzALMMEh2cug==
|
||||||
|
|
||||||
|
"@twilio/voice-sdk@^2.7.1":
|
||||||
|
version "2.7.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@twilio/voice-sdk/-/voice-sdk-2.7.1.tgz#14cd70a5a1ec19252221a7bca563376589d6bdd1"
|
||||||
|
integrity sha512-qZMUckrs5yDWJWLpGEFoR16yOUzod/kD4+Jayzhjhw35hl+5t/YZY0tMLpmJYkW24dUNRfnFvO5bFhpKXkcpmg==
|
||||||
|
dependencies:
|
||||||
|
"@twilio/voice-errors" "1.3.1"
|
||||||
|
"@types/md5" "2.3.2"
|
||||||
|
events "3.3.0"
|
||||||
|
loglevel "1.6.7"
|
||||||
|
md5 "2.3.0"
|
||||||
|
rtcpeerconnection-shim "1.2.8"
|
||||||
|
|
||||||
"@types/aria-query@^5.0.1":
|
"@types/aria-query@^5.0.1":
|
||||||
version "5.0.1"
|
version "5.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.1.tgz#3286741fb8f1e1580ac28784add4c7a1d49bdfbc"
|
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.1.tgz#3286741fb8f1e1580ac28784add4c7a1d49bdfbc"
|
||||||
@ -2901,6 +2918,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.196.tgz#a7c3d6fc52d8d71328b764e28e080b4169ec7a95"
|
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.196.tgz#a7c3d6fc52d8d71328b764e28e080b4169ec7a95"
|
||||||
integrity sha512-22y3o88f4a94mKljsZcanlNWPzO0uBsBdzLAngf2tp533LzZcQzb6+eZPJ+vCTt+bqF2XnvT9gejTLsAcJAJyQ==
|
integrity sha512-22y3o88f4a94mKljsZcanlNWPzO0uBsBdzLAngf2tp533LzZcQzb6+eZPJ+vCTt+bqF2XnvT9gejTLsAcJAJyQ==
|
||||||
|
|
||||||
|
"@types/md5@2.3.2":
|
||||||
|
version "2.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.3.2.tgz#529bb3f8a7e9e9f621094eb76a443f585d882528"
|
||||||
|
integrity sha512-v+JFDu96+UYJ3/UWzB0mEglIS//MZXgRaJ4ubUPwOM0gvLc/kcQ3TWNYwENEK7/EcXGQVrW8h/XqednSjBd/Og==
|
||||||
|
|
||||||
"@types/mdx@^2.0.0":
|
"@types/mdx@^2.0.0":
|
||||||
version "2.0.5"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.5.tgz#9a85a8f70c7c4d9e695a21d5ae5c93645eda64b1"
|
resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.5.tgz#9a85a8f70c7c4d9e695a21d5ae5c93645eda64b1"
|
||||||
@ -3694,6 +3716,11 @@ character-parser@^2.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-regex "^1.0.3"
|
is-regex "^1.0.3"
|
||||||
|
|
||||||
|
charenc@0.0.2:
|
||||||
|
version "0.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
|
||||||
|
integrity sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==
|
||||||
|
|
||||||
chokidar@^3.5.3:
|
chokidar@^3.5.3:
|
||||||
version "3.5.3"
|
version "3.5.3"
|
||||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
|
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
|
||||||
@ -3959,6 +3986,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.3:
|
|||||||
shebang-command "^2.0.0"
|
shebang-command "^2.0.0"
|
||||||
which "^2.0.1"
|
which "^2.0.1"
|
||||||
|
|
||||||
|
crypt@0.0.2:
|
||||||
|
version "0.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
|
||||||
|
integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==
|
||||||
|
|
||||||
crypto-random-string@^2.0.0:
|
crypto-random-string@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
|
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
|
||||||
@ -4366,6 +4398,11 @@ etag@~1.8.1:
|
|||||||
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
|
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
|
||||||
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
|
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
|
||||||
|
|
||||||
|
events@3.3.0:
|
||||||
|
version "3.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
|
||||||
|
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
|
||||||
|
|
||||||
execa@^5.0.0, execa@^5.1.1:
|
execa@^5.0.0, execa@^5.1.1:
|
||||||
version "5.1.1"
|
version "5.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
|
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
|
||||||
@ -5054,6 +5091,11 @@ is-boolean-object@^1.1.0:
|
|||||||
call-bind "^1.0.2"
|
call-bind "^1.0.2"
|
||||||
has-tostringtag "^1.0.0"
|
has-tostringtag "^1.0.0"
|
||||||
|
|
||||||
|
is-buffer@~1.1.6:
|
||||||
|
version "1.1.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
|
||||||
|
integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
|
||||||
|
|
||||||
is-callable@^1.1.3:
|
is-callable@^1.1.3:
|
||||||
version "1.2.7"
|
version "1.2.7"
|
||||||
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
|
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
|
||||||
@ -5603,6 +5645,11 @@ log-update@^4.0.0:
|
|||||||
slice-ansi "^4.0.0"
|
slice-ansi "^4.0.0"
|
||||||
wrap-ansi "^6.2.0"
|
wrap-ansi "^6.2.0"
|
||||||
|
|
||||||
|
loglevel@1.6.7:
|
||||||
|
version "1.6.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.7.tgz#b3e034233188c68b889f5b862415306f565e2c56"
|
||||||
|
integrity sha512-cY2eLFrQSAfVPhCgH1s7JI73tMbg9YC3v3+ZHVW67sBS7UxWzNEk/ZBbSfLykBWHp33dqqtOv82gjhKEi81T/A==
|
||||||
|
|
||||||
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
|
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||||
@ -5699,6 +5746,15 @@ markdown-to-jsx@^7.1.8:
|
|||||||
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.2.1.tgz#87061fd3176ad926ef3d99493e5c57f6335e0c51"
|
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.2.1.tgz#87061fd3176ad926ef3d99493e5c57f6335e0c51"
|
||||||
integrity sha512-9HrdzBAo0+sFz9ZYAGT5fB8ilzTW+q6lPocRxrIesMO+aB40V9MgFfbfMXxlGjf22OpRy+IXlvVaQenicdpgbg==
|
integrity sha512-9HrdzBAo0+sFz9ZYAGT5fB8ilzTW+q6lPocRxrIesMO+aB40V9MgFfbfMXxlGjf22OpRy+IXlvVaQenicdpgbg==
|
||||||
|
|
||||||
|
md5@2.3.0:
|
||||||
|
version "2.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f"
|
||||||
|
integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==
|
||||||
|
dependencies:
|
||||||
|
charenc "0.0.2"
|
||||||
|
crypt "0.0.2"
|
||||||
|
is-buffer "~1.1.6"
|
||||||
|
|
||||||
mdast-util-definitions@^4.0.0:
|
mdast-util-definitions@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz#c5c1a84db799173b4dcf7643cda999e440c24db2"
|
resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz#c5c1a84db799173b4dcf7643cda999e440c24db2"
|
||||||
@ -7066,6 +7122,13 @@ rope-sequence@^1.3.0:
|
|||||||
resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425"
|
resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425"
|
||||||
integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==
|
integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==
|
||||||
|
|
||||||
|
rtcpeerconnection-shim@1.2.8:
|
||||||
|
version "1.2.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.8.tgz#1d579d0f1d7aa8281c78d4ec9251017b04646e3a"
|
||||||
|
integrity sha512-5Sx90FGru1sQw9aGOM+kHU4i6mbP8eJPgxliu2X3Syhg8qgDybx8dpDTxUwfJvPnubXFnZeRNl59DWr4AttJKQ==
|
||||||
|
dependencies:
|
||||||
|
sdp "^2.6.0"
|
||||||
|
|
||||||
run-parallel@^1.1.9:
|
run-parallel@^1.1.9:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
|
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
|
||||||
@ -7107,6 +7170,11 @@ scheduler@^0.23.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
|
|
||||||
|
sdp@^2.6.0:
|
||||||
|
version "2.12.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/sdp/-/sdp-2.12.0.tgz#338a106af7560c86e4523f858349680350d53b22"
|
||||||
|
integrity sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw==
|
||||||
|
|
||||||
"semver@2 || 3 || 4 || 5", semver@^5.6.0:
|
"semver@2 || 3 || 4 || 5", semver@^5.6.0:
|
||||||
version "5.7.2"
|
version "5.7.2"
|
||||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
|
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user