Cell Phones, SMS, Twilio, Pusher, ColdFusion, And Google Maps == Fun
As of late, I've been playing around some very cool technologies like Pusher's HTML5 WebSocket server and Twilio's mobile SMS text messaging web services. Each of these technologies is exciting on its own; but, I wanted to see if I could create a fairly simple but fun way to start integrating these technologies into some sort of ColdFusion work flow. What I came up with was the idea of allowing people to post SMS text messages to a Google map. While this idea isn't hugely useful, it does create a small system that touches Twilio, ColdFusion, Pusher, and the Google Maps API.
The idea here is straight forward: the user opens up the following Google Map:
Then, the user sends an SMS text message to the given phone number. This phone number is a rented number in the Twilio service. The SMS text message gets delivered to Twilio. Twilio then takes the SMS text message and posts it via HTTP to the ColdFusion-powered SMS end point. The end point then looks at the form data to see if it can determine the user's address. If it cannot, it prompts the user (via an SMS response) for their information. Once the address is determined, the SMS end point posts the address information to Pusher's REST API. Pusher then "pushes" that information onto our HTML5 web client over the native WebSockets or a SWF-based fallback. The client then uses the Google Maps API to geocode and display the text message on the open map.
Once the phone / Twilio / ColdFusion / Pusher / Google Maps work flow has completed, the SMS text message appears in realtime in front of the user's eyes:
This might not be the most useful demo; but, I think it nicely illustrates how easily some of these 3rd party services can be integrated with ColdFusion. Of course, seeing the code will demonstrate this more than any video. The core of the demo lies in the Twilio SMS end point and the google maps page. Let's take at a look at the SMS end point first:
Twilio SMS End Point (Public ColdFusion URL)
<!--- Param the form variables. --->
<cfparam name="form.fromCity" type="string" default="" />
<cfparam name="form.fromState" type="string" default="" />
<cfparam name="form.fromZip" type="string" default="" />
<cfparam name="form.fromCountry" type="string" default="" />
<cfparam name="form.body" type="string" default="" />
<!--- ----------------------------------------------------- --->
<!--- ----------------------------------------------------- --->
<!---
If Twilio was not able to give us any location information,
we'll have asked for clarification from the user. Let's see
if we have an outstanding request for the address.
--->
<cfif session.addressRequested>
<!---
Since we already asked the user for their location, we
know (hope) that this message is the user's manually
entered location. Store the current text message as
the address.
NOTE: We are adding a comma to the end here only to make
sure that the serializeJSON() call on a zip code doesn't
create a decimal number. Adding the comma does not adversely
affect the geocoding of the address.
--->
<cfset session.address = "#form.body#," />
<!---
Check to see if Twilio was able to determine the address of
the user based on their phone number.
--->
<cfelseif (
len( form.fromCity ) ||
len( form.fromState ) ||
len( form.fromZip ) ||
len( form.fromCountry )
)>
<!---
Twilio was able to determine the user's location based
on their phone number. As such, we wouldn't need to take
any more steps with this user.
--->
<!--- Store the text message. --->
<cfset session.message = form.body />
<!---
Build the location based on the message meta data. Even
if Twilio didn't fill out all of these values, it doesn't
seem to affect the geocoding very much and it allows us to
err on the side of more data.
--->
<cfset session.address = "#form.fromCity#, #form.fromState# #form.fromZip#, #form.fromCountry#" />
<!---
At this point, Twilio has not been able to determine the
location of the user from their phone number and we have
not asked for it manually yet.
--->
<cfelse>
<!---
Store the user's current message in their session for
the next request.
--->
<cfset session.message = form.body />
<!---
Flag that we are asking the user for their location
on the next request.
--->
<cfset session.addressRequested = true />
<!--- Set the response, asking the user for location. --->
<cfset response = "Sorry, your location could not be determined :( What is your zip code?" />
</cfif>
<!--- ----------------------------------------------------- --->
<!--- ----------------------------------------------------- --->
<!---
At this point, we may or may not have both the message and
the address from the user. We only want to act IF we have both.
Otherwise, we'll assume we are waiting on an action from the
user.
--->
<cfif (
len( session.message ) &&
len( session.address )
)>
<!---
Now that we have both the message and the address, let's
post the message data to the Pusher app so that it can be
pushed to the client and the map.
NOTE: We are using array notation when creating the struct
so that key-case will be retained when ColdFusion serializes
the struct into JSON.
--->
<cfset message = {} />
<cfset message[ "address" ] = session.address />
<cfset message[ "message" ] = session.message />
<!---
Now, we need to push the message. For some reason, I am
getting random 401 Unauthorized problems with the service.
As such, I'm going to try a few times if it fails.
--->
<cfloop
index="tryIndex"
from="1"
to="3"
step="1">
<!--- Push the message. --->
<cfset pushResponse = application.pusher.pushMessage(
"sms",
"textMessage",
message
) />
<!---
<cfdump
var="#pushResponse#"
output="#expandPath( './log.htm' )#"
format="html" />
--->
<!--- Check to see if we should break out. --->
<cfif reFind( "20\d", pushResponse.statusCode )>
<!--- This request worked, break out of try loop. --->
<cfbreak />
</cfif>
<!---
Sleep very briefly to allow the time to change in case
there is something time-specific breaking this approach.
--->
<cfthread
action="sleep"
duration="500"
/>
</cfloop>
<!---
Now that we have tried the Pusher app submission up to a
certain number of times, let's check to see if the Pusher
HTTP request failed.
--->
<cfif reFind( "20\d", pushResponse.statusCode )>
<!---
The pusher request was successful. This means the
message arrive at the client successfully.
--->
<cfset response = "Thanks! Check the map for your text message!" />
<cfelse>
<!---
The pusher request failed for some reason; let the user
know about the failure.
--->
<cfset response = "Sorry! I could not communicate with Pusher's HTML5 WebSockets. Please try again :)" />
</cfif>
<!---
Regardless of what the pusher app has told us, we are going
to consider this pass as "successful" as possible. As such,
let's reset the session information.
To keep this code easier to maintain, just use the
onSessionStart() event handler to manage the session reset.
--->
<cfset createObject( "component", "Application" )
.onSessionStart()
/>
</cfif>
<!--- ----------------------------------------------------- --->
<!--- ----------------------------------------------------- --->
<!--- Convert the message into Twilio XML response. --->
<cfsavecontent variable="responseXml">
<cfoutput>
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Sms>#xmlFormat( response )#</Sms>
</Response>
</cfoutput>
</cfsavecontent>
<!---
Stream XML response to Twilio client. Make sure to TRIM
the XML response such that it is valid XML.
--->
<cfcontent
type="text/xml"
variable="#toBinary( toBase64( trim( responseXml ) ) )#"
/>
At first, this code was actually much shorter. But then during testing, I discovered that not all mobile carriers report the addresses of their mobile customers. As such, I had to expand the code to prompt the user for their location if it could not be determined from Twilio's meta data. To do this, I had to keep track of the user's information across multiple SMS text messages. Fortunately, the Twilio Proxy service acts like a true web browser which allows us to use ColdFusion's native session management (the Application.cfc is shown a bit farther down).
Once both the SMS text message and the user's location have been established, the Twilio SMS end point posts the message information to Pusher's HTML5 WebSocket server. Unfortunately, I was having some trouble getting consistent authorization when posting to Pusher's REST web service. This is why, in the code, I am allowing the SMS text message to be posted and re-posted to Pusher's web service up to 3 times. I have submitted a help question on Pusher's support forum and will be working on debugging this issue with the Pusher team.
Once the message is successfully posted to the Pusher web service, Pusher "pushes" it over the "sms" WebSocket channel and triggers a "textMessage" event on the client (web browser). The client then geocodes the address and posts both a marker and an infoWindow on the visible map:
Google Map Page
<!--- Reset the output buffer. --->
<cfcontent type="text/html" />
<!DOCTYPE HTML>
<html>
<head>
<title>Twilio + Pusher + ColdFusion + Google Maps</title>
<link rel="stylesheet" type="text/css" href="./map.css"></link>
<script type="text/javascript" src="jquery-1.4.2.min.js"></script>
<script type="text/javascript" src="http://js.pusherapp.com/1.4/pusher.min.js"></script>
<script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>
<script type="text/javascript">
// This is for compatability with browsers that don't yet
// support Web Sockets, but DO support Flash.
//
// NOTE: This SWF can be downloaded from the PusherApp
// website. It is a Flash proxy to the standard Web
// Sockets interface.
WebSocket.__swfLocation = "./WebSocketMain.swf";
// When the DOM has loaded, initialize the map and scripts.
$(function(){
// Get a reference to the header.
var siteHeader = $( "#siteHeader" );
// Get a reference to the site map.
var siteMap = $( "#siteMap" );
// Get a reference to the intro message.
var introMessage = $( "#introMessage" );
// The google map object - this will be initialized once
// the page has loaded fully.
var map = null;
// Get an instance of the Google map geocoder object so
// that we can get the lat/long for addresses.
var geocoder = new google.maps.Geocoder();
// This is the visible window that displays the text
// message above a given marker.
var infoWindow = new google.maps.InfoWindow();
// This is a collection of markers, indexed by address.
// We are using this so we don't add mulitple markers for
// repeat text messages.
var markers = {};
// Create a Pusher server object with your app's key and
// the SMS channel we want to listen on.
var server = new Pusher(
"52f3e571a0c9b08ee647",
"sms"
);
// ---------------------------------------------- //
// ---------------------------------------------- //
// Bind to the window resize event so we can re-size the
// map to fill any available space.
$( window ).resize(
function(){
// Resize the map.
siteMap.height(
$( window ).height() - siteHeader.height()
);
}
);
// Trigger the resize to get the right map dimensions
// once the window has loaded.
$( window ).resize();
// ---------------------------------------------- //
// ---------------------------------------------- //
// Bind to the server to listen for SMS text messages.
server.bind(
"textMessage",
function( data ){
// Check to make sure we have a map object. If
// we don't then we can't react yet.
if (!map){
return;
}
// I prepare the text message for display on the
// map using a marker and info window.
addTextMessageToMap( data.address, data.message );
}
);
// ---------------------------------------------- //
// ---------------------------------------------- //
// I take the given address and message and try to
// prepare them for the map interface by turning the
// address into a latitude / longitude point (geocoding).
var addTextMessageToMap = function( address, message ){
// Check to make sure that this marker has not
// already been found.
if (address in markers){
// Store the new message with the existing
// marker.
markers[ address ].smsData = message;
// Simply show the window above the current text
// message.
openInfoWindow( markers[ address ] );
// Return out since we don't need to decode
// anything else at this point.
return;
}
// If we made it here, then this is a new address.
// Geocode the address code into a lat/long object.
geocoder.geocode(
{
"address": address
},
function( results, status ){
// Check to see make sure the geocoding was
// successful and that we got a result based
// on the zip code.
if (
(status == google.maps.GeocoderStatus.OK) &&
results.length
){
// Create the marker at the given
// location with the given message.
var marker = addMarkerToMap(
results[ 0 ].geometry.location,
message
);
// Store the marker based on the address
// so that we can reference it later if
// we need to.
markers[ address ] = marker;
// Open this new info window.
openInfoWindow( marker );
} else {
alert( "Geocoding failed!" );
}
}
);
};
// I add maker to the map and initialize it such that it
// will respond to click events.
var addMarkerToMap = function( latLong, message ){
// Create new marker from the location.
var marker = new google.maps.Marker({
map: map,
position: latLong,
title: "SMS Text Message From Twilio"
});
// Store the SMS data with the marker itself. This
// is a corruption of the Marker object, but I am
// not sure how else to keep this data with the
// marker accross clicks (outside of a closure).
marker.smsData = message;
// Add a click-event handler fo the marker as well to
// allow the info window to be shown on demand.
google.maps.event.addListener(
marker,
"click",
function(){
openInfoWindow( marker );
}
);
// Return the newly created marker.
return( marker );
};
// I open the info window above the given marker using
// the stored SMS text data for the info winod content.
var openInfoWindow = function( marker ){
// Set the info window contents.
infoWindow.setContent(
"<div class='messageBody'>" +
marker.smsData +
"</div>"
);
// Open the info window above the given marker.
infoWindow.open( map, marker );
// Pan to the position of the new marker.
map.panTo( marker.getPosition() );
};
// ---------------------------------------------- //
// ---------------------------------------------- //
// When the window has fully loaded, we need to set up
// the Google map.
$( window ).load(
function(){
// Empty the map container.
siteMap.empty();
// Create the new Goole map controller using our
// site map (pass in the actual DOM object).
// Center it above the United States (lat/long)
// with a reasonable zoom (5).
map = new google.maps.Map(
siteMap[ 0 ],
{
zoom: 5,
center: new google.maps.LatLng(
38.925229,
-96.943359
),
mapTypeId: google.maps.MapTypeId.ROADMAP
}
);
}
);
// ---------------------------------------------- //
// ---------------------------------------------- //
// I hide the intro message.
var hideIntroMessage = function(){
introMessage.animate(
{
opacity: 0,
marginTop: -300
},
1500,
function(){
introMessage.remove();
}
);
};
// Bind to the document click to hide the intro message
// when someone clicks the document.
$( document ).bind(
"click.intro",
function(){
// Unbind this click.
$( document ).unbind( "click.intro" );
// Hide the intro message.
hideIntroMessage()
}
);
});
</script>
</head>
<body>
<div id="siteHeader">
<h1>
Send an SMS text message to <strong>(917) 791-2120</strong>
</h1>
<div id="logos">
Twilio + Pusher + ColdFusion + Google Maps
</div>
</div>
<div id="siteMap">
Loading Map....
</div>
<div id="introMessage">
<span class="instructions">
Send a text message to the following number and it will
show up on the map.
</span>
<span class="number">
(917) 791-2120
</span>
<span class="close">
Click anywhere in this window to hide these instructions.
</span>
</div>
</body>
</html>
And that's pretty much all there is to this. It's not a tiny amount of code; but, considering how many services this work flow touches, the amount of code is rather small! While the code posted above constitutes the bulk of the work flow, I'll post the Application.cfc and the Pusher.cfc below:
Application.cfc
<cfcomponent
output="false"
hint="I define the application settings and event handlers.">
<!--- Define the application. --->
<cfset this.name = hash( getCurrentTemplatePath() ) />
<cfset this.applicationTimeout = createTimeSpan( 0, 1, 0, 0 ) />
<cfset this.sessionManagement = true />
<cfset this.sessionTimeout = createTimeSpan( 0, 0, 7, 0 ) />
<!--- Define the request settings. --->
<cfsetting
requesttimeout="15"
showdebugoutput="false"
/>
<cffunction
name="onApplicationStart"
access="public"
returntype="boolean"
output="false"
hint="I initialize the application.">
<!--- Cache an instance of our Pusher utility. --->
<cfset application.pusher = createObject( "component", "Pusher" ).init(
"1527",
"52f3e571a0c9b08ee647",
"********************"
) />
<!--- Return true so the page can process. --->
<cfreturn true />
</cffunction>
<cffunction
name="onSessionStart"
access="public"
returntype="void"
output="false"
hint="I initialize the session.">
<!---
Set up the session variables. Remember, since
Twilio supports cookies, we can turn on stanard
session management.
--->
<cfset session.message = "" />
<cfset session.address = "" />
<cfset session.addressRequested = false />
<!--- Return out. --->
<cfreturn />
</cffunction>
<cffunction
name="onRequestStart"
access="public"
returntype="boolean"
output="false"
hint="I initialize the request.">
<!--- Check for manually application reset. --->
<cfif structKeyExists( url, "init" )>
<!--- Reset application. --->
<cfset this.onApplicationStart() />
</cfif>
<!--- Return true so the page can process. --->
<cfreturn true />
</cffunction>
<cffunction
name="onError"
access="public"
returntype="void"
output="true"
hint="I handle any uncaught application errors.">
Error!
<cfreturn />
</cffunction>
</cfcomponent>
Remember, since Twilio supports cookies, we can turn on and leverage ColdFusion session management as we would in any standard web application. Here, I am keeping the session timeout low - 7 minutes - since we need to keep track of the user across, at most, two different SMS text messages.
The Pusher.cfc is nothing more than encapsulated version of the Pusher integration code that I have posted before:
Pusher.cfc
<cfcomponent
output="false"
hint="I provide access to the Pusher App's RESTful web service API.">
<cffunction
name="init"
access="public"
returntype="any"
output="false"
hint="I return the initialized component.">
<!--- Define arguments. --->
<cfargument
name="appID"
type="string"
required="true"
hint="I am the Pusher application ID defined by Pusher."
/>
<cfargument
name="appKey"
type="string"
required="true"
hint="I am the Pusher application Key defined by Pusher."
/>
<cfargument
name="appSecret"
type="string"
required="true"
hint="I am the Pusher application secret Key defined by Pusher."
/>
<!--- Store the component properties. --->
<cfset variables.appID = arguments.appID />
<cfset variables.appKey = arguments.appKey />
<cfset variables.appSecret = arguments.appSecret />
<!--- Return this object reference. --->
<cfreturn this />
</cffunction>
<cffunction
name="pushMessage"
access="public"
returntype="any"
output="false"
hint="I push the given message over the given channel. The message will be serialized internally into JSON - be mindful of the case-sensitivity required in Javascript when defininig your data. I return the HTTP response of the HTTP post.">
<!--- Define arguments. --->
<cfargument
name="channel"
type="string"
required="true"
hint="I am the channel over which the message will be pushed to the client (assuming they ar subscribed to the channel)."
/>
<cfargument
name="event"
type="string"
required="true"
hint="I am the event to trigger as part of the message transfer over the given channel."
/>
<cfargument
name="message"
type="any"
required="true"
hint="I am the message being pushed - this can be any kind of data that can be serialized into JSON."
/>
<!--- Define the local scope. --->
<cfset var local = {} />
<!---
Serialize the message into JSON. All data pushed to the
web service must be in JSON format.
NOTE: In ColdFusion, unless you use array-notation to
define struct keys, JSON-serialized keys are turned
into uppercase.
--->
<cfset local.pusherData = serializeJSON( arguments.message ) />
<!--- Authentication information. --->
<cfset local.authVersion = "1.0" />
<cfset local.authMD5Body = lcase( hash( local.pusherData, "md5" ) ) />
<cfset local.authTimeStamp = fix( getTickCount() / 1000 ) />
<!--- Build the post resource (the RESTfule resource). --->
<cfset local.pusherResource = "/apps/#variables.appID#/channels/#arguments.channel#/events" />
<!--- -------------------------------------------------- --->
<!--- -------------------------------------------------- --->
<!---
The following is the digital signing of the HTTP request.
Frankly, this stuff is pretty far above my understanding
of cryptology. I have adapted code from the PusherApp
ColdFusion component written by Bradley Lambert:
http://github.com/blambert/pusher-cfc
--->
<!---
Create the raw signature data. This is the HTTP
method, the resource, and the alpha-ordered query
string (non-URL-encoded values).
--->
<cfset local.signatureData = (
("POST" & chr( 10 )) &
(local.pusherResource & chr( 10 )) &
(
"auth_key=#variables.appKey#&" &
"auth_timestamp=#local.authTimeStamp#&" &
"auth_version=#local.authVersion#&" &
"body_md5=#local.authMD5Body#&" &
"name=#arguments.event#"
)) />
<!---
Create our secret key generator. This can create a secret
key from a given byte array. Initialize it with the byte
array version of our PushApp secret key and the algorithm
we want to use to generate the secret key.
--->
<cfset local.secretKeySpec = createObject(
"java",
"javax.crypto.spec.SecretKeySpec"
).init(
toBinary( toBase64( variables.appSecret ) ),
"HmacSHA256"
)
/>
<!---
Create our MAC (Message Authentication Code) generator
to encrypt the message data using the PusherApp shared
secret key.
--->
<cfset local.mac = createObject( "java", "javax.crypto.Mac" )
.getInstance( "HmacSHA256" )
/>
<!--- Initialize the MAC instance using our secret key. --->
<cfset local.mac.init( local.secretKeySpec ) />
<!---
Complete the mac operation, encrypting the given secret
key (that we created above).
--->
<cfset local.encryptedBytes = local.mac.doFinal(
local.signatureData.getBytes()
) />
<!---
Now that we have the encrypted data, we have to convert
that data to a HEX-encoded string. We will use the big
integer for this.
--->
<cfset local.bigInt = createObject( "java", "java.math.BigInteger" )
.init( 1, local.encryptedBytes )
/>
<!--- Convert the encrypted bytes to the HEX string. --->
<cfset local.secureSignature = local.bigInt.toString(16) />
<!---
Apparently, we need to make sure the signature is at
least 32 characters long. As such, let's just left-pad
with spaces and then replace with zeroes.
--->
<cfset local.secureSignature = replace(
lJustify( local.secureSignature, 32 ),
" ",
"0",
"all"
) />
<!--- -------------------------------------------------- --->
<!--- -------------------------------------------------- --->
<!---
Now that we have all the values we want to post,
including our encrypted signature, we can post to the
PusherApp REST web service using CFHTTP.
--->
<cfhttp
result="local.post"
method="post"
url="http://api.pusherapp.com#local.pusherResource#">
<!---
Alert the post resource that the value is coming
through as JSON data.
--->
<cfhttpparam
type="header"
name="content-type"
value="application/json"
/>
<!--- Set the authorization parameters. --->
<cfhttpparam
type="url"
name="auth_version"
value="#local.authVersion#"
/>
<cfhttpparam
type="url"
name="auth_key"
value="#variables.appKey#"
/>
<cfhttpparam
type="url"
name="auth_timestamp"
value="#local.authTimeStamp#"
/>
<cfhttpparam
type="url"
name="body_md5"
value="#local.authMD5Body#"
/>
<!--- Sent the name of the pusher event. --->
<cfhttpparam
type="url"
name="name"
value="#arguments.event#"
/>
<!--- Send the actual message data (JSON data). --->
<cfhttpparam
type="body"
value="#local.pusherData#"
/>
<!--- Digitally sign the HTTP request. --->
<cfhttpparam
type="url"
name="auth_signature"
value="#local.secureSignature#"
/>
</cfhttp>
<!--- Return the HTTP status code. --->
<cfreturn local.post />
</cffunction>
</cfcomponent>
Again, this demo doesn't have much value in and of itself; but, I hope that it can demonstrate how easily ColdFusion can be the glue behind complex work flows that power mobile and realtime messaging applications.
Want to use code from this post? Check out the license.
Reader Comments
Ben, this is cool creative stuff. Thanks for sharing. One you can do to simplify the demo is take Pusher out of the picture and use BlazeDS within ColdFusion 9. BlazeDS won't push, but with long-polling your client-side piece won't know the difference.
@Aaron,
I'll have to check out BlazeDS. I've heard about it forever, but I have never played with it myself. My only concern about the long-polling is that it might put undue stress on the server? What I like about Pusher is that it only communicates when necessary. Of course, that might just be an invalid concern. I'll have to look into it.
Very cool! Can we try the demo somewhere?
@Baz,
Not currently.
I was thinking about maybe making an SMS Guest Book for fun. Anyone think that would be a nice idea? Or would people be too turned off about the idea of their general locations (zip) being on a map?
How about sending a photo with the text, how complicated would that be? I vote for the SMS Guest Book.
Great demo and examples Ben. This is definitely cool stuff!! I have been playing with Twilio also :)
@Bill,
Currently, none of the SMS integration services that I have found support MMS (multi-media messaging). If you try to send MMS over them, they simply don't respond.
@All,
With the help of the Pusher support team, I finally fixed my bugs in the Pusher.cfc so that I don't get the occassional 401 unauthorized errors.
www.bennadel.com/blog/1970-Pusher-cfc-ColdFusion-Component-For-Realtime-Notification-With-HTML5-WebSockets.htm