SMS conversations with Python

9 minute read

Following on from my last post about sending SMSes with Python, we’re now going to look at ways to get SMS responses and how we can use this to build a basic conversation flow. I find I learn best when I have a goal or project, so here I’ll use the example of an SMS-controlled stopwatch.

If you haven’t read the first article yet, I’d strongly recommend going through it. It’s a reasonably short read and it will cover all the basics that I expand upon here.

Response architecture

There are two mutually-exclusive1 methods we can use to get the response SMS data from the Telstra API. We can either:

  • Use polling, by calling the Retrieve SMS Responses API command; or
  • Use a push method, where Telstra will send a request to a web server we choose whenever a text message is received.

Which method we get to use is determined by the Create Subscription API method, or more accurately, the value of the notifyURL parameter we gave it.

  • If notifyURL is null, or this is the first time we’re creating a subscription, we use the polling method.
  • If notifyURL is a string (our web server address), we use the push method.
  • If we don’t send the notifyURL parameter at all, then no change happens and we use whatever we were using before.

Don’t worry if you start out with one method and change your mind later. You can adjust the method you use (or change the URL that receives the pushes) by sending a new ‘create subscription’ message to the API with a new value for notifyURL set as above.

In this article, we’ll use the polling architecture because it requires no additional infrastructure and is easy to get started with.

Polling for messages

The poll code

Using the Retrieve SMS Responses API call, we can fetch the next message waiting for us in the queue. If there are multiple messages queued up, we will need to call this method multiple times. The API will indicate when the queue becomes empty.

The code for this is very straight-forward, based on the API documentation and the code from the previous article:

def get_sms(token):
    response = telstra_request("messages/sms", payload, token=token, method='GET')

    if response.status_code != 200:
        raise RuntimeError("Bad response from Telstra API! " + response.text)

    return response.json()

The API response

The get_sms function returns a dictionary. If there are no messages waiting, the response is very simple:

{
    "status": "EMPTY"
}

If there is a message waiting for us, there are more keys in the dictionary. Here’s the dictionary representation of a text message I sent to my subscribed number:

{
    "status": "RECEIVED",
    "destinationAddress": "+61472xxx316",
    "senderAddress": "+614xxxxxxxx",
    "message": "Hi Python! This is an SMS. ",
    "messageId": "",
    "apiMsgId": "",
    "sentTimestamp": "2019-03-04T15:33:10+10:00"
}

The handler loop

Take note: in the Github code for this post, I’ve added a try ... except block around the get_sms call, to prevent our entire program from crashing if the API returns an unexpected result. It has been omitted here for readability.

To make sure we deal with all incoming messages, and not just the most recent one, we’ll need to put our get_sms code into a loop. I use the function handle_message (which we haven’t defined yet) to take some action on each incoming message.

def handle_all_messages(token):
    while True:
        message = get_sms(token)
        if message['status'] == "EMPTY":
            break
        handle_message(message)

Stopwatch example

At this point, all of the framework code needed to handle receiving messages is complete; the last function to implement is handle_message. I’ve chosen to implement a stopwatch, as it’s easy to understand and code, and requires us to consider a few different logical conditions.

Methodology

We can use the Python datetime type to track a particular point in time. First, we capture the present moment as our start time using datetime.now(). Using that stored value, we can see how much time has elapsed by comparing it with the latest value in datetime.now().

If we also capture an end time, we can store the total time and refer back to it later.

Stopwatch functions

We’ll support three basic commands: START, STOP and TIME. I’ve written the demo code to reply to the user after each message with a brief summary of the action we’re taking, so we know the server is still listening and as practice with our SMS sending code.

START

If the timer is stopped, this will start the timer and notify the user. If the timer is already running, this will reset the elapsed time to zero and tell the user that the timer has restarted.

STOP

If the timer has never been started, we’ll tell the user to start it first. If the timer is currently running, this will stop it and notify the user with the elapsed time. If the timer is already stopped, it will notify the user that it is already stopped.

TIME

If the timer isn’t running, we’ll tell the user to start the timer first. If the timer has ended, we’ll tell the user the final measured time. If it’s still running, we’ll tell the user how much time has passed since we started.

The logic code

The complete Python code for this function is quite long, so I’ve simplified it for display purposes here in the blog by removing the code that sends replies to the user. The code in Github includes these replies.

from datetime import datetime

STOPWATCH_START = None
STOPWATCH_END = None

def handle_message(token, message):
  global STOPWATCH_START, STOPWATCH_END
    if message['message'] == "START":
        STOPWATCH_START = datetime.now()
        STOPWATCH_END = None
        # tell user that timer is restarted

    elif message['message'] == "STOP":
        if not STOPWATCH_START:  # if we haven't started yet
            # tell user to start timer first
        elif STOPWATCH_END:  # if we've already ended
            # tell user we are already stopped
        else:  # we are currently running
            STOPWATCH_END = datetime.now()
            # tell user how long timer was running for
            # based on (STOPWATCH_END-STOPWATCH_START)

    else if message['message'] == "TIME":
      if not STOPWATCH_START:  # if we haven't started yet
          # tell user to start timer first
      elif STOPWATCH_END:  # if we've already ended
          # tell user how long timer was running for
          # based on (STOPWATCH_END-STOPWATCH_START)
      else:  # we are currently running
          # tell user what timer is currently at
          # based on (datetime.now()-STOPWATCH_START)

    else:  # unknown command
        # tell user we only know 3 commands

The script ‘main’ code

To get all of this to actually run when we execute our code in Python, we need to put it into a loop. I don’t want to burn up the CPU on my laptop, so I’ve made it wait a second between each check for replies. This means our stopwatch is pretty inaccurate, but it’s just an example.

from time import sleep

token = auth("PutYour32CharClientKeyHereThanks", "ClientSecretHere")
create_subscription(token)
send_sms(token, "+61400000000", 'Stopwatch app online! I understand "START", "STOP" and "TIME".')

while True:
    handle_all_messages(token)
    sleep(1)

Seeing it in action

Here’s how it looks on my phone:

The SMS conversation on my phone

Closing

As before, I hope you’ve been able to learn something interesting and useful from this post! The complete Python code shown here can be found in Github.

I’ll take a moment to note that this code has a lot of limitations.

  • The stopwatch state is tracked at a global level, so if we tried to use the same architecture for multiple recipients we’d end up with everyone trying to fiddle with the same stopwatch. A better architecture is to track the sender of the message (message['senderAddress'] in our handle_message function) and provide a per-user stopwatch. This could be a great exercise for you to test your understanding.
  • The stopwatch is not very accurate. The delay in the main loop is necessary to stop our Python program stealing all of the computer’s resources, but it also means we’re probably going to be inaccurate in our stopwatch times by up to 5 seconds once network delays are included.

Lastly, I was originally hoping to include a section here on the push method of handling replies, but just covering polling for replies is already a lot of code. I’m interested in covering the push method in the future, but it might need a couple of other foundational posts first before I get to it.

  1. Either one or the other, but not both