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

n

Add a mapping for /reply/ to the urls.py in your top level Django project.

urls.py

nn

url(r'^reply', 'twilio_sms.views.sms_reply'),

n

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

n

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

n

Now we need to build the views.py file to handle the responses.

The setup is similar to the linkedin application we made previously.

# Pythonnimport oauth2 as oauthnimport simplejson as jsonnimport renfrom xml.dom.minidom import getDOMImplementation,parse,parseStringnn# Djangonfrom django.http import HttpResponsenfrom django.shortcuts import render_to_responsenfrom django.http import HttpResponseRedirectnfrom django.conf import settingsnfrom django.views.decorators.csrf import csrf_exemptnfrom django.contrib.auth.models import Usernn# Projectnfrom linkedin.models import UserProfile,SentArticlenn# from settings.pynconsumer = oauth.Consumer(settings.LINKEDIN_TOKEN, settings.LINKEDIN_SECRET)nclient = oauth.Client(consumer)

n

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):n	impl = getDOMImplementation()n	responsedoc = impl.createDocument(None,"Response",None)n	top_element = responsedoc.documentElementn	sms_element = responsedoc.createElement("Sms")n	top_element.appendChild(sms_element)n	text_node = responsedoc.createTextNode(responsestring)n	sms_element.appendChild(text_node)n	html = responsedoc.toxml(encoding="utf-8")n	return html

n

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_exemptndef sms_reply(request):n    if request.method == 'POST':n        params = request.POSTn        phone = re.sub('\+1','',params['From'])n        smsuser = User.objects.get(userprofile__phone_number=phone)n        responsetext = "This is my reply text"n        token = oauth.Token(smsuser.get_profile().oauth_token, smsuser.get_profile().oauth_secret)n        client = oauth.Client(consumer,token)

n

Figure out what the user wants us to do (save, search, cancel, help, level)

commandmatch = re.compile(r'(\w+)\b',re.I)n        matches = commandmatch.match(params['Body'])n        command = matches.group(0).lower()

n

“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 zeron        # Command is 'cancel'n        if command == 'cancel':n        	profile = smsuser.get_profile()n        	profile.min_score = 0n        	profile.save()n        	return HttpResponse(createSmsResponse("Today SMS Service Cancelled"))

n

“Level ” tells the system the user wants to change their notification level.  Higher means fewer messages, as this is used as the “score” check against articles based on relevance.  See the [previous post on Twilio notifications](http://www.princesspolymath.com/princess_polymath/?p=521) to see how this is implemented.  Notice that in this and the following methods we’re doing generic error catching – there’s a few reasons why it might fail, but the important thing is to tell the user their action didn’t succeed and give them a hint as to why that might be.

# Change level for notifications by setting score to requested leveln        # Command is 'level \d'n        if command == 'level':n        	levelmatch = re.compile(r'level (\d)(.*)',re.I)n        	matches = levelmatch.search(params['Body'])nn        	try:n	        	level = int(matches.group(1))n	        except:n	        	e = sys.exc_info()[1]n	        	print "ERROR: %s" % (str(e))n	        	return HttpResponse(createSmsResponse("Please use a valid level (1-9)."))nn        	profile = smsuser.get_profile()n        	profile.min_score = leveln        	profile.save()n        	return HttpResponse(createSmsResponse("Today SMS minimum score changed to %d" % int(level)))

n

“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 articlen        # Command is 'save <articlenum>'n        if command == 'save':n        	savematch = re.compile(r'save (\d+)(.*)',re.I)n        	matches = savematch.search(params['Body'])n        	try:n	        	article = matches.group(1)n        		sentarticle = SentArticle.objects.get(user=smsuser, id=article)n	        except:n	        	e = sys.exc_info()[1]n	        	print "ERROR: %s" % (str(e))n	        	return HttpResponse(createSmsResponse("Please use a valid article number with save."))nn        	responsetext = "Saved article: %s" % (sentarticle.article_title)n        	saveurl = "http://api.linkedin.com/v1/people/~/articles"nn        	# Oddly JSON doesn't seem to work with the article save APIn        	# Using XML insteadn        	impl = getDOMImplementation()n        	xmlsavedoc = impl.createDocument(None,"article",None)n        	top_element = xmlsavedoc.documentElementn        	article_content_element = xmlsavedoc.createElement("article-content")n        	top_element.appendChild(article_content_element)n        	id_element = xmlsavedoc.createElement("id")n        	article_content_element.appendChild(id_element)n        	text_node = xmlsavedoc.createTextNode(sentarticle.article_number)n        	id_element.appendChild(text_node)n        	body = xmlsavedoc.toxml(encoding="utf-8")nn        	resp, content = client.request(saveurl, "POST",body=body,headers={"Content-Type":"text/xml"})n        	if (resp.status == 200):n        		return HttpResponse(createSmsResponse(responsetext))n        	else:n        		return HttpResponse(createSmsResponse("Unable to save post: %s" % content))

n

“Share <article number> ” shares an article to the user’s network with a comment.  The comment shouldn’t really be optional, but typing on T-9 keyboards is a pain, so I wanted to give a default share message.  I’m not sure I love it as an answer though…

# Share an articlen        # Command is 'share <articlenum> <comment>'n        # If no comment is included, a generic one is sentn        if command == 'share':n        	sharematch = re.compile(r'Share (\d+) (.*)')n        	matches = sharematch.search(params['Body'])n        	try:n        		article = matches.group(1)n	        	sentarticle = SentArticle.objects.get(user=smsuser, id=article)n        		comment = matches.group(2)n	        except:n	        	if sentarticle and not comment:n	        		comment = "Sharing an article from the LinkedIn SMS System"n	        	else:n	        		e = sys.exc_info()[1]n	        		print "ERROR: %s" % (str(e))n	        		return HttpResponse(createSmsResponse("Please use a valid article number with share and include a comment."))nn        	responsetext = "Shared article: %s" % (sentarticle.article_title)n        	shareurl = "http://api.linkedin.com/v1/people/~/shares"n        	body = {"comment":comment,n        		"content":{n        			"article-id":sentarticle.article_numbern       	 		},n        	"visibility":{"code":"anyone"}n        	}nn        	resp, content = client.request(shareurl, "POST",body=json.dumps(body),headers={"Content-Type":"application/json"})n        	if (resp.status == 201):n        		return HttpResponse(createSmsResponse(responsetext))n        	else:n        		return HttpResponse(createSmsResponse("Unable to share post: %s" % content))

n

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 backn        helpstring = "Commands: 'cancel' to cancel Today SMS; 'level #number#' to change minimum score;"n        helpstring += "'save #article#' to save; 'share #article# #comment#' to share"n        return HttpResponse(createSmsResponse(help string))

n

… 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 errorn    return HttpResponseNotAllowed('POST')

nn

Similar Posts