Build a WhatsApp Flashcard Bot With Python, Flask and Twilio

January 14, 2020
Written by
Parry Cadwallader
Contributor
Opinions expressed by Twilio contributors are their own

Build a WhatsApp Flashcard Bot with Python, Flask and Twilio

A flashcard is one of the most tried and true study tools, helping anyone from medical students to language learners all approach their goal of memorizing facts and figures. Software like Anki or Memrise have provided countless features and robust settings that make digital flashcards increasingly useful. However, there are plenty of times where you might not be able to pull up one of the more robust applications and need something more lightweight to study with.

For example, when your cell signal is good enough for texting, but cannot load a full-featured web application. Or perhaps you’re in an airplane that doesn’t allow various websites, but does allow you to text phone numbers down on the ground.

This tutorial will show you how to make a basic flashcard bot for WhatsApp or SMS to get you started on your way to reviewing flashcards wherever you are. You can see an example of what we’ll be building below:

flashcard bot demo

Tutorial Requirements

To follow this tutorial you need the following components:

  • 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.
  • Flask. We will create a web application that responds to incoming WhatsApp messages with it.
  • SQLite. A very simple database that we will use to store flashcards.
  • ngrok. We will use this handy utility to connect the Flask application running on your system to a public URL that Twilio can connect to. This is necessary for the development version of the chatbot because your computer is likely behind a router or firewall, so it isn’t directly reachable on the Internet. If you don’t have ngrok it installed, you can download a copy for Windows, MacOS or Linux.
  • A smartphone with an active phone number and WhatsApp installed. This tutorial works with standard SMSes as well if you prefer that over WhatsApp.
  • 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.

Configure the Twilio WhatsApp Sandbox

Note: as mentioned above, this tutorial works for both SMS and WhatsApp. This section is only necessary if you would like to use WhatsApp. If you would like to use SMS, follow the instructions under the section “Configure Twilio’s Programmable SMS” in the Build a SMS Chatbot tutorial.

Twilio provides a WhatsApp sandbox where you can easily develop and test your application. Once your application is complete you can request production access for your Twilio phone number, which requires approval by WhatsApp.

Let’s connect your smartphone to the sandbox. From your Twilio Console, select Programmable SMS and then click on WhatsApp. The WhatsApp sandbox page will show you the sandbox number assigned to your account, and a join code.

twilio whatsapp sandbox screenshot

To enable the WhatsApp sandbox for your smartphone send a WhatsApp message with the given code to the number assigned to your account. The code is going to begin with the word join, followed by a randomly generated two-word phrase. Shortly after you send the message you should receive a reply from Twilio indicating that your mobile number is connected to the sandbox and can start sending and receiving messages.

Note that this step needs to be repeated for any additional phones you’d like to have connected to your sandbox.

Create a Python Virtual Environment

Following Python best practices, we are going to make a separate directory for our flashcard bot project, and inside it we are going to create a virtual environment. We then are going to install the Python packages that we need for our chatbot on it.

If you are using a Unix or Mac OS system, open a terminal and enter the following commands to do the tasks described above:

$ mkdir flashcard-bot
$ cd flashcard-bot
$ python3 -m venv flashcard-bot-venv
$ source flashcard-bot-venv/bin/activate
(flashcard-bot-venv) $ pip install twilio flask flask-sqlalchemy flask-migrate

For those of you following the tutorial on Windows, enter the following commands in a command prompt window:

$ md flashcard-bot
$ cd flashcard-bot
$ python -m venv flashcard-bot-venv
$ flashcard-bot-venv\Scripts\activate
(flashcard-bot-venv) $ pip install twilio flask flask-sqlalchemy flask-migrate

The last command uses pip, the Python package installer, to install the four packages that we are going to use in this project, which are:

For your reference, at the time this tutorial was released these were the versions of the above packages and their dependencies tested:

alembic==1.3.1
certifi==2019.11.28
chardet==3.0.4
Click==7.0
Flask==1.1.1
Flask-Migrate==2.5.2
Flask-SQLAlchemy==2.4.1
idna==2.8
itsdangerous==1.1.0
Jinja2==2.10.3
Mako==1.1.0
MarkupSafe==1.1.1
PyJWT==1.7.1
python-dateutil==2.8.1
python-editor==1.0.4
pytz==2019.3
requests==2.22.0
six==1.13.0
SQLAlchemy==1.3.11
twilio==6.35.0
urllib3==1.25.7
Werkzeug==0.16.0

Creating a Flashcard Bot

Huzzah! You’re all set up. Let’s take a look at what our flashcard bot is going to be doing. At its most basic, a flashcard has front and back sides. A user looks at the front and guesses what is on the back. Since this is a digital flashcard, if they’re correct, the user moves on to the next flashcard, and if not, the user might try again until they guess it correctly.

This means we need to have three main capabilities with our flashcard bot:

  1. Registering a user: we need to be able to differentiate between users’ (aka phone numbers) flashcards.
  2. Creating flashcards: by default, the bot doesn’t have any flashcards created.
  3. Reviewing flashcards: the fun part of the bot.

Getting Started with Twilio and Python

If this is your first bot with Twilio and Python, I recommend first going through Miguel Grinberg’s Build a WhatsApp Chatbot With Python, Flask and Twilio tutorial. It describes all of the foundational information needed to grasp how Flask, Twilio, and webhooks fit together. The rest of this tutorial assumes a basic understanding of what is presented there and builds on top of it to create another type of bot.

Flashcard Bot Setup

First, let’s create a config.py file in the flashcard-bot folder. This will hold our database configuration. Next, in your flashcard-bot folder, create a new sub-folder called app and inside of it create three blank Python files: __init__.py, models.py, and routes.py.

  • __init__.py stores the basic app setup
  • models.py stores our User and Flashcard database models
  • routes.py stores the webhook that Twilio calls.

The config.py File

Copy the following into the config file. This tells the Flask app where to look for our SQLite database. Make sure to change the SECRET_KEY value to something unique!

import os
basedir = os.path.abspath(os.path.dirname(__file__))


class Config(object):
    DEBUG = False
    TESTING = False
    SECRET_KEY = "the-most-secret-key-in-the-world"
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
        'sqlite:///' + os.path.join(basedir, 'app.db')
    SQLALCHEMY_TRACK_MODIFICATIONS = False

The app/__init__.py File

Let’s start looking at the __init__.py file. This file has all of the setup needed to make our Flask application run. Pay attention to the _update_db(obj) method. We’ll be using this a lot when we start building the webhook, as it is used to update our database when the user makes changes.

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from config import Config

app = Flask(__name__)
app.config.from_object(Config)
db = SQLAlchemy(app)
migrate = Migrate(app, db)


def _update_db(obj):
    db.session.add(obj)
    db.session.commit()
    return obj


from app import routes, models

One thing that may seem peculiar is the line from app import routes, models at the bottom of this file. We add this line at the bottom to avoid circular dependencies when starting the Flask server. Both the routes.py and models.py files require the app and db objects to be instantiated before the entire file can be loaded by Flask correctly. By placing the import for these files at the bottom, we make sure that all necessary objects are instantiated first.

The app/models.py File

Before we can start building the route, we need to setup our two database tables. We have two basic models:

  1. User: stores a phone number,a link to the flashcards created by that phone number, and which flashcard the user is currently reviewing
  2. Flashcard: stores the front and back of each flashcard

Here is what our models.py file looks like.

from app import db, _update_db
import random


class User(db.Model):
    __tablename__ = "users"

    phone_number = db.Column(db.Text, primary_key=True)
    flashcards = db.relationship(
        "Flashcard",
        backref="user",
        primaryjoin="User.phone_number == Flashcard.user_id",
    )
    current_review_id = db.Column(db.Integer(), db.ForeignKey("flashcards.id"))
    current_review = db.relationship("Flashcard", foreign_keys=[current_review_id])
    creating_flashcards = db.Column(db.Boolean)

    def __init__(self, phone_number):
        self.phone_number = phone_number
        self.creating_flashcards = False

    def get_new_review(self):
        if not self.flashcards:
            return None
        new_review = random.choice(self.flashcards)
        self.current_review_id = new_review.id
        _update_db(self)
        return new_review

    def stop_reviewing(self):
        self.current_review = None
        _update_db(self)
        return self


class Flashcard(db.Model):
    __tablename__ = "flashcards"

    id = db.Column(db.Integer, primary_key=True)
    front = db.Column(db.Text())
    back = db.Column(db.Text())
    user_id = db.Column(db.Text(), db.ForeignKey("users.phone_number"))

    def __init__(self, user_id, front, back):
        self.user_id = user_id
        self.front = front
        self.back = back

Note that our first model, User, not only holds the phone number and the flashcards created by the user, but also the current flashcard being reviewed. We store this information since the actual interaction with the user and Twilio doesn’t store state, meaning that if I text my flashcard bot out of the blue, Twilio won’t have stored what I had last texted. It won’t know if I’m reviewing flashcards, creating flashcards, or something else. Hence, we must store some state in the database so we know what actions are allowed.

Keeping that in mind, in addition to the database columns, there are two methods in the model, get_new_review(self) and stop_reviewing(self). These are helper functions to change the state of the user. Note that we have already used our helper function _update_db twice just in this one model.

There are few things of note that you may not have seen in other Flask tutorials that use SQLAlchemy to define its database models, in particular around how the relationship between the User model and the Flashcard model is implemented.

flashcards = db.relationship(
    "Flashcard",
    backref="user",
    primaryjoin="User.phone_number == Flashcard.user_id",
)
current_review_id = db.Column(db.Integer(), db.ForeignKey("flashcards.id"))
current_review = db.relationship("Flashcard", foreign_keys=[current_review_id])

First is the explicit primaryjoin attribute on db.relationship(). We use this because we have multiple relationships between Users and Flashcards and without it we get an AmbiguousForeignKeysError. This is because we are trying to establish two unique relationships between the Users table and the Flashcards table: the user to all of their flashcards and the user to the single flashcard being reviewed. Without an explicitly declared linker between the two tables for each of these relationships, SQLAlchemy cannot figure out how to properly assign the foreign keys. Therefore, to make our relationship unambiguous, we must explicitly define what joins the two tables.

Second, because we have explicitly declared one out of two relationships, we must also be explicit with our second User-to-Flashcard relationship, current_review. This column holds the current flashcard the user is reviewing. In order to make sure that the system knows which review it is, we explicitly set up this relationship to use the current_review_id as the foreign key between the two tables.

The app/routes.py File

Now that we have our models created, it’s time to build out our flashcard bot’s logic! Since we have three main logical sections, we’ll work through them one by one.

At the top of the file, let’s import our functions.

from flask import request
from app import app, _update_db
from app.models import User, Flashcard
from twilio.twiml.messaging_response import MessagingResponse

Before we get started on the logic, because we have a lot of branching in our bot, let’s genericize the most important part: sending a response text message. The _send_message() function takes a list of lines, concatenates them with a newline, and sends them back to the user as a Twilio message.

def _send_message(output_lines):
    resp = MessagingResponse()
    msg = resp.message()
    msg.body("\n".join(output_lines))
    return str(resp)

As we will see below in the section on testing, Twilio sends incoming messages to our Flask server by POSTing to a webhook (much like submitting a form via your web browser). This means that we must have a single route exposed that contains the logic we wish to perform on a newly received message. The endpoint can be called anything unique, but for now we’ll call the endpoint /bot. Since this is our only route, it will be callable from https://{hostname}/bot.

The first state we check is if the user exists. We do this by checking if an account has been created with the phone number used to send the message. Note that we can support both WhatsApp and standard SMS with this bot, since we are just looking for the phone number. For the purposes of testing, I’ve added a fallback number of 123. This can be used when testing on your computer rather than with your phone.

@app.route("/bot", methods=["POST"])
def bot():
    incoming_msg = request.values.get("Body", "").strip().lower()
    remote_number = request.values.get("From", "")
    output_lines = []
    # incoming From is "whatsapp:#######"
    if remote_number.startswith("whatsapp:"):
        remote_number = remote_number.split(":")[1]

    if not remote_number:
        remote_number = "123"

    user = User.query.get(remote_number)

Before we get into the actual creation of data, we start by processing the basic “help” command. This command is available regardless of the state the user is in.

    # Help commands
    # - ‘help
    if incoming_msg == "help":
        output_lines.append("'create account' - create a new account")
        output_lines.append("'create flashcards' - start creating flashcards")
        output_lines.append("'stop creating flashcards' - stop creating flashcards")
        output_lines.append("'start reviewing' - start reviewing flashcards")
        output_lines.append("'stop reviewing' - stop reviewing flashcards")
        return _send_message(output_lines)

Next, we’ll check if the user already has an account created, and if not, prompt them to create one. We also need to check if the user has sent us random commands prior to creating their account. Note that this section terminates before it gets to any other commands if the user has not registered or tries re-registering, since we want to make sure we’re dealing with a legitimate user.

    # User creation commands
    # - 'create account'
    if not user:
        if incoming_msg == "create account":
            new_user = User(remote_number)
            _update_db(new_user)
            output_lines.append(
                f"Account successfully created for number {remote_number}!"
            )
            output_lines.append(
                "To get started, text 'create flashcards' to start creating flashcards."
            )
            output_lines.append(
                "When you're finished, text 'stop creating flashcards'."
            )
            output_lines.append(
                "You can text 'start reviewing' to review the flashcards you've created."
            )
            output_lines.append(
                "Text 'help' at any time to see available commands."
            )
        else:
            output_lines.append("Please register with 'create account', first!")

        return _send_message(output_lines)
    else:
        if incoming_msg == "create account":
            output_lines.append(f"You have already registered {remote_number}. Send 'help' for available options.")
            return _send_message(output_lines)

Once a user account has been created, we need to create the interface for creating cards. We do this by having the user issue a start command, which changes the state of the user to allow creating cards. Flashcards will be texted in the form of {front} / {back}. For example, bonjour / hello. The user would be shown bonjour during their review and be expected to text back hello. Finally, when the user is done creating flashcards, a stop command is sent, to reset the user’s state.

    # Flashcard commands
    # - 'create flashcards'
    #   - '{front} / {back}'
    #   - 'stop creating flashcards'
    # - 'start reviewing'
    #   - '{answer}'
    #   - 'stop reviewing'

    if not user.creating_flashcards and incoming_msg == "create flashcards":
        output_lines.append(
            "Create a new flashcard by texting in the form {front} / {back}."
        )
        output_lines.append(
            "For example 'hello / 你好' would create 'hello' on the front and '你好' on the back."
        )
        user.creating_flashcards = True
        user.current_review = None
        _update_db(user)
        return _send_message(output_lines)

    if user.creating_flashcards and incoming_msg == "stop creating flashcards":
        output_lines.append("Flashcard creation stopped.")
        user.creating_flashcards = False
        _update_db(user)
        return _send_message(output_lines)

    if user.creating_flashcards:
        if "/" not in incoming_msg:
            output_lines.append(
                "Please include a / in your text when creating flash cards."
            )
            output_lines.append("Text 'stop creating flashcards' to end.")
            return _send_message(output_lines)
        else:
            message_parts = incoming_msg.split("/")
            if len(message_parts) > 2:
                output_lines.append("More than one / was sent. Please use only one / in message.")
                return _send_message(output_lines)
            front = message_parts[0].strip()
            back = message_parts[1].strip()
            new_review = Flashcard(user.phone_number, front, back)
            _update_db(new_review)
            output_lines.append(
                f"Flashcard for '{front} / {back}' created successfully."
            )
            return _send_message(output_lines)

The final part, to wrap it all together, is when the user wants to review their flashcards. This uses a similar strategy of changing the state of the user to put them in a “review” mode. At the very end, after the review logic, we put a catch-all, just in case the user sends a command we don’t understand.

    if user.current_review is None and incoming_msg == "start reviewing":
        review = user.get_new_review()
        if not review:
            output_lines.append("First create a flashcard with 'create flashcards'")
        else:
            output_lines.append("Respond with the back of the flashcard.")
            output_lines.append(review.front.title())
        return _send_message(output_lines)

    if user.current_review is not None:
        if incoming_msg == "stop reviewing":
            output_lines.append("Done reviewing!")
            user.stop_reviewing()
        else:
            if incoming_msg == user.current_review.back:
                output_lines.append("Correct!")
                new_review = user.get_new_review()
                output_lines.append(new_review.front.title())
            else:
                output_lines.append("Incorrect!")
        return _send_message(output_lines)

    output_lines.append("Sorry, I don't understand, please try again or text 'help'.")
    return _send_message(output_lines)

The Complete app/routes.py File  

from flask import request
from app import app, _update_db
from app.models import User, Flashcard
from twilio.twiml.messaging_response import MessagingResponse


def _send_message(output_lines):
    resp = MessagingResponse()
    msg = resp.message()
    msg.body("\n".join(output_lines))
    return str(resp)


@app.route("/bot", methods=["POST"])
def bot():
    incoming_msg = request.values.get("Body", "").strip().lower()
    remote_number = request.values.get("From", "")
    output_lines = []
    # incoming From is "whatsapp:#######"
    if remote_number.startswith("whatsapp:"):
        remote_number = remote_number.split(":")[1]

    if not remote_number:
        remote_number = "123"

    user = User.query.get(remote_number)

    # Help commands
    # - 'help'
    if incoming_msg == "help":
        output_lines.append("'create account' - create a new account")
        output_lines.append("'create flashcards' - start creating flashcards")
        output_lines.append("'stop creating flashcards' - stop creating flashcards")
        output_lines.append("'start reviewing' - start reviewing flashcards")
        output_lines.append("'stop reviewing' - stop reviewing flashcards")
        return _send_message(output_lines)

    # User creation commands
    # - 'create account'
    if not user:
        if incoming_msg == "create account":
            new_user = User(remote_number)
            _update_db(new_user)
            output_lines.append(
                f"Account successfully created for number {remote_number}!"
            )
            output_lines.append(
                "To get started, text 'create flashcards' to start creating flashcards."
            )
            output_lines.append(
                "When you're finished, text 'stop creating flashcards'."
            )
            output_lines.append(
                "You can text 'start reviewing' to review the flashcards you've created."
            )
            output_lines.append(
                "Text 'help' at any time to see available commands."
            )
        else:
            output_lines.append("Please register with 'create account', first!")

        return _send_message(output_lines)
    else:
        if incoming_msg == "create account":
            output_lines.append(f"You have already registered {remote_number}. Send 'help' for available options.")
            return _send_message(output_lines)

    # Flashcard commands
    # - 'create flashcards'
    #   - '{front} / {back}'
    #   - 'stop creating flashcards'
    # - 'start reviewing'
    #   - '{answer}'
    #   - 'stop reviewing'

    if not user.creating_flashcards and incoming_msg == "create flashcards":
        output_lines.append(
            "Create a new flashcard by texting in the form {front} / {back}."
        )
        output_lines.append(
            "For example 'hello / 你好' would create 'hello' on the front and '你好' on the back."
        )
        user.creating_flashcards = True
        user.current_review = None
        _update_db(user)
        return _send_message(output_lines)

    if user.creating_flashcards and incoming_msg == "stop creating flashcards":
        output_lines.append("Flashcard creation stopped.")
        user.creating_flashcards = False
        _update_db(user)
        return _send_message(output_lines)

    if user.creating_flashcards:
        if "/" not in incoming_msg:
            output_lines.append(
                "Please include a / in your text when creating flash cards."
            )
            output_lines.append("Text 'stop creating flashcards' to end.")
            return _send_message(output_lines)
       else:
            message_parts = incoming_msg.split("/")
            if len(message_parts) > 2:
                output_lines.append("More than one / was sent. Please use only one / in message.")
                return _send_message(output_lines)
            front = message_parts[0].strip()
            back = message_parts[1].strip()
            new_review = Flashcard(user.phone_number, front, back)
            _update_db(new_review)
            output_lines.append(
                f"Flashcard for '{front} / {back}' created successfully."
            )
            return _send_message(output_lines)

    if user.current_review is None and incoming_msg == "start reviewing":
        review = user.get_new_review()
        if not review:
            output_lines.append("First create a flashcard with 'create flashcards'")
        else:
            output_lines.append("Respond with the back of the flashcard.")
            output_lines.append(review.front.title())
        return _send_message(output_lines)

    if user.current_review is not None:
        if incoming_msg == "stop reviewing":
            output_lines.append("Done reviewing!")
            user.stop_reviewing()
        else:
            if incoming_msg == user.current_review.back:
                output_lines.append("Correct!")
                new_review = user.get_new_review()
                output_lines.append(new_review.front.title())
            else:
                output_lines.append("Incorrect!")
        return _send_message(output_lines)

    output_lines.append("Sorry, I don't understand, please try again or text 'help'.")
    return _send_message(output_lines)

Testing the Flashcard Bot

Now that we’ve built the routes and the models of our flashcard bot, it’s time to boot up the server, initialize the database, and send some messages!

Initializing the Database

We initialize the database using the commands from the Flask-Migrate package, which we installed earlier in this tutorial. The setup comprises three steps: creating our database, detecting what new tables or columns need to be made, and then finally migrating those new changes to our database.

Since this will be the first time setting up the database, all three steps will be run in succession. Make sure you’re in the flashcard-bot folder and your virtual environment is active before running the below commands.

(flashcard-bot-venv) $ flask db init
(flashcard-bot-venv) $ flask db migrate
(flashcard-bot-venv) $ flask db upgrade

After these are run you will see a new migrations folder and a new app.db SQLite database. Inside of the migrations/versions folder are the scripts used to create the database structure we defined in the models.py file above. As you grow and change this flashcard bot to fit your specific needs, you can run the migrate and upgrade commands to update your database’s structure.

Sending Some Test Messages

Time to create some flashcards! Start the bot by running flask run while in the flashcard-bot folder. Your virtual environment should still be active from setting up the database. The output should be something like this:

(flashcard-bot-venv) $ flask run
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

The service is now running as a private service on port 5000 inside your computer and will sit there waiting for incoming connections. To make this service reachable from the Internet we need to use ngrok.

Open a second terminal window and 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. What we need to do now is tell Twilio to use this URL to send incoming message notifications.

Go back to the Twilio Console, click on Programmable SMS, then on WhatsApp, and finally on Sandbox. Copy the https:// URL from the ngrok output and then paste it on the “When a message comes in” field. Since our chatbot is exposed under the /bot URL, append that at the end of the root ngrok URL. Make sure the request method is set to HTTP Post. Don’t forget to click the red Save button at the bottom of the page to record these changes.

whatsapp sandbox webhook configuration

If you want to interact with the bot also over standard SMS, from the Twilio Console, click on Phone Numbers, and then on the phone number that you want to use. Scroll down to the “Messaging” section, copy the https:// URL from the ngrok output and then paste it on the “A message comes in” field. Recall that the chatbot is exposed under the /bot URL, so /bot needs to be appended at the end of the root ngrok URL. Make sure the request method is set to HTTP POST, and don’t forget to save your changes.

sms webhook configuration

Now you can start sending messages to the flashcard bot from the smartphone that you connected to the sandbox. If you’ve forgotten what the flashcard bot can do, send help to find out what commands we’ve programmed in. Otherwise, happy studying!

Keep in mind that when using ngrok for free there are some limitations. In particular, you cannot hold on to an ngrok URL for more than 8 hours, and the domain name that is assigned to you will be different every time you start the ngrok command. You will need to update the URL in the Twilio Console every time you restart ngrok.

If you’d like to use your new flashcard bot in production, take a look over at the SMS chatbot blog post, under the “Notes on Production Deployment”, for details.

Conclusion

Together we’ve built a simple, but effective, flashcard bot. Hopefully it’s gotten new ideas flowing and you can see how powerful a service like Twilio can be with just a few lines of code.

Check out some of the other posts on the Twilio blog for even more cool Python projects!

Parry is a language learner who enjoys making flashcards more than he enjoys studying them. You can find some of the things he's done with his time at https://parryc.com.