Full Contact Center

This guide will show you how to implement basic contact center functionality. We will build off of the Dynamic IVR with JSON Menus guide, and enhance it by adding queues, conferencing, and a little bit of magic!

What do I need to run this code?

You will need a machine with Python installed, the SignalWire Python SDK, a provisioned SignalWire phone number, and optionally Docker if you decide to run it in a container.

If you'd like to first try out the precursor that this application was built on, check out the guide here!

Configuring the Python code

We need to define functions that will enqueue a caller to a specific queue, dial a conference, redirect a call, and update a call to merge caller and agent.

We will start by going over how to enqueue a caller to a specific queue. We will initialize VoiceResponse as response so that we can use <Say>(text to speech) to alert the caller that we will be adding them to the queue. Next, we will use the <Enqueue> verb along with the friendlyName of the queue in join the caller into the queue.

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

Next, we need to define our function for dialing a conference. This will connect us to a conference using the friendlyName as we did with the queue function previously defined. We start by initializing VoiceResponse() again and alert the caller that they are being dialed into the conference. We will also add additional parameters muted, beep, startConferenceOnEnter, endConferenceOnExit, and status callback events. These parameters are explained in further detail below:

muted = Whether or not a caller can speak in a conference. Default is false.
beep = Whether or not a sound is played when callers leave or enter a conference. Default is true.
startConferenceOnEnter = The conference begins once a specific caller enters into the conference room, unless it has already started. If a participant joins and startConferenceOnEnter is false, that participant will hear background music and stay muted until a participant with startConferenceOnEnter set to true joins the call. Default is true.
endConferenceOnExit = If a participant with endConferenceOnExit set to true leaves a conference, the conference terminates and all participants drop out of the call. Default is false.

def dial_conference(conferenceName, muted=False, beep=True, 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

This function will perform the actions of updating a call, redirecting it by using the callSid. We will instantiate the SignalWire Client and update the call with the URL to redirect to. For example, later we will see how if a participant is the last one in a conference this function will be called to redirect them to the post-conference handling route.

def redirectByRestApi(callSid, urlToRedirect):
    client = signalwire_client(SIGNALWIRE_PROJECT, SIGNALWIRE_TOKEN, signalwire_space_url = SIGNALWIRE_SPACE)
    
    caller = client.calls(callSid) \
               .update(
                   url= HOSTNAME + urlToRedirect,
                    method='POST'
                )

    print(caller.call_sid)
    return "200"

This function connects the caller to an agent by calling the SignalWire Communications API to update the call. The agent should always connect first, or make the first request. Once we have an agent ready, we will add the agent to the conference and merge the caller in using '/connect_agent' and '/connect_caller'.

def connect_agent_ready(queueSid, callSidOrFront, agent, channel):
    client = signalwire_client(SIGNALWIRE_PROJECT, SIGNALWIRE_TOKEN, signalwire_space_url = SIGNALWIRE_SPACE)
    
    pprint.pprint(agent)

    endpoint = agent['channels'][channel]['endpoint']
    call = client.calls.create(
                        url = HOSTNAME + '/connect_agent',
                        to = endpoint,
                        from_ = ccConfig['settings']['outboundPhoneNumber']
                    )
    pprint.pprint(call)

    member = client.queues(queueSid) \
               .members(callSidOrFront) \
               .update(
                   url= HOSTNAME + '/connect_caller',
                    method='POST'
                )

    print(member.call_sid)
    return "200"

Now that we have defined all of our functions, we need to take a look at our routes.

We will start with the /get_menu endpoint. Requests to this route will generate and look for dtmf entries to select an action for routing and move along the JSON Menu tree.

We will start by reading the menus from the config.JSON file. If a default menu was specified, we will route to that menu first. Otherwise, we will start at the main menu and give the user all of their options. We need to read the input_type variable to determine if any dtmf has been pressed so that we can correctly move to the next part of the menu based on what digits the user enters. If no user input is detected, then we will present a menu to the user. We will loop through the menu options until user input is pressed, and then add the input to the response.

@app.route('/get_menu', methods=['GET', 'POST'])
def get_menu():
    response = VoiceResponse()
    menus = ccConfig['settings']['menus']

    menu = request.values.get("menu")
    if menu not in menus:
        menu = "main"

    input_type = request.values.get("input_type")


    if input_type == "dtmf":
        digits = request.values.get("Digits")
        input_action = menus[menu][digits]["action"]
        response.redirect(url=input_action)
        response.hangup()
    else:
        gather = Gather(action='/get_menu' + "?menu=" + menu, input='dtmf', timeout="5", method='POST', numDigits="1")

        for key in menus[menu]:
            print(key, '->', menus[menu][key]["verbiage"])
            gather.say(menus[menu][key]["verbiage"], voice=ccConfig['settings']['textToSpeech']['voice'])

        response.append(gather)
        response.hangup()

    return str(response)

The next route we define will be for handling conference events, meaning that every time the status of a conference changes, this route will be called. We need to start by instantiating the SignalWire client so that we can use the API. If the participant is the last conference participant, we will use our redirectByRestApi() function to redirect them to the post conference route that will be defined below.

@app.route('/conference_event', methods=['GET', 'POST'])
def conference_event():
    client = signalwire_client(SIGNALWIRE_PROJECT, SIGNALWIRE_TOKEN, signalwire_space_url = SIGNALWIRE_SPACE)
    
    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')
   
    pprint.pprint(request.values)

    return "200"

The /post_conference() route will be called to handle the caller before the call is terminated. If the enableExitSurvey and enableExitMessage parameters are set to True in the config.JSON file, we will either redirect to the exit survey or play the set exit message and hangup.

@app.route('/post_conference', methods=['GET', 'POST'])
def post_conference():
    response = VoiceResponse()

    if ccConfig['settings']['enableExitSurvey']:
        response.say(ccConfig['settings']['messages']['surveyEntryMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])
        response.redirect(HOSTNAME + "/get_survey", Method="POST") 

    if ccConfig['settings']['enableExitMessage']:
        response.say(ccConfig['settings']['messages']['exitMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])

    return str(response)

We don't want to leave the caller in a queue with no updates and complete silence, so we need to define a way to play some waiting music and provide our caller with updates on their current wait time. This will be our /wait_music_queue route! We will keep track of the number of times we have to call this function using our iterator count. We will load the queueStatsMessage from our config file and replace the template with actual values. We will then play those stats to the caller so they know how long the average wait is and what place in line they are! There is an option in the config file to enable waiting ads, in case you'd like to use this time to tell your customer a little more about your product. However, in this demo, we will play wait music instead! When the music has reached the end of the file, we will call this function again, increase the count by one, and recalculate the queue stats.

@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()

    queueStatsMessage = ccConfig['settings']['messages']['queueStatsMessage']
    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")))

    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)

Next, we will define the /enter_queue route. This route handles requests to enter a queue by passing the queue name as a query string variable.

@app.route('/enter_queue', methods=['GET', 'POST'])
def enter_queue():
    response = enqueue( str(request.values.get("name")) )
    return str(response)

Like the previous route, the /enqueue_event and /voice_event routes are quite short. We will use this route to update our logs with the current stats whenever we get a status callback event for queue status or call status.

@app.route('/enqueue_event', methods=['GET', 'POST'])
def enqueue_event():
    updateLogs('queueStats', request.values)
    return "200"

@app.route('/voice_event', methods=['GET', 'POST'])
def voice_event():
    updateLogs('voiceStats', request.values)
    return "200"

Our next endpoint will be responsible for handling all the inbound voice call requests. We will check in the configuration file to see if enableRecording is set to true and if so, start recording. We then play an entry message to begin the customer interaction. We will check to see if any other announcements have been set to play in the config file, and if not we will redirect to our main menu with the /get_menu route.

@app.route('/inbound_voice', methods=['GET', 'POST'])
def inbound_voice():
    response = VoiceResponse()

    if ccConfig['settings']['enableRecording']:
        enableRecording(str(request.values.get('CallSid')))

    response.say(ccConfig['settings']['messages']['entryMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])

    if ccConfig['settings']['enableAnnouncement']:
        response.say(ccConfig['settings']['messages']['announcementMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])

    response.redirect(HOSTNAME + "/get_menu?menu=" + ccConfig['settings']['mainMenu'], method="POST")
    
    return str(response)

The next two endpoints /connect_agent and /connect_caller are both two sides of the same coin. /connect_agent will be used to connect the agent to the conference with the caller, while /connect_caller will be used to connect the caller to the conference with the agent.

@app.route('/connect_agent', methods=['GET', 'POST'])
def connect_agent():
    response = dial_conference( str(request.values.get("name")) )
    return str(response)

@app.route('/connect_caller', methods=['GET', 'POST'])
def connect_caller():
    response = dial_conference( str(request.values.get("name")) )
    return str(response)

The last endpoint we will define is for the purpose of making the actual connection! By calling this route, the caller and agent will be connected. We will first get the channel and the friendlyQueueName so we know where to route the call. We will check in the config file for a pre-defined queue that matches the friendlyName and if it exists, we will return the queueSid. Next, we will call our previously defined connect_agent_ready() function to connect the caller and agent. If the call is unable to bridge or the queue cannot be found, we will return Failed.

@app.route('/make_connection')
def make_connection():
    pprint.pprint(request.values)

    channel = request.values.get("channel")
    friendlyQueueName = request.values.get("name")

    if friendlyQueueName in ccConfig['settings']['queues']:
        queueSid = ccConfig['settings']['queues'][friendlyQueueName]['queueSid']
        pprint.pprint(queueSid)

        for agent in ccConfig['settings']['agents']:
            pprint.pprint(agent)
            if channel in ccConfig['settings']['agents'][agent]['channelsEnabled']:
                connect_agent_ready(queueSid, 'Front', ccConfig['settings']['agents'][agent], channel)
                return "Connecting ..."
    return "Failed ..."

Configuring the JSON file

The config.json file needs to be set up to your specifications. This example file has endpoints/groups that will contain list of agents. Each menu contains an index, which is equal to the key-press on the menu, a verbiage, and an action.

{
  "settings": {
    "name": "Kevin's Contact Center",
    "outboundPhoneNumber": "--redacted--",
    "hostname": "http://--redacted--",
    "mainMenu": "main",
    "agentHuntMode": "Longest_Idle",
    "acceptedChannels": [ "voice", "sip" ],
    "enableAnnouncement": true,
    "enableExitSurvey": false,
    "enableCallback": false,
    "enableInboundText": false,
    "enableTextSummary": false,
    "enableQueueStatsMessage": true,
    "enableExitMessage": false,
    "enableWaitingMusic": true,
    "enableWaitingAds": true,
    "enableWrapUpUrl": false,
    "wrapUpUrl": "/some/endpoint/to/handle/post/call/stuff",
    "wrapUpUrlMethod": "POST",
    "waitingMusics": [ "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"
    ],
    "textToSpeech": {
      "voice": "Polly.Matthew",
      "language": "en-US"
    },
    "agents": {
      "0": {
        "name": "Kevin G.",
        "channels": {
          "sip": { "endpoint": "sip:[email protected]" },
          "voice": { "endpoint": "--redacted--" },
          "text": { "endpoint": "/some/url/to/process/this" }
        },
        "channelsEnabled": [ "voice", "sip" ],
        "roles": [],
        "skills": [
          "english",
          {
            "desirability": 1,
            "proficiency": 1
          },
          "spanish",
          {
            "desirability": 0,
            "proficiency": 0
          }
        ]
      },
      "1": {
        "name": "Rick Sanchez",
        "channels": {
          "voice": { "endpoint": "--redacted--" }
        },
        "channelsEnabled": [],
        "roles": [],
        "skills": [
          "english",
          {
            "desirability": 1,
            "proficiency": 1
          },
          "spanish",
          {
            "desirability": 0,
            "proficiency": 0
          }
        ]
      },
      "2": {
        "name": "Marty McFly",
        "channels": {
          "voice": { "endpoint": "--redacted--" }
        },
        "channelsEnabled": [],
        "roles": [],
        "skills": [
          "english",
          {
            "desirability": 1,
            "proficiency": 1
          },
          "spanish",
          {
            "desirability": 1,
            "proficiency": 1
          }
        ]
      }
    },
    "queues": {
      "salesPartners": { "queueSid": "--redacted--" },
      "salesSupport": { "queueSid": "--redacted--" },
      "techSupport": { "queueSid": "--redacted--" }
    },
    "messages": {
      "queueStatsMessage": "You are position ... %QueuePosition% ... of ...%CurrentQueueSize% ... Callers with an average wait time of ...%AvgQueueTime% ... seconds. Thank you for holding.",
      "entryMessage": "Thank You For Calling Kevin's Excellent Call Center!!",
      "exitMessage": "Thank You For Calling! The real O Gs.. Original Geeks!  Remember you can reach us on the web at signalwire dot com"
    },
    "menus": {
      "main": {
        "1": {
          "verbiage": "For Sales, Press 1",
          "action": "/get_menu?menu=sales"
        },
        "2": {
          "verbiage": "For Tech Support, Press 2",
          "action": "/get_menu?menu=tech"
        }
      },
      "sales": {
        "1": {
          "verbiage": "For Partners, Press 1",
          "action": "/enter_queue?name=salesPartners"
        },
        "2": {
          "verbiage": "For Help With Your Purchase, Press 2",
          "action": "/enter_queue?name=salesSupport"
        }
      },
      "tech": {
        "1": {
          "verbiage": "For issues with your internet, press 1",
          "action": "/enter_queue?name=techSupport"
        },
        "2": {
          "verbiage": "For issues with your cell phone, press 2",
          "action": "/get_voicemail?group=mobileSupport"
        }
      }
    }
  },
  "signalwire": {
    "space": "",
    "project": "", 
    "token":""
  },
  "mailgun": {
    "domain": "",
    "token": ""
  }
}

Build and Run on Docker

Let us get started!

  1. Use our pre-built image from Docker Hub
For Python:
docker pull signalwire/snippets-simple-contact-center:python

(or build your own image)

  1. Build your image
docker build -t snippets-simple-contact-center .
  1. Run your image
docker run --publish 5000:5000 snippets-simple-contact-center
  1. The application will run on port 5000

Build and Run Natively

For Python

1. Edit config.json, to build out your contact center.
2. From command line run, python3 app.py

Sign Up Here

If you would like to test this example out, you can create a SignalWire account and space here.

Please feel free to reach out to us on our Community Slack or create a Support ticket if you need guidance!


What’s Next

Check out the full code on our SignalWire Github Repo!

Did this page help you?