Full Featured Call Center - JSON Menus & Python
This guide demonstrates how to use SignalWire APIs to create a completely functional call center where the features are controlled by the JSON configuration file config.json
making it exceedingly easy to enable/disable features in minutes! The dynamic setup of the JSON menus adds a greater level of customizability to your IVR and makes modifying the structure on the fly a breeze.
If you'd like to first try out the precursor that this application was built on, check out the guide here!
The features of this demo include all of the following:
**-> **TTS Voice
**-> **Default Entry Point
**-> **Agents Configurations
**-> **Agent Routing (default, is First_Available)
**-> **Multi-Channel (default, shows PSTN/Voice)
**-> **Accepted Channels (Determines, which channels are allowed)
**-> **Recording
**-> **Exit Survey Routing
**-> **Text Summary After Call
**-> **Announcement Message
**-> **Exit Message
**-> **Waiting Music/Message/Ads
**-> **Queue Stats Messaging with Template Vars
**-> **Agent Wrap Up Ability
**-> **Dynamic Menu Building and Routing
**-> **"Smarter" Queues Tricks to extend queues to use meta info such as skills
**-> **Recording Ability
**-> **CDR Log Handling
**-> **Text Bot Ability
What do I need to Run the Code
You can view or fork the code in our Github Repo to get going in no time.
This guide uses the Python SignalWire SDK, for a guide on installation click here.
You will need a SignalWire phone number as well as your API Credentials (API Token, Space URL, and Project ID) which can all be found in an easily copyable format within the API tab of your SignalWire portal.
How to Run the Application
Build and Run on Docker
- Build on docker
./docker-build
- Run your image
docker run --publish 80:80 sw-call-center-demo
or if you have docker compose installed, type
docker-compose up
or
docker-compose up -d
- The application will run on port 80
Build and Run Natively
- Edit config.json to build out your contact center.
- From command line run, python3 app.py
You may need to use an SSH tunnel for testing this code if running on your local machine. – we recommend ngrok. You can learn more about how to use ngrok here.
Step by Step Code Walkthrough
Within the Github repository, you will find several files, but we're interested in:
- app.py
- config.json
Configuring the JSON File
You can use the settings menu to enable/disable certain features such as waiting music, wait time advertisements or the language/dialect for text to speech. The use of a JSON menu to specify menus, settings, and other instructions is a feature that will save you hours of development time when setting up or customizing your call center!
{
"settings": {
"name": "<Signalwire Call Center Name>",
"outboundPhoneNumber": "<Signalwire Phone Number For Inbound and Outbound Interactions>",
"hostname": "<FQDN - Fully Qualified Domain Name>",
"menuEntryPoint": "main",
"agentHuntMode": "First_Available",
"textToSpeech":{
"voice" : "Polly.Joanna-Neural"
},
"acceptedChannels": [
"pstn",
"sip"
],
"enableRecording": false,
"enableExitSurvey": false,
"enableCallback": false,
"enableInboundText": false,
"enableTextSummary": true,
"enableAnnouncment": false,
"enableQueueStatsMessage": true,
"enableExitMessage": false,
"enableExitMessageFile": true,
"enableWaitingMusic": true,
"enableWaitingAds": true,
"waitingMusics": [
"https://sinergyds.blob.core.windows.net/signalwire/8d82b5_The_Muppet_Show_Theme_Song.mp3",
"https://sinergyds.blob.core.windows.net/signalwire/popcorn.mp3"
],
"waitingAds": [
"https://sinergyds.blob.core.windows.net/signalwire/ad_sample_1.mp3",
"https://sinergyds.blob.core.windows.net/signalwire/ad_sample_2.mp3",
"https://sinergyds.blob.core.windows.net/signalwire/ad_sample_3.mp3",
"https://sinergyds.blob.core.windows.net/signalwire/ad_sample_4.mp3"
],
"agents": {
"0": {
"name": "Kevin G.",
"wrapup":{
"enableAgentWrapUp": false,
"wrapUpUrl": "/some/endpoint/to/handle/post/call/stuff",
"wrapUpUrlMethod": "POST",
"meta":[
"call"
]
},
"channels": {
"sip": {
"endpoint": "",
"maxInteractions": 1,
"exclusiveInteraction": true
},
"pstn": {
"endpoint": "<Your Agent Phone Number E.164 Format>",
"maxInteractions": 1,
"exclusiveInteraction": true
},
"sms": {
"endpoint": "<Your Agent Phone Number E.164 Format>",
"maxInteractions": 1,
"exclusiveInteraction": false
},
"email": {
"endpoint": "<Your Agent Email Address>",
"maxInteractions": 2,
"exclusiveInteraction": false
},
"video": {
"endpoint": "",
"maxInteractions": 1,
"exclusiveInteraction": true
},
"dapp": {
"endpoint": "",
"maxInteractions": 1,
"exclusiveInteraction": true
}
},
"channelsEnabled": [
"pstn"
],
"roles": [],
"skills": [
"english",
{
"desirability": 1,
"proficiency": 1
},
"spanish",
{
"desirability": 0,
"proficiency": 0
}
]
},
"1": {
"name": "Rick Sanchez",
"channels": {
"pstn": {
"endpoint": "<Your Agent Phone Number E.164 Format>"
}
},
"wrapup":{
"enableAgentWrapUp": false,
"wrapUpUrl": "/some/endpoint/to/handle/post/call/stuff",
"wrapUpUrlMethod": "POST",
"meta":[
"call"
]
},
"channelsEnabled": [],
"roles": [],
"skills": [
"english",
{
"desirability": 1,
"proficiency": 1
},
"spanish",
{
"desirability": 0,
"proficiency": 0
}
]
}
},
Configuring the Python code
We need to start with a little housekeeping by creating an array to store our queues, opening the config.json file so that we can access the values inside, and setting our SignalWire authentication credentials. There are a few additional functions such as getLogs()
, cycle_check_thread()
, and updateLogs()
that we will not review in this writeup but you can read about them in detail through line comments within app.py
!
# Active Queues Array
ActiveQueues = []
# read menus from json file
with open('config.json') as f:
ccConfig = json.load(f)
# Dump config to console, for debugging
pprint.pprint(ccConfig)
# Set Signalwire Creds from JSON Config
HOSTNAME = ccConfig['settings']['hostname']
SIGNALWIRE_SPACE = ccConfig['signalwire']['space']
SIGNALWIRE_PROJECT= ccConfig['signalwire']['project']
SIGNALWIRE_TOKEN= ccConfig['signalwire']['token']
Our first function to define will be enqueue()
which will use the Enqueue verb to place a caller into a specified queue. We will use the friendlyName
parameter to indicate the queue and specify additional parameters such as the action
URL containing instructions for what to do next and the waitUrl
to play music.
def enqueue(queueName, action = HOSTNAME + "/enqueue_event", method = "POST", waitUrl=HOSTNAME + "/wait_music_queue", waitUrlMethod="POST"):
response = VoiceResponse()
response.say("Dialing " + queueName + " one moment please...", voice=ccConfig['settings']['textToSpeech']['voice'])
response.enqueue(queueName, action=action, method=method, waitUrl=waitUrl, waitUrlMethod=waitUrlMethod)
print(response)
# return response
return response
Our next function dial_conference()
will use the Dial verb and the Conference noun to connect an endpoint to a conference using the friendlyName
parameter and allow to specify additional parameters such as statusCallbackEvent
or startConferenceOnEnter
.
def dial_conference(conferenceName, muted=False, beep=False, startConferenceOnEnter=True, endConferenceOnExit=False, statusCallbackEvent="start end join leave speaker", statusCallback=HOSTNAME + "/conference_event", statusCallbackMethod="POST"):
response = VoiceResponse()
#response.say("Dialing " + conferenceName + " one moment please...", voice=ccConfig['settings']['textToSpeech']['voice'])
dial = Dial()
dial.conference(conferenceName, muted=muted, beep=beep, startConferenceOnEnter=startConferenceOnEnter, endConferenceOnExit=endConferenceOnExit, statusCallbackEvent=statusCallbackEvent,statusCallback=statusCallback, statusCallbackMethod=statusCallbackMethod)
response.append(dial)
print(response)
# return response
return response
The next function to define is redirectByRestApi()
which uses the Update Call API to redirect it using the callSid
. This will merge the caller into the conference with the agent assigned to the caller.
def redirectByRestApi(callSid, urlToRedirect):
client = signalwire_client(SIGNALWIRE_PROJECT, SIGNALWIRE_TOKEN, signalwire_space_url = SIGNALWIRE_SPACE)
# merge the caller into the conference with connect_agent
caller = client.calls(callSid) \
.update(
url= HOSTNAME + urlToRedirect,
method='POST'
)
pprint.pprint(caller)
return "200"
The next function connect_agent_ready()
allows us to update a call by queue like in the example above, but you can use a callSid
or say Front
to pop the next member in the queue and merge the call. In some cases, you may not have a callSid
! This function allows the queue to handle the update and get the next caller connected to the agent even without that parameter.
def connect_agent_ready(queueSid, callSidOrFront, agent, channel):
client = signalwire_client(SIGNALWIRE_PROJECT, SIGNALWIRE_TOKEN, signalwire_space_url = SIGNALWIRE_SPACE)
pprint.pprint(agent)
# Agent should always connect first, or make the first request
# We have an agent ready, willing and able to provide stellar customer service, add agent to conference
# We use conference, because it gives us more power for later, and advanced features.
endpoint = agent['channels'][channel]['endpoint']
call = client.calls.create(
url = HOSTNAME + '/connect_agent',
to = endpoint,
from_ = ccConfig['settings']['outboundPhoneNumber']
)
pprint.pprint(call)
# merge the caller into the conference with connect_agent
member = client.queues(queueSid) \
.members(callSidOrFront) \
.update(
url= HOSTNAME + '/connect_caller',
method='POST'
)
print(member.call_sid)
return "200"
Now that we have displayed all the major conference functionality functions, we will move on to the routes for handling GET or POST requests! As with the previous section, we will leave out some of the housekeeping routes for clarity but you can view the detailed comments within app.py to learn more about them.
Our first route /conference_event
will accept conference events from GET or POST. Within this route we will authenticate the SignalWire Client, look for the last conference participant, and redirect them for post conference using the redirectByRestApi()
function we defined in the previous section.
@app.route('/conference_event', methods=['GET', 'POST'])
def conference_event():
client = signalwire_client(SIGNALWIRE_PROJECT, SIGNALWIRE_TOKEN, signalwire_space_url = SIGNALWIRE_SPACE)
# May optimize later, this will take the last conference participant and redirect them for post conference
if request.values.get('StatusCallbackEvent') == 'participant-leave':
participants = client.conferences(request.values.get('ConferenceSid')) \
.participants \
.list(limit=2)
for record in participants:
print(record.call_sid)
redirectByRestApi(record.call_sid, '/post_conference?fromnumber=' + str(request.values.get('from')))
pprint.pprint(request.values)
# return response
return "200"
The next app route /wait_music_queue
will generate wait music as well as queue stat updates by utilizing our logger functions. Here we will update the queue logs and then play a message stating the caller's position in the queue, the current size of the queue, and the average queue size. If the config.json
file has enableWaitingAds
and enableWaitingMusic
set to True
in the settings
menu, we will play waiting music and ads until the music and ads cycle has completed. At this point, we will redirect back to the same /wait_music_queue
and increase the count by 1.
@app.route('/wait_music_queue', methods=['GET', 'POST'])
def wait_music_queue():
count = 0
if request.values.get('count'):
count = int(request.values.get('count')) + 1
response = VoiceResponse()
# Update the queueStats Logs
updateLogs('queueStats', request.values)
# Load the queueStatsMessage from config
queueStatsMessage = ccConfig['settings']['messages']['queueStatsMessage']
# Replace Template Vars With Values
queueStatsMessage = queueStatsMessage.replace("%QueuePosition%", str(request.values.get("QueuePosition")))
queueStatsMessage = queueStatsMessage.replace("%CurrentQueueSize%", str(request.values.get("CurrentQueueSize")))
queueStatsMessage = queueStatsMessage.replace("%AvgQueueTime%", str(request.values.get("AvgQueueTime")))
# If enabled play queueStats to caller on hold
response.say(queueStatsMessage, voice=ccConfig['settings']['textToSpeech']['voice'])
if ccConfig['settings']['enableWaitingAds']:
if count < len(ccConfig['settings']['waitingAds']):
response.play(ccConfig['settings']['waitingAds'][count])
else:
count = 0
response.play(ccConfig['settings']['waitingAds'][count])
if ccConfig['settings']['enableWaitingMusic']:
for music in ccConfig['settings']['waitingMusics']:
response.play(music)
response.redirect(HOSTNAME + "/wait_music_queue?count=" + str(count), method="POST")
return str(response)
The next few routes are quite short and perform very simple functions! /enqueue_event
and /voice_event
both exist to update the logs with the correct queue stats and voice stats. The /inbound_sms
route handles the acceptance of inbound messages but does not respond.
# accepts enqueue events from GET or POST
@app.route('/enqueue_event', methods=['GET', 'POST'])
def enqueue_event():
updateLogs('queueStats', request.values)
# return response
return "200"
# accepts voice status events from GET or POST
@app.route('/voice_event', methods=['GET', 'POST'])
def voice_event():
updateLogs('voiceStats', request.values)
# return response
return "200"
# accepts sms messages from GET or POST
@app.route('/inbound_sms', methods=['GET', 'POST'])
def inbound_sms():
# return response
return "200"
The next route /post_conference
handles what to do with the caller after their call with the agent has ended but before the termination of the call. We will check the settings
menu in the config.json
file to see if enableExitMessage
is set to True
. If it is, we will play a short exit message for the caller using the verbiage in the menu. If enableTextSummary
is set to True
, we will use the Create Message API to send a message to the caller thanking them for their call. Lastly, we will check if enableExitSurvey
is enabled. If so, we will redirect to the /get_survey
route which will be shown below.
@app.route('/post_conference', methods=['GET', 'POST'])
def post_conference():
response = VoiceResponse()
print("kevin here")
pprint.pprint(request.values)
# Check if announcment should be played
if ccConfig['settings']['enableExitMessage']:
response.say(ccConfig['settings']['messages']['exitMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])
if ccConfig['settings']['enableExitMessageFile']:
response.play(ccConfig['settings']['messages']['exitMessageFile'])
# Send Text Summary Message
#if ccConfig['settings']['enableTextSummary']:
# client = signalwire_client(SIGNALWIRE_PROJECT, SIGNALWIRE_TOKEN, signalwire_space_url = SIGNALWIRE_SPACE)
# message = client.messages.create(
# body='Thank you for participating in our demo today!',
# from_=ccConfig['settings']['outboundPhoneNumber'],
# to=request.values.From)
# print("message sent: " + message.sid)
# Check if survey should be enabled
if ccConfig['settings']['enableExitSurvey']:
response.say(ccConfig['settings']['messages']['surveyEntryMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])
response.redirect(HOSTNAME + "/get_survey", Method="POST")
return str(response)
The next route /enter_queue
is the web entry for entering the queue. The queue name is passed through as a query string variable. If a queue with the specified name doesn't currently exist within our ActiveQueues
array, we will use Enqueue to create one.
# Web entry for entering a queue, passing queue name as a querystring var
@app.route('/enter_queue', methods=['GET', 'POST'])
def enter_queue():
#queueInfo = client.queues.create(friendly_name=queue)
if str(request.values.get("name")) not in ActiveQueues:
ActiveQueues.append(str(request.values.get("name")))
response = enqueue( str(request.values.get("name")) )
# return response
return str(response)
The route /connect_agent
is pretty straightforward - this will accept GET and POST requests in order to connect the agent to the conference with the caller.
@app.route('/connect_agent', methods=['GET', 'POST'])
def connect_agent():
# Logic To Find Best Agent
response = dial_conference( str(request.values.get("name")) )
# return response
return str(response)
The next route /connect_caller
is the exact opposite - it will accept GET and POST requests to connect the caller to the conference with the agent!
# connects the caller to the conference with agent
@app.route('/connect_caller', methods=['GET', 'POST'])
def connect_caller():
response = dial_conference( str(request.values.get("name")) )
# return response
return str(response)
The next route /inbound_voice
will handle inbound calls to our phone number. We will first check the settings
menu to see if enableRecording
is True
and then play an entry message for the caller using the verbiage in the messages
submenu of the settings
menu. We will check if any announcements should be played and then redirect the main menu of options for the caller to sort through in the mainMenu
submenu of the settings
menu in the config.json
file.
@app.route('/inbound_voice', methods=['GET', 'POST'])
def inbound_voice():
response = VoiceResponse()
# Check if call should be recorded
if ccConfig['settings']['enableRecording']:
enableRecording(str(request.values.get('CallSid')))
response.say(ccConfig['settings']['messages']['entryMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])
# Check if announcment should be played
if ccConfig['settings']['enableAnnouncment']:
response.say(ccConfig['settings']['messages']['announcmentMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])
# Redirect to the main menu set in config.json
response.redirect(HOSTNAME + "/get_menu?menu=" + ccConfig['settings']['mainMenu'], method="POST")
# return response
return str(response)
The next route get_menu
dynamically builds the IVR menu tree from the JSON file and uses action routing to continually progress through the various submenus. We will loop through the menus starting at the main menu and ask the user to select input based on the option they'd like to select. After the input has been detected from the caller, we will reroute back to the /get_menu
route except we will specify the next menu to rotate through. This process will continue until the user has reached an agent or ended the call.
@app.route('/get_menu', methods=['GET', 'POST'])
def get_menu():
response = VoiceResponse()
# read menus from config
menus = ccConfig['settings']['menus']
# check to see if a default menu was specified, else default to "main"
menu = request.values.get("menu")
if menu not in menus:
menu = "main"
# read input_type variable
input_type = request.values.get("input_type")
# check if user input was provided via dtmf entry
if input_type == "dtmf":
# get digits pressed at menu
digits = request.values.get("Digits")
input_action = menus[menu][digits]["action"]
response.redirect(url=input_action)
response.hangup()
else:
# no user input was detected, so lets present a menu
gather = Gather(action='/get_menu' + "?menu=" + menu, input='dtmf', timeout="5", method='POST', numDigits="1")
# loop through menus and generate menu options
for key in menus[menu]:
print(key, '->', menus[menu][key]["verbiage"])
gather.say(menus[menu][key]["verbiage"], voice=ccConfig['settings']['textToSpeech']['voice'])
# add menu to response
response.append(gather)
response.hangup()
# return response
return str(response)
The last two routes are quite simple! /get_survey
and /get_voicemail
are both basic examples of routes that could take a survey after a call has ended or gather a voicemail when an agent is not available.
# Mock survey rendering for demo
@app.route('/get_survey', methods=['GET', 'POST'])
def get_survey():
response = VoiceResponse()
response.say('This is a survey placeholder...', voice=ccConfig['settings']['textToSpeech']['voice'])
return str(response)
# Mock voicemail rendering for demo
@app.route('/get_voicemail', methods=['GET', 'POST'])
def get_voicemail():
response = VoiceResponse()
response.say('You have reached our voicemail, please leave a message.', voice=ccConfig['settings']['textToSpeech']['voice'])
response.hangup()
return str(response)
Wrap Up
There are many different methods of building out an IVR, however, when building with a Dynamic JSON menu, the possibilities for customizability increase drastically. Whether you are looking to enable, disable or modify any feature of the IVR, all are achievable with just a couple of curly braces.
Once our application was running, we used ngrok to obtain a webhook that could be configured to your SignalWire phone number. And just like that, you now have the most expansive and impressive IVR on your block.
Required Resources:
Sign Up Here
If you would like to test this example out, create a SignalWire account and Space.
Please feel free to reach out to us on our Community Slack or create a Support ticket if you need guidance!