├── .gitignore ├── LICENSE ├── README.md ├── bart_fb_bot_screenshot.png └── bot ├── bartfbchatbot.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | node_modules 30 | 31 | # Optional npm cache directory 32 | .npm 33 | 34 | # Optional REPL history 35 | .node_repl_history 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Simon Prickett 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BART Chat Bot for Facebook Messenger 2 | 3 | Facebook Messenger chat bot for BART (Bay Area Rapid Transit). 4 | 5 | Implemented using Node JS, and can be hosted anywhere that meets the following criteria: 6 | 7 | * Accessible from the internet 8 | * Uses SSL (Facebook requires bots to use SSL) 9 | 10 | I have been running this using the AWS Elastic Beanstalk hosting environment, and am using Cloudflare to provide SSL. 11 | 12 | Cloudflare is set up for the domain that I am hosting the bot on, there's a DNS CNAME pointing: 13 | 14 | bartfbsecurechatbot.mydomain.mytld 15 | 16 | to the AWS Elastic Beanstalk instance that the bot runs on. 17 | 18 | As Cloudflare sits in front of that DNS CNAME I can use their free SSL, and configure the Facebook platform to see my bot at an SSL protected URL. 19 | 20 | Communications between Cloudflare and Elastic Beanstalk remain via regular HTTP for now, and this isn't something you should do in production where you want SSL enabled hosting. 21 | 22 | ## Video 23 | 24 | The bot isn't publically available as I haven't submitted it to Facebook for approval, nor scaled the infrastructure to operate as internet scale. It's more of a coding exercise / demo than something I would put into production long term. 25 | 26 | To see a YouTube video of the bot working, click the screenshot below. 27 | 28 | [![Hey BART Bot](bart_fb_bot_screenshot.png)](https://www.youtube.com/watch?v=_zUNHfDCsDk "Hey BART Bot") 29 | 30 | ## BART API 31 | 32 | This project uses my BART JSON API: 33 | 34 | * [GitHub Repo](https://github.com/simonprickett/bartnodeapi) 35 | * [Running Instance used by this project to get data from BART](http://bart.crudworks.org/api) 36 | 37 | This in turn makes calls out to the real BART API, which returns XML. I decided a while back that I wanted a JSON based API, so wrote my own conversion layer which is what I am talking to from the bot in this project. 38 | 39 | Right now I am not using the API call to get the route and price for a journey between two stations, but I aim to add that in future. 40 | 41 | ## Bot Backend Node JS Application Initial Setup 42 | 43 | We can write the bot's backend in anything that can live on a SSL URL, receive HTTP requests and respond to them. As all of the requests would be coming from Facebook, we may need to consider making sure our hosting choices scale. 44 | 45 | AWS Lambda would potentially be a good option for this. As I wanted to learn about Facebook Messenger bots with minimum other distractions, I went with AWS Elastic Beanstalk and Node JS as I am familiar with scaffolding applications quickly there, and don't intend putting my bot into production use. 46 | 47 | To keep things simple, I used the popular [Express](http://expressjs.com/) web framework and [Request](https://www.npmjs.com/package/request) HTTP client for making calls to the BART JSON API endpoints. 48 | 49 | We need to get something basic running in order to register a webhook with the Facebook platform in the next step. 50 | 51 | As part of the initial handshake with the Facebook platform, our application needs to respond to a GET request to `/webhook/`, verify a validation token and reply with a "challenge" value that Facebook sends in the request. 52 | 53 | Pick a validation token - for example "super_secret_squirrel", then deploy an application that contains the following route somewhere that it can be accessed at a HTTPS URL: 54 | 55 | ```javascript 56 | app.get('/webhook/', function (req, res) { 57 | if (req.query['hub.verify_token'] === 'super_secret_squirrel') { 58 | res.send(req.query['hub.challenge']); 59 | } 60 | res.send('Error, wrong validation token'); 61 | }) 62 | ``` 63 | 64 | Facebook documentation on this can be found [here](https://developers.facebook.com/docs/messenger-platform/quickstart). 65 | 66 | Before adding any more logic to the application, we need to do some setup on the Facebook platform. 67 | 68 | ## Initial Facebook Setup 69 | 70 | For this exercise, we'll need to create a Facebook Page and App for the bot. I'm assuming you are familiar with the [Facebook Developer Portal](https://developers.facebook.com/) so won't cover this in step by step detail. 71 | 72 | The Facebook documentation for creating a Messenger bot can be found [here](https://developers.facebook.com/docs/messenger-platform). 73 | 74 | ### Create a Facebook Page 75 | 76 | Create a new Facebook Page to use with the bot, or use one you already have. 77 | 78 | For testing a bot, this doesn't have to be a published page. The bot wil use the profile pic from your page as its avatar in Messenger conversations. 79 | 80 | If you're going to run your bot in pre-release / sandbox mode, then you'll want to make your Facebook friends whom you also want to be able to use the bot editors of your unpublished page, as they won't be able to see it otherwise. 81 | 82 | ### Create a Facebook App 83 | 84 | You will need a new Facebook App for your bot, and you can keep it in sandbox mode. When creating a new app, add the "Messenger" product. Facebook documentation for each of these steps can be found [here](https://developers.facebook.com/docs/messenger-platform/quickstart). 85 | 86 | ### Set Callback URL and Verify Token 87 | 88 | You will be asked for a Callback URL, set this to the full HTTPS URL for your Node application's webhook route e.g: 89 | 90 | ``` 91 | https://whatever.something.com/webhook 92 | ``` 93 | You will also need to add your verification token ('super_secret_squirrel' from the webhook code we wrote earlier) into the dialog that appear, and check the boxes to subscribe to: 94 | 95 | * `message_deliveries` 96 | * `messages` 97 | * `message_optins` 98 | * `messaging_postbacks` 99 | 100 | ### Generate Page Token 101 | 102 | In the Messenger properties page for your app, there's a Token Generation section. Select the page that you created earlier from the "Page" drop down. When the token appears, copy that as it will be needed for the next step. 103 | 104 | ### Subscribe the App to the Facebook Page 105 | 106 | To associate your app with the Facebook page for the purposes of receiving updates from it, run the following at the command line on your local machine. 107 | 108 | ``` 109 | curl -X POST "https://graph.facebook.com/v2.6/me/subscribed_apps?access_token=" 110 | ``` 111 | 112 | Substitute `` for the token that was generated in the previous step. 113 | 114 | You should now have things setup so that messages sent to your page from Facebook users are routed to the Node application for processing. 115 | 116 | ## Adding Logic to the Node Backend 117 | 118 | The Node backend application now needs to be modified to receive and process messages from the Facebook platform, and respond with appropriately formatted JSON replies that Facebook will render as messages to the user from the bot. 119 | 120 | The Facebook platform communicates with the bot using a single `POST` route to `/webhook/`, so we need to add a handler for that to our Node/Express application. 121 | 122 | Facebook provides some boilerplate code for this that looks like this: 123 | 124 | ```javascript 125 | app.post('/webhook/', function (req, res) { 126 | messaging_events = req.body.entry[0].messaging; 127 | for (i = 0; i < messaging_events.length; i++) { 128 | event = req.body.entry[0].messaging[i]; 129 | sender = event.sender.id; 130 | if (event.message && event.message.text) { 131 | text = event.message.text; 132 | // Handle a text message from this sender 133 | } 134 | } 135 | res.sendStatus(200); 136 | }); 137 | ``` 138 | 139 | This will receive a message (or array of - Facebook can batch incoming messages together if traffic on the system is high), then get the text of the message out of the incoming request. You then need to add your own code to do something with that text (parse it for keywords for example) and return a response JSON object that Facebook will render back to the user. 140 | 141 | In my bot, I'm handling more than just the basic text messages that the Facebook example code caters for: I'm looking for messages with a location attachment (user has sent their location from Messenger to the bot), and postback messages from previous button presses that the user made on calls to action that the user took when dealing with replies from the bot. So my logic for processing incoming messages from Messenger looks like: 142 | 143 | ```javascript 144 | app.post('/webhook/', function (req, res) { 145 | var messagingEvents, 146 | i = 0, 147 | event, 148 | sender, 149 | text, 150 | attachment; 151 | 152 | if (req.body && req.body.entry) { 153 | messagingEvents = req.body.entry[0].messaging; 154 | 155 | for (; i < messagingEvents.length; i++) { 156 | event = messagingEvents[i]; 157 | sender = event.sender.id; 158 | if (event.message && event.message.attachments && event.message.attachments.length > 0) { 159 | attachment = event.message.attachments[0]; 160 | 161 | if (attachment.type === 'location') { 162 | processLocation(sender, attachment.payload.coordinates); 163 | } 164 | } else if (event.postback && event.postback.payload) { 165 | if (event.postback.payload.indexOf('departures') > -1) { 166 | processMessage(sender, event.postback.payload); 167 | } 168 | } else { 169 | if (event.message && event.message.text) { 170 | text = event.message.text; 171 | processMessage(sender, text); 172 | } 173 | } 174 | } 175 | } 176 | 177 | res.sendStatus(200); 178 | }); 179 | ``` 180 | 181 | Note we always send a 200 status back to Facebook as soon as possible - the response message will be sent as a separate `POST` asychronously. 182 | 183 | We'll cover each of the incoming message types in detail, but we're looking for: 184 | 185 | * Text message: `event.message.text` 186 | * Postback action (call to action button pressed): `event.postback.payload` 187 | * Location sent: `event.message.attachments[0].type === 'location'` 188 | 189 | Then we deal with each using their own function. 190 | 191 | ## Responding to Messages from Users 192 | 193 | Now we can read messages from users, we need to do something with them and send an appropriate response back. 194 | 195 | The Messenger platform supports some basic response types, which are: 196 | 197 | * A text message (plain text) 198 | * A URL to an image (not used in this example) 199 | * An image file (not used in this example) 200 | * A call to action with postback action buttons or links to external websites 201 | * A structured message containing one or more "bubbles", each containing text, optional image and optional calls to action links. "bubbles" scroll horizontally in the user's view in Messenger 202 | * A structured message containing a receipt for goods (not used in this example) 203 | 204 | The message types are all quite basic, represented as JSON, and have little to no formatting options. More information and example JSON schemas for each can be found in the [Send API Reference documentation](https://developers.facebook.com/docs/messenger-platform/send-api-reference). 205 | 206 | The API is not designed for long replies, the currenct limitations are: 207 | 208 | * Title field: 45 characters 209 | * Subtitle field: 80 characters 210 | * Call to action button title: 20 characters 211 | * Maximum call to action items per bubble: 3 212 | * Maximium bubbles per message response: 10 (will scroll horizontally) 213 | 214 | Text appears to have little to no formatting options (seemingly no way to do an unordered or ordered list. `\n` does cause a line break). 215 | 216 | If a message has a text field in it that contains more than the allowed number of characters, Facebook will reject it. 217 | 218 | ### Responding to Text Messages 219 | 220 | When we receive a text message (identified by a `POST` to `/webhook/` containing): 221 | 222 | ```javascript 223 | event.message.text 224 | ``` 225 | 226 | everything that the user typed is delivered as a single string in the above property. 227 | 228 | We can then use any sort of string parsing to try and work out what the user is asking for, and send any type of response message (plain text, one or more bubbles of text / image / link / call to action buttons). 229 | 230 | In this demo, we're using basic string searches to determine the user's query. Ugly, but effective enough to pick out what we need: 231 | 232 | ```javascript 233 | function processMessage(sender, reqText) { 234 | var respText = 'Sorry I don\'t understand. Try:\n\nstatus\nelevators\nstations\ndepartures \n\nOr send your location for nearest station.', 235 | keywordPos = -1, 236 | stationCode; 237 | 238 | reqText = reqText.trim().toLowerCase(); 239 | 240 | if (reqText.indexOf('help') > -1) { 241 | // Deal with sending user help message 242 | } else if (reqText.indexOf('stations') > -1) { 243 | // Get a list of all station codes and send them to the user 244 | } else if (reqText.indexOf('departures') > -1) { 245 | // Parse out a station code from: 246 | // departures from 247 | // departures for 248 | // departures at 249 | // departures 250 | 251 | keywordPos = reqText.indexOf('departures at'); 252 | if (keywordPos > -1 && reqText.length >= keywordPos + 18) { 253 | stationCode = reqText.substring(keywordPos + 14, keywordPos + 18); 254 | } else { 255 | keywordPos = reqText.indexOf('departures for'); 256 | if (keywordPos > -1 && reqText.length >= keywordPos + 19) { 257 | stationCode = reqText.substring(keywordPos + 15, keywordPos + 19); 258 | } else { 259 | keywordPos = reqText.indexOf('departures from'); 260 | if (keywordPos > -1 && reqText.length >= keywordPos + 20) { 261 | stationCode = reqText.substring(keywordPos + 16, keywordPos + 20); 262 | } else { 263 | keywordPos = reqText.indexOf('departures'); 264 | if (reqText.length >= keywordPos + 15) { 265 | stationCode = reqText.substring(keywordPos + 11, keywordPos + 15); 266 | } else { 267 | // Keyword found but no station code 268 | keywordPos = -1; 269 | } 270 | } 271 | } 272 | } 273 | 274 | if (keywordPos > -1) { 275 | stationCode = stationCode.trim(); 276 | // Go get the train departures for the requested 277 | // station code and send to the user 278 | } else { 279 | // Send error message to user 280 | } 281 | } else if (reqText.indexOf('elevators') > -1) { 282 | // Get elevator status and send to the user 283 | } else if (reqText.indexOf('status') > -1) { 284 | // Get system status and service announcements 285 | // and send to the user 286 | } else { 287 | // Unknown command 288 | console.log(respText); 289 | sendTextMessage(sender, respText); 290 | } 291 | } 292 | ``` 293 | 294 | For more complex text processing, Facebook recommends trying [wit.ai](https://wit.ai/). 295 | 296 | #### Sending a Plain Text Response 297 | 298 | In the case where we want to send a basic text reply (for example with the elevator status response that is just a short text message), we simply call a function `sendTextMessage` which expects the sender (from the original webhook call that came from the Facebook platform), and a string for the message to send back to that user: 299 | 300 | ```javascript 301 | sendTextMessage(sender, 'Hello there!'); 302 | ``` 303 | 304 | The implementation of `sendTextMessage` looks like this: 305 | 306 | ```javascript 307 | function sendTextMessage(sender, text) { 308 | var messageData = { 309 | text: text 310 | }; 311 | 312 | httpRequest({ 313 | url: 'https://graph.facebook.com/v2.6/me/messages', 314 | qs: { 315 | access_token: FACEBOOK_PAGE_ACCESS_TOKEN 316 | }, 317 | method: 'POST', 318 | json: { 319 | recipient: { 320 | id: sender 321 | }, 322 | message: messageData, 323 | } 324 | }, function(error, response, body) { 325 | if (error) { 326 | console.log('Error sending message: ', error); 327 | } else if (response.body.error) { 328 | console.log('Error: ', response.body.error); 329 | } 330 | }); 331 | } 332 | ``` 333 | 334 | This simple sends a HTTP `POST` to the Facebook Graph API (must be v2.6 or higher) to generate a message back to the user. The `messageData` object simply wraps the plain text that we want to send. We authenticate to Facebook using the Page Access Token that was obtained when setting up the bot. 335 | 336 | If the message is over 320 characters, Facebook will reject it. 337 | 338 | In other cases, we have more information than the 320 character limit of a text message allows for, and/or we want to format it to include multiple message "bubbles", images, header/sub-headers, links to websites or further calls to action. For this we need to use Facebook's "Generic Template". 339 | 340 | #### Sending a Richer Response 341 | 342 | Strangely, Facebook uses the term "Generic Template" for its richer message template that can include between 1 and 10 "bubbles", each containing a title (45 characters), subtitle (80 characters), 3 buttons each having up to a 20 character label and linking to an external web page or a postback to the bot. 343 | 344 | Again, the message bubble(s) are described by way of JavaScript objects and posted to the Facebook Graph API as JSON. 345 | 346 | An example object that sends 3 bubbles each with a title and subtitle looks like this (we use this to return departure times from a given station): 347 | 348 | ```javascript 349 | messageData = { 350 | attachment: { 351 | type: 'template', 352 | payload: { 353 | template_type: 'generic', 354 | elements: [ 355 | { 356 | title: '24th Street', 357 | 'subtitle': '43 mins, 9 cars. 58 mins, 9 cars. 73 mins, 9 cars.' 358 | }, 359 | { 360 | title: 'Daly City', 361 | 'subtitle': '43 mins, 9 cars. 58 mins, 9 cars. 73 mins, 9 cars. 1 min, 9 cars. 4 mins, 9 cars.' 362 | }, 363 | { 364 | title: 'Millbrae', 365 | 'subtitle': '8 mins, 4 cars. 23 mins, 4 cars. 38 mins, 4 cars. 13 mins, 5 cars.' 366 | } 367 | ] 368 | } 369 | } 370 | }; 371 | ``` 372 | 373 | Each object in the `elements` array becomes its own "bubble" when rendered in Messenger, and these scroll horizontally in the Messenger chat box. 374 | 375 | We'll look at adding images and callback buttons when responding to location messages later. 376 | 377 | When we have our object ready to send, we call a function `sendGenericMessage` which expects the sender (from the original webhook call that came from the Facebook platform), and an object containing the template for the message bubble(s) to send back to that user: 378 | 379 | The implementation of `sendGenericMessage` looks like this: 380 | 381 | ```javascript 382 | function sendGenericMessage(sender, messageData) { 383 | httpRequest({ 384 | url: 'https://graph.facebook.com/v2.6/me/messages', 385 | qs: { 386 | access_token: FACEBOOK_PAGE_ACCESS_TOKEN 387 | }, 388 | method: 'POST', 389 | json: { 390 | recipient: { 391 | id: sender 392 | }, 393 | message: messageData, 394 | } 395 | }, function(error, response, body) { 396 | if (error) { 397 | console.log('Error sending message: ', error); 398 | } else if (response.body.error) { 399 | console.log('Error: ', response.body.error); 400 | } 401 | }); 402 | } 403 | ``` 404 | 405 | We do pretty much the same thing as in `sendTextMessage`, however `sendGenericMessage` expects a `messageData` object rather than a string as its second parameter. 406 | 407 | ### Responding to Location Messages 408 | 409 | When we receive a location message (identified by): 410 | 411 | ```javascript 412 | event.message.attachments[0].type === 'location' 413 | ``` 414 | 415 | We're interested in the user's lat/long co-ordinates, which can be obtained from the incoming message as: 416 | 417 | ```javascript 418 | attachment.payload.coordinates.lat 419 | attachment.payload.coordinates.long 420 | ``` 421 | 422 | and then used in any other API calls or further processing to determine what sort of response to send back to the user. 423 | 424 | In our case, the bot asks the BART API for the station closest to the user's location then responds with a "Generic" template message containing: 425 | 426 | * Name of the closest station 427 | * Distance in miles to closest station 428 | * Image containing an Open Street Map tile showing the nearest station's location 429 | * Call to Action button to open the BART website at the nearest station's page 430 | * Call to Action button to tell the bot that the user would like to see train departures from that tation 431 | * Call to Action button to open a Bing maps URL with driving directions to the station if it is more than 2 miles away, otherwise walking directions 432 | 433 | The code for this looks like: 434 | 435 | ```javascript 436 | function processLocation(sender, coords) { 437 | httpRequest({ 438 | url: BART_API_BASE + '/station/' + coords.lat + '/' + coords.long, 439 | method: 'GET' 440 | }, function(error, response, body) { 441 | var station, 442 | messageData, 443 | directionsUrl; 444 | 445 | if (! error && response.statusCode === 200) { 446 | station = JSON.parse(body); 447 | directionsUrl = 'http://bing.com/maps/default.aspx?rtop=0~~&rtp=pos.' + coords.lat + '_' + coords.long + '~pos.' + station.gtfs_latitude + '_' + station.gtfs_longitude + '&mode='; 448 | 449 | // Walkable if 2 miles or under 450 | directionsUrl += (station.distance <= 2 ? 'W' : 'D'); 451 | 452 | messageData = { 453 | 'attachment': { 454 | 'type': 'template', 455 | 'payload': { 456 | 'template_type': 'generic', 457 | 'elements': [{ 458 | 'title': 'Closest BART: ' + station.name, 459 | 'subtitle': station.distance.toFixed(2) + ' miles', 460 | 'image_url': 'http://staticmap.openstreetmap.de/staticmap.php?center=' + station.gtfs_latitude + ',' + station.gtfs_longitude + '&zoom=18&size=640x480&maptype=osmarenderer&markers=' + station.gtfs_latitude + ',' + station.gtfs_longitude, 461 | 'buttons': [{ 462 | 'type': 'web_url', 463 | 'url': 'http://www.bart.gov/stations/' + station.abbr.toLowerCase(), 464 | 'title': 'Station Information' 465 | }, { 466 | 'type': 'postback', 467 | 'title': 'Departures', 468 | 'payload': 'departures ' + station.abbr, 469 | }, { 470 | 'type': 'web_url', 471 | 'url': directionsUrl, 472 | 'title': 'Directions' 473 | }] 474 | }] 475 | } 476 | } 477 | }; 478 | 479 | sendGenericMessage(sender, messageData); 480 | } else { 481 | console.log(error); 482 | sendTextMessage(sender, 'Sorry I was unable to determine your closest BART station.'); 483 | } 484 | }); 485 | } 486 | ``` 487 | 488 | The message to send back to the user is built up in `messageData` and sent with the `sendGeneric` function. 489 | 490 | To include a button that points to a URL we use: 491 | 492 | ```javascript 493 | { 494 | 'type': 'web_url', 495 | 'url': 'http://google.com', 496 | 'title': 'Google' 497 | } 498 | ``` 499 | 500 | When clicked in Messenger, this button will open the URL in the browser, and the a callback to the bot is not made. 501 | 502 | To include a button that posts back a further action to the bot we use: 503 | 504 | ```javascript 505 | { 506 | 'type': 'postback', 507 | 'title': 'Departures', 508 | 'payload': 'json to send back to bot', 509 | } 510 | ``` 511 | 512 | When clicked in Messenger, this will cause a POST to be sent to the bot's `/webhook/` endpoint containing the payload JSON as `event.postback.payload`. The bot can then use this value to determine what action to take next, and which further response to send. 513 | 514 | If there's an error, we just return an error back to the user using the `sendTextMessage` function. 515 | 516 | ### Responding to Postback Messages 517 | 518 | When we receive a postback message (identified by): 519 | 520 | ```javascript 521 | event.postback.payload 522 | ``` 523 | 524 | existing, and containing any JSON that was included in the `payload` of the button that was pressed in Messenger. We then use that to determine what action to take. 525 | 526 | In our example, the only postback payloads look like `departures powl` which is something that the user can also enter themselves in a text message, so in our webhook POST route we just use the same handler as we would for a text message, and pass it the payload text: 527 | 528 | ```javascript 529 | if (event.postback.payload.indexOf('departures') > -1) { 530 | processMessage(sender, event.postback.payload); 531 | } 532 | ``` 533 | 534 | ## Additional Facebook Setup 535 | 536 | There's some extra Facebook setup that we can do to improve the user experience a little. 537 | 538 | ### Set up Welcome Message 539 | 540 | This is optional, but nice to have. A welcome message is displayed automatically at the start of each new conversation. To set this up, we will need to send Facebook a post request containing the JSON representation of either a plain text (as used below) or structured message with call to action buttons. We will also need the access token for our Facebook page. 541 | 542 | ``` 543 | curl -H "Content-Type: application/json" -X POST -d '{"setting_type":"call_to_actions","thread_state":"new_thread","call_to_actions":[{"message":{"text":"Hi, I am BART bot - I can help with your Bay Area travel needs!"}}]}' https://graph.facebook.com/v2.6/heybartbot/thread_settings?access_token= 544 | ``` 545 | -------------------------------------------------------------------------------- /bart_fb_bot_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonprickett/bartfbchatbot/47a93dfbe04fe8e503403ab53344dba446e66cc8/bart_fb_bot_screenshot.png -------------------------------------------------------------------------------- /bot/bartfbchatbot.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* bartfbchatbot.js */ 4 | 5 | /* 6 | * TODO: 7 | * 8 | * - Add some better parsing with http://wit.ai 9 | */ 10 | 11 | var express = require('express'), 12 | httpRequest = require('request'), 13 | app = express(), 14 | http = require('http'), 15 | bodyParser = require('body-parser'), 16 | port = process.env.PORT || 8888, 17 | FACEBOOK_PAGE_ACCESS_TOKEN = process.env.FACEBOOK_PAGE_ACCESS_TOKEN, 18 | MAPBOX_API_TOKEN = process.env.MAPBOX_API_TOKEN, 19 | BART_API_BASE = 'http://bart.crudworks.org/api'; 20 | 21 | function processMessage(sender, reqText) { 22 | var respText = 'Sorry I don\'t understand. Try:\n\nstatus\nelevators\nstations\ndepartures \n\nOr send your location for nearest station.', 23 | keywordPos = -1, 24 | stationCode; 25 | 26 | reqText = reqText.trim().toLowerCase(); 27 | 28 | if (reqText.indexOf('help') > -1) { 29 | sendTextMessage(sender, respText.substring(26)); 30 | } else if (reqText.indexOf('stations') > -1) { 31 | httpRequest({ 32 | url: BART_API_BASE + '/stations', 33 | method: 'GET' 34 | }, function(error, response, body) { 35 | var stations, 36 | n = 0; 37 | 38 | if (! error && response.statusCode === 200) { 39 | stations = JSON.parse(body); 40 | console.log(stations); 41 | respText = 'Try departures from : '; 42 | 43 | for (; n < stations.length; n++) { 44 | respText += stations[n].abbr + ', '; 45 | } 46 | 47 | console.log(respText); 48 | respText = respText.substring(0, respText.length - 2); 49 | } else { 50 | respText = 'Sorry something happened: ' + error; 51 | } 52 | 53 | sendTextMessage(sender, respText); 54 | }); 55 | } else if (reqText.indexOf('departures') > -1) { 56 | // Parse out a station code from: 57 | // departures from 58 | // departures for 59 | // departures at 60 | // departures 61 | 62 | keywordPos = reqText.indexOf('departures at'); 63 | if (keywordPos > -1 && reqText.length >= keywordPos + 18) { 64 | stationCode = reqText.substring(keywordPos + 14, keywordPos + 18); 65 | } else { 66 | keywordPos = reqText.indexOf('departures for'); 67 | if (keywordPos > -1 && reqText.length >= keywordPos + 19) { 68 | stationCode = reqText.substring(keywordPos + 15, keywordPos + 19); 69 | } else { 70 | keywordPos = reqText.indexOf('departures from'); 71 | if (keywordPos > -1 && reqText.length >= keywordPos + 20) { 72 | stationCode = reqText.substring(keywordPos + 16, keywordPos + 20); 73 | } else { 74 | keywordPos = reqText.indexOf('departures'); 75 | if (reqText.length >= keywordPos + 15) { 76 | stationCode = reqText.substring(keywordPos + 11, keywordPos + 15); 77 | } else { 78 | // Keyword found but no station code 79 | keywordPos = -1; 80 | } 81 | } 82 | } 83 | } 84 | 85 | if (keywordPos > -1) { 86 | stationCode = stationCode.trim(); 87 | 88 | httpRequest({ 89 | url: BART_API_BASE + '/departures/' + stationCode, 90 | method: 'GET' 91 | }, function(error, response, body) { 92 | var departures, 93 | etd, 94 | estimate, 95 | messageData = undefined, 96 | card, 97 | cards = [], 98 | departureTimes = '', 99 | n = 0, 100 | m; 101 | 102 | if (! error && response.statusCode === 200) { 103 | departures = JSON.parse(body); 104 | respText = 'Sorry I don\'t know about a station with the code \'' + stationCode.toUpperCase() + '\': please try \'stations\' for a list of valid station codes.'; 105 | 106 | if (departures.etd && departures.etd.length > 0) { 107 | messageData = { 108 | attachment: { 109 | type: 'template', 110 | payload: { 111 | template_type: 'generic', 112 | elements: [] 113 | } 114 | } 115 | }; 116 | 117 | for (; n < departures.etd.length; n++) { 118 | etd = departures.etd[n]; 119 | card = { 120 | title: etd.destination, 121 | }; 122 | 123 | for (m = 0; m < etd.estimate.length && m < 3; m++) { 124 | estimate = etd.estimate[m]; 125 | if (estimate.minutes === 'Leaving') { 126 | departureTimes += estimate.minutes; 127 | } else { 128 | departureTimes += estimate.minutes + (estimate.minutes > 1 ? ' mins' : ' min'); 129 | } 130 | departureTimes += ', ' + estimate['length'] + ' cars. '; 131 | } 132 | 133 | card.subtitle = departureTimes; 134 | cards.push(card); 135 | } 136 | 137 | messageData.attachment.payload.elements = cards; 138 | } 139 | 140 | if (messageData) { 141 | sendGenericMessage(sender, messageData); 142 | } else { 143 | sendTextMessage(sender, respText); 144 | } 145 | } 146 | }); 147 | } else { 148 | respText = 'I wasn\'t able to work out which station code you wanted to know about. Please try\n\ndepartures from powl\n\ndepartures at powl\n\ndepartures powl'; 149 | sendTextMessage(sender, respText); 150 | } 151 | } else if (reqText.indexOf('elevators') > -1) { 152 | httpRequest({ 153 | url: BART_API_BASE + '/elevatorStatus', 154 | method: 'GET' 155 | }, function(error, response, body) { 156 | var elevatorStatus, 157 | messageData; 158 | 159 | respText = 'There are currently no known elevator issues.'; 160 | 161 | if (! error && response.statusCode === 200) { 162 | elevatorStatus = JSON.parse(body); 163 | 164 | if (elevatorStatus.bsa && elevatorStatus.bsa.description) { 165 | respText = elevatorStatus.bsa.description; 166 | } 167 | } 168 | 169 | sendTextMessage(sender, respText); 170 | }); 171 | } else if (reqText.indexOf('status') > -1) { 172 | httpRequest({ 173 | url: BART_API_BASE + '/status', 174 | method: 'GET' 175 | }, function(error, response, body) { 176 | var status, 177 | numTrains; 178 | 179 | if (! error && response.statusCode === 200) { 180 | status = JSON.parse(body); 181 | 182 | httpRequest({ 183 | url: BART_API_BASE + '/serviceAnnouncements', 184 | method: 'GET' 185 | }, function(err, resp, b) { 186 | var serviceAnnouncements; 187 | if (! err & resp.statusCode === 200) { 188 | serviceAnnouncements = JSON.parse(b); 189 | 190 | if (serviceAnnouncements.bsa && serviceAnnouncements.bsa.length > 0 && status && status.traincount) { 191 | respText = 'There are ' + status.traincount + ' trains operating.\n\n' + serviceAnnouncements.bsa[0].description; 192 | } else { 193 | respText = 'Sorry I\'m unable to determine system status right now :('; 194 | } 195 | } else { 196 | respText = 'Sorry I\'m unable to determine system status right now :('; 197 | } 198 | 199 | sendTextMessage(sender, respText); 200 | }); 201 | } else { 202 | respText = 'Sorry I\'m unable to determine system status right now :('; 203 | sendTextMessage(sender, respText); 204 | } 205 | }); 206 | } else { 207 | // Unknown command 208 | console.log(respText); 209 | sendTextMessage(sender, respText); 210 | } 211 | } 212 | 213 | function processLocation(sender, coords) { 214 | httpRequest({ 215 | url: BART_API_BASE + '/station/' + coords.lat + '/' + coords.long, 216 | method: 'GET' 217 | }, function(error, response, body) { 218 | var station, 219 | messageData, 220 | directionsUrl; 221 | 222 | if (! error && response.statusCode === 200) { 223 | station = JSON.parse(body); 224 | directionsUrl = 'http://bing.com/maps/default.aspx?rtop=0~~&rtp=pos.' + coords.lat + '_' + coords.long + '~pos.' + station.gtfs_latitude + '_' + station.gtfs_longitude + '&mode='; 225 | 226 | // Walkable if 2 miles or under 227 | directionsUrl += (station.distance <= 2 ? 'W' : 'D'); 228 | 229 | messageData = { 230 | 'attachment': { 231 | 'type': 'template', 232 | 'payload': { 233 | 'template_type': 'generic', 234 | 'elements': [{ 235 | 'title': 'Closest BART: ' + station.name, 236 | 'subtitle': station.distance.toFixed(2) + ' miles', 237 | 'image_url': 'https://api.mapbox.com/v4/mapbox.streets/' + station.gtfs_longitude + ',' + station.gtfs_latitude + ',18/640x480@2x.png?access_token=' + MAPBOX_API_TOKEN, 238 | 'buttons': [{ 239 | 'type': 'web_url', 240 | 'url': 'http://www.bart.gov/stations/' + station.abbr.toLowerCase(), 241 | 'title': 'Station Information' 242 | }, { 243 | 'type': 'postback', 244 | 'title': 'Departures', 245 | 'payload': 'departures ' + station.abbr, 246 | }, { 247 | 'type': 'web_url', 248 | 'url': directionsUrl, 249 | 'title': 'Directions' 250 | }] 251 | }] 252 | } 253 | } 254 | }; 255 | 256 | sendGenericMessage(sender, messageData); 257 | } else { 258 | console.log(error); 259 | sendTextMessage(sender, 'Sorry I was unable to determine your closest BART station.'); 260 | } 261 | }); 262 | } 263 | 264 | function sendTextMessage(sender, text) { 265 | var messageData = { 266 | text: text 267 | }; 268 | 269 | httpRequest({ 270 | url: 'https://graph.facebook.com/v2.6/me/messages', 271 | qs: { 272 | access_token: FACEBOOK_PAGE_ACCESS_TOKEN 273 | }, 274 | method: 'POST', 275 | json: { 276 | recipient: { 277 | id: sender 278 | }, 279 | message: messageData, 280 | } 281 | }, function(error, response, body) { 282 | if (error) { 283 | console.log('Error sending message: ', error); 284 | } else if (response.body.error) { 285 | console.log('Error: ', response.body.error); 286 | } 287 | }); 288 | } 289 | 290 | function sendGenericMessage(sender, messageData) { 291 | httpRequest({ 292 | url: 'https://graph.facebook.com/v2.6/me/messages', 293 | qs: { 294 | access_token: FACEBOOK_PAGE_ACCESS_TOKEN 295 | }, 296 | method: 'POST', 297 | json: { 298 | recipient: { 299 | id: sender 300 | }, 301 | message: messageData, 302 | } 303 | }, function(error, response, body) { 304 | if (error) { 305 | console.log('Error sending message: ', error); 306 | } else if (response.body.error) { 307 | console.log('Error: ', response.body.error); 308 | } 309 | }); 310 | } 311 | 312 | app.set('port', port); 313 | app.use(bodyParser.json()); 314 | app.use(bodyParser.urlencoded({ 315 | extended: true 316 | })); 317 | 318 | app.get('/', function(req, res) { 319 | res.send('BART Facebook Chatbot.'); 320 | }); 321 | 322 | app.get('/webhook/', function (req, res) { 323 | if (req.query['hub.verify_token'] === 'lets_talk_mass_trans1t') { 324 | res.send(req.query['hub.challenge']); 325 | } 326 | res.send('Error, wrong validation token'); 327 | }); 328 | 329 | app.post('/webhook/', function (req, res) { 330 | var messagingEvents, 331 | i = 0, 332 | event, 333 | sender, 334 | text, 335 | attachment; 336 | 337 | if (req.body && req.body.entry) { 338 | messagingEvents = req.body.entry[0].messaging; 339 | 340 | for (; i < messagingEvents.length; i++) { 341 | event = messagingEvents[i]; 342 | console.log('^^^^^^^^^^^^^^^^^^^^^^^^'); 343 | console.log(JSON.stringify(event)); 344 | console.log('^^^^^^^^^^^^^^^^^^^^^^^^'); 345 | sender = event.sender.id; 346 | if (event.message && event.message.attachments && event.message.attachments.length > 0) { 347 | attachment = event.message.attachments[0]; 348 | 349 | if (attachment.type === 'location') { 350 | processLocation(sender, attachment.payload.coordinates); 351 | } 352 | } else if (event.postback && event.postback.payload) { 353 | if (event.postback.payload.indexOf('departures') > -1) { 354 | processMessage(sender, event.postback.payload); 355 | } 356 | } else { 357 | if (event.message && event.message.text) { 358 | text = event.message.text; 359 | processMessage(sender, text); 360 | } 361 | } 362 | } 363 | } 364 | 365 | res.sendStatus(200); 366 | }); 367 | 368 | http.createServer(app).listen(port); 369 | console.log('bartcfchatbot listening on port ' + port); 370 | module.exports = app; -------------------------------------------------------------------------------- /bot/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bartfbchatbot", 3 | "version": "0.0.1", 4 | "description": "Bay Area Rapid Transit Chatbot for Facebook Messenger", 5 | "dependencies": { 6 | "express": "4.13.4", 7 | "request": "2.71.0", 8 | "body-parser": "1.15.0" 9 | }, 10 | "scripts": { 11 | "start": "node bartfbchatbot.js" 12 | } 13 | } --------------------------------------------------------------------------------