Sharing Data Between Services Using Python and Twilio Sync

December 11, 2020
Written by
Taylor Facen
Contributor
Opinions expressed by Twilio contributors are their own

Sharing Data Between Services Using Python and Twilio Sync

If you have an application that uses multiple Twilio services, you’ve probably used a database to share data from one medium to another. Although this gets the job done, it’s not always scalable, fast, or easy to implement. Twilio Sync is a service that allows data to be easily stored and shared across devices, services, and even other Twilio APIs. This blog post will demonstrate how to use Sync by building a voice memo application that utilizes Autopilot, SMS, and Voice, all from Python. By the end of this project, you’ll know how to make data flow from text to voice and back to text all within milliseconds!

The goal of the Voice Memo bot is to allow users to create, record, and retrieve voice memos on their phone. First, the user sends a text to the project’s Twilio number saying that they want to create a memo. This then starts an Autopilot task which collects the memo’s name and tag. The user is then called so that they can record the memo over the phone using Programmable Voice’s record command. After the recording is done and the memo has been transcribed on the backend, the user is sent a summary of their new memo. As an added bonus, the application will also allow them to look through previously saved memos. Sharing data between all of these components is easy thanks to Sync.

Prerequisites

To follow this tutorial, you will need the following:

  • Python 3.6 or newer. If your operating system does not provide a Python interpreter, you can go to python.org to download an installer.
  • Virtualenv - Virtualenv is an environment manager. It’s a best practice to use one so that your code runs with the right versions of packages for this project
  • Flask - Flask will be used to create the backend for the application
  • ngrok - Ngrok is a handy tool that assigns a public URL to an application that you’re running locally. We’ll use this public URL in the Autopilot bot. If you don’t have ngrok installed, you can download a copy for Windows, MacOS or Linux.
  • A mobile phone that can send and receive SMS messages
  • A Twilio account. If you are new to Twilio create a free account now. You can review the features and limitations of a free Twilio account.

Because this tutorial will focus more on Sync, some familiarity with Autopilot will be helpful. To learn more about Autopilot, you can view this blog post

Environment Set Up

You can download the starter code for this project here. You can also browse the files on GitHub.

Here’s an overview of the file structure:

  • app.py - The Flask app used to retrieve and send data will live here
  • bot.json and tasks.txt- These files will be used to set up Autopilot later on.
  • config.py - We’ll use this file to manage the project’s environment variables
  • requirements.txt - All of the project’s dependencies live here

After installing the files locally, create a virtual environment and install the dependencies found in the requirements file by running the following from a terminal in the project folder:

virtualenv venv
source venv/bin/activate
pip install -r requirements.txt

Project Set up

Head over to the Twilio console and buy a phone number if you don’t have one yet. Write the number down for reference later.

Next, create a new .env file in the project directory and copy and paste the Account Sid and Auth Token displayed on the Twilio dashboard. The .env file should look like this:

TWILIO_ACCOUNT_SID=ENTER_ACCOUNT_SID_HERE
TWILIO_AUTH_TOKEN=ENTER_AUTH_TOKEN_HERE

On the left panel, click the three dots to open up the services pane and select Sync, which should be near the bottom of the panel. Click on “Services” and create a new service for this project. You can give it any name you like. After the service is created, copy the service SID and paste into a third line in the .env file. Now, the file should look like this:

TWILIO_ACCOUNT_SID=ENTER_ACCOUNT_SID_HERE
TWILIO_AUTH_TOKEN=ENTER_AUTH_TOKEN_HERE
TWILIO_SYNC_SID=ENTER_SYNC_SID_HERE

Lastly, Autopilot can be set up interactively or from the command line with the Autopilot CLI. If you want to use the CLI, install it following these instructions.

From the project’s directory, you can now set up the Autopilot bot, its tasks, and its samples by entering the following command in the terminal:

twilio autopilot:create --schema=bot.json

To set up the bot manually from the Twilio console, open the services pane and click on “Autopilot”. From there, create a new bot with the name voice-memo. Remove all the default tasks and then define two tasks called get_memo_data and list_memos. The code to program the tasks can be found in the tasks.txt file that you downloaded earlier, and below is a list of the samples I used for each of the tasks. Feel free to modify as you see fit.

Samples for get_memo_data:

  • Memo
  • Start memo
  • I want to record a memo
  • Record
  • Record memo
  • Voice memo

Samples for list_memos:

  • List memos
  • What memos do I have?
  • All memos
  • List my last {limit} memos
  • What are my last {limit} memos?
  • List {limit} memos
  • List {limit} memo

The last step is to connect Autopilot to the Twilio phone number. Click on “Channels” in the side bar and select “Programmable Messaging”. Copy the messaging URL to the clipboard and head over to the “Phone Numbers” section of the Twilio console. Click on the number you’ve chosen earlier. Scroll down to the “Messaging” section and paste the URL under “A message comes in”. Make sure the dropdown to the left of the URL is set to “Webhook”, and the dropdown to the right is set to “HTTP POST”.

Everything is all set up! Let’s work on the app.

Starting the Conversation

Remember all of the environment variables we stored earlier? Now it’s time to access them in the application. Update your config.py file so that it looks like the following:

from dotenv import load_dotenv
import os

load_dotenv()

TWILIO_ACCOUNT_SID = os.environ['TWILIO_ACCOUNT_SID']
TWILIO_AUTH_TOKEN = os.environ['TWILIO_AUTH_TOKEN']
TWILIO_SYNC_SID = os.environ['TWILIO_SYNC_SID']

The load_dotenv function loads the environment variables from the .env file into the environment. Then, you’re able to save them into the TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_SYNC_SIDvariables.

At the top of the app.pyfile, import these variables so that they can be accessed later.

from config import TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_SYNC_SID

Let’s go ahead and create an instance of the Twilio client for use throughout the application. First import the Client class at the top of the app.py file:

from twilio.rest import Client

And then create a client instance right below the app = Flask(__name__) line:

client = Client(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)

When a user initiates a conversation with the bot and sends a prompt that matches the samples stored for the get_memo_data task, Autopilot will run a collect task that asks the user to provide a memo title and a tag. It will then send these responses to the URL listed in the task actions.

Import the json package at the top and update the start_memo route with the following lines of code to parse the user’s responses.

import json

# ...

@app.route('/start_memo', methods = ['POST'])
def start_memo():
    # Get data from request
    data = request.form.to_dict()
    api_url = request.host_url

    # Collect memo data from autopilot memory
    memory = json.loads(data['Memory'])
    twilio_number = memory['twilio']['sms']['To']
    user_number = memory['twilio']['sms']['From']
    responses = memory['twilio']['collected_data']['get_memo_data']['answers']
    memo_title = responses['memo_title']['answer']
    memo_tag = responses['memo_tag']['answer']

Here we store the information provided by Autopilot in the data variable, from where we extract all the details from the user. The api_url variable extracts the URL from the server, so that we can use it later in follow-up callbacks.

Now that we have the user’s phone number, information about their memo, and the api_url, the next step is to initiate a phone call. Continue the start_memo function, by adding the following code which calls a user using TwiML.

    # Call user
    twiml = '''
        <Response>
            <Say>When you're done recording, press the pound key.</Say>
            <Record timeout="10" transcribe="true" transcribeCallback="{}process_memo"/>
            <Say>You'll receive a text when your memo is processed. Goodbye</Say>
        </Response>
    '''.format(api_url)

    call = client.calls.create(
        twiml = twiml,
        to = user_number,
        from_ = twilio_number
    )

    print(call.sid)

The TwiML snippet tells Programmable Voice to give some instructions to the user after they pick up the phone, and then start a recording, which should be transcribed and should timeout after 10 seconds of silence. After the recording is complete the call will end. We are requesting that the audio transcription is sent to the process_memo route on this same server. We’ll define this route shortly.

Ready to test the work we’ve done so far? Run python app.py in the terminal which should start the server on localhost port 5000.

(venv) $ python app.py                                         
 * Serving Flask app "app" (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: on
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 176-764-435

Leave the Flask application running and open a second terminal window. On this new terminal run ngrok http 5000 to allocate a temporary public domain that redirects HTTP requests to our local port 5000. On a Unix or Mac OS computer you may need to use ./ngrok http 5000 if you have the ngrok executable in your current directory. The output of ngrok should be something like this:

Ngrok screenshot

Note the lines beginning with “Forwarding”. These show the public URL that ngrok uses to redirect requests into our service. Copy either one of these URLs and replace  <ENTER_API_URL_HERE> with this value in both of the get_memo_data_ and list_memos Autopilot tasks in the console. For example, your get_memo_data task should look something like this:

Change webhook URL

Let’s test the application out by texting “Memo” to the bot’s number. You will be asked to send the memo’s title and tag, and then you will receive a phone call. You should see the call’s sid printed out to the terminal where you launched the Flask application.

Bot demo

Flask server output

If you have any problems running the code up to this point, you can see how it should look here.

Storing Data via Sync

Let’s add Sync! Because we want each user to be able to store multiple memos, we’ll set up a sync list for each unique phone number. For each new memo that a user records, we’ll add a sync list item.

Below the client definition at the top, create a sync variable that we’ll use to access sync services.

client = Client(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
sync = client.sync.services(TWILIO_SYNC_SID)

First we’ll need to get a user’s sync list if they already have one or create one if they don’t. There’s currently no way to check for existence of a sync list, so as a workaround, we’ll use a try, except block. ImportTwilioRestException at the top of app.py:

from twilio.base.exceptions import TwilioRestException

Then, right after we print the call sid, we query for a sync list with the user’s phone number as its unique name. If that raises an exception, meaning the list doesn’t exist, we create a new one.

    # Create a sync list for the phone number if it doesn't already have one
    try:
        sync_list = sync.sync_lists.get(user_number).fetch()
    except TwilioRestException:
        sync_list = sync.sync_lists.create(unique_name = user_number)

Now, we can store the memo data as a list item to the user’s list. Import datetime at the top and then add the following code after you created the sync list.

from datetime import datetime
    data = {
        "title": memo_title,
        "tag": memo_tag,
        "call_sid": call.sid,
        "created_on": datetime.now().strftime("%b %d - %-I:%M %p")
    }

    document = sync_list.sync_list_items.create(data = data)

This list item has the title and tag given by the user, the unique identifier for the call on which the user will record the memo, and a formatted datetime string of when the memo was created.

We’ll finish up this route by returning back an empty list of actions which signifies that the SMS Autopilot conversation should end.

    # Return an empty action to end the autopilot conversation
    actions = {
        "actions": []
    }

    return actions

Here is how the code looks after everything we’ve done so far.

Storing the Memo Recording

When the recording of the memo is finished processing in the backend, Programmable Voice sends the results to the URL listed in the transcribeCallback argument earlier. We’ll start off similar to how we started the start_memo route.

@app.route('/process_memo', methods = ['POST'])
def process_memo():
    # Get data from request
    data = request.form.to_dict()

    # Collect recording info from data
    twilio_number = data['From']
    user_number = data['To']
    recording_url = data['RecordingUrl']
    if data['TranscriptionStatus'] == "failed":
        transcription = "There was an error transcribing this memo."
    else:
        transcription = data['TranscriptionText']
    
    call_sid = data['CallSid']

Then, let’s look through the last 5 items in our sync list to find the memo with the same call_sid as the one relating to this recording:

    sync_list = sync.sync_lists.get(user_number).fetch()
    sync_list_items = sync_list.sync_list_items.list(limit = 5, order = "desc")
    sync_list_item = list(filter(lambda item: item.data.get('call_sid') == call_sid, sync_list_items))[0]

Once we’ve found the right list item, we can now add the recording link and transcription to the document:

    # Add memo to document
    new_data = {
        **sync_list_item.data,
        'recording_url': recording_url,
        'transcription': transcription
    }

    sync_list_item.update(data = new_data)

We now have all the information we need to send the processed memo back to the user.

    # Send user the memo
    memo_title = new_data['title']
    memo_tag = new_data['tag']
    created_on = new_data['created_on']

    client.messages.create(
        body = "%(title)s\nTag: %(tag)s\nCreated on: %(created_on)s\nRecording link: %(recording_url)s\n\n%(transcription)s" % {
            "title": memo_title,
            "tag": memo_tag,
            "created_on": created_on,
            "recording_url": recording_url,
            "transcription": transcription
        }, 
        from_ = twilio_number,
        to= user_number
    )

    return {}, 200

Saving these changes automatically updates the Flask server, so we are now ready to test it out! By this point, you should be able to create a memo, record the memo on the phone, and receive a message describing the memo after it’s been processed.

bot demo

You can check out the code up until this checkpoint here.  

How to Retrieve Previous Memos

Alright, one memo down, many more to go! The user should be able to query all of their previous memos. The second Autopilot task, list_memos, will first ask the user how many memos they would like to look through (if not already presented in the task initiating message) and then return high-level information about those few last memos, including their name, tag, and when they were created. If the user wants to see more, they can specify which memo to retrieve by using the list item’s index. The index is generated by Sync and can be used to access a specific list item.

Update the list_memos route with the following.

def list_memos():
    # Get data from request
    data = request.form.to_dict()
    api_url = request.host_url

    # Collect user phone number and limit from memory
    memory = json.loads(data['Memory'])
    user_number = memory['twilio']['sms']['From']
    responses = memory['twilio']['collected_data']['query']['answers']
    limit = responses['limit']['answer']

    # Fetch memos from user's sync list
    sync_list = sync.sync_lists.get(user_number).fetch()
    sync_list_items = sync_list.sync_list_items.list(limit = int(limit), order = "desc")

    # Return list of memos (metadata only)
    memos = [{
        "id": memo.index,
        "title": memo.data['title'],
        "tag": memo.data['tag'],
        "created_on": memo.data['created_on']
    } for memo in sync_list_items]

    memos_sorted = sorted(memos, key = lambda memo: memo['id'])

    memos_message = '\n\n'.join([ "{}) {} - {} - {}".format(memo['id'], memo['title'], memo['tag'], memo['created_on']) for memo in memos_sorted])

    actions = {
        "actions": [
            {
                "say": memos_message
            }, {
                "collect": {
                    "name": "memo_selection",
                    "questions": [
                        {
                            "question": "Which memo would you like to see?",
                            "name": "memo_id",
                            "type": "Twilio.NUMBER"
                        }
                    ],
                    "on_complete": {
                        "redirect": "{}/fetch_memo".format(api_url)
                    }
                }
            }
        ]
    }

    return actions

This code parses the incoming request from Autopilot, fetches the requested number of memos from the user’s sync list, formats the memo metadata into a string, and then returns actions for the Autopilot conversation, which include showing the list of memos and asking the user to select one for more details.

When the user responds with the index of a particular memo, a request is made to the fetch_memo route, and all of the information of the resulting memo is sent back to the user. To set this up, update the fetch_memo route with this code:

def fetch_memo():
    # Get data from request
    data = request.form.to_dict()

    # Collect user phone number and memo id from memory
    memory = json.loads(data['Memory'])
    user_number = memory['twilio']['sms']['From']
    responses = memory['twilio']['collected_data']['memo_selection']['answers']
    memo_id = responses['memo_id']['answer']

    # Fetch memo from user's sync list
    sync_list = sync.sync_lists.get(user_number).fetch()
    sync_list_item = sync_list.sync_list_items(memo_id).fetch()
    memo = sync_list_item.data

    # Return memo
    memo_message = "%(title)s\nTag: %(tag)s\nCreated on: %(created_on)s\nRecording link: %(recording_url)s\n\n%(transcription)s" % {
        "title": memo['title'],
        "tag": memo['tag'],
        "created_on": memo['created_on'],
        "recording_url": memo['recording_url'],
        "transcription": memo['transcription']
    }

    actions = {
        "actions": [
            {
                "say": memo_message
            }
        ]
    }

    return actions

Now, try to list some memos! You now have a working voice memo bot that you can use to send, store, and retrieve memos!

Final bot demo

You can find the final iteration of the code used for this project here.

You may notice that while memo indices are in ascending order, they may skip some numbers. As stated in the Sync documentation, that’s totally fine.

Conclusion

You’ve now built an application that utilizes Twilio Sync to store and retrieve data across multiple services like Autopilot and Programmable Voice. From here you can add more features such as allowing the user to filter by tag, letting the user delete a memo after it’s been recorded, and even allowing the user to create and retrieve memos from WhatsApp.

Taylor Facen is currently a dual degree MBA and Masters of Engineering student at MBA who loves to build bots, advise startups on their tech strategy, and (now virtually) hanging out with her fellow Twilio Champions. You can learn more about her on her website or by following her on Twitter.