- language:python - sdk:compatibility - product:voice repo: https://github.com/signalwire/guides/tree/main/Voice/Call%20Center%20with%20Dynamic%20JSON%20Menus%20and%20Python 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 youd like to first try out the precursor that this application was built on, check out the guide [here](https://github.com/signalwire/guides/tree/main/Voice/Call%20Center%20with%20Dynamic%20JSON%20Menus%20and%20Python)! 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](https://github.com/signalwire/snippets-simple-contact-center) to get going in no time. This guide uses the Python SignalWire SDK, for a guide on installation click [here](/compatibility-api/sdks). 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 2. 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 3. 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](https://ngrok.com/). You can learn more about how to use ngrok [here](/guides/how-to-test-webhooks-with-ngrok).
Step by Step Code Walkthrough -- Within the Github repository, you will find several files, but were 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!
javascript { settings: { name:
### 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!
python # 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](/compatibility-api/cxml/voice/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.
python 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](pathname:///compatibility-api/cxml/voice/dial) verb and the [Conference](pathname:///compatibility-api/cxml/voice/conference-noun) noun to connect an endpoint to a conference using the friendlyName parameter and allow to specify additional parameters such as statusCallbackEvent or startConferenceOnEnter.
python 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](/rest/compatibility-api/endpoints/update-a-call) to redirect it using the callSid. This will merge the caller into the conference with the agent assigned to the caller.
python 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.
python 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.
python @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 callers 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. python @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.
python # 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](/rest/compatibility-api/endpoints/create-message) 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.
python @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 doesnt currently exist within our ActiveQueues array, we will use [Enqueue](pathname:///compatibility-api/cxml/voice/enqueue) to create one.
python # 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.
python @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!
python # 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.
python @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 theyd 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.
python @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 ](/guides/how-to-set-up-voicemail) when an agent is not available.
python # 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: - [Github Repo](https://github.com/signalwire/guides/tree/main/Voice/Call%20Center%20with%20Dynamic%20JSON%20Menus%20and%20Python) - [Python SignalWire SDK](/compatibility-api/sdks) Sign Up Here If you would like to test this example out, [create a SignalWire account and Space](https://m.signalwire.com/signups/new?s=1). Please feel free to reach out to us on our [Community Slack](http://signalwire.community/) or create a Support ticket if you need guidance!