Handling SMS Responses from Twilio using Django on Heroku
The final piece in the application I’m working on requires that I process SMS replies from Twilio. To do this, I need a public-facing server that can handle a POST request from another server so it can do the right thing on my end. This tutorial will walk through how to do that with a Django server on Heroku. You can grab the twilio_sms application from within my The final piece in the application I’m working on requires that I process SMS replies from Twilio. To do this, I need a public-facing server that can handle a POST request from another server so it can do the right thing on my end. This tutorial will walk through how to do that with a Django server on Heroku. You can grab the twilio_sms application from within my for this application, or you can build it based on the walkthrough here.
URL Setup
Add a mapping for /reply/ to the urls.py in your top level Django project.
urls.py
url(r'^reply', 'twilio_sms.views.sms_reply'),
You’ll need to tell Twilio where to send SMS replies on the dashboard. For instance, my Heroku instance runs at http://falling-summer-4605.herokuapp.com so my SMS Response URL is set to http://falling-summer-4605.herokuapp.com/reply.
Creating twilio_sms
In order for that URL mapping to do the right thing it needs to point to the right application. If you aren’t using the code samples from GitHub, you can create a new application with:
django-admin.py startapp twilio_sms
Now we need to build the views.py file to handle the responses.
The setup is similar to the linkedin application we made previously.
# Python import oauth2 as oauth import simplejson as json import re from xml.dom.minidom import getDOMImplementation,parse,parseString # Django from django.http import HttpResponse from django.shortcuts import render_to_response from django.http import HttpResponseRedirect from django.conf import settings from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.models import User # Project from linkedin.models import UserProfile,SentArticle # from settings.py consumer = oauth.Consumer(settings.LINKEDIN_TOKEN, settings.LINKEDIN_SECRET) client = oauth.Client(consumer)
We’ll create a convenience method to build SMS responses in the format expected by the Twilio server (so that the user gets a reasonable SMS response)
def createSmsResponse(responsestring): impl = getDOMImplementation() responsedoc = impl.createDocument(None,"Response",None) top_element = responsedoc.documentElement sms_element = responsedoc.createElement("Sms") top_element.appendChild(sms_element) text_node = responsedoc.createTextNode(responsestring) sms_element.appendChild(text_node) html = responsedoc.toxml(encoding="utf-8") return html
Because the POST will be coming from an external server without a session cookie, we need to use the @csrf_exempt decorator to tell Django to allow these POSTs without authentication. For security, you might check the incoming IP to make sure it’s coming from Twilio, or make sure that the other information matches what you expect. For this demo, we’ll allow it to proceed assuming it’s the right thing.
Grab the parameters, get the user’s phone number, and determine which of our users matches that phone number, then grab their credentials and create the LinkedIn client to make requests.
@csrf_exempt def sms_reply(request): if request.method == 'POST': params = request.POST phone = re.sub('\+1','',params['From']) smsuser = User.objects.get(userprofile__phone_number=phone) responsetext = "This is my reply text" token = oauth.Token(smsuser.get_profile().oauth_token, smsuser.get_profile().oauth_secret) client = oauth.Client(consumer,token)
Figure out what the user wants us to do (save, search, cancel, help, level)
commandmatch = re.compile(r'(\w+)\b',re.I) matches = commandmatch.match(params['Body']) command = matches.group(0).lower()
“Cancel” tells the system the user doesn’t want notifications anymore. For now, we’re going to keep their user and profile around, so that we don’t send them all the same articles again in the future. But sendArticles.py won’t send them anything if the level is set to zero.
# Cancel notifications by setting score to zero # Command is 'cancel' if command == 'cancel': profile = smsuser.get_profile() profile.min_score = 0 profile.save() return HttpResponse(createSmsResponse("Today SMS Service Cancelled"))
“Level
# Change level for notifications by setting score to requested level # Command is 'level \d' if command == 'level': levelmatch = re.compile(r'level (\d)(.*)',re.I) matches = levelmatch.search(params['Body']) try: level = int(matches.group(1)) except: e = sys.exc_info()[1] print "ERROR: %s" % (str(e)) return HttpResponse(createSmsResponse("Please use a valid level (1-9).")) profile = smsuser.get_profile() profile.min_score = level profile.save() return HttpResponse(createSmsResponse("Today SMS minimum score changed to %d" % int(level)))
“Save <article number>” saves an article to the user’s LinkedIn saved articles. Remember that in the setup we grabbed the credentials for the user who sent the SMS based on their phone number, so this (and share) are done against the LinkedIn API on their behalf. In this new (preview only) API JSON doesn’t seem to be working well, so I’m building and using XML.
# Save an article # Command is 'save <articlenum>' if command == 'save': savematch = re.compile(r'save (\d+)(.*)',re.I) matches = savematch.search(params['Body']) try: article = matches.group(1) sentarticle = SentArticle.objects.get(user=smsuser, id=article) except: e = sys.exc_info()[1] print "ERROR: %s" % (str(e)) return HttpResponse(createSmsResponse("Please use a valid article number with save.")) responsetext = "Saved article: %s" % (sentarticle.article_title) saveurl = "http://api.linkedin.com/v1/people/~/articles" # Oddly JSON doesn't seem to work with the article save API # Using XML instead impl = getDOMImplementation() xmlsavedoc = impl.createDocument(None,"article",None) top_element = xmlsavedoc.documentElement article_content_element = xmlsavedoc.createElement("article-content") top_element.appendChild(article_content_element) id_element = xmlsavedoc.createElement("id") article_content_element.appendChild(id_element) text_node = xmlsavedoc.createTextNode(sentarticle.article_number) id_element.appendChild(text_node) body = xmlsavedoc.toxml(encoding="utf-8") resp, content = client.request(saveurl, "POST",body=body,headers={"Content-Type":"text/xml"}) if (resp.status == 200): return HttpResponse(createSmsResponse(responsetext)) else: return HttpResponse(createSmsResponse("Unable to save post: %s" % content))
“Share <article number>
# Share an article # Command is 'share <articlenum> <comment>' # If no comment is included, a generic one is sent if command == 'share': sharematch = re.compile(r'Share (\d+) (.*)') matches = sharematch.search(params['Body']) try: article = matches.group(1) sentarticle = SentArticle.objects.get(user=smsuser, id=article) comment = matches.group(2) except: if sentarticle and not comment: comment = "Sharing an article from the LinkedIn SMS System" else: e = sys.exc_info()[1] print "ERROR: %s" % (str(e)) return HttpResponse(createSmsResponse("Please use a valid article number with share and include a comment.")) responsetext = "Shared article: %s" % (sentarticle.article_title) shareurl = "http://api.linkedin.com/v1/people/~/shares" body = {"comment":comment, "content":{ "article-id":sentarticle.article_number }, "visibility":{"code":"anyone"} } resp, content = client.request(shareurl, "POST",body=json.dumps(body),headers={"Content-Type":"application/json"}) if (resp.status == 201): return HttpResponse(createSmsResponse(responsetext)) else: return HttpResponse(createSmsResponse("Unable to share post: %s" % content))
If we’ve fallen through to here, the user may have asked for ‘help’ – but whatever they did we didn’t understand it so we should give them the help text in any case.
# If command is help, or anything we didn't recognize, send help back helpstring = "Commands: 'cancel' to cancel Today SMS; 'level #number#' to change minimum score;" helpstring += "'save #article#' to save; 'share #article# #comment#' to share" return HttpResponse(createSmsResponse(help string))
… and, if the request wasn’t a POST, send a 405 response back to the system (it won’t be Twilio, it might have been someone else). This URL is only for processing these SMS messages.
# If it's not a post, return an error return HttpResponseNotAllowed('POST')
GEEK STUFF · LINKEDIN · WEB APIS
django heroku linkedin python twilio