Skip to main content

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

  1. Build on docker
./docker-build
  1. 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
  1. The application will run on port 80

Build and Run Natively

  1. Edit config.json to build out your contact center.
  2. 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!