├── functions └── webhook │ ├── event.json │ ├── handlers │ ├── postback.js │ ├── authentication.js │ ├── messageDelivered.js │ ├── textReceived.js │ └── attachmentsReceived.js │ ├── package.json │ ├── handler.js │ └── s-function.json ├── .babelrc ├── .travis.yml ├── s-project.json ├── __tests__ ├── assets │ ├── authenticationPayload.json │ ├── postbackPayload.json │ ├── textReceivedPayload.json │ ├── messageDeliveredPayload.json │ ├── audioReceivedPayload.json │ ├── imageReceivedPayload.json │ ├── videoReceivedPayload.json │ ├── locationReceivedPayload.json │ └── textBatchReceivedPayload.json ├── webhook.js └── webhook.POST.js ├── .gitignore ├── webpack.config.js ├── LICENSE ├── package.json ├── s-resources-cf.json └── README.md /functions/webhook/event.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5.1" 4 | 5 | before_install: 6 | - npm config set spin false --global -------------------------------------------------------------------------------- /functions/webhook/handlers/postback.js: -------------------------------------------------------------------------------- 1 | 2 | export function handlePostback(messagingItem, pageId, entryTimestamp) { 3 | return Promise.resolve(true); 4 | } -------------------------------------------------------------------------------- /functions/webhook/handlers/authentication.js: -------------------------------------------------------------------------------- 1 | 2 | export function handleAuthentication(messagingItem, pageId, entryTimestamp) { 3 | return Promise.resolve(true); 4 | } -------------------------------------------------------------------------------- /functions/webhook/handlers/messageDelivered.js: -------------------------------------------------------------------------------- 1 | 2 | export function handleMessageDelivered(messagingItem, pageId, entryTimestamp) { 3 | return Promise.resolve(true); 4 | } -------------------------------------------------------------------------------- /functions/webhook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webhook", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "handler.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "MIT", 11 | "dependencies": {} 12 | } 13 | -------------------------------------------------------------------------------- /s-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "facebook-messenger-bot", 3 | "custom": { 4 | "serverless-offline": { 5 | "babelOptions": { 6 | "presets": [ 7 | "es2015" 8 | ] 9 | } 10 | } 11 | }, 12 | "plugins": [ 13 | "serverless-offline", 14 | "serverless-webpack-plugin" 15 | ] 16 | } -------------------------------------------------------------------------------- /__tests__/assets/authenticationPayload.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "PAGE_ID", 3 | "time":12341, 4 | "messaging":[ 5 | { 6 | "sender":{ 7 | "id":"USER_ID" 8 | }, 9 | "recipient":{ 10 | "id":"PAGE_ID" 11 | }, 12 | "timestamp":1234567890, 13 | "optin":{ 14 | "ref":"PASS_THROUGH_PARAM" 15 | } 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /__tests__/assets/postbackPayload.json: -------------------------------------------------------------------------------- 1 | { 2 | "id":"PAGE_ID", 3 | "time":1458692752478, 4 | "messaging":[ 5 | { 6 | "sender":{ 7 | "id":"USER_ID" 8 | }, 9 | "recipient":{ 10 | "id":"PAGE_ID" 11 | }, 12 | "timestamp":1458692752478, 13 | "postback":{ 14 | "payload":"USER_DEFINED_PAYLOAD" 15 | } 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /__tests__/assets/textReceivedPayload.json: -------------------------------------------------------------------------------- 1 | { 2 | "id":"PAGE_ID", 3 | "time":1457764198246, 4 | "messaging":[ 5 | { 6 | "sender":{ 7 | "id":"USER_ID" 8 | }, 9 | "recipient":{ 10 | "id":"PAGE_ID" 11 | }, 12 | "timestamp":1457764197627, 13 | "message":{ 14 | "mid":"mid.1457764197618:41d102a3e1ae206a38", 15 | "seq":73, 16 | "text":"hello, world!" 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /__tests__/assets/messageDeliveredPayload.json: -------------------------------------------------------------------------------- 1 | { 2 | "id":"PAGE_ID", 3 | "time":1458668856451, 4 | "messaging":[ 5 | { 6 | "sender":{ 7 | "id":"USER_ID" 8 | }, 9 | "recipient":{ 10 | "id":"PAGE_ID" 11 | }, 12 | "delivery":{ 13 | "mids":[ 14 | "mid.1458668856218:ed81099e15d3f4f233" 15 | ], 16 | "watermark":1458668856253, 17 | "seq":37 18 | } 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /functions/webhook/handlers/textReceived.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const fbUrl = 'https://graph.facebook.com/v2.6/me/messages?access_token=' + process.env.PAGE_ACCESS_TOKEN; 4 | 5 | export function handleTextReceived(messagingItem, pageId, entryTimestamp) { 6 | const payload = { 7 | recipient: { 8 | id: messagingItem.sender.id, 9 | }, 10 | message: { 11 | text: "Text received, echo: " + messagingItem.message.text, 12 | }, 13 | }; 14 | 15 | return axios.post(fbUrl, payload); 16 | } -------------------------------------------------------------------------------- /__tests__/assets/audioReceivedPayload.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "PAGE_ID", 3 | "time": 1458696618911, 4 | "messaging": [{ 5 | "sender": { 6 | "id": "USER_ID" 7 | }, 8 | "recipient": { 9 | "id": "PAGE_ID" 10 | }, 11 | "timestamp": 1458696618268, 12 | "message": { 13 | "mid": "mid.1458696618141:b4ef9d19ec21086067", 14 | "seq": 51, 15 | "attachments": [{ 16 | "type": "audio", 17 | "payload": { 18 | "url": "AUDIO_URL" 19 | } 20 | }] 21 | } 22 | }] 23 | } -------------------------------------------------------------------------------- /__tests__/assets/imageReceivedPayload.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "PAGE_ID", 3 | "time": 1458696618911, 4 | "messaging": [{ 5 | "sender": { 6 | "id": "USER_ID" 7 | }, 8 | "recipient": { 9 | "id": "PAGE_ID" 10 | }, 11 | "timestamp": 1458696618268, 12 | "message": { 13 | "mid": "mid.1458696618141:b4ef9d19ec21086067", 14 | "seq": 51, 15 | "attachments": [{ 16 | "type": "image", 17 | "payload": { 18 | "url": "IMAGE_URL" 19 | } 20 | }] 21 | } 22 | }] 23 | } -------------------------------------------------------------------------------- /__tests__/assets/videoReceivedPayload.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "PAGE_ID", 3 | "time": 1458696618911, 4 | "messaging": [{ 5 | "sender": { 6 | "id": "USER_ID" 7 | }, 8 | "recipient": { 9 | "id": "PAGE_ID" 10 | }, 11 | "timestamp": 1458696618268, 12 | "message": { 13 | "mid": "mid.1458696618141:b4ef9d19ec21086067", 14 | "seq": 51, 15 | "attachments": [{ 16 | "type": "video", 17 | "payload": { 18 | "url": "VIDEO_URL" 19 | } 20 | }] 21 | } 22 | }] 23 | } -------------------------------------------------------------------------------- /__tests__/assets/locationReceivedPayload.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "PAGE_ID", 3 | "time": 1458696618911, 4 | "messaging": [{ 5 | "sender": { 6 | "id": "USER_ID" 7 | }, 8 | "recipient": { 9 | "id": "PAGE_ID" 10 | }, 11 | "timestamp": 1458696618268, 12 | "message": { 13 | "mid": "mid.1458696618141:b4ef9d19ec21086067", 14 | "seq": 51, 15 | "attachments": [{ 16 | "title": "John's Location", 17 | "url": "BING_MAP_URL", 18 | "type": "location", 19 | "payload": { 20 | "coordinates": { 21 | "lat": 37.4846024, 22 | "long": -122.1505022 23 | } 24 | } 25 | }] 26 | } 27 | }] 28 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | dist 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # node-waf configuration 22 | .lock-wscript 23 | 24 | # Compiled binary addons (http://nodejs.org/api/addons.html) 25 | build/Release 26 | 27 | # Dependency directory 28 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 29 | node_modules 30 | 31 | #IDE 32 | **/.idea 33 | 34 | #OS 35 | .DS_Store 36 | .tmp 37 | 38 | #SERVERLESS 39 | admin.env 40 | .env 41 | 42 | #Ignore _meta folder 43 | _meta -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = { 4 | // entry: provided by serverless 5 | // output: provided by serverless 6 | target: 'node', 7 | externals: [ 8 | 'aws-sdk', 9 | ], 10 | resolve: { 11 | extensions: ['', '.js', '.jsx'] 12 | }, 13 | plugins: [ 14 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), 15 | new webpack.optimize.DedupePlugin(), 16 | new webpack.optimize.OccurenceOrderPlugin(), 17 | new webpack.optimize.UglifyJsPlugin({ 18 | compress: { 19 | unused: true, 20 | dead_code: true, 21 | warnings: false, 22 | drop_debugger: true 23 | } 24 | }) 25 | ], 26 | module: { 27 | loaders: [ 28 | { 29 | test: /\.jsx?$/, 30 | loader: 'babel', 31 | exclude: /node_modules/, 32 | query: { 33 | presets: ['es2015'] 34 | } 35 | }, 36 | {test: /\.json?$/, loader: 'json'} 37 | ] 38 | }, 39 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Michal Sänger 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-facebook-messenger-bot", 3 | "version": "0.1.3", 4 | "description": "Serverless backend for Facebook Messenger Bot.", 5 | "scripts": { 6 | "offline": "serverless offline start", 7 | "deploy": "sls dash deploy", 8 | "test": "mocha --compilers js:babel-core/register --require babel-polyfill __tests__/*.js" 9 | }, 10 | "author": "Michal Sänger ", 11 | "license": "MIT", 12 | "private": false, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/michalsanger/serverless-facebook-messenger-bot.git" 16 | }, 17 | "keywords": [ 18 | "serverless", 19 | "serverless framework", 20 | "serverless applications", 21 | "api gateway", 22 | "lambda", 23 | "aws", 24 | "aws lambda", 25 | "amazon", 26 | "amazon web services", 27 | "facebook messenger", 28 | "facebook messenger bot" 29 | ], 30 | "dependencies": { 31 | "axios": "^0.9.1", 32 | "babel-core": "^6.7.7", 33 | "babel-loader": "^6.2.4", 34 | "babel-polyfill": "^6.7.4", 35 | "babel-preset-es2015": "^6.6.0", 36 | "chai": "^3.5.0", 37 | "json-loader": "^0.5.4", 38 | "mocha": "^2.4.5", 39 | "serverless-offline": "^2.2.10", 40 | "serverless-webpack-plugin": "^0.4.1", 41 | "sinon": "^1.17.3", 42 | "webpack": "^1.13.0" 43 | } 44 | } -------------------------------------------------------------------------------- /__tests__/assets/textBatchReceivedPayload.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1676159085968406, 4 | "time": 1461175114608, 5 | "messaging": [ 6 | { 7 | "sender": { 8 | "id": 1149445025088407 9 | }, 10 | "recipient": { 11 | "id": 1676159085968406 12 | }, 13 | "timestamp": 1461175114460, 14 | "message": { 15 | "mid": "mid.1461175114234:846577ef5408ee2737", 16 | "seq": 1, 17 | "text": "Foo" 18 | } 19 | } 20 | ] 21 | }, 22 | { 23 | "id": 1676159085968407, 24 | "time": 1461175114609, 25 | "messaging": [ 26 | { 27 | "sender": { 28 | "id": 1149445025088408 29 | }, 30 | "recipient": { 31 | "id": 1676159085968406 32 | }, 33 | "timestamp": 1461175114461, 34 | "message": { 35 | "mid": "mid.1461175114234:846577ef5408ee2738", 36 | "seq": 2, 37 | "text": "Foo bar" 38 | } 39 | }, 40 | { 41 | "sender": { 42 | "id": 1149445025088409 43 | }, 44 | "recipient": { 45 | "id": 1676159085968406 46 | }, 47 | "timestamp": 1461175114462, 48 | "message": { 49 | "mid": "mid.1461175114234:846577ef5408ee2739", 50 | "seq": 3, 51 | "text": "Foo baz" 52 | } 53 | } 54 | ] 55 | } 56 | ] -------------------------------------------------------------------------------- /functions/webhook/handlers/attachmentsReceived.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const fbUrl = 'https://graph.facebook.com/v2.6/me/messages?access_token=' + process.env.PAGE_ACCESS_TOKEN; 4 | 5 | export function handleAttachmentsReceived(messagingItem, pageId, entryTimestamp) { 6 | let promises = []; 7 | messagingItem.message.attachments.map((attachment) => { 8 | let message = {} 9 | 10 | if (attachment.type === 'image') { 11 | message = createImageMessage(attachment) 12 | } else if (attachment.type === 'audio') { 13 | message = createAudioMessage(attachment) 14 | } else if (attachment.type === 'location') { 15 | message = createLocationMessage(attachment) 16 | } else if (attachment.type === 'video') { 17 | message = createVideoMessage(attachment) 18 | } 19 | 20 | promises.push(sendMessage(message, messagingItem.sender.id)); 21 | }); 22 | 23 | return promises; 24 | } 25 | 26 | function sendMessage(message, recipientId) { 27 | const payload = { 28 | recipient: { 29 | id: recipientId, 30 | }, 31 | message: message, 32 | }; 33 | 34 | return axios.post(fbUrl, payload); 35 | } 36 | 37 | function createImageMessage(attachment) { 38 | return { 39 | text: "Image received, thanks", 40 | } 41 | } 42 | 43 | function createAudioMessage(attachment) { 44 | return { 45 | text: "Audio received, thanks", 46 | } 47 | } 48 | 49 | function createLocationMessage(attachment) { 50 | const lat = attachment.payload.coordinates.lat; 51 | const lng = attachment.payload.coordinates.long; 52 | return { 53 | text: `Location received, echo: ${lat},${lng}`, 54 | } 55 | } 56 | 57 | function createVideoMessage(attachment) { 58 | return { 59 | text: "Video received, thanks", 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /s-resources-cf.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Description": "The AWS CloudFormation template for this Serverless application's resources outside of Lambdas and Api Gateway", 4 | "Resources": { 5 | "IamRoleLambda": { 6 | "Type": "AWS::IAM::Role", 7 | "Properties": { 8 | "AssumeRolePolicyDocument": { 9 | "Version": "2012-10-17", 10 | "Statement": [ 11 | { 12 | "Effect": "Allow", 13 | "Principal": { 14 | "Service": [ 15 | "lambda.amazonaws.com" 16 | ] 17 | }, 18 | "Action": [ 19 | "sts:AssumeRole" 20 | ] 21 | } 22 | ] 23 | }, 24 | "Path": "/" 25 | } 26 | }, 27 | "IamPolicyLambda": { 28 | "Type": "AWS::IAM::Policy", 29 | "Properties": { 30 | "PolicyName": "${stage}-${project}-lambda", 31 | "PolicyDocument": { 32 | "Version": "2012-10-17", 33 | "Statement": [ 34 | { 35 | "Effect": "Allow", 36 | "Action": [ 37 | "logs:CreateLogGroup", 38 | "logs:CreateLogStream", 39 | "logs:PutLogEvents" 40 | ], 41 | "Resource": "arn:aws:logs:${region}:*:*" 42 | } 43 | ] 44 | }, 45 | "Roles": [ 46 | { 47 | "Ref": "IamRoleLambda" 48 | } 49 | ] 50 | } 51 | } 52 | }, 53 | "Outputs": { 54 | "IamRoleArnLambda": { 55 | "Description": "ARN of the lambda IAM role", 56 | "Value": { 57 | "Fn::GetAtt": [ 58 | "IamRoleLambda", 59 | "Arn" 60 | ] 61 | } 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /functions/webhook/handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import {handleAuthentication} from './handlers/authentication'; 4 | import {handleTextReceived} from './handlers/textReceived'; 5 | import {handleMessageDelivered} from './handlers/messageDelivered'; 6 | import {handlePostback} from './handlers/postback'; 7 | import {handleAttachmentsReceived} from './handlers/attachmentsReceived'; 8 | 9 | module.exports.handler = function(event, context, callback) { 10 | if (event.httpMethod === 'GET' && event.hubMode === "subscribe" && event.hubVerifyToken && event.hubChallenge) { 11 | if (String(event.hubVerifyToken) === String(process.env.VERIFY_TOKEN)) { 12 | return callback(null, parseInt(event.hubChallenge, 10)); 13 | } else { 14 | return callback("Invalid verify token"); 15 | } 16 | } 17 | 18 | if (event.httpMethod === 'GET') { 19 | return callback( 20 | "Invalid request. GET is used for subscribe only. " 21 | + "This request is missing one of required parameters " 22 | + "(hubMode, hubVerifyToken, hubChallenge)" 23 | ); 24 | } 25 | 26 | if (event.httpMethod === 'POST') { 27 | if (process.env.LOG_WEBHOOK_MESSAGES === 'true') { 28 | console.log(JSON.stringify(event.payload, null, ' ')); 29 | } 30 | 31 | const promises = []; 32 | let errorCount = 0; 33 | event.payload.entry.map((entry) => { 34 | entry.messaging.map((messagingItem) => { 35 | 36 | let handlePromises = routeMessagingItem(messagingItem, entry); 37 | handlePromises ? promises.concat(handlePromises) : errorCount++; 38 | 39 | }); 40 | }); 41 | 42 | return callback(null, { 43 | messageCount: promises.length, 44 | errorCount: errorCount, 45 | }); 46 | 47 | } 48 | 49 | return callback("Invalid request"); 50 | }; 51 | 52 | function routeMessagingItem(messagingItem, entry) { 53 | if (messagingItem.optin) { 54 | return [handleAuthentication(messagingItem, entry.id, entry.time)]; 55 | } 56 | if (messagingItem.message && messagingItem.message.text) { 57 | return [handleTextReceived(messagingItem, entry.id, entry.time)]; 58 | } 59 | 60 | if (messagingItem.message && messagingItem.message.attachments) { 61 | let res = handleAttachmentsReceived(messagingItem, entry.id, entry.time); 62 | return res; 63 | } 64 | 65 | if (messagingItem.delivery) { 66 | return [handleMessageDelivered(messagingItem, entry.id, entry.time)]; 67 | } 68 | 69 | if (messagingItem.postback) { 70 | return [handlePostback(messagingItem, entry.id, entry.time)]; 71 | } 72 | 73 | return []; // no match 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serverless Facebook Messenger Bot 2 | 3 | [![Build Status](https://travis-ci.org/michalsanger/serverless-facebook-messenger-bot.svg?branch=master)](https://travis-ci.org/michalsanger/serverless-facebook-messenger-bot) 4 | 5 | Serverless backend for Facebook Messenger Bot 6 | 7 | ## [Demo](https://www.messenger.com/t/serverless.messenger.bot/) - not working yet, it takes time to review the app :-( 8 | Send message, photo, location, anything Messenger supports. 9 | 10 | ## Install 11 | 12 | You need to have installed the [Serverless Framework](https://github.com/serverless/serverless) (version 0.5.2 or higher) and you're using Node.js v4.0+. 13 | 14 | 15 | ### 1. Init project 16 | ``` 17 | sls project install serverless-facebook-messenger-bot 18 | ``` 19 | 20 | ### 2. Setup FB App 21 | Follow [quickstart](https://developers.facebook.com/docs/messenger-platform/quickstart) but start with [Step 1](https://developers.facebook.com/docs/messenger-platform/quickstart#create_app_page) and [Step 3](https://developers.facebook.com/docs/messenger-platform/quickstart#get_page_access_token) to get Page Access Token before deploying backend. 22 | 23 | ### 3. Set variables 24 | ``` 25 | sls variables set -k PAGE_ACCESS_TOKEN 26 | sls variables set -k VERIFY_TOKEN 27 | ``` 28 | `VERIFY_TOKEN` is used for subsciption verification. 29 | 30 | ### 4. Deploy backend app 31 | Deploy all functions and endpoints 32 | ``` 33 | sls function deploy --all 34 | sls endpoint deploy --all 35 | ``` 36 | Now you have public webhook URL. 37 | 38 | ### 5. Setup Webhook 39 | Back to the [FB App Quickstart](https://developers.facebook.com/docs/messenger-platform/quickstart#setup_webhook) and verify your webhook. 40 | 41 | ### 6. Subscribe the App to the Page 42 | ``` 43 | curl -ik -X POST "https://graph.facebook.com/v2.6/me/subscribed_apps?access_token=" 44 | ``` 45 | 46 | ### 7. Send messages 47 | Go to your Facebook Page and send a message to it. The response will come from your brand new servreless backend! See screenshots in the [Quickstart](https://developers.facebook.com/docs/messenger-platform/quickstart#receive_messages) 48 | 49 | ## Running Tests 50 | ``` 51 | npm test 52 | ``` 53 | 54 | ## Develop 55 | Want to add some logic into bot's responses? Take a look in `functions/webhook/handlers/` 56 | 57 | ## Deploy 58 | ``` 59 | sls function deploy --all 60 | ``` 61 | 62 | ## Running localy 63 | ``` 64 | npm run offline 65 | ``` 66 | and you can send POST requests to [http://localhost:3000/webhook](http://localhost:3000/webhook) 67 | 68 | To get an idea about the POST body, enable logging: 69 | ``` 70 | sls variables set -k LOG_WEBHOOK_MESSAGES -v true 71 | ``` 72 | 73 | deploy, send few messages via Messenger and see the logs: 74 | ``` 75 | sls function logs webhook 76 | ``` 77 | -------------------------------------------------------------------------------- /functions/webhook/s-function.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webhook", 3 | "runtime": "nodejs4.3", 4 | "description": "Serverless Lambda function for project: messenger-bot-serverless", 5 | "customName": false, 6 | "customRole": false, 7 | "handler": "handler.handler", 8 | "timeout": 6, 9 | "memorySize": 128, 10 | "authorizer": {}, 11 | "custom": { 12 | "excludePatterns": [], 13 | "webpack": { 14 | "configPath": "webpack.config.js" 15 | } 16 | }, 17 | "endpoints": [ 18 | { 19 | "path": "webhook", 20 | "method": "GET", 21 | "type": "AWS", 22 | "authorizationType": "none", 23 | "authorizerFunction": false, 24 | "apiKeyRequired": false, 25 | "requestParameters": {}, 26 | "requestTemplates": { 27 | "application/json": { 28 | "hubChallenge": "$input.params('hub.challenge')", 29 | "hubMode": "$input.params('hub.mode')", 30 | "hubVerifyToken": "$input.params('hub.verify_token')", 31 | "httpMethod": "$context.httpMethod" 32 | } 33 | }, 34 | "responses": { 35 | "400": { 36 | "statusCode": "400" 37 | }, 38 | "default": { 39 | "statusCode": "200", 40 | "responseParameters": {}, 41 | "responseModels": { 42 | "application/json;charset=UTF-8": "Empty" 43 | }, 44 | "responseTemplates": { 45 | "application/json;charset=UTF-8": "" 46 | } 47 | } 48 | } 49 | }, 50 | { 51 | "path": "webhook", 52 | "method": "POST", 53 | "type": "AWS", 54 | "authorizationType": "none", 55 | "authorizerFunction": false, 56 | "apiKeyRequired": false, 57 | "requestParameters": {}, 58 | "requestTemplates": { 59 | "application/json": { 60 | "payload": "$input.json(\"$\")", 61 | "httpMethod": "$context.httpMethod" 62 | } 63 | }, 64 | "responses": { 65 | "400": { 66 | "statusCode": "400" 67 | }, 68 | "default": { 69 | "statusCode": "200", 70 | "responseParameters": {}, 71 | "responseModels": { 72 | "application/json;charset=UTF-8": "Empty" 73 | }, 74 | "responseTemplates": { 75 | "application/json;charset=UTF-8": "" 76 | } 77 | } 78 | } 79 | } 80 | ], 81 | "events": [], 82 | "environment": { 83 | "SERVERLESS_PROJECT": "${project}", 84 | "SERVERLESS_STAGE": "${stage}", 85 | "SERVERLESS_REGION": "${region}", 86 | "VERIFY_TOKEN": "${VERIFY_TOKEN}", 87 | "PAGE_ACCESS_TOKEN": "${PAGE_ACCESS_TOKEN}", 88 | "LOG_WEBHOOK_MESSAGES": "${LOG_WEBHOOK_MESSAGES}" 89 | }, 90 | "vpc": { 91 | "securityGroupIds": [], 92 | "subnetIds": [] 93 | } 94 | } -------------------------------------------------------------------------------- /__tests__/webhook.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { expect } from 'chai'; 4 | import { describe, it } from 'mocha'; 5 | import {handler} from './../functions/webhook/handler'; 6 | 7 | describe('Webhook handler', () => { 8 | describe('on GET request', () => { 9 | it('Can subscribe', (done) => { 10 | let event = { 11 | httpMethod: 'GET', 12 | hubMode: "subscribe", 13 | hubVerifyToken: "secret token", 14 | hubChallenge: 987654321, 15 | }; 16 | process.env.VERIFY_TOKEN = 'secret token'; 17 | 18 | handler(event, null, (error, response) => { 19 | expect(error).to.be.null; 20 | expect(response).to.equal(987654321); 21 | done(); 22 | }); 23 | }); 24 | 25 | it('Can handle integer verify token', (done) => { 26 | let event = { 27 | httpMethod: 'GET', 28 | hubMode: "subscribe", 29 | hubVerifyToken: "123", 30 | hubChallenge: 987654321, 31 | }; 32 | process.env.VERIFY_TOKEN = 123; 33 | 34 | handler(event, null, (error, response) => { 35 | expect(error).to.be.null; 36 | expect(response).to.equal(987654321); 37 | done(); 38 | }); 39 | }); 40 | 41 | it('Cast hubChallenge to Int', (done) => { 42 | let event = { 43 | httpMethod: 'GET', 44 | hubMode: "subscribe", 45 | hubVerifyToken: "secret token", 46 | hubChallenge: '123', 47 | }; 48 | process.env.VERIFY_TOKEN = 'secret token'; 49 | 50 | handler(event, null, (error, response) => { 51 | expect(error).to.be.null; 52 | expect(response).to.equal(123); 53 | done(); 54 | }); 55 | }); 56 | 57 | it('Will crash on invalid params', (done) => { 58 | let events = { 59 | 'Wrong hubMode': { 60 | hubMode: "foo", 61 | hubVerifyToken: "secret token", 62 | hubChallenge: 1234567890, 63 | }, 64 | 'No hubVerifyToken and hubChallenge': { 65 | hubMode: "subscribe", 66 | hubVerifyToken: undefined, 67 | hubChallenge: null, 68 | }, 69 | 'No hub vars at all': {}, 70 | }; 71 | 72 | for (let subject in events) { 73 | let event = events[subject]; 74 | event.httpMethod = 'GET'; 75 | 76 | handler(event, null, (error, response) => { 77 | expect(error).to.be.equal( 78 | "Invalid request. GET is used for subscribe only. " 79 | + "This request is missing one of required parameters " 80 | + "(hubMode, hubVerifyToken, hubChallenge)" 81 | ); 82 | }); 83 | } 84 | done(); 85 | }); 86 | 87 | it('Will crash if hubVerifyToken does not equal process.env.VERIFY_TOKEN', (done) => { 88 | let event = { 89 | httpMethod: 'GET', 90 | hubMode: "subscribe", 91 | hubVerifyToken: "secret token", 92 | hubChallenge: '123', 93 | }; 94 | process.env.VERIFY_TOKEN = 'foo token'; 95 | 96 | handler(event, null, (error, response) => { 97 | expect(error).to.equal('Invalid verify token'); 98 | done(); 99 | }); 100 | }); 101 | 102 | }); 103 | 104 | describe('on invalid request', () => { 105 | it('Will crash', (done) => { 106 | let event = {}; 107 | handler(event, null, (error, response) => { 108 | expect(error).to.equal('Invalid request'); 109 | done(); 110 | }); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /__tests__/webhook.POST.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { expect } from 'chai'; 4 | import { describe, it } from 'mocha'; 5 | import sinon from 'sinon'; 6 | import {handler} from './../functions/webhook/handler'; 7 | import * as authentication from './../functions/webhook/handlers/authentication'; 8 | import * as textReceived from './../functions/webhook/handlers/textReceived'; 9 | import * as messageDelivered from './../functions/webhook/handlers/messageDelivered'; 10 | import * as postback from './../functions/webhook/handlers/postback'; 11 | import * as attachmentsReceived from './../functions/webhook/handlers/attachmentsReceived'; 12 | 13 | import authenticationPayload from './assets/authenticationPayload.json'; 14 | import textReceivedPayload from './assets/textReceivedPayload.json'; 15 | import textBatchReceivedPayload from './assets/textBatchReceivedPayload.json'; 16 | import messageDeliveredPayload from './assets/messageDeliveredPayload.json'; 17 | import postbackPayload from './assets/postbackPayload.json'; 18 | import imageReceivedPayload from './assets/imageReceivedPayload.json'; 19 | import audioReceivedPayload from './assets/audioReceivedPayload.json'; 20 | import locationReceivedPayload from './assets/locationReceivedPayload.json'; 21 | import videoReceivedPayload from './assets/videoReceivedPayload.json'; 22 | 23 | describe('Webhook handler on POST request', () => { 24 | let sandbox; 25 | 26 | beforeEach(function() { 27 | sandbox = sinon.sandbox.create(); 28 | sandbox.stub(authentication, "handleAuthentication"); 29 | sandbox.stub(textReceived, "handleTextReceived"); 30 | sandbox.stub(messageDelivered, "handleMessageDelivered"); 31 | sandbox.stub(postback, "handlePostback"); 32 | sandbox.stub(attachmentsReceived, "handleAttachmentsReceived"); 33 | }); 34 | 35 | afterEach(function() { 36 | sandbox.restore(); 37 | }); 38 | 39 | it('Can handle Authentication Callback', (done) => { 40 | let event = { 41 | httpMethod: 'POST', 42 | payload: { 43 | "object": "page", 44 | "entry": [ 45 | authenticationPayload, 46 | ] 47 | } 48 | }; 49 | 50 | handler(event, null, (error, response) => { 51 | sinon.assert.calledOnce(authentication.handleAuthentication); 52 | 53 | const firstCall = authentication.handleAuthentication.getCall(0); 54 | sinon.assert.calledWithExactly( 55 | firstCall, 56 | authenticationPayload.messaging[0], 57 | authenticationPayload.id, 58 | authenticationPayload.time 59 | ); 60 | 61 | done(); 62 | }); 63 | }); 64 | 65 | it('Can handle Message-Received Callback', (done) => { 66 | let event = { 67 | httpMethod: 'POST', 68 | payload: { 69 | "object": "page", 70 | "entry": [ 71 | textReceivedPayload, 72 | ] 73 | } 74 | }; 75 | 76 | handler(event, null, (error, response) => { 77 | sinon.assert.calledOnce(textReceived.handleTextReceived); 78 | 79 | const firstCall = textReceived.handleTextReceived.getCall(0); 80 | sinon.assert.calledWithExactly( 81 | firstCall, 82 | textReceivedPayload.messaging[0], 83 | textReceivedPayload.id, 84 | textReceivedPayload.time 85 | ); 86 | 87 | done(); 88 | }); 89 | }); 90 | 91 | it('Can handle batch Message-Received Callback', (done) => { 92 | let event = { 93 | httpMethod: 'POST', 94 | payload: { 95 | "object": "page", 96 | "entry": textBatchReceivedPayload 97 | } 98 | }; 99 | 100 | handler(event, null, (error, response) => { 101 | sinon.assert.calledThrice(textReceived.handleTextReceived); 102 | 103 | const firstCall = textReceived.handleTextReceived.getCall(0); 104 | sinon.assert.calledWithExactly( 105 | firstCall, 106 | textBatchReceivedPayload[0].messaging[0], 107 | textBatchReceivedPayload[0].id, 108 | textBatchReceivedPayload[0].time 109 | ); 110 | const secondCall = textReceived.handleTextReceived.getCall(1); 111 | sinon.assert.calledWithExactly( 112 | secondCall, 113 | textBatchReceivedPayload[1].messaging[0], 114 | textBatchReceivedPayload[1].id, 115 | textBatchReceivedPayload[1].time 116 | ); 117 | const thirdCall = textReceived.handleTextReceived.getCall(2); 118 | sinon.assert.calledWithExactly( 119 | thirdCall, 120 | textBatchReceivedPayload[1].messaging[1], 121 | textBatchReceivedPayload[1].id, 122 | textBatchReceivedPayload[1].time 123 | ); 124 | 125 | done(); 126 | }); 127 | }); 128 | 129 | it('Can handle Message-Received Callback with image', (done) => { 130 | let event = { 131 | httpMethod: 'POST', 132 | payload: { 133 | "object": "page", 134 | "entry": [ 135 | imageReceivedPayload, 136 | ] 137 | } 138 | }; 139 | 140 | handler(event, null, (error, response) => { 141 | sinon.assert.calledOnce(attachmentsReceived.handleAttachmentsReceived); 142 | 143 | const firstCall = attachmentsReceived.handleAttachmentsReceived.getCall(0); 144 | sinon.assert.calledWithExactly( 145 | firstCall, 146 | imageReceivedPayload.messaging[0], 147 | imageReceivedPayload.id, 148 | imageReceivedPayload.time 149 | ); 150 | 151 | done(); 152 | }); 153 | }); 154 | 155 | it('Can handle Message-Received Callback with audio', (done) => { 156 | let event = { 157 | httpMethod: 'POST', 158 | payload: { 159 | "object": "page", 160 | "entry": [ 161 | audioReceivedPayload, 162 | ] 163 | } 164 | }; 165 | 166 | handler(event, null, (error, response) => { 167 | sinon.assert.calledOnce(attachmentsReceived.handleAttachmentsReceived); 168 | 169 | const firstCall = attachmentsReceived.handleAttachmentsReceived.getCall(0); 170 | sinon.assert.calledWithExactly( 171 | firstCall, 172 | audioReceivedPayload.messaging[0], 173 | audioReceivedPayload.id, 174 | audioReceivedPayload.time 175 | ); 176 | 177 | done(); 178 | }); 179 | }); 180 | 181 | it('Can handle Message-Received Callback with video', (done) => { 182 | let event = { 183 | httpMethod: 'POST', 184 | payload: { 185 | "object": "page", 186 | "entry": [ 187 | videoReceivedPayload, 188 | ] 189 | } 190 | }; 191 | 192 | handler(event, null, (error, response) => { 193 | sinon.assert.calledOnce(attachmentsReceived.handleAttachmentsReceived); 194 | 195 | const firstCall = attachmentsReceived.handleAttachmentsReceived.getCall(0); 196 | sinon.assert.calledWithExactly( 197 | firstCall, 198 | videoReceivedPayload.messaging[0], 199 | videoReceivedPayload.id, 200 | videoReceivedPayload.time 201 | ); 202 | 203 | done(); 204 | }); 205 | }); 206 | 207 | it('Can handle Message-Received Callback with location', (done) => { 208 | let event = { 209 | httpMethod: 'POST', 210 | payload: { 211 | "object": "page", 212 | "entry": [ 213 | locationReceivedPayload, 214 | ] 215 | } 216 | }; 217 | 218 | handler(event, null, (error, response) => { 219 | sinon.assert.calledOnce(attachmentsReceived.handleAttachmentsReceived); 220 | 221 | const firstCall = attachmentsReceived.handleAttachmentsReceived.getCall(0); 222 | sinon.assert.calledWithExactly( 223 | firstCall, 224 | locationReceivedPayload.messaging[0], 225 | locationReceivedPayload.id, 226 | locationReceivedPayload.time 227 | ); 228 | 229 | done(); 230 | }); 231 | }); 232 | 233 | it('Can handle Message-Delivered Callback', (done) => { 234 | let event = { 235 | httpMethod: 'POST', 236 | payload: { 237 | "object": "page", 238 | "entry": [ 239 | messageDeliveredPayload, 240 | ] 241 | } 242 | }; 243 | 244 | handler(event, null, (error, response) => { 245 | sinon.assert.calledOnce(messageDelivered.handleMessageDelivered); 246 | 247 | const firstCall = messageDelivered.handleMessageDelivered.getCall(0); 248 | sinon.assert.calledWithExactly( 249 | firstCall, 250 | messageDeliveredPayload.messaging[0], 251 | messageDeliveredPayload.id, 252 | messageDeliveredPayload.time 253 | ); 254 | 255 | done(); 256 | }); 257 | }); 258 | 259 | it('Can handle Postback Callback', (done) => { 260 | let event = { 261 | httpMethod: 'POST', 262 | payload: { 263 | "object": "page", 264 | "entry": [ 265 | postbackPayload, 266 | ] 267 | } 268 | }; 269 | 270 | handler(event, null, (error, response) => { 271 | sinon.assert.calledOnce(postback.handlePostback); 272 | 273 | const firstCall = postback.handlePostback.getCall(0); 274 | sinon.assert.calledWithExactly( 275 | firstCall, 276 | postbackPayload.messaging[0], 277 | postbackPayload.id, 278 | postbackPayload.time 279 | ); 280 | 281 | done(); 282 | }); 283 | }); 284 | }); 285 | --------------------------------------------------------------------------------