File: //home/arjun/projects/buyercall_new/buyercall/buyercall/blueprints/phonenumbers/twilio_inbound.py
"""
Governs the logic of the incoming lead calls and lead/agent interations.
At any given moment the call is in one of the following states:
* 'NEW': New inbound call
* 'CALLBACK': Callback call, agent called first
* 'LEAD_ONHOLD': Lead selected a routing, placed on hold until agent answers
* 'AGENT_ONHOLD': In a scheduled callback, the agent is called first and placed
on hold.
* 'CALLBACK_PROMPT': Lead has been waiting for a while and is asked if he wants
to be called back.
* 'ONGOING': Agent answered/call ongoing.
* 'MISSED': Call ended/lead missed.
* 'CAPTURED': Call ended/lead captured.
"""
import traceback
import logging as log
import time
from datetime import datetime, timedelta
from flask import (
Blueprint,
current_app as app,
make_response,
request,
url_for,
)
from twilio.twiml.voice_response import VoiceResponse
from twilio.base.exceptions import TwilioRestException
import redis
from sqlalchemy.orm import load_only, joinedload
import pytz
from buyercall.blueprints.block_numbers.models import Block
from buyercall.blueprints.phonenumbers.models import Audio, HoldMusic
from buyercall.extensions import csrf, db
from buyercall.lib.flask_mailplus import _try_renderer_template
from buyercall.lib.util_ses_email import send_ses_email
from buyercall.lib.util_twilio import (
CallStorage, subaccount_client, InboundCallState as State,
update_response_time, BusyAgents
)
from buyercall.lib.util_webhooks import WebhookUtil
from .models import Phone
from ..leads.models import Lead
from ..agents.models import Agent
from ..user.models import User
from ..partnership.models import Partnership, PartnershipAccount
from .routing import (
get_routing_agents, schedule_callback,
get_agent_number, get_agent_text
)
webhooker = WebhookUtil()
twilio_inbound = Blueprint(
'twilio_inbound', __name__, template_folder='templates'
)
HOURS = 3600
""" The length of an hour in seconds """
DAYS = 86400
""" The length of a day in seconds """
TWIMLET_CLASSICAL = 'http://twimlets.com/holdmusic?Bucket=com.twilio.music.classical' # noqa
""" The URL for the Twimlet with some nice hold music """
CALLBACK_PROMPT_DELAY = 60
""" Seconds to wait until prompting the user for callback """
DEFAULT_VOICE = 'alice'
""" The voice to use when speaking a message for the lead """
AGENT_TIMEOUT = 18
""" The agent calls will be timed out/dropped in the seconds specified if no answer from agent """
@twilio_inbound.route('/twiml/inbound/<int:inbound_id>', methods=['POST'])
@csrf.exempt
def lead_inbound_call(inbound_id):
""" Entry point for the incoming lead call.
"""
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
# Check first if the phone number that's calling is not blocked
is_number_blocked = Block.blocked(inbound_id, request.form.get('From', ''))
if is_number_blocked:
log.info(
'This call was not processed because phone number: {} was blocked'.format(request.form.get('From', '')))
r = VoiceResponse()
r.reject()
return str(r)
inbound = Phone.query.options(
joinedload(Phone.partnership_account).joinedload(
PartnershipAccount.subscription,
)
).filter(Phone.id == inbound_id).first()
if inbound is None:
app.logger.error('Cannot find inbound route {} in the database!'.format(
inbound_id
))
r = VoiceResponse()
r.reject()
return str(r)
# Check if the account has been disabled; if so, reject the call
subscription = inbound.partnership_account.subscription
if not subscription:
subscription = inbound.partnership_account.partnership.subscription
if subscription.cancelled_subscription_on or subscription.usage_over_limit:
r = VoiceResponse()
r.reject()
return str(r)
caller_name = request.form.get('CallerName', '').lower()
phone_number = request.form.get('From', '')
app.logger.info("The caller's phone number is {}".format(phone_number))
first_name, last_name = Lead.get_last_known_name(
inbound.partnership_account_id, phone_number
)
previous_lead = Lead.query.filter(
phone_number == Lead.phonenumber, inbound.partnership_account_id == Lead.partnership_account_id
).first()
if previous_lead is not None:
contact_id = previous_lead.contact_id
caller_id = previous_lead.caller_id
progress_status = previous_lead.progress_status
else:
contact_id = None
caller_id = ''
progress_status = 'no status'
if caller_id == '':
caller_id = caller_name
lead = Lead(
partnership_account_id=inbound.partnership_account_id,
firstname=first_name,
lastname=last_name,
caller_id=caller_id,
phonenumber=phone_number,
email='',
starttime=datetime.utcnow(),
call_sid=request.form.get('CallSid', ''),
call_type='inbound',
my_phone=inbound.phonenumber,
inbound_id=inbound_id,
status='ringing',
contact_id=contact_id,
call_source=inbound.friendly_name,
originating_number=phone_number,
progress_status=progress_status
)
db.session.add(lead)
db.session.commit()
# Add contact based on new lead using celery
from .tasks import add_tw_contact
add_tw_contact(phone_number, first_name, last_name, caller_id, inbound.partnership_account_id)
call_sid = request.form.get('CallSid', '')
key = 'LEAD_{}'.format(call_sid)
redis_db.setex(key, 2*HOURS, lead.id)
storage = CallStorage(redis_db, lead.id)
storage.init()
storage.call_sid = call_sid
storage.routing_config = inbound.routing_config
storage.clear_retry_cnt()
storage.clear_callback_cnt()
storage.caller_name = caller_name
storage.subaccount_sid = inbound.partnership_account.subscription.twilio_subaccount_sid
storage.state = State.NEW
webhooker.trigger_generic_webhook('operational_start_call', lead.id)
r = VoiceResponse()
r.redirect(url=url_for(
'twilio_inbound.lead_inbound_call_continue',
lead_id=lead.id,
_external=True,
_scheme='https'
))
return to_response(r)
@twilio_inbound.route(
'/twiml/inbound/callback/<int:agent_id>/<int:lead_id>', methods=['POST']
)
@csrf.exempt
def lead_callback(agent_id, lead_id):
""" Entry point for the lead's callback call. Place lead into the
confererence call where the agent is already waiting.
"""
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
app.logger.debug('Lead {}: Callback call started.'.format(lead_id))
lead = Lead.query.filter(Lead.id == lead_id).first()
if not lead:
return
lead.agent_id = agent_id
lead.status = 'in-progress'
db.session.commit()
call_sid = request.form.get('CallSid', '')
key = 'LEAD_{}'.format(call_sid)
redis_db.setex(key, 2*HOURS, lead.id)
storage.call_sid = call_sid
storage.clear_retry_cnt()
storage.state = State.ONGOING
routing_config = storage.routing_config
whisper_message_status = routing_config.get('callBackMessage')
whisper_message_type = routing_config.get('callBackTextType')
whisper_message = routing_config.get('callBackText')
language = routing_config.get('language', 'en')
r = VoiceResponse()
# Find the the first lead row that includes inbound id
phonenumber = Lead.query.filter(lead_id == Lead.id).first()
# Query the audio url table and find the row that matches the inbound id and whisper message type
audio_file = Audio.query.filter(Audio.inbound_id == phonenumber.inbound_id,
Audio.whisper_message_type == 'CallBackText')\
.order_by(Audio.id.desc()).first()
# Check to see if there's an audio file row and then check if it's enabled to play
# If it's enabled it should be played else use the text-to-speech whisper message
if whisper_message_status:
if whisper_message_type == 'audio':
if audio_file and audio_file.enabled:
app.logger.debug('An audio file is being played instead of text-to-speech for the call back whisper message')
r.play(audio_file.audio_url)
else:
log.info('No audio file has been uploaded or there is an error for call back message')
else:
if whisper_message:
r.say(whisper_message, language=language, voice=DEFAULT_VOICE)
else:
log.info('There is no text specified for the text to speach call back message')
else:
log.info('The call back message is turned off for phone number')
with r.dial(
action=url_for(
'twilio_inbound.lead_dial_result',
lead_id=lead_id,
_external=True,
_scheme='https'
)
) as d:
waitUrl = get_hold_music(routing_config)
record = 'do-not-record'
if routing_config.get('recordCalls', False):
record = 'record-from-start'
d.conference(
name='lead_%d' % lead_id,
muted=False,
beep=False,
startConferenceOnEnter=True,
endConferenceOnExit=True,
waitUrl=waitUrl,
waitMethod='GET',
record=record,
eventCallbackUrl=url_for(
'twilio_inbound.lead_conference_callback',
lead_id=lead_id,
_external=True,
_scheme='https'
)
)
return to_response(r)
@twilio_inbound.route(
'/twiml/inbound/continue/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_inbound_call_continue(lead_id):
""" Continuation of the lead's call. Why two methods for the same call?
To make sure that we call the agent after the lead's call connected.
"""
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
app.logger.debug('Lead {}: Continuing inbound call.'.format(lead_id))
routing_config = storage.routing_config
routing_type = routing_config.get('routingType', '')
r = VoiceResponse()
whisper_message_status = routing_config.get('greetingMessage', '')
whisper_message_type = routing_config.get('whisperMessageType', '')
whisper_message = routing_config.get('whisperMessage', '')
language = routing_config.get('language', 'en')
log.info('The greeting message is set as {}'.format(whisper_message_status))
log.info('The greeting message type is set as {}'.format(whisper_message_type))
if routing_type == 'default':
routing = routing_config['defaultRouting']
# Find the the first lead row that includes inbound id
phonenumber = Lead.query.filter(lead_id == Lead.id).first()
# Query the audio url table and find the row that matches the inbound id and whisper message type
audio_file = Audio.query.filter(Audio.inbound_id == phonenumber.inbound_id,
Audio.whisper_message_type == 'whisperMessage')\
.order_by(Audio.id.desc()).first()
# Get the routing phone number
routing_phonenumber = Phone.query.filter(Phone.id == phonenumber.inbound_id).first()
# Check to see if there's an audio file row and then check if it's enabled to play
# If it's enabled it should be played else use the text-to-speech whisper message
if whisper_message_status:
if whisper_message_type == 'audio':
if audio_file and audio_file.enabled:
app.logger.debug('An audio file is being played instead of text-to-speech for whisper message')
r.play(audio_file.audio_url)
else:
log.info('There is no audio file to play for the greeting whisper message to caller')
else:
if whisper_message:
r.say(whisper_message, language=language, voice=DEFAULT_VOICE)
else:
log.info('There is no text message to play as greeting whisper message to caller')
else:
log.info('The caller greeting whisper message is turned off for phone number: {}'.format(routing_phonenumber.phonenumber))
agents = get_routing_agents(routing)
if not agents:
voicemail_redirect(storage, r)
return to_response(r)
r.redirect(url=url_for(
'twilio_inbound.lead_join_conference',
lead_id=lead_id,
_external=True,
_scheme='https'
))
storage.routing = routing
storage.state = State.LEAD_ONHOLD
elif routing_type == 'digits':
r = digits_choice(lead_id, routing_config)
else:
app.logger.error(
'Unknown routing type for lead {}'.format(lead_id)
)
r.say('We\'re sorry, our service is currently unavailable. An agent '
'will call you back as soon as possible.', voice=DEFAULT_VOICE)
return to_response(r)
@twilio_inbound.route(
'/twiml/inbound/lead_join_conference/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_join_conference(lead_id):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
app.logger.debug('Lead {}: Lead joined conference.'.format(lead_id))
routing_config = storage.routing_config
routing = storage.routing
lead = Lead.query.filter(Lead.id == lead_id).first()
if not lead:
log.warning('Lead not found in the database')
return
# import partnership information to get partnership id.
partner_account = PartnershipAccount.query \
.filter(PartnershipAccount.id == lead.partnership_account_id).first()
# Get the partner id to get relevant twilio credentails with twilio client
partner = Partnership.query.filter(Partnership.id == partner_account.partnership_id).first()
subaccount_sid = storage.subaccount_sid
if isinstance(subaccount_sid, bytes):
subaccount_sid = subaccount_sid.decode()
client = subaccount_client(subaccount_sid, partner.id)
client.queues.create('lead_{}'.format(lead_id))
call_agents(lead_id, routing)
with storage.lock():
if storage.state == State.ONGOING:
pass
elif storage.state == State.AGENT_ONHOLD:
storage.state = State.ONGOING
else:
storage.state = State.LEAD_ONHOLD
# prompt_task = redirect_to_callback_prompt.apply_async(
# args=[lead_id], countdown=CALLBACK_PROMPT_DELAY
# )
# storage.prompt_task = prompt_task.id
r = VoiceResponse()
waitUrl = get_hold_music(routing_config)
r.enqueue(
name='lead_%d' % lead_id,
waitUrl=waitUrl,
waitUrlMethod='GET'
)
return str(r)
@twilio_inbound.route(
'/twiml/inbound/lead_callback_prompt/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_callback_prompt(lead_id):
app.logger.debug('Lead {}: Whispering callback prompt audio.'.format(lead_id))
r = VoiceResponse()
with r.gather(
action=url_for(
'twilio_inbound.lead_callback_prompt_result',
lead_id=lead_id,
_external=True,
_scheme='https'
),
numDigits=1,
finishOnKey=''
) as g:
g.say(
'We\'re sorry about the delay. Press the pound key to be called'
'back in a few minutes, or any other key to remain on hold.',
voice=DEFAULT_VOICE
)
r.redirect(url_for(
'twilio_inbound.lead_join_conference',
lead_id=lead_id,
_external=True,
_scheme='https'
))
return str(r)
@twilio_inbound.route(
'/twiml/inbound/lead_callback_prompt_result/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_callback_prompt_result(lead_id):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
digits = request.form['Digits']
r = VoiceResponse()
if digits and digits[0] != '#':
app.logger.debug('Lead {}: Lead chose to remain on hold.'.format(lead_id))
storage.state = State.LEAD_ONHOLD
r.redirect(url=url_for(
'twilio_inbound.lead_join_conference',
lead_id=lead_id,
_external=True,
_scheme='https'
))
else:
app.logger.debug('Lead {}: Lead chose to be called back.'.format(lead_id))
storage.state = State.CALL_ME_BACK
redis_db.hset('CALL_ME_BACK', lead_id, time.time())
Lead.query.filter(Lead.id == lead_id).update(
{Lead.status: 'retry-pending'}
)
db.session.commit()
return str(r)
@twilio_inbound.route(
'/twiml/inbound/lead_digits_choice/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_digits_choice(lead_id):
""" Gather the digits from the lead.
"""
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
app.logger.debug('Lead {}: Selecting the routing digit.'.format(lead_id))
routing_config = storage.routing_config
r = digits_choice(lead_id, routing_config)
return to_response(r)
@twilio_inbound.route(
'/twiml/inbound/lead_digit_result/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_digit_result(lead_id):
""" Route the lead according to the digit pressed.
"""
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
routing_config = storage.routing_config
digit = request.form['Digits']
app.logger.debug('Lead {}: The lead has pressed {}.'.format(lead_id, digit))
r = VoiceResponse()
for routing in routing_config['digitRoutings']:
if routing['dialDigit'] == digit:
agents = get_routing_agents(routing)
if not agents:
voicemail_redirect(storage, r)
return to_response(r)
r.redirect(url=url_for(
'twilio_inbound.lead_join_conference',
lead_id=lead_id,
_external=True,
_scheme='https'
))
storage.routing = routing
storage.state = State.LEAD_ONHOLD
return to_response(r)
r.say(
'You have pressed an invalid digit. Please try again.',
voice=DEFAULT_VOICE
)
r.redirect(url=url_for(
'twilio_inbound.lead_digits_choice',
lead_id=lead_id,
_external=True,
_scheme='https'
))
return to_response(r)
@twilio_inbound.route(
'/twiml/inbound/lead_dial_result/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_dial_result(lead_id):
""" Result of dialing a lead who asked to be called back.
"""
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
cancel_agent_calls(lead_id)
lead = Lead.query.filter(Lead.id == lead_id).first()
status = request.form['DialCallStatus']
app.logger.debug('Lead {}: Callback call status {}.'.format(lead_id, status))
if status in ['completed', 'answered']:
storage.state = State.CAPTURED
lead.status = 'completed'
elif status in ['no-answer']:
storage.state = State.MISSED
lead.status = 'unanswered'
else:
storage.state = State.MISSED
lead.status = 'missed'
lead.endtime = datetime.utcnow()
lead.duration = (lead.endtime - lead.starttime).total_seconds()
db.session.commit()
return str(VoiceResponse())
@twilio_inbound.route(
'/twiml/inbound/lead_conference_callback/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_conference_callback(lead_id):
""" Result of recording a lead call.
"""
r_url = request.form.get('RecordingUrl')
if r_url:
lead = Lead.query.filter(Lead.id == lead_id).first()
partner_account = PartnershipAccount.query. \
filter(PartnershipAccount.id == lead.partnership_account_id).first()
partner_name = partner_account.name
from .tasks import tw_upload_recording
delay_time = datetime.utcnow() + timedelta(minutes=3)
tw_upload_recording.apply_async((lead_id, r_url, partner_name), eta=delay_time)
log.info('The recording upload, if available, will occur in 3 minutes')
return str(VoiceResponse())
@twilio_inbound.route(
'/twiml/inbound/lead_voicemail/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_voicemail(lead_id):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
app.logger.debug('Lead {}: Playing voicemail prompt.'.format(lead_id))
routing_config = storage.routing_config
language = routing_config.get('language', 'en')
voicemail_message_type = routing_config.get('voicemailMessageType', '')
r = VoiceResponse()
# Find the the first lead row that includes inbound id
phonenumber = Lead.query.filter(lead_id == Lead.id).first()
# Query the audio url table and find the row that matches the inbound id and whisper message type
audio_file = Audio.query.filter(Audio.inbound_id == phonenumber.inbound_id,
Audio.whisper_message_type == 'voicemailMessage').order_by(Audio.id.desc()).first()
if routing_config.get('voicemail', False):
# Check to see if there's an audio file row and then check if it's enabled to play
# If it's enabled it should be played else use the text-to-speech whisper message
if voicemail_message_type =='audio':
if audio_file and audio_file.enabled:
app.logger.debug('An audio file is being played instead of text-to-speech for voicemail whisper message')
r.play(
audio_file.audio_url
)
r.play(app.config.get('BEEP_SOUND'))
r.record(
action=url_for(
'twilio_inbound.lead_voicemail_callback',
lead_id=lead_id,
_external=True,
_scheme='https'
),
maxLength=300,
timeout=20
)
else:
log.info('There is no audio file to play for the voicemail. We will use the text-to-speech')
r.say(
routing_config['voicemailMessage'], language=language,
voice=DEFAULT_VOICE
)
r.play(app.config.get('BEEP_SOUND'))
r.record(
action=url_for(
'twilio_inbound.lead_voicemail_callback',
lead_id=lead_id,
_external=True,
_scheme='https'
),
maxLength=300,
timeout=20
)
else:
r.say(
routing_config['voicemailMessage'], language=language,
voice=DEFAULT_VOICE
)
r.play(app.config.get('BEEP_SOUND'))
r.record(
action=url_for(
'twilio_inbound.lead_voicemail_callback',
lead_id=lead_id,
_external=True,
_scheme='https'
),
maxLength=300,
timeout=20
)
return str(r)
@twilio_inbound.route(
'/twiml/inbound/lead_hangup/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_hangup(lead_id):
r = VoiceResponse()
# r.say(
# 'Sorry, we are not available at the moment. We will remember your '
# 'call, and will get back to you as soon as possible.',
# voice=DEFAULT_VOICE
# )
return str(r)
@twilio_inbound.route(
'/twiml/inbound/lead_voicemail_callback/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_voicemail_callback(lead_id):
r_url = request.form.get('RecordingUrl')
if r_url:
lead = Lead.query.filter(Lead.id == lead_id).first()
partner_account = PartnershipAccount.query.\
filter(PartnershipAccount.id == lead.partnership_account_id).first()
partner_name = partner_account.name
from .tasks import tw_upload_recording
delay_time = datetime.utcnow() + timedelta(minutes=3)
tw_upload_recording.apply_async((lead_id, r_url, partner_name), eta=delay_time)
log.info('The voicemail upload, if available, will occur in 3 minutes')
r = VoiceResponse()
return str(r)
@twilio_inbound.route(
'/twiml/inbound/lead_callback_call_status/<int:agent_id>/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def lead_callback_call_status(agent_id, lead_id):
""" Called after the lead hangs up after being called back. """
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
app.logger.debug('Lead {}: Callback call finished.'.format(lead_id))
cancel_agent_calls(lead_id)
lead = Lead.query.join(Lead.partnership_account)\
.join(PartnershipAccount.partnership)\
.filter(Lead.id == lead_id).first()
lead.endtime = datetime.utcnow()
lead.duration = (lead.endtime - lead.starttime).total_seconds()
call_duration = int(request.form.get('CallDuration', '0'))
if call_duration:
subscription = lead.partnership_account.subscription
if not subscription:
subscription = lead.partnership_account.partnership.subscription
subscription.update_usage(lead.partnership_account_id, seconds=call_duration)
if storage.state in [State.ONGOING, State.CAPTURED]:
storage.state = State.CAPTURED
lead.status = 'completed'
elif storage.state != State.CALL_ME_BACK:
lead.status = 'missed'
schedule_callback(storage)
db.session.commit()
send_notifications(lead)
# webhooker.trigger_generic_webhook('operational_end_call', lead.id)
return ''
@twilio_inbound.route(
'/twiml/inbound/call_result',
methods=['POST']
)
@csrf.exempt
def call_result_callback():
""" Called after the lead hangs up. """
# Declare redis url
redis_db = redis.StrictRedis(
host=app.config['REDIS_CONFIG_URL'],
port=app.config['REDIS_CONFIG_PORT'],
decode_responses=True
)
call_sid = request.form.get('CallSid', '')
key = 'LEAD_{}'.format(call_sid)
lead_id = redis_db.get(key)
storage = CallStorage(redis_db, lead_id)
routing_config = storage.routing_config
if not lead_id:
app.logger.warning('Cannot determine lead ID')
return ''
with storage.lock():
if storage.state in [State.ONGOING, State.CAPTURED]:
storage.state = State.CAPTURED
elif storage.state != State.CALL_ME_BACK:
storage.state = State.MISSED
app.logger.debug('Lead {}: Lead hung up. Status: {}, call SID: {}'.format(
lead_id, storage.state, call_sid
))
cancel_agent_calls(lead_id)
lead = Lead.query.filter(Lead.id == lead_id).first()
lead.endtime = datetime.utcnow()
lead.duration = (lead.endtime - lead.starttime).total_seconds()
if storage.state == State.CAPTURED:
lead.status = 'completed'
send_notifications(lead)
# If recording is enabled then the webhook will be called later with saving of recording
if not routing_config.get('recordCalls', False):
webhooker.trigger_generic_webhook('operational_end_call', lead.id)
elif storage.state == State.MISSED:
lead.status = 'missed'
schedule_callback(storage)
# If voicemail is enabled then the webhook will be called later with saving of voicemail recording
if not routing_config.get('voicemail', False):
webhooker.trigger_generic_webhook('operational_end_call', lead.id)
from ..phonenumbers.tasks import send_email
send_email.delay(lead.id)
if routing_config.get('configSMSSetup') and routing_config.get('MissedCallAutoReply'):
from ..sms.views import send_text_message
text = routing_config.get('MissedCallAutoReplyText') + ' Reply STOP to unsubscribe.'
media_url = ''
send_text_message(lead.inbound_id, lead.phonenumber, text, media_url)
else:
log.info('The SMS Configuration is turned off for this phone number')
db.session.commit()
return ''
@twilio_inbound.route(
'/twiml/inbound/agent/<int:agent_id>/digit/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def agent_digit_prompt(agent_id, lead_id):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
app.logger.debug('Lead {}, agent {}: Agent is prompted to press a digit.'.format(
lead_id, agent_id
))
r = VoiceResponse()
routing_config = storage.routing_config
lead = Lead.query.filter(Lead.id == lead_id).one()
# get the inbound phone number associated with the lead
phonenumber = Phone.query.filter(Phone.id == lead.inbound_id).first()
app.logger.debug('The lead inbound id is {} and the phone number id is {}'.format(lead.inbound_id, phonenumber.id))
# Check to see if there's a firstname saved to the database, which could've
# been entered manually. If there is, use it as whisper message name instead
# of the caller id (caller name). If not use caller id in agent whisper message
if phonenumber.caller_id:
if lead.firstname:
storage.caller_name = ''.join((lead.firstname, lead.lastname))
agent_text = get_agent_text(
routing_config.get('hiddenInformation', ''), storage.caller_name
)
else:
agent_text = get_agent_text(
routing_config.get('hiddenInformation', ''), storage.caller_name
)
else:
storage.caller_name = ''
agent_text = get_agent_text(
routing_config.get('hiddenInformation', ''), storage.caller_name
)
if agent_text:
r.say(agent_text, voice=DEFAULT_VOICE)
# If digit prompt is disabled for the phone number, just continue
if not storage.routing_config.get('digitPrompt', False):
r.redirect(url_for(
'twilio_inbound.agent_join_conference',
agent_id=agent_id,
lead_id=lead_id,
_external=True,
_scheme='https'
))
return str(r)
# Return all active inbound phone numbers setup for buyercall
phone_numbers = [x.phonenumber for x in Phone.query.filter(Phone.active.is_(True)).all()]
app.logger.debug('The list of active phone numbers are {}'.format(phone_numbers))
# Get the information on the agent. We are intrested in their phone number
agent = Agent.query.filter(Agent.id == agent_id).first()
app.logger.debug('The agent number before formatting is {}'.format(agent.phonenumber))
# check the format of the agent number. It needs to be without spaces, special characters and contain +1
if agent.phonenumber.startswith('+1'):
agent_phone = agent.phonenumber.replace(" ", "").replace("-", "")
elif agent.phonenumber.startswith('1'):
agent_phone = '+' + agent.phonenumber.replace(" ", "").replace("-", "")
else:
agent_phone = '+1'+agent.phonenumber.replace(" ", "").replace("-", "")
app.logger.debug('The agent number after formatting is {} and agent id {}'.format(agent_phone,agent_id))
# if agent's number is a twilio/buyercall number do not ask for digit press and continue
if agent_phone in phone_numbers:
app.logger.debug('The agent has been identified as a buyercall number and no digit press is required. Number: {}'.format(agent_phone))
r.redirect(url_for(
'twilio_inbound.agent_join_conference',
agent_id=agent_id,
lead_id=lead_id,
_external=True,
_scheme='https'
))
return str(r)
with r.gather(
action=url_for(
'twilio_inbound.agent_join_conference',
agent_id=agent_id,
lead_id=lead_id,
_external=True,
_scheme='https'
),
numDigits=1,
timeout=int(app.config.get('AMD_DIGIT_PROMPT_TIMEOUT', 5)),
finishOnKey=''
) as g:
g.say(
'Press any key to accept this call, or this call with be canceled.',
voice=DEFAULT_VOICE
)
r.say('This call will be canceled now.', voice=DEFAULT_VOICE)
app.logger.debug('The call is not connected. The id is {}, the state is {} and the status is {}'.format(lead_id, storage.state,lead.status))
return str(r)
@twilio_inbound.route(
'/twiml/inbound/agent/<int:agent_id>/conference/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def agent_join_conference(agent_id, lead_id):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
from .tasks import celery
r = VoiceResponse()
storage = CallStorage(redis_db, lead_id)
routing_config = storage.routing_config
lead = Lead.query.filter(Lead.id == lead_id).one()
with storage.lock():
if storage.agent_id is None:
storage.agent_id = agent_id
if storage.agent_id != agent_id:
call_status = request.form.get('CallStatus')
app.logger.debug(
f'Another agent with id {agent_id} has accepted the call with call '
f'status {call_status} and lead status {lead.status} and state {storage.state}'
)
r.say(
"We're sorry, another agent has accepted this call.",
voice=DEFAULT_VOICE
)
return to_response(r)
app.logger.debug('Lead {}, agent {}: The agent has joined the conference.'.format(
lead_id, agent_id,
))
cancel_agent_calls(lead_id, lambda id_, _: id_ != agent_id)
lead = Lead.query\
.join(Lead.partnership_account)\
.join(PartnershipAccount.partnership)\
.filter(Lead.id == lead_id)\
.one()
# import partnership information to get partnership id
partner_account = PartnershipAccount.query \
.filter(PartnershipAccount.id == lead.partnership_account_id).first()
# Get the partner id to get relevant twilio credentails with twilio client
partner = Partnership.query.filter(Partnership.id == partner_account.partnership_id).first()
with storage.lock():
if storage.state == State.LEAD_ONHOLD:
storage.state = State.ONGOING
if storage.prompt_task:
celery.control.revoke(storage.prompt_task)
storage.prompt_task = None
elif storage.state == State.CALLBACK_PROMPT:
storage.state = State.AGENT_ONHOLD
# Redirect lead's call to agent
subscription = lead.partnership_account.subscription
if not subscription:
subscription = lead.partnership_account.partnership.subscription
twilio_client = subaccount_client(subscription.twilio_subaccount_sid, partner.id)
twilio_client.calls.route(storage.call_sid, url=url_for(
'twilio_inbound.lead_join_conference',
lead_id=lead_id,
_external=True,
_scheme='https'
))
else:
app.logger.error('Invalid state: {}'.format(storage.state))
lead.agent_id = agent_id
lead.status = 'in-progress'
db.session.commit()
webhooker.trigger_generic_webhook('operational_agent_call', lead.id)
if agent_id and lead:
from buyercall.blueprints.contacts.models import Contact
contact = Contact.query.filter(Contact.id == lead.contact_id).first()
agent = Agent.query.filter(Agent.id == agent_id).first()
if contact and agent and contact.agent_id != agent.id:
contact.agent_id = agent_id
contact.agent_assigned = agent.full_name
db.session.commit()
from buyercall.blueprints.mobile.utils import send_agent_push_notification
send_agent_push_notification(contact)
record = 'do-not-record'
if routing_config.get('recordCalls', False):
record = 'record-from-answer-dual'
app.logger.debug('the recording routing value is {} and the record value is {}'.format(routing_config.get('recordCalls'),record))
r.play(app.config.get('BEEP_SOUND'), loop=1)
with r.dial(record=record,
action=url_for(
'twilio_inbound.lead_conference_callback',
lead_id=lead_id,
_external=True,
_scheme='https')
) as d:
d.queue(
name='lead_%d' % lead_id
)
return to_response(r)
@twilio_inbound.route(
'/twiml/inbound/agent/<int:agent_id>/callback/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def agent_callback_lead(agent_id, lead_id):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
app.logger.debug('{}: {}'.format(lead_id, storage.state))
lead = Lead.query.join(Lead.partnership_account).join(PartnershipAccount.partnership).filter(Lead.id == lead_id).first()
lead.status = 'ringing'
db.session.commit()
redis_db.hdel('CALL_ME_BACK', lead_id)
routing_config = storage.routing_config
r = VoiceResponse()
agent_text = get_agent_text(
routing_config.get('hiddenInformation', ''), storage.caller_name,
callback=True, manual_call=storage.manual_call
)
if agent_text:
r.say(agent_text, voice=DEFAULT_VOICE)
# If digit prompt is disabled for the phone number, just continue
if not storage.routing_config.get('digitPrompt', False):
r.redirect(url_for(
'twilio_inbound.agent_callback_lead_result',
agent_id=agent_id,
lead_id=lead_id,
_external=True,
_scheme='https'
))
return str(r)
with r.gather(
action=url_for(
'twilio_inbound.agent_callback_lead_result',
agent_id=agent_id,
lead_id=lead_id,
_external=True,
_scheme='https'
),
numDigits=1,
timeout=int(app.config.get('AMD_DIGIT_PROMPT_TIMEOUT', 5)),
finishOnKey=''
) as g:
g.say(
'Press any key to accept this call, or this call with be canceled.',
voice=DEFAULT_VOICE
)
r.say('This call will be canceled now.', voice=DEFAULT_VOICE)
call_status = request.form.get('CallStatus')
app.logger.debug('The id is {}, the state is {} and the call status is {}'.format(
lead_id, storage.state, call_status
))
storage.state = State.MISSED
app.logger.debug(
'The id is {}, The state is {} and status is {} and storage agent id is {} and '
'the call status is {}'.format(
lead_id, storage.state, lead.status, storage.agent_id, call_status
)
)
return str(r)
@twilio_inbound.route(
'/twiml/inbound/agent/<int:agent_id>/callback_digit_result/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def agent_callback_lead_result(agent_id, lead_id):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
lead = Lead.query.filter(Lead.id == lead_id).first()
if not lead:
return
# import partnership information to get partnership id
partner_account = PartnershipAccount.query \
.filter(PartnershipAccount.id == lead.partnership_account_id).first()
# Get the partner id to get relevant twilio credentails with twilio client
partner = Partnership.query.filter(Partnership.id == partner_account.partnership_id).first()
r = VoiceResponse()
storage = CallStorage(redis_db, lead_id)
app.logger.debug('The storage agent id is {} and the agent id is {}'.format(storage.agent_id,agent_id))
with storage.lock():
if storage.agent_id is None:
storage.agent_id = agent_id
if storage.agent_id != agent_id:
r.say(
"We're sorry, another agent has accepted this call.",
voice=DEFAULT_VOICE
)
app.logger.debug('The call was accepted by another agent. The storage agent id is {} and the agent id is {}'.format(storage.agent_id, agent_id))
return to_response(r)
cancel_agent_calls(lead_id, lambda id_, _: id_ != agent_id)
digits = request.form.get('Digits', '')
app.logger.debug('The agent pressed {}'.format(digits))
with r.dial() as d:
waitUrl = url_for(
'twilio_inbound.twiml_hold_music', file_guid='loop.mp3',
_external=True,
_scheme='https'
)
d.conference(
name='lead_%d' % lead_id,
muted=False,
beep=False,
waitUrl=waitUrl,
waitMethod='GET',
startConferenceOnEnter=True,
endConferenceOnExit=True
)
# Now call the lead
subscription = lead.partnership_account.subscription
if not subscription:
subscription = lead.partnership_account.partnership.subscription
twilio_client = subaccount_client(subscription.twilio_subaccount_sid, partner.id)
try:
call = twilio_client.calls.create(
from_=lead.inbound.phonenumber,
to=lead.phonenumber,
url=url_for(
'twilio_inbound.lead_callback',
lead_id=lead_id,
agent_id=agent_id,
_external=True,
_scheme='https'
),
status_callback=url_for(
'twilio_inbound.lead_callback_call_status',
agent_id=agent_id,
lead_id=lead_id,
_external=True,
_scheme='https'
))
storage.call_sid = call.sid
except Exception as e:
app.logger.error(traceback.format_exc())
pass
return to_response(r)
@twilio_inbound.route(
'/twiml/inbound/agent_callback_call_status/<int:agent_id>/<int:lead_id>', # noqa
methods=['POST']
)
@csrf.exempt
def agent_callback_call_status(agent_id, lead_id):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
app.logger.debug('{}: {}'.format(lead_id, storage.state))
call_sid = request.form.get('CallSid')
call_status = request.form.get('CallStatus')
app.logger.debug('The call status is {}'.format(call_status))
lead = Lead.query.filter(Lead.id == lead_id).first()
# import partnership information to get partnership id
partner_account = PartnershipAccount.query \
.filter(PartnershipAccount.id == lead.partnership_account_id).first()
# Get the partner id to get relevant twilio credentails with twilio client
partner = Partnership.query.filter(Partnership.id == partner_account.partnership_id).first()
# Update agent response time
time_key = 'TIME_{}_{}'.format(lead_id, agent_id)
if call_status == 'in-progress':
BusyAgents.add(agent_id)
timestamp_now = time.time()
timestamp_then = float(redis_db.get(time_key) or 0)
with storage.lock():
if storage.agent_id is None:
storage.agent_id = agent_id
active_agent_id = storage.agent_id
#if active_agent_id == str(agent_id):
# cancel_agent_calls(lead_id, lambda id_, _: id_ != agent_id)
if timestamp_then:
Lead.query.filter(Lead.id == lead_id).update({
Lead.response_time_seconds: timestamp_now - timestamp_then
})
db.session.commit()
return ''
# The call has ended
BusyAgents.remove(agent_id)
answered_by = request.form.get('AnsweredBy', '')
if answered_by == 'machine':
call_status = 'no-answer'
calls_left = storage.remove_agent_call(agent_id, call_sid)
storage.agent_id = None
if calls_left == 0:
if storage.state == State.CALLBACK:
# The agent has selected to call back lead? but the agent didn't
# answer.
# Mark lead as missed
Lead.query.filter(Lead.id == lead_id).update(
{Lead.status: 'missed'}
)
db.session.commit()
schedule_callback(storage)
elif storage.state == State.MISSED:
schedule_callback(storage)
elif storage.state not in [
State.CALL_ME_BACK, State.ONGOING, State.CAPTURED
]:
# Cancel lead's call
twilio_client = subaccount_client(storage.subaccount_sid, partner.id)
try:
# TODO: Do we even need this?
twilio_client.calls(storage.call_sid).update(status='completed')
app.logger.debug('The call has been hangup for lead {}...'.format(lead_id))
except TwilioRestException as e:
# Likely the user hung up on us before we could do the same
app.logger.warning(str(e))
if not schedule_callback(storage):
# Mark lead as missed
Lead.query.filter(Lead.id == lead_id).update(
{Lead.status: 'missed'}
)
db.session.commit()
if storage.state == State.CALL_ME_BACK:
Lead.query.filter(Lead.id == lead_id).update(
{Lead.status: 'retry-pending'}
)
db.session.commit()
app.logger.debug('Send out email for state {} and lead status {}'.format(storage.state, lead.status))
send_notifications(lead)
elif storage.state == State.MISSED and call_status == 'completed' or call_status == 'no-answer':
Lead.query.filter(Lead.id == lead_id).update(
{Lead.status: 'missed'}
)
db.session.commit()
if calls_left == 0:
send_notifications(lead)
app.logger.debug(
'Send out email for state {} and lead status {} and storage status {}'.format(storage.state,
lead.status,
call_status))
elif storage.state == State.CAPTURED and call_status == 'completed':
Lead.query.filter(Lead.id == lead_id).update(
{Lead.status: 'completed'}
)
db.session.commit()
# Update minutes consumed
call_duration = int(request.form.get('CallDuration', '0'))
if call_duration:
lead = Lead.query.join(Lead.partnership_account).join(PartnershipAccount.partnership).filter(Lead.id == lead_id).first()
subscription = lead.partnership_account.subscription
if not subscription:
subscription = lead.partnership_account.partnership.subscription
subscription.update_usage(lead.partnership_account_id, seconds=call_duration)
app.logger.debug('The call state is {} and lead status is {} and the call status is {}'.format(storage.state, lead.status, call_status))
return ''
@twilio_inbound.route(
'/twiml/inbound/agent_inbound_call_status/<int:agent_id>/<int:lead_id>',
methods=['POST']
)
@csrf.exempt
def agent_inbound_call_status(agent_id, lead_id):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
lead = Lead.query.join(Lead.partnership_account).join(PartnershipAccount.partnership).filter(
Lead.id == lead_id).first()
partner_account = PartnershipAccount.query \
.filter(PartnershipAccount.id == lead.partnership_account_id).first()
# Get the partner id to get relevant twilio credentails with twilio client
partner = Partnership.query.filter(Partnership.id == partner_account.partnership_id).first()
call_sid = request.form.get('CallSid')
call_duration = int(request.form.get('CallDuration', '0'))
call_status = request.form['CallStatus']
agent = Agent.query.options(load_only('firstname', 'lastname')).filter(
Agent.id == agent_id
).first()
agent_name = agent.full_name if agent else ''
app.logger.debug(
'Lead {}, agent {} ({}): call status: {}, '
'call duration: {}, call state: {}'.format(
lead_id, agent_id, agent_name, call_status, call_duration, storage.state
))
if call_status in ['ringing', 'in-progress']:
update_response_time(call_status, agent_id, lead_id, redis_db)
if call_status == 'ringing':
return ''
if call_status == 'in-progress':
# Mark the agent as busy
BusyAgents.add(agent_id)
return ''
# We can be sure the call is over, one way or another
# Mark the agent as free
BusyAgents.remove(agent_id)
answered_by = request.form.get('AnsweredBy', '')
if answered_by == 'machine' or (
call_status == 'completed' and not call_duration):
call_status = 'no-answer'
calls_left = storage.remove_agent_call(agent_id, call_sid)
# Update minutes consumed
if call_duration:
subscription = lead.partnership_account.subscription
if not subscription:
subscription = lead.partnership_account.partnership.subscription
subscription.update_usage(lead.partnership_account_id, seconds=call_duration)
routing = storage.routing
if call_status in ['busy', 'failed', 'no-answer'] and storage.state in [
State.NEW, State.LEAD_ONHOLD, State.CALLBACK_PROMPT
]:
if routing['callOrder'] == 'simultaneous' or storage.is_group:
app.logger.debug('Agents still being called right now: {}'.format(calls_left))
if calls_left == 0:
if not storage.is_group:
retry_once_more(routing, lead_id)
else:
app.logger.debug('Lead {}: All calls for current group failed; continuing with next sequential agent...'.format(
lead_id,
))
call_agent_sequence(routing, lead_id)
else:
app.logger.debug('Lead {}, agent "{}": Call failed; continuing with next agent...'.format(
lead_id, agent_name
))
call_agent_sequence(routing, lead_id)
elif call_status == 'completed':
# If we haven't accepted the call, and the lead voicemail is setup,
# redirect them to voicemail.
if storage.state in [
State.NEW, State.LEAD_ONHOLD, State.CALLBACK_PROMPT
]:
if routing['callOrder'] == 'simultaneous':
if calls_left == 0:
redirect_to_voicemail(lead_id)
else:
app.logger.debug("Call not accepted; continuing with next agent...")
call_agent_sequence(routing, lead_id)
elif calls_left == 0:
# Otherwise, cancel lead's call
twilio_client = subaccount_client(storage.subaccount_sid, partner.id)
twilio_client.calls(storage.call_sid).update(status='completed')
app.logger.debug('The calls_left is 0 and the call has been hungup for lead {}...'.format(lead_id))
return ''
def cancel_agent_calls(lead_id, predicate=None):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
lead = Lead.query.filter(Lead.id == lead_id).first()
if not lead:
log.warning('Lead not found in the database')
return
# import partnership information to get partnership id
partner_account = PartnershipAccount.query \
.filter(PartnershipAccount.id == lead.partnership_account_id).first()
# Get the partner id to get relevant twilio credentails with twilio client
partner = Partnership.query.filter(Partnership.id == partner_account.partnership_id).first()
app.logger.debug('Lead {}: Canceling {} agent calls...'.format(
lead_id, 'some' if predicate else 'all pending'
))
storage = CallStorage(redis_db, lead_id)
agent_calls = storage.clear_agent_calls(predicate)
if not agent_calls:
return
twilio_client = subaccount_client(storage.subaccount_sid, partner.id)
for (agent_id, call_sid) in agent_calls:
try:
app.logger.debug('...agent {}, call {}'.format(agent_id, call_sid))
twilio_client.calls(call_sid).update(status='completed')
except Exception as e:
app.logger.info('Cannot hangup call {}'.format(call_sid))
def digits_choice(lead_id, routing_config):
""" :returns twiml.Response
"""
no_digit_whisper_message = routing_config.get('noDigitWhisperMessage', '')
no_digit_whisper_message_type = routing_config.get('noDigitWhisperMessageType')
digit_whisper_message = routing_config.get('digitWhisperMessage', '')
digit_whisper_message_type = routing_config.get('digitWhisperMessageType')
whisper_message_type = routing_config.get('whisperMessageType')
whisper_message = routing_config.get('whisperMessage')
language = routing_config.get('language', 'en')
r = VoiceResponse()
# Find the the first lead row that includes inbound id
phonenumber = Lead.query.filter(lead_id == Lead.id).first()
# Query the audio url table and find the row that matches the inbound id and whisper message type
audio_file = Audio.query.filter(Audio.inbound_id == phonenumber.inbound_id,
Audio.whisper_message_type == 'whisperMessage').order_by(Audio.id.desc()).first()
# Check to see if there's an audio file row and then check if it's enabled to play
# If it's enabled it should be played else use the text-to-speech whisper message
if whisper_message_type == 'audio':
if audio_file and audio_file.enabled:
app.logger.debug('An audio file is being played instead of text-to-speech for whisper message')
r.play(audio_file.audio_url)
else:
if whisper_message:
r.say(whisper_message, language=language, voice=DEFAULT_VOICE)
log.info('No audio file for the whisper message available')
else:
if whisper_message:
r.say(whisper_message, language=language, voice=DEFAULT_VOICE)
else:
log.info('There is no text to speak for the whisper message')
with r.gather(
action=url_for(
'twilio_inbound.lead_digit_result',
lead_id=lead_id,
_external=True,
_scheme='https'
),
numDigits=1
) as g:
# Query the audio url table and find the row that matches the inbound id and whisper message type
digit_audio_file = Audio.query.filter(Audio.inbound_id == phonenumber.inbound_id,
Audio.whisper_message_type == 'digitWhisperMessage')\
.order_by(Audio.id.desc()).first()
# Request digit press input based of direction of call
if digit_whisper_message_type == 'audio':
if digit_audio_file and digit_audio_file.enabled:
app.logger.debug('An audio file is being played to give the caller menu option for auto attendance')
g.play(digit_audio_file.audio_url,loop=1)
else:
app.logger.debug('text-to-speech is used to give the caller menu option for auto attendance')
g.say(digit_whisper_message, language=language,
voice=DEFAULT_VOICE)
else:
if digit_whisper_message:
g.say(digit_whisper_message, language=language, voice=DEFAULT_VOICE)
else:
log.info('No text message added for digit whisper message')
# Query the audio url table and find the row that matches the inbound id and whisper message type
no_digit_audio_file = Audio.query.filter(Audio.inbound_id == phonenumber.inbound_id,
Audio.whisper_message_type == 'noDigitWhisperMessage')\
.order_by(Audio.id.desc()).first()
if no_digit_whisper_message_type == 'audio':
if no_digit_audio_file and no_digit_audio_file.enabled:
for i in range(3):
g.pause(length=5)
g.play(no_digit_audio_file.audio_url)
g.pause(length=5)
app.logger.debug('An audio file is being played instead of text-to-speech for the no digit press whisper message')
else:
if no_digit_whisper_message:
for i in range(3):
g.pause(length=5)
g.say(
no_digit_whisper_message, language=language,
voice=DEFAULT_VOICE
)
g.pause(length=5)
return r
def voicemail_redirect(storage, r):
routing_config = storage.routing_config
try:
# If voicemail enabled, redirect there...
voicemail = routing_config.get('voicemail', False)
if voicemail:
app.logger.info('Redirecting lead to voicemail')
r.redirect(url=url_for(
'twilio_inbound.lead_voicemail',
lead_id=storage.lead_id,
_external=True,
_scheme='https'
))
else:
# ...otherwise, hang up
r.redirect(url=url_for(
'twilio_inbound.lead_hangup',
lead_id=storage.lead_id,
_external=True,
_scheme='https'
))
except Exception as e:
app.logger.error(traceback.format_exc())
return
def redirect_to_voicemail(lead_id):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
from .tasks import celery
lead = Lead.query.filter(Lead.id == lead_id).first()
if not lead:
log.warning('Lead not found in the database')
return
# import partnership information to get partnership id
partner_account = PartnershipAccount.query \
.filter(PartnershipAccount.id == lead.partnership_account_id).first()
# Get the partner id to get relevant twilio credentails with twilio client
partner = Partnership.query.filter(Partnership.id == partner_account.partnership_id).first()
storage = CallStorage(redis_db, lead_id)
routing_config = storage.routing_config
with storage.lock():
if storage.prompt_task:
celery.control.revoke(storage.prompt_task)
storage.prompt_task = None
twilio_client = subaccount_client(storage.subaccount_sid, partner.id)
try:
# If voicemail enabled, redirect there...
voicemail = routing_config.get('voicemail', False)
if voicemail:
app.logger.debug('Lead {}: redirecting to voicemail'.format(lead_id))
twilio_client.calls(storage.call_sid).update(
method="POST",
url=url_for(
'twilio_inbound.lead_voicemail',
lead_id=lead_id,
_external=True,
_scheme='https'
)
)
else:
app.logger.debug('Lead {}: voicemail not enabled for routing; hanging up.'.format(lead_id))
# ...otherwise, hang up
twilio_client.calls(storage.call_sid).update(
method="POST",
url=url_for(
'twilio_inbound.lead_hangup',
lead_id=lead_id,
_external=True,
_scheme='https'
)
)
except Exception as e:
app.logger.error(traceback.format_exc())
return
def call_agents(lead_id, routing):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
from .tasks import call_simultaneous
call_order = routing.get('callOrder', '')
agents = get_routing_agents(routing)
if call_order == 'shuffle':
import random
app.logger.debug('Shuffling agent list...')
random.shuffle(agents)
call_order = 'sequence'
if call_order == 'sequence':
storage = CallStorage(redis_db, lead_id)
agent_ids = [agent.id for agent in agents]
app.logger.info('Calling agents {}'.format(agent_ids))
app.logger.debug('{}: agent list in Redis'.format(agent_ids))
storage.set_agents_to_call(agent_ids)
call_agent_sequence(routing, lead_id)
elif call_order == 'simultaneous':
agent_ids = [a.id for a in agents]
call_simultaneous.delay(agent_ids, lead_id, routing)
def retry_once_more(routing, lead_id):
""" Try calling the agents once more, up to the maximum number of retries.
Return False if the maximum number of retries exceeded, otherwise True.
"""
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
cnt = storage.inc_retry_cnt()
if cnt <= int(routing['retryRouting']):
app.logger.debug('Retrying once more for lead {}...'.format(lead_id))
call_agents(lead_id, routing)
return True
redirect_to_voicemail(lead_id)
return False
def call_agent_sequence(routing, lead_id):
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
storage = CallStorage(redis_db, lead_id)
lead = Lead.query.join(Lead.inbound).filter(Lead.id == lead_id).first()
# import partnership information to get partnership id
partner_account = PartnershipAccount.query \
.filter(PartnershipAccount.id == lead.partnership_account_id).first()
# Get the partner id to get relevant twilio credentails with twilio client
partner = Partnership.query.filter(Partnership.id == partner_account.partnership_id).first()
twilio_client = subaccount_client(storage.subaccount_sid, partner.id)
call = None
while not call:
agent_id = storage.next_agent_to_call()
if not agent_id:
retry_once_more(routing, lead_id)
return
agent = Agent.query.filter(Agent.id == agent_id).first()
if not agent:
continue
agents = [agent]
if agent.is_group:
agents = agent.agents
storage.is_group = True
for agent in agents:
try:
agent_number, extension = get_agent_number(agent, routing)
digits = 'wwww' + extension if extension else None
with storage.lock():
if storage.state in [
State.NEW, State.LEAD_ONHOLD, State.CALLBACK_PROMPT
]:
call = twilio_client.calls.create(
from_=lead.inbound.phonenumber,
to=agent_number,
timeout=AGENT_TIMEOUT,
url=url_for(
'twilio_inbound.agent_digit_prompt',
agent_id=agent.id,
lead_id=lead_id,
_external=True,
_scheme='https'
),
status_callback=url_for(
'twilio_inbound.agent_inbound_call_status',
agent_id=agent.id,
lead_id=lead_id,
_external=True,
_scheme='https'
),
send_digits=digits,
status_callback_event=['ringing', 'answered', 'completed'],
)
storage.push_agent_call(agent.id, call.sid)
except Exception as e:
app.logger.warning(traceback.format_exc())
app.logger.warning('Error calling agent {}...'.format(
agent.id
))
@twilio_inbound.route('/twiml/inbound/hold-music/<file_guid>', methods=['GET', 'POST'])
def twiml_hold_music(file_guid):
r = VoiceResponse()
r.play(url=url_for('phonenumbers.hold_music', file_guid=file_guid, _external=True, _scheme='https'), loop='0'
)
# r.redirect()
response = make_response(str(r))
response.headers['Content-Type'] = 'text/xml; charset=utf-8'
return response
@twilio_inbound.route('/twiml/inbound/custom-hold-music/<file_guid>', methods=['GET'])
def twiml_custom_hold_music(file_guid):
r = VoiceResponse()
hold_music = HoldMusic.query.filter(HoldMusic.uuid == file_guid).first()
hold_music_url = hold_music.url
if hold_music_url:
r.play(hold_music_url, loop='0'
)
else:
r.play('https://buyercall.com/inbound/hold-music/loop.mp3', loop=0
)
log.info('There is no audio file to play as hold music. Reverting to ring tone')
r.redirect()
response = make_response(str(r))
response.headers['Content-Type'] = 'text/xml; charset=utf-8'
return response
@twilio_inbound.route('/twiml/inbound/standard-hold-music', methods=['GET'])
def standard_hold_music():
r = VoiceResponse()
r.play('https://s3.amazonaws.com/buyercall-static-sounds/holdmusic.mp3'
)
r.say('Thank you for waiting. Your call is important to us. Please stay on the line.',
voice=DEFAULT_VOICE
)
r.play('https://s3.amazonaws.com/buyercall-static-sounds/holdmusic.mp3', loop=2
)
r.say('Thank you for waiting. Your call is important to us. Please stay on the line.',
voice=DEFAULT_VOICE
)
r.play('https://s3.amazonaws.com/buyercall-static-sounds/holdmusic.mp3', loop=5
)
r.say('Thank you for waiting. Your call is important to us. Please stay on the line.',
voice=DEFAULT_VOICE
)
r.play('https://s3.amazonaws.com/buyercall-static-sounds/holdmusic.mp3', loop=5
)
r.redirect()
response = make_response(str(r))
response.headers['Content-Type'] = 'text/xml; charset=utf-8'
return response
def get_hold_music(routing_config):
if not routing_config.get('playHoldMusic'):
waitUrl = url_for(
'twilio_inbound.twiml_hold_music', file_guid='loop.mp3',
_external=True,
_scheme='https'
)
elif (routing_config.get('customHoldMusic') and
routing_config.get('holdMusicId')):
waitUrl = url_for('twilio_inbound.twiml_custom_hold_music', file_guid=routing_config['holdMusicId'],
_external=True, _scheme='https')
else:
waitUrl = url_for(
'twilio_inbound.standard_hold_music',
_external=True,
_scheme='https'
)
return waitUrl
def send_notifications(lead): # noqa
# Declare redis url
redis_db = redis.StrictRedis(host=app.config['REDIS_CONFIG_URL'], port=app.config['REDIS_CONFIG_PORT'])
from ..widgets.models import split_emails
storage = CallStorage(redis_db, lead.id)
if storage.manual_call:
app.logger.debug('Notification email suppressed.')
return
inbound = Phone.query.filter(
Phone.id == lead.inbound_id,
Phone.partnership_account_id == lead.partnership_account_id
).first()
if not inbound:
return
notifications = inbound.notifications
routings = inbound.routing_config
try:
if notifications['notifyLeads'] == 'none':
return None
except KeyError:
log.info('The notifications dict does not have a notifyLeads key for inbound id: {}'.format(inbound.id))
if notifications == {}:
log.info('The notifications is an empty dict for inbound id: {}'.format(inbound.id))
return None
# Send eventual notifications
emails = []
adf_emails = []
user_email = None
user = User.query.filter(
User.partnership_account_id == lead.partnership_account_id,
User.active,
User.is_deactivated.is_(False),
User.role == 'admin'
).first()
if user:
user_email = user.email
agent_ids = [
a['id'] for a in inbound.routing_config['defaultRouting']['agents']
]
agent_emails = [
a.email
for a in Agent.query.options(load_only('email')).filter(
Agent.partnership_account_id == lead.partnership_account_id,
Agent.id.in_(agent_ids)
).all()
]
partner_account = PartnershipAccount.query.filter(PartnershipAccount.id == lead.partnership_account_id).first()
partner = Partnership.query.filter(Partnership.id == partner_account.partnership_id).first()
if notifications['notifyLeads'] == 'missed' and lead.status == 'missed':
emails.extend(split_emails(notifications['notifyMissedCustom']))
if notifications['notifyMissedAgents']:
emails.extend(agent_emails)
if notifications['notifyMissedMe'] and user_email:
emails.append(user_email)
if notifications['notifyLeads'] == 'all':
emails.extend(split_emails(notifications['notifyAllCustom']))
if notifications['notifyAllAgents']:
emails.extend(agent_emails)
if notifications['notifyAllMe'] and user_email:
emails.append(user_email)
ctx = vars(lead)
eastern = pytz.timezone('US/Eastern')
ctx['created_on'] = lead.created_on.astimezone(eastern).strftime('%c')
ctx['updated_on'] = lead.updated_on.astimezone(eastern).strftime('%c')
ctx['adf_updated_on'] = lead.interaction_time
ctx['partner_logo'] = partner.logo
ctx['company'] = partner.name
if partner.partner_url:
ctx['lead_contact_url'] = partner.partner_url + '/contacts/contact/' + str(lead.contact_id)
else:
ctx['lead_contact_url'] = url_for('contacts.contact_lead_page', id=lead.contact_id, _external=True, _scheme='https')
if lead.firstname:
ctx['caller_name_label'] = 'Caller Name:'
ctx['caller_name'] = ''.join((lead.firstname, ' ', lead.lastname))
if lead.caller_id:
ctx['caller_id_label'] = 'Caller Id:'
ctx['caller_id'] = lead.caller_id
if lead.recording_url:
ctx['vm_div_style'] = 'block'
else:
ctx['vm_div_style'] = 'none'
if lead.source:
ctx['call_source'] = lead.source
if lead.inbound:
ctx['reference'] = lead.inbound.routing_config.get(
'hiddenInformation', ''
)
# webhooker.trigger_generic_webhook('operational_end_call', lead.id)
try:
if routings.get('notifyAdf'):
if routings['notifyAdfCustom'] is not None and routings['notifyAdfCustom'] is not '':
multi_adf_emails = split_emails(routings['notifyAdfCustom'])
if multi_adf_emails is not None:
adf_emails.extend(multi_adf_emails)
else:
adf_emails.append(routings['notifyAdfCustom'])
ctx['lead_comments'] = 'Call status: ' + lead.status
ctx['receiver'] = ''
agent_name = lead.agent_name
friendly_name = lead.source
phonenumber = lead.inbound.phonenumber
dealership = partner_account.name
if dealership:
ctx['vendor_name'] = dealership
else:
ctx['vendor_name'] = ''
call_lead_id = lead.id
if call_lead_id:
ctx['call_lead_id'] = call_lead_id
else:
ctx['call_lead_id'] = ''
if agent_name is not None and agent_name is not '' and len(agent_name) > 2:
ctx['lead_comments'] = ctx['lead_comments'] + ', Agent name: ' + agent_name
if friendly_name is not None and friendly_name is not '':
ctx['lead_comments'] = ctx['lead_comments'] + ', Call source: ' + friendly_name
ctx['receiver'] = friendly_name
if phonenumber is not None and phonenumber is not '':
ctx['lead_comments'] = ctx['lead_comments'] + ', Phonenumber: ' + phonenumber
ctx['campaign_name'] = ''
ctx['campaign_exists'] = False
from buyercall.blueprints.contacts.models import Contact, Campaigns
contact = Contact.query.filter(
Contact.id == lead.contact_id,
Contact.partnership_account_id == lead.partnership_account_id)\
.first()
if contact and contact.campaign_id:
campaign = Campaigns.query.filter(
Campaigns.id == contact.campaign_id)\
.first()
if campaign:
ctx['campaign_exists'] = True
ctx['campaign_name'] = campaign.display_name
if adf_emails is not None and len(adf_emails) > 0:
# Render text template for adf email
adf_lead_email_template = _try_renderer_template('mail/adf_lead', ext='txt', **ctx)
send_ses_email(recipients=adf_emails,
p_id=partner.id,
subject=partner.name + ' - ADF lead notification',
text=adf_lead_email_template
)
except Exception as e:
log.error(traceback.format_exc())
log.error('something went wrong with the ADF email notification for lead id: {}'.format(lead.id))
try:
if emails:
log.debug('The email list are {}'.format(emails))
if lead.status == 'missed' and storage.state == State.MISSED:
# Render html template for email
missed_lead_email_template = _try_renderer_template('mail/missed_lead', ext='html', **ctx)
send_ses_email(recipients=emails,
p_id=partner.id,
subject=partner.name + ' - Missed lead notification',
html=missed_lead_email_template
)
elif lead.status == 'missed' and storage.state == State.CALLBACK:
# Render html template for email
callback_lead_email_template = _try_renderer_template('mail/missed_lead', ext='html', **ctx)
send_ses_email(recipients=emails,
p_id=partner.id,
subject=partner.name + ' - Missed lead notification',
html=callback_lead_email_template
)
elif lead.status == 'retry-pending' or lead.status == 'missed' and storage.state == State.CALL_ME_BACK:
# Render html template for email
retry_lead_email_template = _try_renderer_template('mail/call_back', ext='html', **ctx)
send_ses_email(recipients=emails,
p_id=partner.id,
subject=partner.name + ' - Missed Call-back lead notification',
html=retry_lead_email_template
)
elif lead.status == 'completed' and storage.state == State.CAPTURED:
# Render html template for email
completed_lead_email_template = _try_renderer_template('mail/captured_lead', ext='html', **ctx)
send_ses_email(recipients=emails,
p_id=partner.id,
subject=partner.name + ' - Answered lead notification',
html=completed_lead_email_template
)
else:
# Render html template for email
captured_lead_email_template = _try_renderer_template('mail/captured_lead', ext='html', **ctx)
send_ses_email(recipients=emails,
p_id=partner.id,
subject='{} - New lead notification'.format(partner.name),
html=captured_lead_email_template
)
else:
log.info('Theres no normal emails added for notifications on phone number: {} and lead id {}'.
format(lead.inbound.phonenumber, lead.id))
except Exception as e:
log.error(traceback.format_exc())
# DEBUG
def to_response(r):
""" Transform a Twiml Response to a Flask response object. """
import xml.dom.minidom
xml = xml.dom.minidom.parseString(str(r))
return xml.toprettyxml()