SMS conversations with Python
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
isnull
, 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:
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 ourhandle_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.
-
Either one or the other, but not both ↩