├── .eslintignore ├── .gitignore ├── .npmignore ├── outgoing ├── index.js ├── quick_reply.js └── button_postback.js ├── .editorconfig ├── incoming ├── index.js ├── postback.js ├── quick_reply.js ├── user_init.js └── type_cast.js ├── test ├── fixtures │ ├── outgoing │ │ ├── non_existent.json │ │ ├── bad_transport.json │ │ ├── audio.json │ │ ├── image.json │ │ ├── video.json │ │ ├── mark_seen.json │ │ ├── typing_on.json │ │ ├── typing_off.json │ │ ├── file.json │ │ ├── text.json │ │ ├── button.json │ │ ├── quick_replies.json │ │ ├── message.json │ │ ├── generic.json │ │ └── receipt.json │ └── incoming │ │ ├── delivery.json │ │ ├── message-text.json │ │ ├── message-attachment-file.json │ │ ├── message-sticker-attachment-image.json │ │ ├── message-attachment-audio.json │ │ ├── message-attachment-fallback-text.json │ │ ├── message-quick_reply-text.json │ │ ├── postback.json │ │ ├── message-attachment-image.json │ │ ├── message-attachment-location.json │ │ ├── message-text-multi-entry.json │ │ └── message-text-multi-entry-multi-messaging.json ├── test-traverse.js ├── test-http.js ├── test-express.js ├── test-restify.js ├── test-hapi.js ├── test-send.js ├── shared-tests.js └── common.js ├── .travis.yml ├── appveyor.yml ├── lib ├── attach.js ├── messaging.js ├── normalize.js ├── pipeline.js ├── verify_endpoint.js ├── middleware.js ├── request.js └── send.js ├── .istanbul.yml ├── templates ├── button.js ├── generic.js └── receipt.js ├── LICENSE ├── .eslintrc ├── package.json ├── traverse ├── incoming.js ├── outgoing.js └── index.js ├── index.js └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | coverage 3 | node_modules 4 | 5 | *.log 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .editorconfig 3 | .eslintignore 4 | .eslintrc 5 | .gitignore 6 | .istanbul.yml 7 | .npmignore 8 | .travis.yml 9 | appveyor.yml 10 | 11 | coverage 12 | node_modules 13 | test 14 | 15 | *.log 16 | -------------------------------------------------------------------------------- /outgoing/index.js: -------------------------------------------------------------------------------- 1 | var attach = require('../lib/attach.js'); 2 | 3 | var filters = { 4 | 5 | 'send.button.postback': [ 6 | require('./button_postback.js') 7 | ], 8 | 9 | 'send.quick_reply': [ 10 | require('./quick_reply.js') 11 | ] 12 | 13 | }; 14 | 15 | module.exports = attach.bind(null, filters); 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | 11 | [**.{css,hbs,js,json,md,scss}] 12 | indent_style = space 13 | indent_size = 2 14 | insert_final_newline = true 15 | -------------------------------------------------------------------------------- /incoming/index.js: -------------------------------------------------------------------------------- 1 | var attach = require('../lib/attach.js'); 2 | 3 | var filters = { 4 | 5 | 'messaging': [ 6 | require('./user_init.js') 7 | ], 8 | 9 | 'postback': [ 10 | require('./postback.js') 11 | ], 12 | 13 | 'message': [ 14 | require('./type_cast.js'), 15 | require('./quick_reply.js') 16 | ] 17 | }; 18 | 19 | module.exports = attach.bind(null, filters); 20 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/non_existent.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "meta": { 4 | "description": "Does not allow custom types" 5 | }, 6 | "arguments": { 7 | "user": "10157033896470455", 8 | "type": "NON_EXISTENT", 9 | "data": { 10 | "this": "is", 11 | "non-existent": "type" 12 | } 13 | }, 14 | 15 | "error": "Unable to generate message from type NON_EXISTENT" 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/bad_transport.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "meta": { 4 | "description": "Handles bad transport" 5 | }, 6 | "expectedTests": 1, 7 | "endpoint": "http://localhost:25/", 8 | "arguments": { 9 | "user": "10157033896470455", 10 | "type": "MESSAGE", 11 | "data": { 12 | "text": "Should go nowhere" 13 | } 14 | }, 15 | 16 | "error": "connect ECONNREFUSED" 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | node_js: 5 | - "0.12" 6 | - "iojs" 7 | - "4" 8 | - "5" 9 | - "6" 10 | 11 | os: 12 | - osx 13 | - linux 14 | 15 | install: 16 | - travis_retry npm install 17 | 18 | script: 19 | - uname -a 20 | - node --version 21 | - npm --version 22 | - npm run ci-lint 23 | - npm run ci-test 24 | 25 | after_success: 26 | - "cat coverage/lcov.info | ./node_modules/.bin/coveralls" 27 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: '0.12' 4 | - nodejs_version: '3' 5 | - nodejs_version: '4' 6 | - nodejs_version: '5' 7 | - nodejs_version: '6' 8 | platform: 9 | - x86 10 | - x64 11 | install: 12 | - ps: Install-Product node $env:nodejs_version $env:platform 13 | - npm install 14 | test_script: 15 | - node --version 16 | - npm --version 17 | - npm run test 18 | build: off 19 | matrix: 20 | fast_finish: true 21 | -------------------------------------------------------------------------------- /outgoing/quick_reply.js: -------------------------------------------------------------------------------- 1 | module.exports = quickReply; 2 | 3 | /** 4 | * Stringifies provided quick reply payload 5 | * 6 | * @this Fbbot# 7 | * @param {object} payload - quick_reply object 8 | * @param {function} callback - invoked after stringification is done 9 | */ 10 | function quickReply(payload, callback) 11 | { 12 | if (typeof payload.payload != 'string') 13 | { 14 | payload.payload = JSON.stringify(payload.payload); 15 | } 16 | 17 | callback(null, payload); 18 | } 19 | -------------------------------------------------------------------------------- /lib/attach.js: -------------------------------------------------------------------------------- 1 | module.exports = attach; 2 | 3 | /** 4 | * Attaches list of middleware filters to the provided instance 5 | * 6 | * @param {object} filters - list of filters per step (event) 7 | * @param {function} instance - filter instance to attach to an event (step) 8 | */ 9 | function attach(filters, instance) 10 | { 11 | Object.keys(filters).forEach(function(step) 12 | { 13 | filters[step].forEach(function(filter) 14 | { 15 | instance.use(step, filter); 16 | }); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /outgoing/button_postback.js: -------------------------------------------------------------------------------- 1 | module.exports = buttonPostback; 2 | 3 | /** 4 | * Stringifies provided button.postback payload 5 | * 6 | * @this Fbbot# 7 | * @param {object} payload - button.postback object 8 | * @param {function} callback - invoked after stringification is done 9 | */ 10 | function buttonPostback(payload, callback) 11 | { 12 | if (typeof payload.payload != 'string') 13 | { 14 | payload.payload = JSON.stringify(payload.payload); 15 | } 16 | 17 | callback(null, payload); 18 | } 19 | -------------------------------------------------------------------------------- /incoming/postback.js: -------------------------------------------------------------------------------- 1 | module.exports = postback; 2 | 3 | /** 4 | * Parses provided payload 5 | * Note: always expects postback payload to be JSON 6 | * 7 | * @this Fbbot# 8 | * @param {object} payload - messaging envelop object 9 | * @param {function} callback - invoked after parsing is done 10 | */ 11 | function postback(payload, callback) 12 | { 13 | if (typeof payload.payload == 'string') 14 | { 15 | payload.payload = JSON.parse(payload.payload); 16 | } 17 | 18 | callback(null, payload); 19 | } 20 | -------------------------------------------------------------------------------- /lib/messaging.js: -------------------------------------------------------------------------------- 1 | var find = require('array-find') 2 | , messagingTypes = ['delivery', 'postback', 'message'] 3 | ; 4 | 5 | module.exports = { 6 | getType: getType 7 | }; 8 | 9 | /** 10 | * Returns preferred messaging type 11 | * 12 | * @param {object} messaging - messaging object 13 | * @returns {string|undefined} - messaging type name or `undefined` if no match 14 | */ 15 | function getType(messaging) 16 | { 17 | return find(messagingTypes, function(key){ return (key in messaging); }); 18 | } 19 | -------------------------------------------------------------------------------- /.istanbul.yml: -------------------------------------------------------------------------------- 1 | verbose: false 2 | instrumentation: 3 | root: . 4 | extensions: 5 | - .js 6 | default-excludes: true 7 | excludes: ['**/node_modules/**'] 8 | compact: false 9 | reporting: 10 | root: ./coverage/tmp 11 | print: summary 12 | reports: 13 | - lcov 14 | dir: ./coverage 15 | report-config: 16 | json: 17 | file: coverage.json 18 | watermarks: 19 | statements: [90, 99] 20 | lines: [90, 99] 21 | functions: [90, 99] 22 | branches: [90, 99] 23 | -------------------------------------------------------------------------------- /lib/normalize.js: -------------------------------------------------------------------------------- 1 | var canonical = { 2 | // incoming: 3 | 'sticker_id' : 'sticker', 4 | 'attachments': 'attachment', 5 | // outgoing: 6 | 'send.quick_replies': 'send.quick_reply', 7 | 'send.elements' : 'send.element', 8 | 'send.buttons' : 'send.button', 9 | }; 10 | 11 | module.exports = normalize; 12 | 13 | /** 14 | * Normalizes name to use within public interfaces 15 | * 16 | * @param {string} type - internal protocol naming 17 | * @returns {string} - normalized "public" naming 18 | */ 19 | function normalize(type) 20 | { 21 | return canonical[type] || type; 22 | } 23 | -------------------------------------------------------------------------------- /incoming/quick_reply.js: -------------------------------------------------------------------------------- 1 | module.exports = quickReply; 2 | 3 | /** 4 | * Parse quick reply payload 5 | * Note: always expects postback payload to be JSON 6 | * 7 | * @this Fbbot# 8 | * @param {object} payload - messaging envelop object 9 | * @param {function} callback - invoked after type casting is done 10 | */ 11 | function quickReply(payload, callback) 12 | { 13 | if (payload.quick_reply && typeof payload.quick_reply.payload == 'string') 14 | { 15 | // TODO: Make better parse and as a library 16 | payload.quick_reply.payload = JSON.parse(payload.quick_reply.payload); 17 | } 18 | 19 | callback(null, payload); 20 | } 21 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/audio.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "arguments": { 4 | "user": "10157033896470655", 5 | "type": "AUDIO", 6 | "data": "https://petersapparel.com/bin/clip.mp3" 7 | }, 8 | 9 | "expected": { 10 | "recipient": { 11 | "id": "10157033896470655" 12 | }, 13 | "message": { 14 | "attachment": { 15 | "type": "audio", 16 | "payload": { 17 | "url": "https://petersapparel.com/bin/clip.mp3" 18 | } 19 | } 20 | } 21 | }, 22 | 23 | "response": { 24 | "recipient_id": "10157033896470655", 25 | "message_id": "mid.1456970488048:c34767dfe57ee6e339" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/image.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "arguments": { 4 | "user": "10157033896470855", 5 | "type": "IMAGE", 6 | "data": "https://petersapparel.com/img/shirt.png" 7 | }, 8 | 9 | "expected": { 10 | "recipient": { 11 | "id": "10157033896470855" 12 | }, 13 | "message": { 14 | "attachment": { 15 | "type": "image", 16 | "payload": { 17 | "url": "https://petersapparel.com/img/shirt.png" 18 | } 19 | } 20 | } 21 | }, 22 | 23 | "response": { 24 | "recipient_id": "10157033896470855", 25 | "message_id": "mid.1456970488058:c34767dfe57ee6e339" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/video.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "arguments": { 4 | "user": "10157033896470955", 5 | "type": "VIDEO", 6 | "data": "https://petersapparel.com/bin/clip.mp4" 7 | }, 8 | 9 | "expected": { 10 | "recipient": { 11 | "id": "10157033896470955" 12 | }, 13 | "message": { 14 | "attachment": { 15 | "type": "video", 16 | "payload": { 17 | "url": "https://petersapparel.com/bin/clip.mp4" 18 | } 19 | } 20 | } 21 | }, 22 | 23 | "response": { 24 | "recipient_id": "10157033896470955", 25 | "message_id": "mid.1456970488068:c34767dfe57ee6e339" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/mark_seen.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "expectedTests": -1, 4 | 5 | "arguments": { 6 | "user": "10157033896470455", 7 | "type": "MARK_SEEN" 8 | }, 9 | 10 | "expected": { 11 | "recipient": { 12 | "id": "10157033896470455" 13 | }, 14 | "sender_action": "mark_seen" 15 | }, 16 | 17 | "response": { 18 | "recipient_id": "10157033896470455" 19 | } 20 | }, 21 | 22 | { 23 | "expectedTests": -1, 24 | 25 | "arguments": { 26 | "user": { 27 | "id": "10157033896470465", 28 | "unused": "field" 29 | }, 30 | "type": "MARK_SEEN" 31 | }, 32 | 33 | "expected": { 34 | "recipient": { 35 | "id": "10157033896470465" 36 | }, 37 | "sender_action": "mark_seen" 38 | }, 39 | 40 | "response": { 41 | "recipient_id": "10157033896470465" 42 | } 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/typing_on.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "expectedTests": -1, 4 | 5 | "arguments": { 6 | "user": "10157033896470453", 7 | "type": "TYPING_ON" 8 | }, 9 | 10 | "expected": { 11 | "recipient": { 12 | "id": "10157033896470453" 13 | }, 14 | "sender_action": "typing_on" 15 | }, 16 | 17 | "response": { 18 | "recipient_id": "10157033896470453" 19 | } 20 | }, 21 | 22 | { 23 | "expectedTests": -1, 24 | 25 | "arguments": { 26 | "user": { 27 | "id": "10157033896470463", 28 | "unused": "field" 29 | }, 30 | "type": "TYPING_ON" 31 | }, 32 | 33 | "expected": { 34 | "recipient": { 35 | "id": "10157033896470463" 36 | }, 37 | "sender_action": "typing_on" 38 | }, 39 | 40 | "response": { 41 | "recipient_id": "10157033896470463" 42 | } 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/typing_off.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "expectedTests": -1, 4 | 5 | "arguments": { 6 | "user": "10157033896470454", 7 | "type": "TYPING_OFF" 8 | }, 9 | 10 | "expected": { 11 | "recipient": { 12 | "id": "10157033896470454" 13 | }, 14 | "sender_action": "typing_off" 15 | }, 16 | 17 | "response": { 18 | "recipient_id": "10157033896470454" 19 | } 20 | }, 21 | 22 | { 23 | "expectedTests": -1, 24 | 25 | "arguments": { 26 | "user": { 27 | "phone_number": "1234567890", 28 | "unused": "field" 29 | }, 30 | "type": "TYPING_OFF" 31 | }, 32 | 33 | "expected": { 34 | "recipient": { 35 | "phone_number": "1234567890" 36 | }, 37 | "sender_action": "typing_off" 38 | }, 39 | 40 | "response": { 41 | "recipient_id": "10157033896470464" 42 | } 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /incoming/user_init.js: -------------------------------------------------------------------------------- 1 | var clone = require('deeply') 2 | , send = require('../lib/send.js') 3 | , messaging = require('../lib/messaging.js') 4 | ; 5 | 6 | module.exports = userInit; 7 | 8 | /** 9 | * Creates `user` object within `message`, 10 | * based on `sender` property. 11 | * 12 | * @this Fbbot# 13 | * @param {object} payload - messaging envelop object 14 | * @param {function} callback - invoked after type casting is done 15 | */ 16 | function userInit(payload, callback) 17 | { 18 | var type = messaging.getType(payload); 19 | 20 | if (payload.sender && payload[type]) 21 | { 22 | // detach new object from the source 23 | payload[type].user = clone(payload.sender); 24 | 25 | // add user tailored send functions 26 | // but keep it outside of own properties 27 | payload[type].user.__proto__ = {send: send.factory(this, payload[type].user)}; 28 | } 29 | 30 | callback(null, payload); 31 | } 32 | -------------------------------------------------------------------------------- /lib/pipeline.js: -------------------------------------------------------------------------------- 1 | var async = require('asynckit/lib/async'); 2 | 3 | module.exports = pipeline; 4 | 5 | /** 6 | * Pipelines provided payload through the list of of item via handler 7 | * 8 | * @param {array} list - items to iterate over 9 | * @param {object} payload - object to pass to pass iteratees 10 | * @param {function} handler - function to invoke for each item/payload iteration 11 | * @param {function} callback - invoked after all item were processed 12 | */ 13 | function pipeline(list, payload, handler, callback) 14 | { 15 | var item; 16 | 17 | if (!list.length) 18 | { 19 | callback(null, payload); 20 | return; 21 | } 22 | 23 | list = list.concat(); 24 | item = list.shift(); 25 | 26 | // pipeline it 27 | handler(item, payload, async(function(err, updatedPayload) 28 | { 29 | if (err) 30 | { 31 | callback(err, updatedPayload); 32 | return; 33 | } 34 | 35 | // rinse, repeat 36 | pipeline(list, updatedPayload, handler, callback); 37 | })); 38 | } 39 | -------------------------------------------------------------------------------- /test/fixtures/incoming/delivery.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469430649209, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 0, 21 | "delivery": { 22 | "mids": ["mid.1469430648815:928f626810676c5d05"], 23 | "watermark": 1469430649039, 24 | "seq": 2238 25 | } 26 | }] 27 | }] 28 | }, 29 | 30 | "+expected": 31 | { 32 | "plan": 13, 33 | "entry": [{ 34 | "messaging": [{ 35 | "delivery": { 36 | "user": { 37 | "id": "10157033896470455" 38 | } 39 | } 40 | }] 41 | }] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/fixtures/incoming/message-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469430646874, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 1469430646786, 21 | "message": { 22 | "mid": "mid.1469430646641:faf898695ebb3f0f86", 23 | "seq": 2236, 24 | "text": "94301" 25 | } 26 | }] 27 | }] 28 | }, 29 | 30 | "+expected": 31 | { 32 | "plan": 14, 33 | "entry": [{ 34 | "messaging": [{ 35 | "message": { 36 | "type": "text", 37 | "user": { 38 | "id": "10157033896470455" 39 | } 40 | } 41 | }] 42 | }] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /templates/button.js: -------------------------------------------------------------------------------- 1 | module.exports = renderButton; 2 | 3 | /** 4 | * Renders payload from provided data with button template 5 | * https://developers.facebook.com/docs/messenger-platform/send-api-reference/button-template 6 | * 7 | * @this Fbbot# 8 | * @param {object} data - text + buttons object 9 | * @returns {object} - button template message payload 10 | */ 11 | function renderButton(data) 12 | { 13 | var message, limit = 3; 14 | 15 | if (data.buttons.length > limit) 16 | { 17 | this.logger.warn({message: 'Truncated provided list of buttons to first ' + limit + ' elements (maximum for the Button Template)', data: data}); 18 | data.buttons = data.buttons.slice(0, limit); 19 | } 20 | 21 | message = { 22 | attachment: { 23 | type: 'template', 24 | payload: { 25 | template_type: 'button', 26 | text : data.text, 27 | buttons : data.buttons 28 | } 29 | } 30 | }; 31 | 32 | this.logger.debug({message: 'Generated message payload with Button Template', payload: message}); 33 | 34 | return message; 35 | } 36 | -------------------------------------------------------------------------------- /templates/generic.js: -------------------------------------------------------------------------------- 1 | module.exports = renderGeneric; 2 | 3 | /** 4 | * Renders payload from provided data with generic template 5 | * https://developers.facebook.com/docs/messenger-platform/send-api-reference/generic-template 6 | * 7 | * @this Fbbot# 8 | * @param {array} elements - list of elements to render with generic template 9 | * @returns {object} - generic template message payload 10 | */ 11 | function renderGeneric(elements) 12 | { 13 | var message, limit = 10; 14 | 15 | if (elements.length > limit) 16 | { 17 | this.logger.warn({message: 'Truncated provided list of elements to first ' + limit + ' elements (maximum for the Generic Template)', elements: elements}); 18 | elements = elements.slice(0, limit); 19 | } 20 | 21 | message = { 22 | attachment: { 23 | type: 'template', 24 | payload: { 25 | template_type: 'generic', 26 | elements : elements 27 | } 28 | } 29 | }; 30 | 31 | this.logger.debug({message: 'Generated message payload with Generic Template', payload: message}); 32 | 33 | return message; 34 | } 35 | -------------------------------------------------------------------------------- /templates/receipt.js: -------------------------------------------------------------------------------- 1 | var merge = require('deeply'); 2 | 3 | module.exports = renderReceipt; 4 | 5 | /** 6 | * Renders payload from provided data with receipt template 7 | * https://developers.facebook.com/docs/messenger-platform/send-api-reference/receipt-template 8 | * 9 | * @this Fbbot# 10 | * @param {object} receipt - receipt object to render with receipt template 11 | * @returns {object} - receipt template message payload 12 | */ 13 | function renderReceipt(receipt) 14 | { 15 | var message, limit = 100; 16 | 17 | if (receipt.elements.length > limit) 18 | { 19 | this.logger.warn({message: 'Truncated provided list of elements to first ' + limit + ' elements (maximum for the Receipt Template)', receipt: receipt}); 20 | receipt.elements = receipt.elements.slice(0, limit); 21 | } 22 | 23 | message = { 24 | attachment: { 25 | type : 'template', 26 | payload: merge(receipt, { 27 | template_type: 'receipt' 28 | }) 29 | } 30 | }; 31 | 32 | this.logger.debug({message: 'Generated message payload with Receipt Template', payload: message}); 33 | 34 | return message; 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Alex Indigo 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 | -------------------------------------------------------------------------------- /incoming/type_cast.js: -------------------------------------------------------------------------------- 1 | var find = require('array-find') 2 | , normalize = require('../lib/normalize.js') 3 | ; 4 | 5 | module.exports = typeCast; 6 | 7 | // order here is important, not just alphabetical, although I did try to keep it nice 8 | // some messages contain `text` and other fields, and shouldn't be treated as separate type 9 | // examples: attachments#fallback (with url), quick_reply (with payload), sticker_id+attachments#image (sticker) 10 | // TODO: Clean it up 11 | var types = [ 12 | // meta 13 | 'delivery', 14 | 'echo', 15 | 'read', 16 | 17 | // data 18 | 'sticker_id', 19 | 'attachments', 20 | 'postback', 21 | 'quick_reply', 22 | 'text' 23 | ]; 24 | 25 | /** 26 | * Casts message type based on the present fields from the list 27 | * 28 | * @this Fbbot# 29 | * @param {object} payload - message object 30 | * @param {function} callback - invoked after type casting is done 31 | */ 32 | function typeCast(payload, callback) 33 | { 34 | var type = find(types, function(t){ return (t in payload); }); 35 | 36 | if (type) 37 | { 38 | payload.type = normalize(type); 39 | } 40 | 41 | callback(null, payload); 42 | } 43 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [2, 2, {"SwitchCase": 1}], 4 | "quotes": [2, "single"], 5 | "linebreak-style": [2, "unix"], 6 | "semi": [2, "always"], 7 | "curly": [2, "multi-line"], 8 | "handle-callback-err": [2, "^err"], 9 | "valid-jsdoc": [2, { 10 | "requireReturn": false, 11 | "requireReturnDescription": false, 12 | "prefer": { 13 | "return": "returns" 14 | } 15 | }], 16 | "require-jsdoc": [2, { 17 | "require": { 18 | "FunctionDeclaration": true 19 | } 20 | }], 21 | "no-redeclare": [2, { "builtinGlobals": true }], 22 | "no-shadow": [2, { "builtinGlobals": true, "hoist": "all" }], 23 | "no-use-before-define": [2, "nofunc"], 24 | "no-shadow-restricted-names": 2, 25 | "no-extra-semi": 2, 26 | "no-unused-vars": 2, 27 | "no-undef": 2, 28 | "no-irregular-whitespace": 2, 29 | "no-console": 2, 30 | "key-spacing": 0, 31 | "strict": 0, 32 | "dot-notation": 0, 33 | "eol-last": 0, 34 | "no-new": 0, 35 | "semi-spacing": 0, 36 | "no-multi-spaces": 0, 37 | "eqeqeq": 0, 38 | "no-mixed-requires": 0, 39 | }, 40 | "env": { 41 | "node": true 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/verify_endpoint.js: -------------------------------------------------------------------------------- 1 | var qs = require('querystring'); 2 | 3 | module.exports = verifyEndpoint; 4 | 5 | /** 6 | * Verifies endpoint by replying with the provided challenge 7 | * 8 | * @this Fbbot# 9 | * @param {EventEmitter} request - incoming http request object 10 | * @param {function} respond - http response function 11 | */ 12 | function verifyEndpoint(request, respond) 13 | { 14 | var challenge; 15 | 16 | if (typeof request.query == 'string') 17 | { 18 | request.query = qs.parse(request.query); 19 | } 20 | 21 | if (typeof request.query == 'object' 22 | && ((request.query['hub.verify_token'] === this.credentials.secret) 23 | // for hapi@10 and restify/express with queryParser check for `hub` object 24 | || (typeof request.query.hub == 'object' && request.query.hub['verify_token'] === this.credentials.secret) 25 | ) 26 | ) 27 | { 28 | challenge = request.query['hub.challenge'] || (request.query.hub ? request.query.hub.challenge : null); 29 | 30 | this.logger.debug({message: 'Successfully verified', challenge: challenge}); 31 | respond(challenge); 32 | return; 33 | } 34 | 35 | this.logger.error({message: 'Unable to verify endpoint', query: request.query}); 36 | respond(400, 'Error, wrong validation token'); 37 | } 38 | -------------------------------------------------------------------------------- /test/fixtures/incoming/message-attachment-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469435039346, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 1469435039310, 21 | "message": { 22 | "mid": "mid.1469435039239:1fc6e19f056a982685", 23 | "seq": 2279, 24 | "attachments": [{ 25 | "type": "file", 26 | "payload": { 27 | "url": "https://cdn.fbsbx.com/v/t59.2708-21/13709196_10157298449080455_1367318841_n.txt/hist_example.txt?oh=8e928a230dba2dc6418e10e40f0535ea&oe=57984ED2" 28 | } 29 | }] 30 | } 31 | }] 32 | }] 33 | }, 34 | 35 | "+expected": 36 | { 37 | "plan": 14, 38 | "entry": [{ 39 | "messaging": [{ 40 | "message": { 41 | "type": "attachment", 42 | "user": { 43 | "id": "10157033896470455" 44 | } 45 | } 46 | }] 47 | }] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/fixtures/incoming/message-sticker-attachment-image.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469431589130, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 1469431589103, 21 | "message": { 22 | "mid": "mid.1469431589088:5de10f231496154062", 23 | "seq": 2246, 24 | "sticker_id": 610917939033960, 25 | "attachments": [{ 26 | "type": "image", 27 | "payload": { 28 | "url": "https://scontent.xx.fbcdn.net/t39.1997-6/p100x100/12056965_787824108010008_1642362681_n.png?_nc_ad=z-m" 29 | } 30 | }] 31 | } 32 | }] 33 | }] 34 | }, 35 | 36 | "+expected": 37 | { 38 | "plan": 14, 39 | "entry": [{ 40 | "messaging": [{ 41 | "message": { 42 | "type": "sticker", 43 | "user": { 44 | "id": "10157033896470455" 45 | } 46 | } 47 | }] 48 | }] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/fixtures/incoming/message-attachment-audio.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469431745244, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 1469431745203, 21 | "message": { 22 | "mid": "mid.1469431744913:f67f0fa3b14a987b03", 23 | "seq": 2247, 24 | "attachments": [{ 25 | "type": "audio", 26 | "payload": { 27 | "url": "https://cdn.fbsbx.com/v/t59.3654-21/13841407_10157298310850455_866757824_n.aac/audioclip-1469431744746-9754.aac?oh=05ffa0fe133d7c2ea46764f7bde172f4&oe=57978D3A" 28 | } 29 | }] 30 | } 31 | }] 32 | }] 33 | }, 34 | 35 | "+expected": 36 | { 37 | "plan": 14, 38 | "entry": [{ 39 | "messaging": [{ 40 | "message": { 41 | "type": "attachment", 42 | "user": { 43 | "id": "10157033896470455" 44 | } 45 | } 46 | }] 47 | }] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/file.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "arguments": { 4 | "user": "10157033896470755", 5 | "type": "FILE", 6 | "data": "https://petersapparel.com/bin/receipt.pdf" 7 | }, 8 | 9 | "expected": { 10 | "recipient": { 11 | "id": "10157033896470755" 12 | }, 13 | "message": { 14 | "attachment": { 15 | "type": "file", 16 | "payload": { 17 | "url": "https://petersapparel.com/bin/receipt.pdf" 18 | } 19 | } 20 | } 21 | }, 22 | 23 | "response": { 24 | "recipient_id": "10157033896470755", 25 | "message_id": "mid.1456970488058:c34767dfe57ee6e339" 26 | } 27 | }, 28 | 29 | { 30 | "arguments": { 31 | "user": { 32 | "phone_number": "2345678901" 33 | }, 34 | "type": "FILE", 35 | "data": "https://petersapparel.com/bin/receipt.pdf" 36 | }, 37 | 38 | "expected": { 39 | "recipient": { 40 | "phone_number": "2345678901" 41 | }, 42 | "message": { 43 | "attachment": { 44 | "type": "file", 45 | "payload": { 46 | "url": "https://petersapparel.com/bin/receipt.pdf" 47 | } 48 | } 49 | } 50 | }, 51 | 52 | "response": { 53 | "recipient_id": "10157033896470755", 54 | "message_id": "mid.1456970488058:c34767dfe57ee6e339" 55 | } 56 | } 57 | ] 58 | -------------------------------------------------------------------------------- /test/fixtures/incoming/message-attachment-fallback-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469437728090, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 1469437728018, 21 | "message": { 22 | "mid": "mid.1469437727898:3d506401f2e7407653", 23 | "seq": 2284, 24 | "text": "https://developers.facebook.com/docs/messenger-platform/plugin-reference", 25 | "attachments": [{ 26 | "title": "Plugin Reference - Messenger Platform - Documentation - Facebook for Developers", 27 | "url": "https://developers.facebook.com/docs/messenger-platform/plugin-reference", 28 | "type": "fallback", 29 | "payload": null 30 | }] 31 | } 32 | }] 33 | }] 34 | }, 35 | 36 | "+expected": 37 | { 38 | "plan": 14, 39 | "entry": [{ 40 | "messaging": [{ 41 | "message": { 42 | "type": "attachment", 43 | "user": { 44 | "id": "10157033896470455" 45 | } 46 | } 47 | }] 48 | }] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/fixtures/incoming/message-quick_reply-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469433306866, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 1469433306846, 21 | "message": { 22 | "quick_reply": { 23 | "payload": "{\"developer\": {\"defined\": {\"payload\": \"for\", \"picking\": \"red\"}}}" 24 | }, 25 | "mid": "mid.1469433306838:778f28c4921a57d296", 26 | "seq": 2277, 27 | "text": "Red" 28 | } 29 | }] 30 | }] 31 | }, 32 | 33 | "+expected": 34 | { 35 | "plan": 15, 36 | "entry": [{ 37 | "messaging": [{ 38 | "message": { 39 | "type": "quick_reply", 40 | "user": { 41 | "id": "10157033896470455" 42 | }, 43 | "quick_reply": { 44 | "payload": { 45 | "developer": { 46 | "defined": { 47 | "payload": "for", 48 | "picking": "red" 49 | } 50 | } 51 | } 52 | } 53 | } 54 | }] 55 | }] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/fixtures/incoming/postback.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469431949973, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 1469431949973, 21 | "postback": { 22 | "payload": "{\"action\":\"property\",\"url\":\"/properties/for_sale/CA/170011\",\"id\":\"170011\",\"back\":{\"action\":\"search\",\"url\":\"/for_sale/94301_zip/\",\"type\":\"h\",\"text\":\"94301\",\"page\":1}}" 23 | } 24 | }] 25 | }] 26 | }, 27 | 28 | "+expected": 29 | { 30 | "plan": 14, 31 | "entry": [{ 32 | "messaging": [{ 33 | "postback": { 34 | "payload": { 35 | "action": "property", 36 | "url": "/properties/for_sale/CA/170011", 37 | "id": "170011", 38 | "back": { 39 | "action": "search", 40 | "url": "/for_sale/94301_zip/", 41 | "type": "h", 42 | "text": "94301", 43 | "page": 1 44 | } 45 | }, 46 | "user": { 47 | "id": "10157033896470455" 48 | } 49 | } 50 | }] 51 | }] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/fixtures/incoming/message-attachment-image.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469431481130, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 1469431481085, 21 | "message": { 22 | "mid": "mid.1469431481004:de5a5b6fcde7c15849", 23 | "seq": 2245, 24 | "attachments": [{ 25 | "type": "image", 26 | "payload": { 27 | "url": "https://scontent.xx.fbcdn.net/v/t35.0-12/13838166_10157298294835455_1784596700_o.jpg?_nc_ad=z-m&oh=2567989401ca8aaaabba2f51b541fbb8&oe=57987057" 28 | } 29 | }, { 30 | "type": "image", 31 | "payload": { 32 | "url": "https://scontent.xx.fbcdn.net/v/t35.0-12/13833417_10157298294840455_182043233_o.jpg?_nc_ad=z-m&oh=29a86c2372ba651d7a6c4024f8f34887&oe=57986164" 33 | } 34 | }] 35 | } 36 | }] 37 | }] 38 | }, 39 | 40 | "+expected": 41 | { 42 | "plan": 14, 43 | "entry": [{ 44 | "messaging": [{ 45 | "message": { 46 | "type": "attachment", 47 | "user": { 48 | "id": "10157033896470455" 49 | } 50 | } 51 | }] 52 | }] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/fixtures/incoming/message-attachment-location.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469431137719, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 1469431137638, 21 | "message": { 22 | "mid": "mid.1469431137525:552824a619d22db578", 23 | "seq": 2240, 24 | "attachments": [{ 25 | "title": "HP Garage", 26 | "url": "https://www.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D367%2BAddison%2BAve%252C%2BPalo%2BAlto%252C%2BCalifornia%2B94301%26FORM%3DFBKPL1%26mkt%3Den-US&h=BAQENaLCg&s=1&enc=AZONX8Q0PyBC1lJidXBGQLBsiB-_tEqSr2Mqsr9cwL4ms2YBXp1V1dAcn35Hk0hTbv6fGfk6HFDivTI5Se-BJP3csnDYgps4czoKy2DBe2IIEQ", 27 | "type": "location", 28 | "payload": { 29 | "coordinates": { 30 | "lat": 37.4430389, 31 | "long": -122.1546631 32 | } 33 | } 34 | }] 35 | } 36 | }] 37 | }] 38 | }, 39 | 40 | "+expected": 41 | { 42 | "plan": 14, 43 | "entry": [{ 44 | "messaging": [{ 45 | "message": { 46 | "type": "attachment", 47 | "user": { 48 | "id": "10157033896470455" 49 | } 50 | } 51 | }] 52 | }] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/fixtures/incoming/message-text-multi-entry.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469430646874, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 1469430646786, 21 | "message": { 22 | "mid": "mid.1469430646641:faf898695ebb3f0f86", 23 | "seq": 2236, 24 | "text": "94301" 25 | } 26 | }] 27 | }, { 28 | "id": "847061682115597", 29 | "time": 1469430646997, 30 | "messaging": [{ 31 | "sender": { 32 | "id": "21267033896471566" 33 | }, 34 | "recipient": { 35 | "id": "514061682115264" 36 | }, 37 | "timestamp": 1469430646985, 38 | "message": { 39 | "mid": "mid.1469435039239:1fc6e19f056a982685", 40 | "seq": 2237, 41 | "text": "90210" 42 | } 43 | }] 44 | }] 45 | }, 46 | 47 | "+expected": 48 | { 49 | "plan": 23, 50 | "entry": [{ 51 | "messaging": [{ 52 | "message": { 53 | "type": "text", 54 | "user": { 55 | "id": "10157033896470455" 56 | } 57 | } 58 | }] 59 | }, { 60 | "messaging": [{ 61 | "message": { 62 | "type": "text", 63 | "user": { 64 | "id": "21267033896471566" 65 | } 66 | } 67 | }] 68 | }] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/text.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "arguments": { 4 | "user": "10157033896470555", 5 | "type": "TEXT", 6 | "data": "hello bot!" 7 | }, 8 | 9 | "expected": { 10 | "recipient": { 11 | "id": "10157033896470555" 12 | }, 13 | "message": { 14 | "text": "hello bot!" 15 | } 16 | }, 17 | 18 | "response": { 19 | "recipient_id": "10157033896470555", 20 | "message_id": "mid.1456970487948:c34767dfe57ee6e339" 21 | } 22 | }, 23 | 24 | { 25 | "meta": { 26 | "description": "Truncates extra characters from the provided string" 27 | }, 28 | "arguments": { 29 | "user": "10157033896470556", 30 | "type": "TEXT", 31 | "data": "In 1988, Joseph D. Becker published the first Unicode draft proposal. At the basis of his design was the naïve assumption that 16 bits per character would suffice. In 1991, the first version of the Unicode standard was published, with code points limited to 16 bits. In the following years many systems have added support for Unicode and switched to the UCS-2 encoding. It was especially attractive for new technologies, such as the Qt framework (1992), Windows NT 3.1 (1993) and Java (1995)." 32 | }, 33 | 34 | "expected": { 35 | "recipient": { 36 | "id": "10157033896470556" 37 | }, 38 | "message": { 39 | "text": "In 1988, Joseph D. Becker published the first Unicode draft proposal. At the basis of his design was the naïve assumption that 16 bits per character would suffice. In 1991, the first version of the Unicode standard was published, with code points limited to 16 bits. In the following years many systems have added suppor" 40 | } 41 | }, 42 | 43 | "response": { 44 | "recipient_id": "10157033896470556", 45 | "message_id": "mid.1456970487948:c44767dfe57ee6e339" 46 | } 47 | } 48 | ] 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fbbot", 3 | "version": "1.1.1", 4 | "description": "Minimal framework/SDK for facebook messenger bots. BYOS (Bring Your Own Server)", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "eslint *.js */*.js", 8 | "pretest": "is-node-not-modern && npm install hapi@9 || is-node-modern", 9 | "test": "tape test/test-*.js | tap-spec", 10 | "precover": "rimraf coverage", 11 | "cover": "istanbul cover tape -- test/test-*.js | tap-spec", 12 | "preci-test": "is-node-not-modern && npm install hapi@9 || is-node-modern", 13 | "ci-test": "npm run cover", 14 | "ci-lint": "is-node-modern && npm run lint || is-node-not-modern", 15 | "toc": "toc-md README.md", 16 | "get-version": "node -e \"console.log(require('./package.json').version)\"", 17 | "update-readme": "sed -i.bak 's/\\/master\\.svg/\\/v'$(npm --silent run get-version)'.svg/g' README.md", 18 | "restore-readme": "mv README.md.bak README.md", 19 | "prepublish": "in-publish && npm run update-readme || not-in-publish", 20 | "postpublish": "npm run restore-readme" 21 | }, 22 | "pre-commit": [ 23 | "lint", 24 | "test", 25 | "toc" 26 | ], 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/alexindigo/fbbot.git" 30 | }, 31 | "keywords": [ 32 | "bot", 33 | "facebook", 34 | "messenger", 35 | "fb", 36 | "fbbot", 37 | "api", 38 | "hook", 39 | "webhook", 40 | "sdk", 41 | "byos" 42 | ], 43 | "author": "Alex Indigo ", 44 | "license": "MIT", 45 | "bugs": { 46 | "url": "https://github.com/alexindigo/fbbot/issues" 47 | }, 48 | "homepage": "https://github.com/alexindigo/fbbot#readme", 49 | "devDependencies": { 50 | "coveralls": "^2.11.14", 51 | "eslint": "^3.6.1", 52 | "express": "^4.14.0", 53 | "glob": "^7.1.0", 54 | "hapi": "^15.1.0", 55 | "in-publish": "^2.0.0", 56 | "is-node-modern": "^1.0.0", 57 | "istanbul": "^0.4.5", 58 | "pre-commit": "^1.1.3", 59 | "restify": "^4.1.1", 60 | "rimraf": "^2.5.4", 61 | "tap-spec": "^4.1.1", 62 | "tape": "^4.6.0", 63 | "toc-md": "^0.2.0" 64 | }, 65 | "dependencies": { 66 | "agnostic": "^1.2.2", 67 | "array-find": "^1.0.0", 68 | "async-cache": "^1.1.0", 69 | "asynckit": "^0.4.0", 70 | "bole": "^3.0.1", 71 | "deeply": "^2.0.3", 72 | "hyperquest": "^2.1.0", 73 | "once": "^1.4.0", 74 | "precise-typeof": "^1.0.2", 75 | "to-camel-case": "^1.0.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /test/test-traverse.js: -------------------------------------------------------------------------------- 1 | var tape = require('tape') 2 | , Traverse = require('../traverse/index.js') 3 | , steps = {'': 'a', 'a': 'b', 'b': 'c'} 4 | ; 5 | 6 | tape('traverse - default', function(t) 7 | { 8 | t.plan(6); 9 | 10 | var slider, original = slider = {a: {b: {c: {d: 25}}}} 11 | // + "auto" constructor 12 | , tr = Traverse(steps, {middleware: function(branch, payload, cb) 13 | { 14 | t.deepEqual(payload, branch ? slider[branch] : slider, 'middleware payload, should match original object'); 15 | 16 | // proceed to the next level 17 | if (branch) 18 | { 19 | slider = slider[branch]; 20 | } 21 | 22 | cb(null, payload); 23 | }}) 24 | ; 25 | 26 | tr.traverse(original, function(err, result) 27 | { 28 | t.error(err, 'expect no errors'); 29 | t.deepEqual(result, original, 'expect unchanged result object'); 30 | }); 31 | }); 32 | 33 | tape('traverse - short circuit', function(t) 34 | { 35 | t.plan(2); 36 | 37 | var original = {a: {b: 42}} 38 | , tr = new Traverse(steps) 39 | ; 40 | 41 | tr.traverse(original, function(err, result) 42 | { 43 | t.equal(err.message, 'payload is not an Object'); 44 | t.deepEqual(result, undefined, 'expect no result object'); 45 | }); 46 | }); 47 | 48 | tape('traverse - middleware error', function(t) 49 | { 50 | t.plan(3); 51 | 52 | var original = {a: {b: {c: {d: 25}}}} 53 | , tr = new Traverse(steps, {middleware: function(branch, payload, cb) 54 | { 55 | t.deepEqual(payload, original, 'middleware payload, should match original object'); 56 | 57 | cb(new Error('Meh'), payload); 58 | }}) 59 | ; 60 | 61 | tr.traverse(original, function(err, result) 62 | { 63 | t.equal(err.message, 'Meh', 'expect middleware error'); 64 | t.deepEqual(result, original, 'expect unchanged result object with middleware error'); 65 | }); 66 | }); 67 | 68 | tape('traverse - broken middleware', function(t) 69 | { 70 | t.plan(4); 71 | 72 | var original = {a: {b: {c: {d: 25}}}} 73 | , tr = new Traverse(steps, {middleware: function(branch, payload, cb) 74 | { 75 | t.deepEqual(payload, branch ? original[branch] : original, 'middleware payload, should match original object'); 76 | 77 | cb(null, branch ? 42 : payload); 78 | }}) 79 | ; 80 | 81 | tr.traverse(original, function(err, result) 82 | { 83 | t.equal(err.message, 'payload after middleware is not an Object or an Array', 'expect after middleware error on second iteration'); 84 | t.deepEqual(result, undefined, 'expect no result object with after middleware error'); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/test-http.js: -------------------------------------------------------------------------------- 1 | var http = require('http') 2 | , tape = require('tape') 3 | , common = require('./common.js') 4 | , Fbbot = require('../') 5 | ; 6 | 7 | tape('http', function(test) 8 | { 9 | common.iterateRequests(function(request, handle, callback) 10 | { 11 | var payloadType = handle.split('-')[0]; 12 | 13 | test.test('with ' + handle, function(t) 14 | { 15 | t.plan(request.expected.plan); 16 | 17 | var server 18 | , fbbot = new Fbbot(common.fbbot) 19 | ; 20 | 21 | // setup tests per instance 22 | common.setupTests(fbbot, payloadType, request, t, callback); 23 | 24 | // create server plug-in fbbot 25 | server = http.createServer(fbbot.requestHandler); 26 | 27 | // start the server 28 | server.listen(common.server.port, function() 29 | { 30 | common.sendRequest(handle, function(error, response) 31 | { 32 | t.error(error, 'POST request should return no error'); 33 | t.equal(response.statusCode, 200, 'POST request should return code 200'); 34 | 35 | server.close(function() 36 | { 37 | t.ok(true, 'make sure server is closed'); 38 | }); 39 | }); 40 | }); 41 | }); 42 | }); 43 | }); 44 | 45 | tape('http - handshake - success', function(t) 46 | { 47 | t.plan(4); 48 | 49 | var server 50 | , fbbot = new Fbbot(common.fbbot) 51 | ; 52 | 53 | // create server plug-in fbbot 54 | server = http.createServer(fbbot.requestHandler); 55 | 56 | // start the server 57 | server.listen(common.server.port, function() 58 | { 59 | common.sendHandshake('ok', function(error, response) 60 | { 61 | t.error(error, 'GET request should return no error'); 62 | t.equal(response.statusCode, 200, 'GET request should return code 200'); 63 | t.equal(response.body, common.handshakes['ok'].query['hub.challenge'], 'should receive provided challenge back'); 64 | 65 | server.close(function() 66 | { 67 | t.ok(true, 'make sure server is closed'); 68 | }); 69 | }); 70 | }); 71 | }); 72 | 73 | tape('http - handshake - failed', function(t) 74 | { 75 | t.plan(4); 76 | 77 | var server 78 | , fbbot = new Fbbot(common.fbbot) 79 | ; 80 | 81 | // create server plug-in fbbot 82 | server = http.createServer(fbbot.requestHandler); 83 | 84 | // start the server 85 | server.listen(common.server.port, function() 86 | { 87 | common.sendHandshake('bad', function(error, response) 88 | { 89 | t.error(error, 'GET request should return no error'); 90 | t.equal(response.statusCode, 400, 'GET request should return code 400'); 91 | t.equal(response.body, common.handshakes['bad'].error, 'should received error message'); 92 | 93 | server.close(function() 94 | { 95 | t.ok(true, 'make sure server is closed'); 96 | }); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/test-express.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | , test = require('tape') 3 | , common = require('./common.js') 4 | , Fbbot = require('../') 5 | ; 6 | 7 | common.iterateRequests(function(request, handle, callback) 8 | { 9 | var payloadType = handle.split('-')[0]; 10 | 11 | test.test('express with ' + handle, function(t) 12 | { 13 | t.plan(request.expected.plan); 14 | 15 | var server 16 | , app = express() 17 | , fbbot = new Fbbot(common.fbbot) 18 | ; 19 | 20 | // setup tests per instance 21 | common.setupTests(fbbot, payloadType, request, t, callback); 22 | 23 | // plug-in fbbot 24 | app.all(common.server.endpoint, fbbot.requestHandler); 25 | 26 | // start the server 27 | server = app.listen(common.server.port, function() 28 | { 29 | common.sendRequest(handle, function(error, response) 30 | { 31 | t.error(error, 'POST request should return no error'); 32 | t.equal(response.statusCode, 200, 'POST request should return code 200'); 33 | 34 | server.close(function() 35 | { 36 | t.ok(true, 'make sure server is closed'); 37 | }); 38 | }); 39 | }); 40 | 41 | }); 42 | }); 43 | 44 | test('express - handshake - success', function(t) 45 | { 46 | t.plan(4); 47 | 48 | var server 49 | , app = express() 50 | , fbbot = new Fbbot(common.fbbot) 51 | ; 52 | 53 | // plug-in fbbot 54 | app.all(common.server.endpoint, fbbot.requestHandler); 55 | 56 | // start the server 57 | server = app.listen(common.server.port, function() 58 | { 59 | common.sendHandshake('ok', function(error, response) 60 | { 61 | t.error(error, 'GET request should return no error'); 62 | t.equal(response.statusCode, 200, 'GET request should return code 200'); 63 | t.equal(response.body, common.handshakes['ok'].query['hub.challenge'], 'should receive provided challenge back'); 64 | 65 | server.close(function() 66 | { 67 | t.ok(true, 'make sure server is closed'); 68 | }); 69 | }); 70 | }); 71 | }); 72 | 73 | test('express - handshake - failed', function(t) 74 | { 75 | t.plan(4); 76 | 77 | var server 78 | , app = express() 79 | , fbbot = new Fbbot(common.fbbot) 80 | ; 81 | 82 | // plug-in fbbot 83 | app.all(common.server.endpoint, fbbot.requestHandler); 84 | 85 | // start the server 86 | server = app.listen(common.server.port, function() 87 | { 88 | common.sendHandshake('bad', function(error, response) 89 | { 90 | t.error(error, 'GET request should return no error'); 91 | t.equal(response.statusCode, 400, 'GET request should return code 400'); 92 | t.equal(response.body, common.handshakes['bad'].error, 'should received error message'); 93 | 94 | server.close(function() 95 | { 96 | t.ok(true, 'make sure server is closed'); 97 | }); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /test/test-restify.js: -------------------------------------------------------------------------------- 1 | var restify = require('restify') 2 | , tape = require('tape') 3 | , common = require('./common.js') 4 | , Fbbot = require('../') 5 | ; 6 | 7 | tape('restify', function(test) 8 | { 9 | common.iterateRequests(function(request, handle, callback) 10 | { 11 | var payloadType = handle.split('-')[0]; 12 | 13 | test.test('with ' + handle, function(t) 14 | { 15 | t.plan(request.expected.plan); 16 | 17 | var server = restify.createServer() 18 | , fbbot = new Fbbot(common.fbbot) 19 | ; 20 | 21 | // setup tests per instance 22 | common.setupTests(fbbot, payloadType, request, t, callback); 23 | 24 | // plug-in fbbot 25 | server.get(common.server.endpoint, fbbot.requestHandler); 26 | server.post(common.server.endpoint, fbbot.requestHandler); 27 | 28 | // start the server 29 | server.listen(common.server.port, function() 30 | { 31 | common.sendRequest(handle, function(error, response) 32 | { 33 | t.error(error, 'POST request should return no error'); 34 | t.equal(response.statusCode, 200, 'POST request should return code 200'); 35 | 36 | server.close(function() 37 | { 38 | t.ok(true, 'make sure server is closed'); 39 | }); 40 | }); 41 | }); 42 | }); 43 | }); 44 | }); 45 | 46 | tape('restify - handshake - success', function(t) 47 | { 48 | t.plan(4); 49 | 50 | var server = restify.createServer() 51 | , fbbot = new Fbbot(common.fbbot) 52 | ; 53 | 54 | // plug-in fbbot 55 | server.get(common.server.endpoint, fbbot.requestHandler); 56 | server.post(common.server.endpoint, fbbot.requestHandler); 57 | 58 | // start the server 59 | server.listen(common.server.port, function() 60 | { 61 | common.sendHandshake('ok', function(error, response) 62 | { 63 | t.error(error, 'GET request should return no error'); 64 | t.equal(response.statusCode, 200, 'GET request should return code 200'); 65 | t.equal(response.body, common.handshakes['ok'].query['hub.challenge'], 'should receive provided challenge back'); 66 | 67 | server.close(function() 68 | { 69 | t.ok(true, 'make sure server is closed'); 70 | }); 71 | }); 72 | }); 73 | }); 74 | 75 | tape('restify - handshake - failed', function(t) 76 | { 77 | t.plan(4); 78 | 79 | var server = restify.createServer() 80 | , fbbot = new Fbbot(common.fbbot) 81 | ; 82 | 83 | // plug-in fbbot 84 | server.get(common.server.endpoint, fbbot.requestHandler); 85 | server.post(common.server.endpoint, fbbot.requestHandler); 86 | 87 | // start the server 88 | server.listen(common.server.port, function() 89 | { 90 | common.sendHandshake('bad', function(error, response) 91 | { 92 | t.error(error, 'GET request should return no error'); 93 | t.equal(response.statusCode, 400, 'GET request should return code 400'); 94 | t.equal(response.body, common.handshakes['bad'].error, 'should received error message'); 95 | 96 | server.close(function() 97 | { 98 | t.ok(true, 'make sure server is closed'); 99 | }); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /traverse/incoming.js: -------------------------------------------------------------------------------- 1 | var messaging = require('../lib/messaging.js') 2 | , normalize = require('../lib/normalize.js') 3 | ; 4 | 5 | module.exports = { 6 | 7 | // list of receive steps 8 | steps: { 9 | 'payload' : 'entry', 10 | 'entry' : 'messaging', 11 | 'messaging' : messaging.getType, // function that returns next step based on payload 12 | 'message' : ['quick_reply', 'attachments'] // list of possible paths 13 | }, 14 | 15 | linkParent: linkParent, 16 | middleware: middleware, 17 | emitter : emitter 18 | }; 19 | 20 | /** 21 | * Wraps original linkParent method 22 | * and adds normalized handle reference to the parent object 23 | * 24 | * @param {function} original - original linkParent method 25 | * @param {string} branch - current branch of the payload 26 | * @param {object} parentPayload - parent payload object 27 | * @param {mixed} nextPayload - next step payload object 28 | * @returns {mixed} - augmented next step payload object 29 | */ 30 | function linkParent(original, branch, parentPayload, nextPayload) 31 | { 32 | // get proper name 33 | var normalized = normalize(branch); 34 | 35 | var result = original(branch, parentPayload, nextPayload); 36 | 37 | // add normalized handle reference 38 | // skip if it's empty string 39 | if (normalized) 40 | { 41 | result.__proto__[normalized] = parentPayload; 42 | } 43 | 44 | // add user object reference 45 | if (parentPayload.user) 46 | { 47 | result.__proto__['user'] = parentPayload.user; 48 | } 49 | 50 | return result; 51 | } 52 | 53 | /** 54 | * Traverse middleware for incoming flow 55 | * 56 | * @this Fbbot# 57 | * @param {string} branch - branch name of the payload 58 | * @param {object} payload - initial payload object from facebook messenger 59 | * @param {function} callback - invoked upon error or when all entries were processed 60 | */ 61 | function middleware(branch, payload, callback) 62 | { 63 | // get proper name 64 | var normalized = normalize(branch); 65 | 66 | this.logger.debug({message: 'Running middleware for incoming payload', branch: branch, normalized: normalized, payload: payload}); 67 | 68 | // add branch reference to the parent object 69 | 70 | // run through all registered middleware 71 | this._run(normalized, payload, function(error, resolvedPayload) 72 | { 73 | var normalizeType; 74 | 75 | if (payload.type) 76 | { 77 | normalizeType = normalize(payload.type); 78 | this._run([normalized, normalizeType].join('.'), resolvedPayload, callback); 79 | } 80 | // be done here 81 | else 82 | { 83 | callback(error, resolvedPayload); 84 | } 85 | }.bind(this)); 86 | } 87 | 88 | /** 89 | * Emits normalized event with payload and extracted send method. 90 | * Also tries to emit specific type event 91 | * 92 | * @param {string} event - event handle 93 | * @param {object} payload - current payload object 94 | */ 95 | function emitter(event, payload) 96 | { 97 | // get proper name 98 | var normalizeType, normalized = normalize(event); 99 | 100 | // notify listeners 101 | this.emit(normalized, payload, (payload.user || {}).send); 102 | 103 | // notify listeners of the specific type 104 | if (payload.type) 105 | { 106 | normalizeType = normalize(payload.type); 107 | this.emit([normalized, normalizeType].join('.'), payload, (payload.user || {}).send); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /lib/middleware.js: -------------------------------------------------------------------------------- 1 | var pipeline = require('./pipeline.js'); 2 | 3 | // placeholder for "catch-all" middleware 4 | // when no event name been supplied 5 | var entryPoint = 'payload'; 6 | 7 | module.exports = { 8 | use : use, 9 | run : run, 10 | // convenience names 11 | entryPoint : entryPoint 12 | }; 13 | 14 | /** 15 | * Adds provided middleware to the stack, 16 | * with optional list of events 17 | * 18 | * @this Fbbot# 19 | * @param {string|array} [events] - list of events to associate middleware with 20 | * @param {function} middleware - request/event handler 21 | */ 22 | function use(events, middleware) 23 | { 24 | // if no middleware supplied assume it's single argument call 25 | if (!middleware) 26 | { 27 | middleware = events; 28 | events = null; 29 | } 30 | 31 | // make it uniform 32 | events = normalizeEvents(events); 33 | 34 | // store middleware per event 35 | events.forEach(function(e) 36 | { 37 | // bind middleware to the current context 38 | (this._stack[e] = this._stack[e] || []).push(middleware.bind(this)); 39 | 40 | // make mark in history 41 | this.logger.debug({message: 'Added middleware to the stack', event: e, middleware: middleware}); 42 | }.bind(this)); 43 | } 44 | 45 | /** 46 | * Runs middleware in the stack for the provided events with passed payload 47 | * 48 | * @this Fbbot# 49 | * @param {string|array} [events] - list of events to determine middleware set to iterate over 50 | * @param {object} payload - event payload to pass along to middleware 51 | * @param {function} callback - invoked after (if) all middleware been processed 52 | */ 53 | function run(events, payload, callback) 54 | { 55 | // if no callback supplied assume it's two arguments call 56 | if (!callback) 57 | { 58 | callback = payload; 59 | payload = events; 60 | events = null; 61 | } 62 | 63 | // make it uniform 64 | events = normalizeEvents(events); 65 | 66 | // apply events/middleware asynchronously and sequentially 67 | pipeline(events, payload, function(e, data, cb) 68 | { 69 | if (!Array.isArray(this._stack[e])) 70 | { 71 | this.logger.debug({message: 'Reached end of the stack', event: e, data: data}); 72 | cb(null, payload); 73 | return; 74 | } 75 | 76 | pipeline(this._stack[e], data, tryCall, cb); 77 | }.bind(this), callback); 78 | } 79 | 80 | /** 81 | * Runs handler with provided data 82 | * wrapped into try/catch 83 | * 84 | * @private 85 | * @param {function} handler - handler function to run 86 | * @param {object} data - data to pass to the handler 87 | * @param {function} callback - invoked after handler finishes, or with caught error 88 | */ 89 | function tryCall(handler, data, callback) 90 | { 91 | try { 92 | handler(data, callback); 93 | } catch (e) { 94 | callback(e); 95 | } 96 | } 97 | 98 | /** 99 | * Normalizes provided events 100 | * to keep `add` and `apply` on the same page 101 | * 102 | * @private 103 | * @param {null|string|array} events - events to normalize 104 | * @returns {array} - normalized list of events 105 | */ 106 | function normalizeEvents(events) 107 | { 108 | // start with converting falsy events into catch-all placeholder 109 | events = events || entryPoint; 110 | 111 | if (!Array.isArray(events)) 112 | { 113 | events = [events]; 114 | } 115 | 116 | // return shallow copy 117 | return events.concat(); 118 | } 119 | -------------------------------------------------------------------------------- /traverse/outgoing.js: -------------------------------------------------------------------------------- 1 | var normalize = require('../lib/normalize.js'); 2 | 3 | module.exports = { 4 | 5 | // list of receive steps 6 | steps: { 7 | '' : 'message', 8 | 'message' : ['attachment', 'quick_replies'], 9 | 'attachment' : 'payload', 10 | 'payload' : ['buttons', 'elements'], 11 | 'elements' : 'buttons' 12 | }, 13 | 14 | linkParent: linkParent, 15 | middleware: middleware, 16 | emitter : emitter, 17 | prefix : 'send' 18 | }; 19 | 20 | /** 21 | * Wraps original linkParent method 22 | * and adds normalized handle reference to the parent object 23 | * 24 | * @param {function} original - original linkParent method 25 | * @param {string} branch - current branch of the payload 26 | * @param {object} parentPayload - parent payload object 27 | * @param {mixed} nextPayload - next step payload object 28 | * @returns {mixed} - augmented next step payload object 29 | */ 30 | function linkParent(original, branch, parentPayload, nextPayload) 31 | { 32 | // get proper name 33 | var normalized = normalize(branch); 34 | 35 | var result = original(branch, parentPayload, nextPayload); 36 | 37 | // add normalized handle reference 38 | // skip if it's empty string 39 | if (normalized) 40 | { 41 | result.__proto__[normalized] = parentPayload; 42 | } 43 | 44 | return result; 45 | } 46 | 47 | /** 48 | * Traverse middleware for outgoing flow 49 | * 50 | * @this Fbbot# 51 | * @param {string} branch - branch name of the payload 52 | * @param {object} payload - initial payload object from facebook messenger 53 | * @param {function} callback - invoked upon error or when all entries were processed 54 | */ 55 | function middleware(branch, payload, callback) 56 | { 57 | // get proper name 58 | var normalized = normalize(branch); 59 | 60 | this.logger.debug({message: 'Running middleware for outgoing payload', branch: branch, normalized: normalized, payload: payload}); 61 | 62 | // add branch reference to the parent object 63 | 64 | // run through all registered middleware 65 | this._run(normalized, payload, function(error, resolvedPayload) 66 | { 67 | var normalizeType; 68 | 69 | if (payload['template_type']) 70 | { 71 | normalizeType = normalize(payload['template_type']); 72 | this._run([normalized, normalizeType].join('.'), resolvedPayload, callback); 73 | } 74 | else if (payload.type) 75 | { 76 | normalizeType = normalize(payload.type); 77 | this._run([normalized, normalizeType].join('.'), resolvedPayload, callback); 78 | } 79 | // be done here 80 | else 81 | { 82 | callback(error, resolvedPayload); 83 | } 84 | }.bind(this)); 85 | } 86 | 87 | /** 88 | * Emits normalized event with payload and extracted send method. 89 | * Also tries to emit specific type event 90 | * 91 | * @param {string} event - event handle 92 | * @param {object} payload - current payload object 93 | */ 94 | function emitter(event, payload) 95 | { 96 | // get proper name 97 | var normalized = normalize(event); 98 | 99 | // notify listeners 100 | this.emit(normalized, payload); 101 | 102 | // notify listeners of the specific type 103 | if (payload['template_type']) 104 | { 105 | this.emit([normalized, payload['template_type']].join('.'), payload); 106 | } 107 | else if (payload.type) 108 | { 109 | this.emit([normalized, normalize(payload.type)].join('.'), payload); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /test/fixtures/incoming/message-text-multi-entry-multi-messaging.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "host": "localhost", 4 | "accept": "*/*", 5 | "accept-encoding": "deflate, gzip", 6 | "content-type": "application/json" 7 | }, 8 | "body": { 9 | "object": "page", 10 | "entry": [{ 11 | "id": "514061682115264", 12 | "time": 1469430646874, 13 | "messaging": [{ 14 | "sender": { 15 | "id": "10157033896470455" 16 | }, 17 | "recipient": { 18 | "id": "514061682115264" 19 | }, 20 | "timestamp": 1469430646786, 21 | "message": { 22 | "mid": "mid.1469430646641:faf898695ebb3f0f86", 23 | "seq": 2236, 24 | "text": "94301" 25 | } 26 | }, { 27 | "sender": { 28 | "id": "10157033896470455" 29 | }, 30 | "recipient": { 31 | "id": "514061682115264" 32 | }, 33 | "timestamp": 1469430646788, 34 | "message": { 35 | "mid": "mid.1469430646641:faf898695ebb3f0f86", 36 | "seq": 2237, 37 | "text": "San Francisco, CA" 38 | } 39 | }] 40 | }, { 41 | "id": "847061682115597", 42 | "time": 1469430646997, 43 | "messaging": [{ 44 | "sender": { 45 | "id": "21267033896471566" 46 | }, 47 | "recipient": { 48 | "id": "514061682115264" 49 | }, 50 | "timestamp": 1469430646985, 51 | "message": { 52 | "mid": "mid.1469435039239:1fc6e19f056a982685", 53 | "seq": 2246, 54 | "text": "90210" 55 | } 56 | }, { 57 | "sender": { 58 | "id": "21267033896471566" 59 | }, 60 | "recipient": { 61 | "id": "514061682115264" 62 | }, 63 | "timestamp": 1469430646987, 64 | "message": { 65 | "mid": "mid.1469435039239:1fc6e19f056a982685", 66 | "seq": 2247, 67 | "text": "Palo Alto, CA" 68 | } 69 | }, { 70 | "sender": { 71 | "id": "21267033896471566" 72 | }, 73 | "recipient": { 74 | "id": "514061682115264" 75 | }, 76 | "timestamp": 1469430646989, 77 | "message": { 78 | "mid": "mid.1469435039239:1fc6e19f056a982685", 79 | "seq": 2248, 80 | "text": "Toronto, ON" 81 | } 82 | }] 83 | }] 84 | }, 85 | 86 | "+expected": 87 | { 88 | "plan": 50, 89 | "entry": [{ 90 | "messaging": [{ 91 | "message": { 92 | "type": "text", 93 | "user": { 94 | "id": "10157033896470455" 95 | } 96 | } 97 | }, { 98 | "message": { 99 | "type": "text", 100 | "user": { 101 | "id": "10157033896470455" 102 | } 103 | } 104 | }] 105 | }, { 106 | "messaging": [{ 107 | "message": { 108 | "type": "text", 109 | "user": { 110 | "id": "21267033896471566" 111 | } 112 | } 113 | }, { 114 | "message": { 115 | "type": "text", 116 | "user": { 117 | "id": "21267033896471566" 118 | } 119 | } 120 | }, { 121 | "message": { 122 | "type": "text", 123 | "user": { 124 | "id": "21267033896471566" 125 | } 126 | } 127 | }] 128 | }] 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/button.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "arguments": { 4 | "user": "10157033896470955", 5 | "type": "BUTTON", 6 | "data": { 7 | "text": "What do you want to do next?", 8 | "buttons": [{ 9 | "type": "web_url", 10 | "url": "https://petersapparel.parseapp.com", 11 | "title": "Show Website" 12 | }, { 13 | "type": "postback", 14 | "title": "Start Chatting", 15 | "payload": { 16 | "custom": "payload", 17 | "provides_as": "object" 18 | } 19 | }] 20 | } 21 | }, 22 | 23 | "expected": { 24 | "recipient": { 25 | "id": "10157033896470955" 26 | }, 27 | "message": { 28 | "attachment": { 29 | "type": "template", 30 | "payload": { 31 | "template_type": "button", 32 | "text": "What do you want to do next?", 33 | "buttons": [{ 34 | "type": "web_url", 35 | "url": "https://petersapparel.parseapp.com", 36 | "title": "Show Website" 37 | }, { 38 | "type": "postback", 39 | "title": "Start Chatting", 40 | "payload": "{\"custom\":\"payload\",\"provides_as\":\"object\"}" 41 | }] 42 | } 43 | } 44 | } 45 | }, 46 | 47 | "response": { 48 | "recipient_id": "10157033896470955", 49 | "message_id": "mid.1456970488058:c34767dfe57ee6e339" 50 | } 51 | }, 52 | 53 | { 54 | "count": { 55 | "expected": 3, 56 | "hook": "send.button" 57 | }, 58 | "arguments": { 59 | "user": { 60 | "id": "10157033896470956" 61 | }, 62 | "type": "BUTTON", 63 | "data": { 64 | "text": "What do you want to do next?", 65 | "buttons": [{ 66 | "type":"phone_number", 67 | "title":"Call Representative", 68 | "payload":"+15105551234" 69 | }, { 70 | "type": "postback", 71 | "title": "Second Chatting", 72 | "payload": "USER_DEFINED_SECOND_PAYLOAD" 73 | }, { 74 | "type": "web_url", 75 | "url": "https://third.parseapp.com", 76 | "title": "Show Third Website" 77 | }, { 78 | "type": "postback", 79 | "title": "Fourth Chatting", 80 | "payload": "USER_DEFINED_FOURTH_PAYLOAD" 81 | }] 82 | } 83 | }, 84 | 85 | "expected": { 86 | "recipient": { 87 | "id": "10157033896470956" 88 | }, 89 | "message": { 90 | "attachment": { 91 | "type": "template", 92 | "payload": { 93 | "template_type": "button", 94 | "text": "What do you want to do next?", 95 | "buttons": [{ 96 | "type":"phone_number", 97 | "title":"Call Representative", 98 | "payload":"+15105551234" 99 | }, { 100 | "type": "postback", 101 | "title": "Second Chatting", 102 | "payload": "USER_DEFINED_SECOND_PAYLOAD" 103 | }, { 104 | "type": "web_url", 105 | "url": "https://third.parseapp.com", 106 | "title": "Show Third Website" 107 | }] 108 | } 109 | } 110 | } 111 | }, 112 | 113 | "response": { 114 | "recipient_id": "10157033896470956", 115 | "message_id": "mid.1456970488058:c34767dfe57ee6e339" 116 | } 117 | } 118 | ] 119 | -------------------------------------------------------------------------------- /test/test-hapi.js: -------------------------------------------------------------------------------- 1 | var Hapi = require('hapi') 2 | , tape = require('tape') 3 | , common = require('./common.js') 4 | , Fbbot = require('../') 5 | ; 6 | 7 | tape('hapi', function(test) 8 | { 9 | common.iterateRequests(function(request, handle, callback) 10 | { 11 | var payloadType = handle.split('-')[0]; 12 | 13 | test.test('with ' + handle, function(t) 14 | { 15 | t.plan(request.expected.plan); 16 | 17 | var server = new Hapi.Server() 18 | , fbbot = new Fbbot(common.fbbot) 19 | ; 20 | 21 | // setup hapi server 22 | server.connection({ port: common.server.port }); 23 | 24 | // setup tests per instance 25 | common.setupTests(fbbot, payloadType, request, t, callback); 26 | 27 | // plug-in fbbot 28 | server.route({ 29 | method: 'GET', 30 | path: common.server.endpoint, 31 | handler: fbbot.requestHandler 32 | }); 33 | server.route({ 34 | method: 'POST', 35 | path: common.server.endpoint, 36 | handler: fbbot.requestHandler 37 | }); 38 | 39 | // start the server 40 | server.start(function() 41 | { 42 | common.sendRequest(handle, function(error, response) 43 | { 44 | t.error(error, 'should be no error'); 45 | t.equal(response.statusCode, 200, 'should return code 200'); 46 | 47 | server.stop(function() 48 | { 49 | t.ok(true, 'make sure server is closed'); 50 | }); 51 | }); 52 | }); 53 | 54 | }); 55 | }); 56 | 57 | }); 58 | 59 | tape('hapi - handshake - success', function(t) 60 | { 61 | t.plan(4); 62 | 63 | var server = new Hapi.Server() 64 | , fbbot = new Fbbot(common.fbbot) 65 | ; 66 | 67 | // setup hapi server 68 | server.connection({ port: common.server.port }); 69 | 70 | // plug-in fbbot 71 | server.route({ 72 | method: 'GET', 73 | path: common.server.endpoint, 74 | handler: fbbot.requestHandler 75 | }); 76 | server.route({ 77 | method: 'POST', 78 | path: common.server.endpoint, 79 | handler: fbbot.requestHandler 80 | }); 81 | 82 | // start the server 83 | server.start(function() 84 | { 85 | common.sendHandshake('ok', function(error, response) 86 | { 87 | t.error(error, 'GET request should return no error'); 88 | t.equal(response.statusCode, 200, 'GET request should return code 200'); 89 | t.equal(response.body, common.handshakes['ok'].query['hub.challenge'], 'should receive provided challenge back'); 90 | 91 | server.stop(function() 92 | { 93 | t.ok(true, 'make sure server is closed'); 94 | }); 95 | }); 96 | }); 97 | }); 98 | 99 | tape('hapi - handshake - failed', function(t) 100 | { 101 | t.plan(4); 102 | 103 | var server = new Hapi.Server() 104 | , fbbot = new Fbbot(common.fbbot) 105 | ; 106 | 107 | // setup hapi server 108 | server.connection({ port: common.server.port }); 109 | 110 | // plug-in fbbot 111 | server.route({ 112 | method: 'GET', 113 | path: common.server.endpoint, 114 | handler: fbbot.requestHandler 115 | }); 116 | server.route({ 117 | method: 'POST', 118 | path: common.server.endpoint, 119 | handler: fbbot.requestHandler 120 | }); 121 | 122 | // start the server 123 | server.start(function() 124 | { 125 | common.sendHandshake('bad', function(error, response) 126 | { 127 | t.error(error, 'GET request should return no error'); 128 | t.equal(response.statusCode, 400, 'GET request should return code 400'); 129 | t.equal(response.body, common.handshakes['bad'].error, 'should received error message'); 130 | 131 | server.stop(function() 132 | { 133 | t.ok(true, 'make sure server is closed'); 134 | }); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /test/test-send.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | , common = require('./common.js') 3 | , Fbbot = require('../') 4 | ; 5 | 6 | // reusable attachment 7 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference 8 | // { 9 | // "recipient_id": "USER_ID", 10 | // "message_id": "mid.1473372944816:94f72b88c597657974", 11 | // "attachment_id": "1745504518999123" 12 | // } 13 | 14 | // regular response 15 | // { 16 | // "recipient_id": "1008372609250235", 17 | // "message_id": "mid.1456970487936:c34767dfe57ee6e339" 18 | // } 19 | 20 | 21 | common.iterateSendings(function(sending, handle, callback) 22 | { 23 | var type = handle.split('-')[0]; 24 | 25 | test.test('send ' + handle + ' with ' + Object.keys(sending.arguments).join(', ') + ' arguments', function(t) 26 | { 27 | // three checks for erroneous requests, and six checks for successful requests 28 | // with addition or expected extra tests 29 | t.plan((sending.error ? 3 : 6) + (sending.expectedTests || 0) + (sending.count ? sending.count.expected : 0)); 30 | 31 | // Id takes over phone_humber 32 | var expectedPhone = sending.arguments.user['phone_number'] 33 | , expectedId = sending.arguments.user.id || (expectedPhone ? null : sending.arguments.user) 34 | ; 35 | 36 | common.startApiServer(function(request, respond) 37 | { 38 | t.equal(request.query.access_token, common.fbbot.pageAccessToken, 'should supply proper access token'); 39 | t.deepEqual(request.body, sending.expected, 'expects to have proper payload for message type: ' + type); 40 | respond(sending.response); 41 | }, 42 | function(fbbotOptions, done) 43 | { 44 | var args = [] 45 | , fbbot 46 | ; 47 | 48 | // custom per test endpoint 49 | if (sending.endpoint) 50 | { 51 | fbbotOptions.apiUrl = sending.endpoint; 52 | } 53 | 54 | fbbot = new Fbbot(fbbotOptions); 55 | 56 | t.equal(fbbot.options.apiUrl, fbbotOptions.apiUrl + fbbotOptions.pageAccessToken, 'respect custom apiUrl and augment it with correct token'); 57 | 58 | if (sending.arguments.user) 59 | { 60 | args.push(sending.arguments.user); 61 | } 62 | 63 | if (sending.arguments.type) 64 | { 65 | args.push(fbbot[sending.arguments.type] || sending.arguments.type); // e.g. fbbot['MARK_SEEN'] or 'custom_thing' 66 | } 67 | 68 | if (sending.arguments.data) 69 | { 70 | args.push(sending.arguments.data); 71 | } 72 | 73 | // count events 74 | if (sending.count) 75 | { 76 | fbbot.on(sending.count.hook, function(payload) 77 | { 78 | t.equal(typeof payload, 'object', 'expect event ' + sending.count.hook + ' payload to be object'); 79 | }); 80 | } 81 | 82 | // check events 83 | fbbot.on('send.message', function(data) 84 | { 85 | if (expectedId) 86 | { 87 | t.equal(data.parent.recipient.id, expectedId, 'should have recipient id available via linked parent object'); 88 | } 89 | else 90 | { 91 | t.equal(data.parent.recipient['phone_number'], expectedPhone, 'should have recipient phone_number available via linked parent object'); 92 | } 93 | }); 94 | 95 | fbbot.send.apply(fbbot, args.concat(function(error, result) 96 | { 97 | if (sending.error) 98 | { 99 | t.ok(error.message.match(sending.error), 'expect to error with message: ' + sending.error); 100 | } 101 | else 102 | { 103 | t.error(error, 'should result in no error'); 104 | } 105 | 106 | t.deepEqual(result, sending.response, 'expect to pass response all the way through'); 107 | 108 | done(callback); 109 | })); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /lib/request.js: -------------------------------------------------------------------------------- 1 | var once = require('once') 2 | , merge = require('deeply') 3 | , hyperquest = require('hyperquest') 4 | , defaults = {method: 'POST', headers: {'content-type': 'application/json'}} 5 | ; 6 | 7 | module.exports = request; 8 | module.exports.send = send; 9 | 10 | /** 11 | * Wraps hyperquest to provide extra convenience 12 | * with handling responses 13 | * 14 | * @param {string} url - url to request 15 | * @param {object} [options] - request options 16 | * @param {function} callback - invoked on response 17 | * @returns {stream.Duplex} - request stream 18 | */ 19 | function request(url, options, callback) 20 | { 21 | var connection; 22 | 23 | if (typeof options == 'function') 24 | { 25 | callback = options; 26 | options = {}; 27 | } 28 | 29 | connection = hyperquest(url, options, once(function(error, response) 30 | { 31 | if (error) 32 | { 33 | callback(error, response); 34 | return; 35 | } 36 | 37 | // set body 38 | response.body = ''; 39 | 40 | // accumulate response body 41 | response.on('data', function(data) 42 | { 43 | response.body += data.toString(); 44 | }); 45 | 46 | response.on('end', function() 47 | { 48 | callback(null, response); 49 | }); 50 | })); 51 | 52 | return connection; 53 | } 54 | 55 | // curl -X POST -H "Content-Type: application/json" -d '{ 56 | // "recipient":{ 57 | // "id":"USER_ID" 58 | // }, 59 | // "sender_action":"typing_on" 60 | // }' "https://graph.facebook.com/v2.6/me/messages?access_token=PAGE_ACCESS_TOKEN" 61 | 62 | // curl -X POST -H "Content-Type: application/json" -d '{ 63 | // "recipient":{ 64 | // "id":"USER_ID" 65 | // }, 66 | // "message":{ 67 | // "attachment":{ 68 | // "type":"image", 69 | // "payload":{ 70 | // "url":"https://petersapparel.com/img/shirt.png" 71 | // } 72 | // } 73 | // } 74 | // }' "https://graph.facebook.com/v2.6/me/messages?access_token=PAGE_ACCESS_TOKEN" 75 | 76 | /** 77 | * Sends provided payload to the FB Graph API 78 | * and passes parsed result to the provided callback 79 | * 80 | * @this Fbbot# 81 | * @param {object} payload - payload to send 82 | * @param {object} [options] - custom transport options 83 | * @param {function} callback - invoked with the response 84 | */ 85 | function send(payload, options, callback) 86 | { 87 | var body, reqOptions = {}; 88 | 89 | if (typeof options == 'function') 90 | { 91 | callback = options; 92 | options = {}; 93 | } 94 | 95 | body = JSON.stringify(payload); 96 | 97 | reqOptions['headers'] = {'content-length': Buffer.byteLength(body, 'utf8')}; 98 | reqOptions['timeout'] = this.options.timeout; 99 | 100 | options = merge(defaults, reqOptions, options); 101 | 102 | request(this.options.apiUrl, options, function(error, response) 103 | { 104 | if (error) 105 | { 106 | this.logger.error({message: 'Unable to send request to FB API', error: error, response: response, url: this.options.apiUrl.replace(this.credentials.token, '[hidden token]'), body: body, options: options}); 107 | callback(error, response); 108 | return; 109 | } 110 | 111 | callback(null, parseJson.call(this, response.body)); 112 | }.bind(this)).write(body); 113 | } 114 | 115 | /** 116 | * Parses provided JSON payload into object 117 | * 118 | * @private 119 | * @this Fbbot# 120 | * @param {string} payload - JSON string to parse 121 | * @returns {object|undefined} - parsed object or `undefined` if unable to parse 122 | */ 123 | function parseJson(payload) 124 | { 125 | var data; 126 | 127 | try 128 | { 129 | data = JSON.parse(payload); 130 | } 131 | catch (e) 132 | { 133 | this.logger.error({message: 'Unable to parse provided JSON', error: e, payload: payload}); 134 | } 135 | 136 | return data; 137 | } 138 | -------------------------------------------------------------------------------- /test/shared-tests.js: -------------------------------------------------------------------------------- 1 | var payloads = {}; 2 | 3 | module.exports = { 4 | perRequest : perRequest, 5 | perMessage : perMessage 6 | }; 7 | 8 | /** 9 | * Creates listeners with asserts on per request basis 10 | * 11 | * @param {object} fbbot - fbbot instance 12 | * @param {string} payloadType - payload's type to test 13 | * @param {object} request - expected request object 14 | * @param {object} t - test suite instance 15 | * @param {function} callback - invoked after all tests for this payload type is done 16 | */ 17 | function perRequest(fbbot, payloadType, request, t, callback) 18 | { 19 | payloads[payloadType] = {}; 20 | 21 | // use middleware 22 | fbbot.use(function(payload, cb) 23 | { 24 | t.deepEquals(payload, request.body, 'global middleware should receive full payload, and it should not be modified at this point'); 25 | cb(null, payload); 26 | }); 27 | 28 | // [payloadType] middleware 29 | fbbot.use(payloadType, function(payload, cb) 30 | { 31 | var expected = payloads[payloadType]['middleware'].shift(); 32 | 33 | t.deepEquals(payload, expected[payloadType], 'middleware should receive ' + payloadType + ' object, augmented by built-in middleware'); 34 | t.deepEquals(payload.messaging, expected, payloadType + ' object has reference to the parent object, as prototyte'); 35 | t.notOk(payload.hasOwnProperty('messaging'), 'parent property does not pollute ' + payloadType + ' object'); 36 | cb(null, payload); 37 | }); 38 | 39 | // [payloadType] event 40 | fbbot.on(payloadType, function(payload, send) 41 | { 42 | var expected = payloads[payloadType]['event'].shift(); 43 | 44 | t.equal(typeof send, 'function', 'expect send function for each message event'); 45 | 46 | t.deepEquals(payload, expected[payloadType], 'event should receive ' + payloadType + ' object'); 47 | t.deepEquals(payload.messaging, expected, payloadType + ' event object has reference to the parent object, as prototyte'); 48 | t.notOk(payload.hasOwnProperty('messaging'), 'parent property does not pollute ' + payloadType + ' event object'); 49 | t.deepEqual(payload, payload.messaging[payloadType], 'keeps reference itself (kind of) through parent object'); 50 | 51 | if (payloadType == 'message' && expected[payloadType].type == 'quick_reply') 52 | { 53 | t.equal(typeof payload.quick_reply.payload, 'object', 'should parse quick_reply payload into an object'); 54 | } 55 | 56 | if (payloadType == 'postback') 57 | { 58 | t.equal(typeof payload.payload, 'object', 'should parse postback payload into an object'); 59 | } 60 | }); 61 | 62 | fbbot.on(payloadType + '.non-existent-type', function() 63 | { 64 | t.fail('should not receive anything for non existent type'); 65 | }); 66 | 67 | fbbot.on('end', function(error) 68 | { 69 | t.error(error, 'should finish without errors'); 70 | callback(null); 71 | }); 72 | } 73 | 74 | /** 75 | * Stores expected object for middleware and event handlers 76 | * 77 | * @param {object} fbbot - fbbot instance 78 | * @param {string} payloadType - payload's type to test 79 | * @param {object} envelop - expected envelop (message with meta-data) object 80 | * @param {object} t - test suite instance 81 | */ 82 | function perMessage(fbbot, payloadType, envelop, t) 83 | { 84 | (payloads[payloadType]['middleware'] = payloads[payloadType]['middleware'] || []).push(envelop); 85 | (payloads[payloadType]['event'] = payloads[payloadType]['event'] || []).push(envelop); 86 | 87 | // message specific check 88 | if (envelop.message && envelop.message.type) 89 | { 90 | if (!payloads[payloadType][envelop.message.type]) 91 | { 92 | payloads[payloadType][envelop.message.type] = []; 93 | 94 | fbbot.on('message.' + envelop.message.type, function(message) 95 | { 96 | var expected = payloads[payloadType][message.type].shift(); 97 | 98 | t.deepEquals(message, expected.message, 'message.' + expected.message.type + ' event should receive message object'); 99 | }); 100 | } 101 | 102 | payloads[payloadType][envelop.message.type].push(envelop); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/quick_replies.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "count": { 4 | "expected": 3, 5 | "hook": "send.quick_reply" 6 | }, 7 | "arguments": { 8 | "user": "10157033896470341", 9 | "type": "QUICK_REPLIES", 10 | "data": { 11 | "text": "Pick a color:", 12 | "quick_replies": [{ 13 | "content_type": "text", 14 | "title": "Red", 15 | "payload": "DEVELOPER_DEFINED_PAYLOAD_FOR_PICKING_RED" 16 | }, { 17 | "content_type": "text", 18 | "title": "Green", 19 | "payload": { 20 | "custom": "payload", 21 | "for": "quick_reply" 22 | } 23 | }, { 24 | "content_type": "location" 25 | }] 26 | } 27 | }, 28 | 29 | "expected": { 30 | "recipient": { 31 | "id": "10157033896470341" 32 | }, 33 | "message": { 34 | "text": "Pick a color:", 35 | "quick_replies": [{ 36 | "content_type": "text", 37 | "title": "Red", 38 | "payload": "DEVELOPER_DEFINED_PAYLOAD_FOR_PICKING_RED" 39 | }, { 40 | "content_type": "text", 41 | "title": "Green", 42 | "payload": "{\"custom\":\"payload\",\"for\":\"quick_reply\"}" 43 | }, { 44 | "content_type": "location" 45 | }] 46 | } 47 | }, 48 | 49 | "response": { 50 | "recipient_id": "10157033896470341", 51 | "message_id": "mid.1456970487939:c34767dfe57ee6e339" 52 | } 53 | }, 54 | 55 | { 56 | "meta": { 57 | "description": "It should truncate extra options" 58 | }, 59 | "count": { 60 | "expected": 10, 61 | "hook": "send.quick_reply" 62 | }, 63 | "arguments": { 64 | "user": "10157033896470342", 65 | "type": "QUICK_REPLIES", 66 | "data": { 67 | "text": "Pick a number:", 68 | "quick_replies": [{ 69 | "content_type": "text", 70 | "title": "#1", 71 | "payload": "{one: 1}" 72 | }, { 73 | "content_type": "text", 74 | "title": "#2", 75 | "payload": "{two: 2}" 76 | }, { 77 | "content_type": "text", 78 | "title": "#3", 79 | "payload": "{three: 3}" 80 | }, { 81 | "content_type": "text", 82 | "title": "#4", 83 | "payload": "{four: 4}" 84 | }, { 85 | "content_type": "text", 86 | "title": "#5", 87 | "payload": "{five: 5}" 88 | }, { 89 | "content_type": "text", 90 | "title": "#6", 91 | "payload": "{six: 6}" 92 | }, { 93 | "content_type": "text", 94 | "title": "#7", 95 | "payload": "{seven: 7}" 96 | }, { 97 | "content_type": "text", 98 | "title": "#8", 99 | "payload": "{eight: 8}" 100 | }, { 101 | "content_type": "text", 102 | "title": "#9", 103 | "payload": "{nine: 9}" 104 | }, { 105 | "content_type": "text", 106 | "title": "#10", 107 | "payload": "{ten: 10}" 108 | }, { 109 | "content_type": "text", 110 | "title": "#11", 111 | "payload": "{eleven: 11}" 112 | }, { 113 | "content_type": "text", 114 | "title": "#12", 115 | "payload": "{twelve: 12}" 116 | }] 117 | } 118 | }, 119 | 120 | "expected": { 121 | "recipient": { 122 | "id": "10157033896470342" 123 | }, 124 | "message": { 125 | "text": "Pick a number:", 126 | "quick_replies": [{ 127 | "content_type": "text", 128 | "title": "#1", 129 | "payload": "{one: 1}" 130 | }, { 131 | "content_type": "text", 132 | "title": "#2", 133 | "payload": "{two: 2}" 134 | }, { 135 | "content_type": "text", 136 | "title": "#3", 137 | "payload": "{three: 3}" 138 | }, { 139 | "content_type": "text", 140 | "title": "#4", 141 | "payload": "{four: 4}" 142 | }, { 143 | "content_type": "text", 144 | "title": "#5", 145 | "payload": "{five: 5}" 146 | }, { 147 | "content_type": "text", 148 | "title": "#6", 149 | "payload": "{six: 6}" 150 | }, { 151 | "content_type": "text", 152 | "title": "#7", 153 | "payload": "{seven: 7}" 154 | }, { 155 | "content_type": "text", 156 | "title": "#8", 157 | "payload": "{eight: 8}" 158 | }, { 159 | "content_type": "text", 160 | "title": "#9", 161 | "payload": "{nine: 9}" 162 | }, { 163 | "content_type": "text", 164 | "title": "#10", 165 | "payload": "{ten: 10}" 166 | }] 167 | } 168 | }, 169 | 170 | "response": { 171 | "recipient_id": "10157033896470342", 172 | "message_id": "mid.1456970487939:c34767dfe57ee6e340" 173 | } 174 | } 175 | ] 176 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var util = require('util') 2 | , events = require('events') 3 | , bole = require('bole') 4 | , merge = require('deeply') 5 | , agnostic = require('agnostic') 6 | // , cache = require('async-cache') 7 | // , stringify = require('fast-safe-stringify') 8 | , verify = require('./lib/verify_endpoint.js') 9 | , send = require('./lib/send.js') 10 | , middleware = require('./lib/middleware.js') 11 | , inMiddleware = require('./incoming/index.js') 12 | , outMiddleware = require('./outgoing/index.js') 13 | , Traverse = require('./traverse/index.js') 14 | , inTraverse = require('./traverse/incoming.js') 15 | , outTraverse = require('./traverse/outgoing.js') 16 | ; 17 | 18 | module.exports = Fbbot; 19 | util.inherits(Fbbot, events.EventEmitter); 20 | 21 | // defaults 22 | Fbbot.defaults = { 23 | bodyMaxLength: '1mb', 24 | bodyEncoding : 'utf8', 25 | timeout : 5000, 26 | apiUrl : 'https://graph.facebook.com/v2.6/me/messages?access_token=' 27 | }; 28 | 29 | // expose logger 30 | Fbbot.logger = bole; 31 | 32 | // -- public methods 33 | 34 | // registers middleware 35 | Fbbot.prototype.use = middleware.use; 36 | 37 | // send message to a user 38 | Fbbot.prototype.send = send; 39 | // add message types to the top level 40 | // shouldn't be conflicts since types are all uppercase 41 | util._extend(Fbbot.prototype, send.types); 42 | 43 | // -- private methods 44 | 45 | // no need to expose middleware handler as public api 46 | Fbbot.prototype._run = middleware.run; 47 | // verifies endpoint to facebook 48 | Fbbot.prototype._verifyEndpoint = verify; 49 | 50 | /** 51 | * Fbbot instance constructor 52 | * 53 | * @this Fbbot# 54 | * @param {object} options - list of customization parameters 55 | * @constructor 56 | */ 57 | function Fbbot(options) 58 | { 59 | if (!(this instanceof Fbbot)) return new Fbbot(options); 60 | 61 | /** 62 | * Custom options per instance 63 | * @type {object} 64 | */ 65 | this.options = merge(Fbbot.defaults, options || {}); 66 | 67 | /** 68 | * Store credentials 69 | * @type {object} 70 | */ 71 | this.credentials = 72 | { 73 | // keep simple naming for internal reference 74 | token : this.options.pageAccessToken || this.options.token, 75 | secret: this.options.verifyToken || this.options.secret 76 | }; 77 | 78 | if (!this.credentials.token || !this.credentials.secret) 79 | { 80 | throw new Error('Both `token` (pageAccessToken) and `secret` (verifyToken) are required'); 81 | } 82 | 83 | // compose apiUrl 84 | this.options.apiUrl += this.credentials.token; 85 | 86 | /** 87 | * expose logger 88 | * @type {object} 89 | */ 90 | this.logger = options.logger || bole(options.name || 'fbbot'); 91 | 92 | /** 93 | * middleware storage (per event) 94 | * @type {object} 95 | * @private 96 | */ 97 | this._stack = {}; 98 | 99 | /** 100 | * lock-in public methods 101 | * wrap `_handler` with agnostic to accommodate different http servers 102 | * @type {function} 103 | */ 104 | this.requestHandler = agnostic(this._handler.bind(this)); 105 | 106 | // attach lifecycle filters 107 | inMiddleware(this); 108 | outMiddleware(this); 109 | 110 | /** 111 | * create incoming traverse paths 112 | * @type {Traverse} 113 | * @private 114 | */ 115 | this._incoming = new Traverse(inTraverse.steps, { 116 | entry : middleware.entryPoint, 117 | middleware: inTraverse.middleware.bind(this), 118 | emitter : inTraverse.emitter.bind(this), 119 | prefix : inTraverse.prefix 120 | }); 121 | // wrap linkParent method 122 | this._incoming.linkParent = inTraverse.linkParent.bind(null, this._incoming.linkParent); 123 | 124 | /** 125 | * create outgoing traverse paths 126 | * @type {Traverse} 127 | * @private 128 | */ 129 | this._outgoing = new Traverse(outTraverse.steps, { 130 | middleware: outTraverse.middleware.bind(this), 131 | emitter : outTraverse.emitter.bind(this), 132 | prefix : outTraverse.prefix 133 | }); 134 | // wrap linkParent method 135 | this._outgoing.linkParent = outTraverse.linkParent.bind(null, this._outgoing.linkParent); 136 | } 137 | 138 | /** 139 | * HTTP requests handler, could be used as middleware 140 | * 141 | * @private 142 | * @this Fbbot# 143 | * @param {EventEmitter} request - incoming http request object 144 | * @param {function} respond - http response function 145 | */ 146 | Fbbot.prototype._handler = function(request, respond) 147 | { 148 | this.logger.info(request); 149 | 150 | // GET request handling 151 | if (request.method == 'GET') 152 | { 153 | this._verifyEndpoint(request, respond); 154 | return; 155 | } 156 | 157 | // as per facebook doc – respond as soon as non-humanly possible, always respond with 200 OK 158 | // https://developers.facebook.com/docs/messenger-platform/webhook-reference#response 159 | respond(200); 160 | 161 | this._incoming.traverse(request.body, function(err, payload) 162 | { 163 | this.emit('end', err, payload); 164 | }.bind(this)); 165 | }; 166 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | var qs = require('querystring') 2 | , path = require('path') 3 | , http = require('http') 4 | , util = require('util') 5 | , glob = require('glob') 6 | , merge = require('deeply') 7 | , assert = require('assert') 8 | , asynckit = require('asynckit') 9 | , agnostic = require('agnostic') 10 | , request = require('../lib/request.js') 11 | , shared = require('./shared-tests.js') 12 | // turn on array combination 13 | , mergeArrays = { 14 | useCustomAdapters: merge.behaviors.useCustomAdapters, 15 | 'array' : merge.adapters.arraysCombine 16 | } 17 | ; 18 | 19 | // expose suite methods 20 | var common = module.exports = 21 | { 22 | server: { 23 | endpoint: '/webhook', 24 | port : 56789 25 | }, 26 | 27 | api: 28 | { 29 | port: 45678 30 | }, 31 | 32 | // fbbot instance 33 | fbbot: { 34 | pageAccessToken: 'ESDQmINhZC1osBACNOI8aQWoOYR4vsMzxZAyeW8baL0xUFdmu123McxihZAZAMHZBhiubQWE0kRIzoA7RTVflZAOmAlBMNUhRdoXoQo0UGocJZAJkijqr4PwJ878onSJu0oigzaBEQCfkAPR2PAIXZB8qLjuegan7qTDl5cmntoBqxOwABAB', 35 | verifyToken : 'wear sunscreen' 36 | }, 37 | 38 | // shared methods 39 | setupTests : setupTests, 40 | iterateRequests: iterateRequests, 41 | iterateSendings: iterateSendings, 42 | sendRequest : sendRequest, 43 | sendHandshake : sendHandshake, 44 | startApiServer : startApiServer, 45 | 46 | // test handshake 47 | handshakes: { 48 | // sends proper fields 49 | 'ok': { 50 | query: { 51 | 'hub.verify_token': 'wear sunscreen', 52 | 'hub.challenge' : '' + Math.random() 53 | } 54 | }, 55 | 56 | // sends unacceptable data 57 | 'bad': { 58 | query: { 59 | 'hub.verify_token': 'XXXXX-wrong-token-XXXXX' 60 | }, 61 | error: 'Error, wrong validation token' 62 | } 63 | }, 64 | 65 | // test requests 66 | requests: {}, 67 | 68 | // test api calls 69 | sendings: {} 70 | }; 71 | 72 | // load request fixtures 73 | glob.sync(path.join(__dirname, './fixtures/incoming/*.json')).forEach(function(file) 74 | { 75 | var name = path.basename(file, '.json'); 76 | 77 | common.requests[name] = require(file); 78 | 79 | // augment expected 80 | if ('+expected' in common.requests[name]) 81 | { 82 | common.requests[name].expected = merge.call(mergeArrays, common.requests[name].body, common.requests[name]['+expected']); 83 | } 84 | }); 85 | 86 | // load sending fixtures 87 | glob.sync(path.join(__dirname, './fixtures/outgoing/*.json')).forEach(function(file) 88 | { 89 | var name = path.basename(file, '.json'); 90 | common.sendings[name] = require(file); 91 | }); 92 | 93 | /** 94 | * Adds tests to the provided instance 95 | * 96 | * @param {object} fbbot - fbbot instance 97 | * @param {string} payloadType - payload's type to test 98 | * @param {object} subject - test request subject 99 | * @param {object} t - test suite instance 100 | * @param {function} callback - invoked after all tests for this payload type is done 101 | */ 102 | function setupTests(fbbot, payloadType, subject, t, callback) 103 | { 104 | // run request wide tests 105 | shared.perRequest(fbbot, payloadType, subject, t, callback); 106 | 107 | // iterate over entries-messages 108 | subject.expected.entry.forEach(function(entry) 109 | { 110 | entry.messaging.forEach(function(message) 111 | { 112 | shared.perMessage(fbbot, payloadType, message, t); 113 | }); 114 | }); 115 | } 116 | 117 | /** 118 | * Iterates over requests asynchronously 119 | * 120 | * @param {function} iterator - iterator function 121 | */ 122 | function iterateRequests(iterator) 123 | { 124 | asynckit.serial(common.requests, iterator, function noop(err){ assert.ifError(err, 'expects all requests to finish without errors'); }); 125 | } 126 | 127 | /** 128 | * Iterates over sendings asynchronously 129 | * 130 | * @param {function} iterator - iterator function 131 | */ 132 | function iterateSendings(iterator) 133 | { 134 | asynckit.serial(common.sendings, function(item, type, callback) 135 | { 136 | // each item is an array by itself 137 | asynckit.serial(item, function(test, id, cb) 138 | { 139 | // differentiate elements within same type 140 | iterator(test, type + '-' + id, cb); 141 | }, callback); 142 | 143 | }, function noop(err){ assert.ifError(err, 'expects all sendings to finish without errors'); }); 144 | } 145 | 146 | /** 147 | * Sends request with specified method, headers and body 148 | * to the simulated backend 149 | * 150 | * @param {string} type - type of the request 151 | * @param {function} callback - invoke on response from the simulated server 152 | */ 153 | function sendRequest(type, callback) 154 | { 155 | if (!common.requests[type]) throw new Error('Unsupported request type: ' + type + '.'); 156 | 157 | var url = 'http://localhost:' + common.server.port + common.server.endpoint; 158 | var body = JSON.stringify(common.requests[type].body); 159 | 160 | var options = { 161 | method : 'POST', 162 | headers : common.requests[type].headers 163 | }; 164 | options.headers['content-length'] = body.length; 165 | 166 | request(url, options, callback).write(body); 167 | } 168 | 169 | /** 170 | * Sends handshake based on the requested typed 171 | * 172 | * @param {string} type - handshake type, `ok` or `bad` 173 | * @param {function} callback - invoked on response 174 | */ 175 | function sendHandshake(type, callback) 176 | { 177 | if (!common.handshakes[type]) throw new Error('Unsupported handshake type: ' + type + '.'); 178 | 179 | var url = 'http://localhost:' + common.server.port + common.server.endpoint; 180 | var query = qs.stringify(common.handshakes[type].query); 181 | 182 | request(url + '?' + query, callback); 183 | } 184 | 185 | /** 186 | * Start API server with common port and augmented request handler 187 | * 188 | * @param {function} handler - request handler 189 | * @param {function} callback - invoked after server has started 190 | */ 191 | function startApiServer(handler, callback) 192 | { 193 | var server = http.createServer(agnostic(handler)).listen(common.api.port, function() 194 | { 195 | // supply endpoint to the consumer 196 | var tailoredOptions = util._extend(common.fbbot, {apiUrl: 'http://localhost:' + common.api.port + '/?access_token='}); 197 | callback(tailoredOptions, server.close.bind(server)); 198 | }); 199 | } 200 | -------------------------------------------------------------------------------- /traverse/index.js: -------------------------------------------------------------------------------- 1 | var util = require('util') 2 | , events = require('events') 3 | , typeOf = require('precise-typeof') 4 | , find = require('array-find') 5 | , asynckit = require('asynckit') 6 | , logger = require('bole')('traverse') 7 | ; 8 | 9 | module.exports = Traverse; 10 | util.inherits(Traverse, events.EventEmitter); 11 | 12 | /** 13 | * Traverse constructor 14 | * 15 | * @constructor 16 | * @param {object} steps - list of steps to follow 17 | * @param {object} [options] - custom options 18 | */ 19 | function Traverse(steps, options) 20 | { 21 | // always create new instance 22 | if (!(this instanceof Traverse)) return new Traverse(steps, options); 23 | 24 | // make options optional :) 25 | options = options || {}; 26 | 27 | // store the main thing 28 | this.steps = steps; 29 | 30 | // custom logger instance 31 | // expected bunyan-compatiable API 32 | this.logger = options.logger || logger; 33 | 34 | // custom entry point, otherwise empty string will be used 35 | this.entry = options.entry || ''; 36 | // allow prefixed handles 37 | this.prefix = options.prefix || null; 38 | 39 | // if no middleware controller provided, just make it pass though untouched 40 | this.middleware = options.middleware || this.middlewarePassthru; 41 | 42 | // use custom emitter or built-in 43 | this.emitter = options.emitter || this.emit; 44 | } 45 | 46 | /** 47 | * default passthru middleware 48 | * 49 | * @param {string} branch - handle of the current branch 50 | * @param {object} payload - branch's data object 51 | * @param {function} callback - invoked after middleware is done 52 | */ 53 | Traverse.prototype.middlewarePassthru = function(branch, payload, callback) 54 | { 55 | callback(null, payload); 56 | }; 57 | 58 | /** 59 | * Traverses asynchronously over provided structure 60 | * 61 | * @param {string} [branch] - branch name of the payload, skipped for the top level one 62 | * @param {object|array} payload - initial payload object 63 | * @param {function} callback - invoked upon error or when all entries were processed 64 | */ 65 | Traverse.prototype.traverse = function(branch, payload, callback) 66 | { 67 | // check for optional argument 68 | if (typeOf(branch) == 'object') 69 | { 70 | callback = payload; 71 | payload = branch; 72 | branch = this.entry; 73 | } 74 | 75 | // allow it to handle arrays as entry payload 76 | if (typeOf(payload) == 'array') 77 | { 78 | // process all entries in parallel 79 | // supposedly they are unrelated to each other on this level of details 80 | asynckit.parallel(payload, this.traverse.bind(this, branch), callback); 81 | return; 82 | } 83 | 84 | this.logger.debug({message: 'Traversing payload under branch handle', branch: branch, payload: payload}); 85 | 86 | if (typeOf(payload) != 'object') 87 | { 88 | this.logger.error({message: 'payload is not an Object', branch: branch, payload: payload}); 89 | callback(new Error('payload <' + branch + '> is not an Object')); 90 | return; 91 | } 92 | 93 | // invoke middleware controller with custom context 94 | this.middleware(this.prefixedBranch(branch), payload, function(error, resolvedPayload) 95 | { 96 | var nextStep = this.steps[branch] 97 | , nextStepPayload 98 | ; 99 | 100 | if (error) 101 | { 102 | this.logger.error({message: 'middleware ran into an error', error: error, branch: branch, prefixed: this.prefixedBranch(branch), nextStep: nextStep, originalPayload: payload, resolvedPayload: resolvedPayload}); 103 | callback(error, resolvedPayload); 104 | return; 105 | } 106 | 107 | if (typeOf(resolvedPayload) != 'object' && typeOf(resolvedPayload) != 'array') 108 | { 109 | this.logger.error({message: 'payload after middleware is not an Object or an Array', branch: branch, prefixed: this.prefixedBranch(branch), nextStep: nextStep, payload: resolvedPayload}); 110 | callback(new Error('payload after <' + branch + '> middleware is not an Object or an Array')); 111 | return; 112 | } 113 | 114 | this.logger.info({message: 'Successfully executed all middleware for current branch', branch: branch, prefixed: this.prefixedBranch(branch), payload: resolvedPayload}); 115 | 116 | // done with all the error checks 117 | // notify listeners 118 | this.emitter(this.prefixedBranch(branch), resolvedPayload); 119 | 120 | // conditional next step 121 | // this order allows for custom function to return array of possible next steps 122 | if (typeOf(nextStep) == 'function') 123 | { 124 | nextStep = nextStep(resolvedPayload); 125 | } 126 | 127 | // pick first matching, out of multi-value 128 | if (typeOf(nextStep) == 'array') 129 | { 130 | nextStep = find(nextStep, function(key){ return (key in resolvedPayload); }); 131 | } 132 | 133 | if (!nextStep || !(nextStep in resolvedPayload)) 134 | { 135 | this.logger.debug({message: 'no further steps possible, stop right here', branch: branch, prefixed: this.prefixedBranch(branch), nextStep: nextStep, payload: resolvedPayload}); 136 | callback(null, resolvedPayload); 137 | return; 138 | } 139 | 140 | // proceed to the next step 141 | nextStepPayload = this.linkParent(branch, resolvedPayload, resolvedPayload[nextStep]); 142 | 143 | // do another round 144 | this.traverse.call(this, nextStep, nextStepPayload, function(err, nextStepResolvedPayload) 145 | { 146 | if (err) 147 | { 148 | callback(err, nextStepResolvedPayload); 149 | return; 150 | } 151 | 152 | // re-construct original structure with updated data 153 | resolvedPayload[nextStep] = nextStepResolvedPayload; 154 | callback(null, resolvedPayload); 155 | }); 156 | }.bind(this)); 157 | }; 158 | 159 | /** 160 | * Links parent object to the next-step object 161 | * 162 | * @param {string} branch - current branch of the payload 163 | * @param {object} parentPayload - parent payload object 164 | * @param {mixed} nextPayload - next step payload object 165 | * @returns {mixed} - augmented next step payload object 166 | */ 167 | Traverse.prototype.linkParent = function(branch, parentPayload, nextPayload) 168 | { 169 | var parent = {parent: parentPayload}; 170 | 171 | // keep "friendly" reference to the parent object 172 | parent[branch] = parentPayload; 173 | 174 | // inject `parent` object into the prototype chain 175 | parent.__proto__ = nextPayload.__proto__; 176 | nextPayload.__proto__ = parent; 177 | 178 | return nextPayload; 179 | }; 180 | 181 | /** 182 | * Resolves branch name with instance prefix 183 | * 184 | * @param {string} branch - handle of the branch 185 | * @returns {string} - reslved branch name 186 | */ 187 | Traverse.prototype.prefixedBranch = function(branch) 188 | { 189 | return [this.prefix, branch].join('.').replace(/^\.+|\.+$/g, ''); 190 | }; 191 | -------------------------------------------------------------------------------- /lib/send.js: -------------------------------------------------------------------------------- 1 | var toCamelCase = require('to-camel-case') 2 | , request = require('./request.js') 3 | , templates = 4 | { 5 | generic: require('../templates/generic.js'), 6 | button: require('../templates/button.js'), 7 | receipt: require('../templates/receipt.js') 8 | } 9 | ; 10 | 11 | // API 12 | module.exports = send; 13 | module.exports.factory = factory; 14 | 15 | // predefined message types 16 | var types = module.exports.types = 17 | { 18 | // default type, doesn't define any specific message type 19 | // requires all the fields in the payload 20 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference 21 | MESSAGE: 'message', 22 | 23 | // sender actions types 24 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/sender-actions 25 | MARK_SEEN: 'mark_seen', 26 | TYPING_ON: 'typing_on', 27 | TYPING_OFF: 'typing_off', 28 | 29 | // regular text message 30 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/text-message 31 | TEXT: 'text', 32 | 33 | // single image attachment 34 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/image-attachment 35 | IMAGE: 'image', 36 | 37 | // audio file attachment 38 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/audio-attachment 39 | AUDIO: 'audio', 40 | 41 | // video file attachment 42 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/video-attachment 43 | VIDEO: 'video', 44 | 45 | // generic file attachment 46 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/file-attachment 47 | FILE: 'file', 48 | 49 | // generic template 50 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/generic-template 51 | GENERIC: 'generic', 52 | 53 | // button template 54 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/button-template 55 | BUTTON: 'button', 56 | 57 | // receipt template 58 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/receipt-template 59 | RECEIPT: 'receipt', 60 | 61 | // quick replies 62 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/quick-replies 63 | QUICK_REPLIES: 'quick_replies' 64 | }; 65 | 66 | /** 67 | * Sends provided messages to the user, specified by user object or id 68 | * Note: `type` or `data` must be provided 69 | * 70 | * TODO: Add notification types 71 | * TODO: Send multiple messages of different types 72 | * TODO: Support local files upload 73 | * TODO: Add airline templates 74 | * 75 | * @this Fbbot# 76 | * @param {object|string} user - either user object `{id: '...'}` or user id as string 77 | * @param {string} [type] - type/template of the message to send data as 78 | * @param {object|array|string} [data] - data object to send 79 | * @param {function} [callback] - invoked on response from facebook server 80 | */ 81 | function send(user, type, data, callback) 82 | { 83 | var payload; 84 | 85 | // handle optional `data` 86 | if (typeof data == 'function') 87 | { 88 | callback = data; 89 | data = {}; 90 | } 91 | 92 | // handle optional `type` 93 | if (typeof type == 'object') 94 | { 95 | data = type; 96 | type = types.MESSAGE; 97 | } 98 | 99 | // handle optional callback 100 | if (!callback) 101 | { 102 | callback = function(){}; 103 | } 104 | 105 | // if string was provided, assume it's user id 106 | if (typeof user == 'string') 107 | { 108 | user = {id: user}; 109 | } 110 | 111 | // Get only `id` or `phone_number` for the object, 112 | // since it might have other things like `first_name`, `last_name`, etc 113 | payload = {recipient: user.id ? {id: user.id} : {phone_number: user.phone_number}}; 114 | 115 | // everything is a message, except sender_actions 116 | if ([types.MARK_SEEN, types.TYPING_ON, types.TYPING_OFF].indexOf(type) != -1) 117 | { 118 | payload['sender_action'] = type; 119 | } 120 | else if (!(payload['message'] = generateMessage.call(this, type, data))) 121 | { 122 | callback(new Error('Unable to generate message from type ' + type)); 123 | return; 124 | } 125 | 126 | // Run through middleware controller 127 | this._outgoing.traverse(payload, function(error, resolvedPayload) 128 | { 129 | if (error) 130 | { 131 | this.logger.error({message: 'Unsuccessful outgoing middleware termination', error: error, user: user, type: type, data: data, payload: payload}); 132 | callback(error, resolvedPayload); 133 | return; 134 | } 135 | 136 | // send it 137 | request.send.call(this, resolvedPayload, function(sendError, result) 138 | { 139 | if (sendError) 140 | { 141 | this.logger.error({message: 'Unable to send message to user', error: sendError, user: user, type: type, data: data, payload: resolvedPayload}); 142 | callback(sendError, result); 143 | return; 144 | } 145 | 146 | this.logger.info({message: 'Sent message to user', result: result, user: user, type: type}); 147 | this.logger.debug({message: 'Sent message to user', result: result, user: user, type: type, data: data, payload: resolvedPayload}); 148 | 149 | callback(null, result); 150 | }.bind(this)); 151 | 152 | }.bind(this)); 153 | } 154 | 155 | /** 156 | * Generates message from the provided data object 157 | * with respect for the message type 158 | * 159 | * @private 160 | * @this Fbbot# 161 | * @param {string} type - message type 162 | * @param {object} data - message data object 163 | * @returns {object} - generated message object 164 | */ 165 | function generateMessage(type, data) 166 | { 167 | var message; 168 | 169 | switch (type) 170 | { 171 | // default message type, no need to perform special actions, will be sent as is 172 | case types.MESSAGE: 173 | message = data; 174 | break; 175 | 176 | case types.TEXT: 177 | // `text` must be UTF-8 and has a 320 character limit 178 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/text-message 179 | message = {text: data.substr(0, 320)}; 180 | break; 181 | 182 | case types.AUDIO: 183 | case types.FILE: 184 | case types.IMAGE: 185 | case types.VIDEO: 186 | message = {attachment: {type: type, payload: {url: data}}}; 187 | break; 188 | 189 | case types.QUICK_REPLIES: 190 | // `quick_replies` is limited to 10 191 | // https://developers.facebook.com/docs/messenger-platform/send-api-reference/quick-replies 192 | message = {text: data.text, quick_replies: data.quick_replies.slice(0, 10)}; 193 | break; 194 | 195 | case types.GENERIC: 196 | message = templates.generic.call(this, data); 197 | break; 198 | 199 | case types.BUTTON: 200 | message = templates.button.call(this, data); 201 | break; 202 | 203 | case types.RECEIPT: 204 | message = templates.receipt.call(this, data); 205 | break; 206 | 207 | default: 208 | this.logger.error({message: 'Unrecognized message type', type: type, payload: data}); 209 | } 210 | 211 | return message; 212 | } 213 | 214 | /** 215 | * Creates user tailored (per message type) set of send methods 216 | * 217 | * @param {object} context - context to bind to 218 | * @param {object|string} user - user account to tailor to 219 | * @returns {function} - user tailored function 220 | */ 221 | function factory(context, user) 222 | { 223 | var tailored = send.bind(context, user); 224 | 225 | // add custom per type send method 226 | Object.keys(types).forEach(function(type) 227 | { 228 | tailored[toCamelCase(type)] = send.bind(context, user, types[type]); 229 | }); 230 | 231 | return tailored; 232 | } 233 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/message.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "meta": { 4 | "description": "Sending full message object with text" 5 | }, 6 | "arguments": { 7 | "user": "10157033896470455", 8 | "type": "MESSAGE", 9 | "data": { 10 | "text": "hello, world!" 11 | } 12 | }, 13 | 14 | "expected": { 15 | "recipient": { 16 | "id": "10157033896470455" 17 | }, 18 | "message": { 19 | "text": "hello, world!" 20 | } 21 | }, 22 | 23 | "response": { 24 | "recipient_id": "10157033896470455", 25 | "message_id": "mid.1456970487936:c34767dfe57ee6e339" 26 | } 27 | }, 28 | 29 | { 30 | "meta": { 31 | "description": "Sending full message object with image attachment, with extra fields in the user object" 32 | }, 33 | "arguments": { 34 | "user": { 35 | "id": "10157033896470456", 36 | "phone_number": "1234567890", 37 | "name": { 38 | "last": "Smith", 39 | "first": "John" 40 | } 41 | }, 42 | "type": "MESSAGE", 43 | "data": { 44 | "attachment": { 45 | "type": "image", 46 | "payload": { 47 | "url": "https://petersapparel.com/img/shirt.png" 48 | } 49 | } 50 | } 51 | }, 52 | 53 | "expected": { 54 | "recipient": { 55 | "id": "10157033896470456" 56 | }, 57 | "message":{ 58 | "attachment": { 59 | "type": "image", 60 | "payload": { 61 | "url": "https://petersapparel.com/img/shirt.png" 62 | } 63 | } 64 | } 65 | }, 66 | 67 | "response": { 68 | "recipient_id": "10157033896470456", 69 | "message_id": "mid.1456970487937:c34767dfe57ee6e339" 70 | } 71 | }, 72 | 73 | { 74 | "meta": { 75 | "description": "Sending full message object with audio attachment, with out message type argument (fallback to default)" 76 | }, 77 | "arguments": { 78 | "user": { 79 | "id": "10157033896470457" 80 | }, 81 | "data": { 82 | "attachment": { 83 | "type": "audio", 84 | "payload": { 85 | "url": "https://petersapparel.com/bin/clip.mp3" 86 | } 87 | } 88 | } 89 | }, 90 | 91 | "expected": { 92 | "recipient": { 93 | "id": "10157033896470457" 94 | }, 95 | "message":{ 96 | "attachment": { 97 | "type": "audio", 98 | "payload": { 99 | "url": "https://petersapparel.com/bin/clip.mp3" 100 | } 101 | } 102 | } 103 | }, 104 | 105 | "response": { 106 | "recipient_id": "10157033896470457", 107 | "message_id": "mid.1456970487938:c34767dfe57ee6e339" 108 | } 109 | }, 110 | 111 | { 112 | "meta": { 113 | "description": "Sending full message object of the Generic Template" 114 | }, 115 | "arguments": { 116 | "user": { 117 | "id": "10157033896470457" 118 | }, 119 | "type": "MESSAGE", 120 | "data": { 121 | "attachment": { 122 | "type": "template", 123 | "payload": { 124 | "template_type": "generic", 125 | "elements": [{ 126 | "title": "Welcome to Peter's Hats", 127 | "item_url": "https://petersfancybrownhats.com", 128 | "image_url": "https://petersfancybrownhats.com/company_image.png", 129 | "subtitle": "We've got the right hat for everyone.", 130 | "buttons": [{ 131 | "type": "web_url", 132 | "url": "https://petersfancybrownhats.com", 133 | "title": "View Website" 134 | }, { 135 | "type": "postback", 136 | "title": "Start Chatting", 137 | "payload": "DEVELOPER_DEFINED_PAYLOAD" 138 | }] 139 | }] 140 | } 141 | } 142 | } 143 | }, 144 | 145 | "expected": { 146 | "recipient": { 147 | "id": "10157033896470457" 148 | }, 149 | "message": { 150 | "attachment": { 151 | "type": "template", 152 | "payload": { 153 | "template_type": "generic", 154 | "elements": [{ 155 | "title": "Welcome to Peter's Hats", 156 | "item_url": "https://petersfancybrownhats.com", 157 | "image_url": "https://petersfancybrownhats.com/company_image.png", 158 | "subtitle": "We've got the right hat for everyone.", 159 | "buttons": [{ 160 | "type": "web_url", 161 | "url": "https://petersfancybrownhats.com", 162 | "title": "View Website" 163 | }, { 164 | "type": "postback", 165 | "title": "Start Chatting", 166 | "payload": "DEVELOPER_DEFINED_PAYLOAD" 167 | }] 168 | }] 169 | } 170 | } 171 | } 172 | }, 173 | 174 | "response": { 175 | "recipient_id": "10157033896470457", 176 | "message_id": "mid.1456970487938:c34767dfe57ee6e339" 177 | } 178 | }, 179 | 180 | { 181 | "meta": { 182 | "description": "Sending full message object with Receipt Template attachment, with out message type argument" 183 | }, 184 | "arguments": { 185 | "user": "10157033896470457", 186 | "data": { 187 | "attachment": { 188 | "type": "template", 189 | "payload": { 190 | "template_type": "receipt", 191 | "recipient_name": "Stephane Crozatier", 192 | "order_number": "12345678902", 193 | "currency": "USD", 194 | "payment_method": "Visa 2345", 195 | "order_url": "http://petersapparel.parseapp.com/order?order_id=123456", 196 | "timestamp": "1428444852", 197 | "elements": [{ 198 | "title": "Classic White T-Shirt", 199 | "subtitle": "100% Soft and Luxurious Cotton", 200 | "quantity": 2, 201 | "price": 50, 202 | "currency": "USD", 203 | "image_url": "http://petersapparel.parseapp.com/img/whiteshirt.png" 204 | }, { 205 | "title": "Classic Gray T-Shirt", 206 | "subtitle": "100% Soft and Luxurious Cotton", 207 | "quantity": 1, 208 | "price": 25, 209 | "currency": "USD", 210 | "image_url": "http://petersapparel.parseapp.com/img/grayshirt.png" 211 | }], 212 | "address": { 213 | "street_1": "1 Hacker Way", 214 | "street_2": "", 215 | "city": "Menlo Park", 216 | "postal_code": "94025", 217 | "state": "CA", 218 | "country": "US" 219 | }, 220 | "summary": { 221 | "subtotal": 75.00, 222 | "shipping_cost": 4.95, 223 | "total_tax": 6.19, 224 | "total_cost": 56.14 225 | }, 226 | "adjustments": [{ 227 | "name": "New Customer Discount", 228 | "amount": 20 229 | }, { 230 | "name": "$10 Off Coupon", 231 | "amount": 10 232 | }] 233 | } 234 | } 235 | } 236 | }, 237 | 238 | "expected": { 239 | "recipient": { 240 | "id": "10157033896470457" 241 | }, 242 | "message": { 243 | "attachment": { 244 | "type": "template", 245 | "payload": { 246 | "template_type": "receipt", 247 | "recipient_name": "Stephane Crozatier", 248 | "order_number": "12345678902", 249 | "currency": "USD", 250 | "payment_method": "Visa 2345", 251 | "order_url": "http://petersapparel.parseapp.com/order?order_id=123456", 252 | "timestamp": "1428444852", 253 | "elements": [{ 254 | "title": "Classic White T-Shirt", 255 | "subtitle": "100% Soft and Luxurious Cotton", 256 | "quantity": 2, 257 | "price": 50, 258 | "currency": "USD", 259 | "image_url": "http://petersapparel.parseapp.com/img/whiteshirt.png" 260 | }, { 261 | "title": "Classic Gray T-Shirt", 262 | "subtitle": "100% Soft and Luxurious Cotton", 263 | "quantity": 1, 264 | "price": 25, 265 | "currency": "USD", 266 | "image_url": "http://petersapparel.parseapp.com/img/grayshirt.png" 267 | }], 268 | "address": { 269 | "street_1": "1 Hacker Way", 270 | "street_2": "", 271 | "city": "Menlo Park", 272 | "postal_code": "94025", 273 | "state": "CA", 274 | "country": "US" 275 | }, 276 | "summary": { 277 | "subtotal": 75.00, 278 | "shipping_cost": 4.95, 279 | "total_tax": 6.19, 280 | "total_cost": 56.14 281 | }, 282 | "adjustments": [{ 283 | "name": "New Customer Discount", 284 | "amount": 20 285 | }, { 286 | "name": "$10 Off Coupon", 287 | "amount": 10 288 | }] 289 | } 290 | } 291 | } 292 | }, 293 | 294 | "response": { 295 | "recipient_id": "10157033896470457", 296 | "message_id": "mid.1456970487938:c34767dfe57ee6e339" 297 | } 298 | }, 299 | 300 | { 301 | "meta": { 302 | "description": "Sending full message object with quick reply options, with phone number instead of id" 303 | }, 304 | "count": { 305 | "expected": 2, 306 | "hook": "send.quick_reply" 307 | }, 308 | "arguments": { 309 | "user": { 310 | "phone_number": "12345678901" 311 | }, 312 | "type": "MESSAGE", 313 | "data": { 314 | "text": "Pick a color:", 315 | "quick_replies": [{ 316 | "content_type": "text", 317 | "title": "Red", 318 | "payload": "DEVELOPER_DEFINED_PAYLOAD_FOR_PICKING_RED" 319 | }, { 320 | "content_type": "text", 321 | "title": "Green", 322 | "payload": { 323 | "developer": ["defined", "payload", "for", "picking", "green"] 324 | } 325 | }] 326 | } 327 | }, 328 | 329 | "expected": { 330 | "recipient": { 331 | "phone_number": "12345678901" 332 | }, 333 | "message": { 334 | "text": "Pick a color:", 335 | "quick_replies": [{ 336 | "content_type": "text", 337 | "title": "Red", 338 | "payload": "DEVELOPER_DEFINED_PAYLOAD_FOR_PICKING_RED" 339 | }, { 340 | "content_type": "text", 341 | "title": "Green", 342 | "payload": "{\"developer\":[\"defined\",\"payload\",\"for\",\"picking\",\"green\"]}" 343 | }] 344 | } 345 | }, 346 | 347 | "response": { 348 | "recipient_id": "10157033896470458", 349 | "message_id": "mid.1456970487939:c34767dfe57ee6e339" 350 | } 351 | } 352 | ] 353 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/generic.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "count": { 4 | "expected": 2, 5 | "hook": "send.button" 6 | }, 7 | "arguments": { 8 | "user": "10157033896470855", 9 | "type": "GENERIC", 10 | "data": [{ 11 | "title": "Welcome to Peter's Hats", 12 | "item_url": "https://petersfancybrownhats.com", 13 | "image_url": "https://petersfancybrownhats.com/company_image.png", 14 | "subtitle": "We've got the right hat for everyone.", 15 | "buttons": [{ 16 | "type": "payment", 17 | "title": "buy", 18 | "payload": "DEVELOPER_DEFINED_PAYLOAD", 19 | "payment_summary": { 20 | "currency": "USD", 21 | "payment_type": "FIXED_AMOUNT", 22 | "merchant_name": "Peter's Apparel", 23 | "requested_user_info": [ 24 | "shipping_address", 25 | "contact_name", 26 | "contact_phone", 27 | "contact_email" 28 | ], 29 | "price_list": [{ 30 | "label": "Subtotal", 31 | "amount": "29.99" 32 | }, { 33 | "label": "Taxes", 34 | "amount": "2.47" 35 | }] 36 | } 37 | }, { 38 | "type": "element_share" 39 | }] 40 | }] 41 | }, 42 | 43 | "expected": { 44 | "recipient": { 45 | "id": "10157033896470855" 46 | }, 47 | "message": { 48 | "attachment": { 49 | "type": "template", 50 | "payload": { 51 | "template_type": "generic", 52 | "elements": [{ 53 | "title": "Welcome to Peter's Hats", 54 | "item_url": "https://petersfancybrownhats.com", 55 | "image_url": "https://petersfancybrownhats.com/company_image.png", 56 | "subtitle": "We've got the right hat for everyone.", 57 | "buttons": [{ 58 | "type": "payment", 59 | "title": "buy", 60 | "payload": "DEVELOPER_DEFINED_PAYLOAD", 61 | "payment_summary": { 62 | "currency": "USD", 63 | "payment_type": "FIXED_AMOUNT", 64 | "merchant_name": "Peter's Apparel", 65 | "requested_user_info": [ 66 | "shipping_address", 67 | "contact_name", 68 | "contact_phone", 69 | "contact_email" 70 | ], 71 | "price_list": [{ 72 | "label": "Subtotal", 73 | "amount": "29.99" 74 | }, { 75 | "label": "Taxes", 76 | "amount": "2.47" 77 | }] 78 | } 79 | }, { 80 | "type": "element_share" 81 | }] 82 | }] 83 | } 84 | } 85 | } 86 | }, 87 | 88 | "response": { 89 | "recipient_id": "10157033896470855", 90 | "message_id": "mid.1456970488058:c34767dfe57ee6e339" 91 | } 92 | }, 93 | 94 | { 95 | "meta": { 96 | "description": "It should truncate extra cards" 97 | }, 98 | "arguments": { 99 | "user": "10157033896470855", 100 | "type": "GENERIC", 101 | "data": [{ 102 | "title": "#1", 103 | "item_url": "https://first.com", 104 | "image_url": "https://first.com/one.png", 105 | "subtitle": "Number one", 106 | "buttons": [{ 107 | "type": "web_url", 108 | "url": "https://first.com", 109 | "title": "#1" 110 | }] 111 | }, { 112 | "title": "#2", 113 | "item_url": "https://second.com", 114 | "image_url": "https://second.com/two.png", 115 | "subtitle": "Number two", 116 | "buttons": [{ 117 | "type": "web_url", 118 | "url": "https://second.com", 119 | "title": "#2" 120 | }] 121 | }, { 122 | "title": "#3", 123 | "item_url": "https://third.com", 124 | "image_url": "https://third.com/three.png", 125 | "subtitle": "Number three", 126 | "buttons": [{ 127 | "type": "web_url", 128 | "url": "https://third.com", 129 | "title": "#3" 130 | }] 131 | }, { 132 | "title": "#4", 133 | "item_url": "https://fourth.com", 134 | "image_url": "https://fourth.com/four.png", 135 | "subtitle": "Number four", 136 | "buttons": [{ 137 | "type": "web_url", 138 | "url": "https://fourth.com", 139 | "title": "#4" 140 | }] 141 | }, { 142 | "title": "#5", 143 | "item_url": "https://fifth.com", 144 | "image_url": "https://fifth.com/five.png", 145 | "subtitle": "Number five", 146 | "buttons": [{ 147 | "type": "web_url", 148 | "url": "https://fifth.com", 149 | "title": "#5" 150 | }] 151 | }, { 152 | "title": "#6", 153 | "item_url": "https://sixth.com", 154 | "image_url": "https://sixth.com/six.png", 155 | "subtitle": "Number six", 156 | "buttons": [{ 157 | "type": "web_url", 158 | "url": "https://sixth.com", 159 | "title": "#6" 160 | }] 161 | }, { 162 | "title": "#7", 163 | "item_url": "https://seventh.com", 164 | "image_url": "https://seventh.com/seven.png", 165 | "subtitle": "Number seven", 166 | "buttons": [{ 167 | "type": "web_url", 168 | "url": "https://seventh.com", 169 | "title": "#7" 170 | }] 171 | }, { 172 | "title": "#8", 173 | "item_url": "https://eighth.com", 174 | "image_url": "https://eighth.com/eight.png", 175 | "subtitle": "Number eight", 176 | "buttons": [{ 177 | "type": "web_url", 178 | "url": "https://eighth.com", 179 | "title": "#8" 180 | }] 181 | }, { 182 | "title": "#9", 183 | "item_url": "https://nineth.com", 184 | "image_url": "https://nineth.com/nine.png", 185 | "subtitle": "Number nine", 186 | "buttons": [{ 187 | "type": "web_url", 188 | "url": "https://nineth.com", 189 | "title": "#9" 190 | }] 191 | }, { 192 | "title": "#10", 193 | "item_url": "https://tenth.com", 194 | "image_url": "https://tenth.com/ten.png", 195 | "subtitle": "Number ten", 196 | "buttons": [{ 197 | "type": "web_url", 198 | "url": "https://tenth.com", 199 | "title": "#10" 200 | }] 201 | }, { 202 | "title": "#11", 203 | "item_url": "https://eleventh.com", 204 | "image_url": "https://eleventh.com/eleven.png", 205 | "subtitle": "Number eleven", 206 | "buttons": [{ 207 | "type": "web_url", 208 | "url": "https://eleventh.com", 209 | "title": "#11" 210 | }] 211 | }, { 212 | "title": "#12", 213 | "item_url": "https://twelveth.com", 214 | "image_url": "https://twelveth.com/twelve.png", 215 | "subtitle": "Number twelve", 216 | "buttons": [{ 217 | "type": "web_url", 218 | "url": "https://twelveth.com", 219 | "title": "#12" 220 | }] 221 | }] 222 | }, 223 | 224 | "expected": { 225 | "recipient": { 226 | "id": "10157033896470855" 227 | }, 228 | "message": { 229 | "attachment": { 230 | "type": "template", 231 | "payload": { 232 | "template_type": "generic", 233 | "elements": [{ 234 | "title": "#1", 235 | "item_url": "https://first.com", 236 | "image_url": "https://first.com/one.png", 237 | "subtitle": "Number one", 238 | "buttons": [{ 239 | "type": "web_url", 240 | "url": "https://first.com", 241 | "title": "#1" 242 | }] 243 | }, { 244 | "title": "#2", 245 | "item_url": "https://second.com", 246 | "image_url": "https://second.com/two.png", 247 | "subtitle": "Number two", 248 | "buttons": [{ 249 | "type": "web_url", 250 | "url": "https://second.com", 251 | "title": "#2" 252 | }] 253 | }, { 254 | "title": "#3", 255 | "item_url": "https://third.com", 256 | "image_url": "https://third.com/three.png", 257 | "subtitle": "Number three", 258 | "buttons": [{ 259 | "type": "web_url", 260 | "url": "https://third.com", 261 | "title": "#3" 262 | }] 263 | }, { 264 | "title": "#4", 265 | "item_url": "https://fourth.com", 266 | "image_url": "https://fourth.com/four.png", 267 | "subtitle": "Number four", 268 | "buttons": [{ 269 | "type": "web_url", 270 | "url": "https://fourth.com", 271 | "title": "#4" 272 | }] 273 | }, { 274 | "title": "#5", 275 | "item_url": "https://fifth.com", 276 | "image_url": "https://fifth.com/five.png", 277 | "subtitle": "Number five", 278 | "buttons": [{ 279 | "type": "web_url", 280 | "url": "https://fifth.com", 281 | "title": "#5" 282 | }] 283 | }, { 284 | "title": "#6", 285 | "item_url": "https://sixth.com", 286 | "image_url": "https://sixth.com/six.png", 287 | "subtitle": "Number six", 288 | "buttons": [{ 289 | "type": "web_url", 290 | "url": "https://sixth.com", 291 | "title": "#6" 292 | }] 293 | }, { 294 | "title": "#7", 295 | "item_url": "https://seventh.com", 296 | "image_url": "https://seventh.com/seven.png", 297 | "subtitle": "Number seven", 298 | "buttons": [{ 299 | "type": "web_url", 300 | "url": "https://seventh.com", 301 | "title": "#7" 302 | }] 303 | }, { 304 | "title": "#8", 305 | "item_url": "https://eighth.com", 306 | "image_url": "https://eighth.com/eight.png", 307 | "subtitle": "Number eight", 308 | "buttons": [{ 309 | "type": "web_url", 310 | "url": "https://eighth.com", 311 | "title": "#8" 312 | }] 313 | }, { 314 | "title": "#9", 315 | "item_url": "https://nineth.com", 316 | "image_url": "https://nineth.com/nine.png", 317 | "subtitle": "Number nine", 318 | "buttons": [{ 319 | "type": "web_url", 320 | "url": "https://nineth.com", 321 | "title": "#9" 322 | }] 323 | }, { 324 | "title": "#10", 325 | "item_url": "https://tenth.com", 326 | "image_url": "https://tenth.com/ten.png", 327 | "subtitle": "Number ten", 328 | "buttons": [{ 329 | "type": "web_url", 330 | "url": "https://tenth.com", 331 | "title": "#10" 332 | }] 333 | }] 334 | } 335 | } 336 | } 337 | }, 338 | 339 | "response": { 340 | "recipient_id": "10157033896470855", 341 | "message_id": "mid.1456970488058:c34767dfe57ee6e339" 342 | } 343 | } 344 | ] 345 | -------------------------------------------------------------------------------- /test/fixtures/outgoing/receipt.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "count": { 4 | "expected": 2, 5 | "hook": "send.element" 6 | }, 7 | "arguments": { 8 | "user": "10157033896471055", 9 | "type": "RECEIPT", 10 | "data": { 11 | "template_type": "receipt", 12 | "recipient_name": "Stephane Crozatier", 13 | "order_number": "12345678902", 14 | "currency": "USD", 15 | "payment_method": "Visa 2345", 16 | "order_url": "http://petersapparel.parseapp.com/order?order_id=123456", 17 | "timestamp": "1428444852", 18 | "elements": [{ 19 | "title": "Classic White T-Shirt", 20 | "subtitle": "100% Soft and Luxurious Cotton", 21 | "quantity": 2, 22 | "price": 50, 23 | "currency": "USD", 24 | "image_url": "http://petersapparel.parseapp.com/img/whiteshirt.png" 25 | }, { 26 | "title": "Classic Gray T-Shirt", 27 | "subtitle": "100% Soft and Luxurious Cotton", 28 | "quantity": 1, 29 | "price": 25, 30 | "currency": "USD", 31 | "image_url": "http://petersapparel.parseapp.com/img/grayshirt.png" 32 | }], 33 | "address": { 34 | "street_1": "1 Hacker Way", 35 | "street_2": "", 36 | "city": "Menlo Park", 37 | "postal_code": "94025", 38 | "state": "CA", 39 | "country": "US" 40 | }, 41 | "summary": { 42 | "subtotal": 75.00, 43 | "shipping_cost": 4.95, 44 | "total_tax": 6.19, 45 | "total_cost": 56.14 46 | }, 47 | "adjustments": [{ 48 | "name": "New Customer Discount", 49 | "amount": 20 50 | }, { 51 | "name": "$10 Off Coupon", 52 | "amount": 10 53 | }] 54 | } 55 | }, 56 | 57 | "expected": { 58 | "recipient": { 59 | "id": "10157033896471055" 60 | }, 61 | "message": { 62 | "attachment": { 63 | "type": "template", 64 | "payload": { 65 | "template_type": "receipt", 66 | "recipient_name": "Stephane Crozatier", 67 | "order_number": "12345678902", 68 | "currency": "USD", 69 | "payment_method": "Visa 2345", 70 | "order_url": "http://petersapparel.parseapp.com/order?order_id=123456", 71 | "timestamp": "1428444852", 72 | "elements": [{ 73 | "title": "Classic White T-Shirt", 74 | "subtitle": "100% Soft and Luxurious Cotton", 75 | "quantity": 2, 76 | "price": 50, 77 | "currency": "USD", 78 | "image_url": "http://petersapparel.parseapp.com/img/whiteshirt.png" 79 | }, { 80 | "title": "Classic Gray T-Shirt", 81 | "subtitle": "100% Soft and Luxurious Cotton", 82 | "quantity": 1, 83 | "price": 25, 84 | "currency": "USD", 85 | "image_url": "http://petersapparel.parseapp.com/img/grayshirt.png" 86 | }], 87 | "address": { 88 | "street_1": "1 Hacker Way", 89 | "street_2": "", 90 | "city": "Menlo Park", 91 | "postal_code": "94025", 92 | "state": "CA", 93 | "country": "US" 94 | }, 95 | "summary": { 96 | "subtotal": 75.00, 97 | "shipping_cost": 4.95, 98 | "total_tax": 6.19, 99 | "total_cost": 56.14 100 | }, 101 | "adjustments": [{ 102 | "name": "New Customer Discount", 103 | "amount": 20 104 | }, { 105 | "name": "$10 Off Coupon", 106 | "amount": 10 107 | }] 108 | } 109 | } 110 | } 111 | }, 112 | 113 | "response": { 114 | "recipient_id": "10157033896471055", 115 | "message_id": "mid.1456970488158:c34767dfe57ee6e339" 116 | } 117 | }, 118 | 119 | { 120 | "meta": { 121 | "description": "It should truncate extra elements" 122 | }, 123 | "count": { 124 | "expected": 100, 125 | "hook": "send.element" 126 | }, 127 | "arguments": { 128 | "user": "10157033896471155", 129 | "type": "RECEIPT", 130 | "data": { 131 | "template_type": "receipt", 132 | "recipient_name": "Stephane Crozatier", 133 | "order_number": "12345678902", 134 | "currency": "USD", 135 | "payment_method": "Visa 2345", 136 | "order_url": "http://petersapparel.parseapp.com/order?order_id=123456", 137 | "timestamp": "1428444852", 138 | "elements": [{ 139 | "title": "1" 140 | }, { 141 | "title": "2" 142 | }, { 143 | "title": "3" 144 | }, { 145 | "title": "4" 146 | }, { 147 | "title": "5" 148 | }, { 149 | "title": "6" 150 | }, { 151 | "title": "7" 152 | }, { 153 | "title": "8" 154 | }, { 155 | "title": "9" 156 | }, { 157 | "title": "10" 158 | }, { 159 | "title": "11" 160 | }, { 161 | "title": "12" 162 | }, { 163 | "title": "13" 164 | }, { 165 | "title": "14" 166 | }, { 167 | "title": "15" 168 | }, { 169 | "title": "16" 170 | }, { 171 | "title": "17" 172 | }, { 173 | "title": "18" 174 | }, { 175 | "title": "19" 176 | }, { 177 | "title": "20" 178 | }, { 179 | "title": "21" 180 | }, { 181 | "title": "22" 182 | }, { 183 | "title": "23" 184 | }, { 185 | "title": "24" 186 | }, { 187 | "title": "25" 188 | }, { 189 | "title": "26" 190 | }, { 191 | "title": "27" 192 | }, { 193 | "title": "28" 194 | }, { 195 | "title": "29" 196 | }, { 197 | "title": "30" 198 | }, { 199 | "title": "31" 200 | }, { 201 | "title": "32" 202 | }, { 203 | "title": "33" 204 | }, { 205 | "title": "34" 206 | }, { 207 | "title": "35" 208 | }, { 209 | "title": "36" 210 | }, { 211 | "title": "37" 212 | }, { 213 | "title": "38" 214 | }, { 215 | "title": "39" 216 | }, { 217 | "title": "40" 218 | }, { 219 | "title": "41" 220 | }, { 221 | "title": "42" 222 | }, { 223 | "title": "43" 224 | }, { 225 | "title": "44" 226 | }, { 227 | "title": "45" 228 | }, { 229 | "title": "46" 230 | }, { 231 | "title": "47" 232 | }, { 233 | "title": "48" 234 | }, { 235 | "title": "49" 236 | }, { 237 | "title": "50" 238 | }, { 239 | "title": "51" 240 | }, { 241 | "title": "52" 242 | }, { 243 | "title": "53" 244 | }, { 245 | "title": "54" 246 | }, { 247 | "title": "55" 248 | }, { 249 | "title": "56" 250 | }, { 251 | "title": "57" 252 | }, { 253 | "title": "58" 254 | }, { 255 | "title": "59" 256 | }, { 257 | "title": "60" 258 | }, { 259 | "title": "61" 260 | }, { 261 | "title": "62" 262 | }, { 263 | "title": "63" 264 | }, { 265 | "title": "64" 266 | }, { 267 | "title": "65" 268 | }, { 269 | "title": "66" 270 | }, { 271 | "title": "67" 272 | }, { 273 | "title": "68" 274 | }, { 275 | "title": "69" 276 | }, { 277 | "title": "70" 278 | }, { 279 | "title": "71" 280 | }, { 281 | "title": "72" 282 | }, { 283 | "title": "73" 284 | }, { 285 | "title": "74" 286 | }, { 287 | "title": "75" 288 | }, { 289 | "title": "76" 290 | }, { 291 | "title": "77" 292 | }, { 293 | "title": "78" 294 | }, { 295 | "title": "79" 296 | }, { 297 | "title": "80" 298 | }, { 299 | "title": "81" 300 | }, { 301 | "title": "82" 302 | }, { 303 | "title": "83" 304 | }, { 305 | "title": "84" 306 | }, { 307 | "title": "85" 308 | }, { 309 | "title": "86" 310 | }, { 311 | "title": "87" 312 | }, { 313 | "title": "88" 314 | }, { 315 | "title": "89" 316 | }, { 317 | "title": "90" 318 | }, { 319 | "title": "91" 320 | }, { 321 | "title": "92" 322 | }, { 323 | "title": "93" 324 | }, { 325 | "title": "94" 326 | }, { 327 | "title": "95" 328 | }, { 329 | "title": "96" 330 | }, { 331 | "title": "97" 332 | }, { 333 | "title": "98" 334 | }, { 335 | "title": "99" 336 | }, { 337 | "title": "100" 338 | }, { 339 | "title": "101" 340 | }, { 341 | "title": "102" 342 | }, { 343 | "title": "103" 344 | }, { 345 | "title": "104" 346 | }, { 347 | "title": "105" 348 | }, { 349 | "title": "106" 350 | }, { 351 | "title": "107" 352 | }, { 353 | "title": "107" 354 | }, { 355 | "title": "108" 356 | }, { 357 | "title": "109" 358 | }, { 359 | "title": "110" 360 | }], 361 | "address": { 362 | "street_1": "1 Hacker Way", 363 | "street_2": "", 364 | "city": "Menlo Park", 365 | "postal_code": "94025", 366 | "state": "CA", 367 | "country": "US" 368 | }, 369 | "summary": { 370 | "subtotal": 75.00, 371 | "shipping_cost": 4.95, 372 | "total_tax": 6.19, 373 | "total_cost": 56.14 374 | }, 375 | "adjustments": [{ 376 | "name": "New Customer Discount", 377 | "amount": 20 378 | }, { 379 | "name": "$10 Off Coupon", 380 | "amount": 10 381 | }] 382 | } 383 | }, 384 | 385 | "expected": { 386 | "recipient": { 387 | "id": "10157033896471155" 388 | }, 389 | "message": { 390 | "attachment": { 391 | "type": "template", 392 | "payload": { 393 | "template_type": "receipt", 394 | "recipient_name": "Stephane Crozatier", 395 | "order_number": "12345678902", 396 | "currency": "USD", 397 | "payment_method": "Visa 2345", 398 | "order_url": "http://petersapparel.parseapp.com/order?order_id=123456", 399 | "timestamp": "1428444852", 400 | "elements": [{ 401 | "title": "1" 402 | }, { 403 | "title": "2" 404 | }, { 405 | "title": "3" 406 | }, { 407 | "title": "4" 408 | }, { 409 | "title": "5" 410 | }, { 411 | "title": "6" 412 | }, { 413 | "title": "7" 414 | }, { 415 | "title": "8" 416 | }, { 417 | "title": "9" 418 | }, { 419 | "title": "10" 420 | }, { 421 | "title": "11" 422 | }, { 423 | "title": "12" 424 | }, { 425 | "title": "13" 426 | }, { 427 | "title": "14" 428 | }, { 429 | "title": "15" 430 | }, { 431 | "title": "16" 432 | }, { 433 | "title": "17" 434 | }, { 435 | "title": "18" 436 | }, { 437 | "title": "19" 438 | }, { 439 | "title": "20" 440 | }, { 441 | "title": "21" 442 | }, { 443 | "title": "22" 444 | }, { 445 | "title": "23" 446 | }, { 447 | "title": "24" 448 | }, { 449 | "title": "25" 450 | }, { 451 | "title": "26" 452 | }, { 453 | "title": "27" 454 | }, { 455 | "title": "28" 456 | }, { 457 | "title": "29" 458 | }, { 459 | "title": "30" 460 | }, { 461 | "title": "31" 462 | }, { 463 | "title": "32" 464 | }, { 465 | "title": "33" 466 | }, { 467 | "title": "34" 468 | }, { 469 | "title": "35" 470 | }, { 471 | "title": "36" 472 | }, { 473 | "title": "37" 474 | }, { 475 | "title": "38" 476 | }, { 477 | "title": "39" 478 | }, { 479 | "title": "40" 480 | }, { 481 | "title": "41" 482 | }, { 483 | "title": "42" 484 | }, { 485 | "title": "43" 486 | }, { 487 | "title": "44" 488 | }, { 489 | "title": "45" 490 | }, { 491 | "title": "46" 492 | }, { 493 | "title": "47" 494 | }, { 495 | "title": "48" 496 | }, { 497 | "title": "49" 498 | }, { 499 | "title": "50" 500 | }, { 501 | "title": "51" 502 | }, { 503 | "title": "52" 504 | }, { 505 | "title": "53" 506 | }, { 507 | "title": "54" 508 | }, { 509 | "title": "55" 510 | }, { 511 | "title": "56" 512 | }, { 513 | "title": "57" 514 | }, { 515 | "title": "58" 516 | }, { 517 | "title": "59" 518 | }, { 519 | "title": "60" 520 | }, { 521 | "title": "61" 522 | }, { 523 | "title": "62" 524 | }, { 525 | "title": "63" 526 | }, { 527 | "title": "64" 528 | }, { 529 | "title": "65" 530 | }, { 531 | "title": "66" 532 | }, { 533 | "title": "67" 534 | }, { 535 | "title": "68" 536 | }, { 537 | "title": "69" 538 | }, { 539 | "title": "70" 540 | }, { 541 | "title": "71" 542 | }, { 543 | "title": "72" 544 | }, { 545 | "title": "73" 546 | }, { 547 | "title": "74" 548 | }, { 549 | "title": "75" 550 | }, { 551 | "title": "76" 552 | }, { 553 | "title": "77" 554 | }, { 555 | "title": "78" 556 | }, { 557 | "title": "79" 558 | }, { 559 | "title": "80" 560 | }, { 561 | "title": "81" 562 | }, { 563 | "title": "82" 564 | }, { 565 | "title": "83" 566 | }, { 567 | "title": "84" 568 | }, { 569 | "title": "85" 570 | }, { 571 | "title": "86" 572 | }, { 573 | "title": "87" 574 | }, { 575 | "title": "88" 576 | }, { 577 | "title": "89" 578 | }, { 579 | "title": "90" 580 | }, { 581 | "title": "91" 582 | }, { 583 | "title": "92" 584 | }, { 585 | "title": "93" 586 | }, { 587 | "title": "94" 588 | }, { 589 | "title": "95" 590 | }, { 591 | "title": "96" 592 | }, { 593 | "title": "97" 594 | }, { 595 | "title": "98" 596 | }, { 597 | "title": "99" 598 | }, { 599 | "title": "100" 600 | }], 601 | "address": { 602 | "street_1": "1 Hacker Way", 603 | "street_2": "", 604 | "city": "Menlo Park", 605 | "postal_code": "94025", 606 | "state": "CA", 607 | "country": "US" 608 | }, 609 | "summary": { 610 | "subtotal": 75.00, 611 | "shipping_cost": 4.95, 612 | "total_tax": 6.19, 613 | "total_cost": 56.14 614 | }, 615 | "adjustments": [{ 616 | "name": "New Customer Discount", 617 | "amount": 20 618 | }, { 619 | "name": "$10 Off Coupon", 620 | "amount": 10 621 | }] 622 | } 623 | } 624 | } 625 | }, 626 | 627 | "response": { 628 | "recipient_id": "10157033896471155", 629 | "message_id": "mid.1456970488158:c34767dfe57ee6e339" 630 | } 631 | } 632 | ] 633 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fbbot [![NPM Module](https://img.shields.io/npm/v/fbbot.svg?style=flat)](https://www.npmjs.com/package/fbbot) 2 | 3 | Minimal framework/SDK for facebook messenger bots. BYOS (Bring Your Own Server). 4 | 5 | [![patform@1.2](https://img.shields.io/badge/messenger_platform-v1.2-brightgreen.svg?style=flat)](https://developers.facebook.com/docs/messenger-platform) 6 | 7 | [![Linux Build](https://img.shields.io/travis/alexindigo/fbbot/master.svg?label=linux:0.12-6.x&style=flat)](https://travis-ci.org/alexindigo/fbbot) 8 | [![MacOS Build](https://img.shields.io/travis/alexindigo/fbbot/master.svg?label=macos:0.12-6.x&style=flat)](https://travis-ci.org/alexindigo/fbbot) 9 | [![Windows Build](https://img.shields.io/appveyor/ci/alexindigo/fbbot/master.svg?label=windows:0.12-6.x&style=flat)](https://ci.appveyor.com/project/alexindigo/fbbot) 10 | 11 | [![Coverage Status](https://img.shields.io/coveralls/alexindigo/fbbot/master.svg?label=code+coverage&style=flat)](https://coveralls.io/github/alexindigo/fbbot?branch=master) 12 | [![Dependency Status](https://img.shields.io/david/alexindigo/fbbot/master.svg?style=flat)](https://david-dm.org/alexindigo/fbbot) 13 | [![bitHound Overall Score](https://www.bithound.io/github/alexindigo/fbbot/badges/score.svg)](https://www.bithound.io/github/alexindigo/fbbot) 14 | 15 | [![express](https://img.shields.io/badge/express-tested-brightgreen.svg?style=flat)](http://expressjs.com) 16 | [![hapi](https://img.shields.io/badge/hapi-tested-brightgreen.svg?lstyle=flat)](http://hapijs.com) 17 | [![restify](https://img.shields.io/badge/restify-tested-brightgreen.svg?style=flat)](http://restify.com) 18 | [![http](https://img.shields.io/badge/http-tested-brightgreen.svg?style=flat)](https://nodejs.org/api/http.html) 19 | 20 | ## Install 21 | 22 | ``` 23 | npm install --save fbbot 24 | ``` 25 | 26 | ## Table of Contents 27 | 28 | 29 | - [Examples](#examples) 30 | - [Listening for messages](#listening-for-messages) 31 | - [Adding middleware](#adding-middleware) 32 | - [Sending messages to user](#sending-messages-to-user) 33 | - [Logging](#logging) 34 | - [API](#api) 35 | - [Fbbot#use](#fbbotuse) 36 | - [Fbbot#on](#fbboton) 37 | - [Fbbot#send](#fbbotsend) 38 | - [Convenience Methods](#convenience-methods) 39 | - [`send.message`](#sendmessage) 40 | - [`send.markSeen`](#sendmarkseen) 41 | - [`send.typingOn`](#sendtypingon) 42 | - [`send.typingOff`](#sendtypingoff) 43 | - [`send.text`](#sendtext) 44 | - [`send.image`](#sendimage) 45 | - [`send.audio`](#sendaudio) 46 | - [`send.video`](#sendvideo) 47 | - [`send.file`](#sendfile) 48 | - [`send.generic`](#sendgeneric) 49 | - [`send.button`](#sendbutton) 50 | - [`send.receipt`](#sendreceipt) 51 | - [`send.quickReplies`](#sendquickreplies) 52 | - [Message Types](#message-types) 53 | - [`MESSAGE`](#message) 54 | - [`MARK_SEEN`](#mark_seen) 55 | - [`TYPING_ON`](#typing_on) 56 | - [`TYPING_OFF`](#typing_off) 57 | - [`TEXT`](#text) 58 | - [`IMAGE`](#image) 59 | - [`AUDIO`](#audio) 60 | - [`VIDEO`](#video) 61 | - [`FILE`](#file) 62 | - [`GENERIC`](#generic) 63 | - [`BUTTON`](#button) 64 | - [`RECEIPT`](#receipt) 65 | - [`QUICK_REPLIES`](#quick_replies) 66 | - [Hooks](#hooks) 67 | - [Incoming](#incoming) 68 | - [Outgoing](#outgoing) 69 | - [Roadmap](#roadmap) 70 | - [License](#license) 71 | 72 | 73 | 74 | ## Examples 75 | 76 | ### Listening for messages 77 | 78 | ```javascript 79 | // also works with `hapi`, `restify` and built-in `http` 80 | var express = require('express'); 81 | var Fbbot = require('fbbot'); 82 | 83 | var app = express(); 84 | var fbbot = new Fbbot({token: '...', secret: '...'}); 85 | 86 | // plug-in fbbot 87 | // It will also listen for GET requests to authorize fb app. 88 | app.all('/webhook', fbbot.requestHandler); 89 | // assuming HTTPS is terminated elsewhere, 90 | // or you can use standard express https capabilities 91 | app.listen(8080); 92 | 93 | // catching messages 94 | fbbot.on('message', function(message, send) 95 | { 96 | // message.type <-- type of the message (text, attachment, quick_reply, sticker, etc) 97 | // message.user <-- user object 98 | // message.text <-- text for text messages 99 | // message.attachments <-- list of attachments if available 100 | // send <-- send method with baked in user.id `send(fbbot., , )` 101 | }); 102 | 103 | // handle only text messages 104 | fbbot.on('message.text', function(message, send) 105 | { 106 | // message.user <-- user object 107 | // message.text <-- text for text messages 108 | // send <-- send method with baked in user.id `send(fbbot., , )` 109 | }); 110 | 111 | fbbot.on('postback', function(postback, send) 112 | { 113 | // postback.user <-- user object 114 | // postback.payload <-- parsed payload 115 | // send <-- send method with baked in user.id `send(fbbot., , )` 116 | }); 117 | ``` 118 | 119 | Check out [test folder](test/fixtures) for available options. 120 | 121 | ### Adding middleware 122 | 123 | ```javascript 124 | var express = require('express'); 125 | var Fbbot = require('fbbot'); 126 | 127 | var app = express(); 128 | var fbbot = new Fbbot({token: '...', secret: '...'}); 129 | 130 | // plug-in fbbot 131 | app.all('/webhook', fbbot.requestHandler); 132 | // assuming HTTPS is terminated elsewhere, 133 | // or you can use standard express https capabilities 134 | app.listen(8080); 135 | 136 | fbbot.use('message', function(payload, callback) 137 | { 138 | // do something with the payload, async or sync 139 | setTimeout(function() 140 | { 141 | payload.fooWasHere = true; 142 | // pass it to callback 143 | callback(null, payload); 144 | }, 500); 145 | }); 146 | 147 | // catching messages 148 | fbbot.on('message', function(message, send) 149 | { 150 | // modified message payload 151 | message.fooWasHere; // true 152 | }); 153 | 154 | ``` 155 | 156 | More middleware examples could be found in [incoming](incoming/) folder. 157 | 158 | ### Sending messages to user 159 | 160 | Here are two ways of sending messages, using per-instance fbbot.send method, 161 | or the one tailored to the user, provided to the event handlers. 162 | 163 | ```javascript 164 | var express = require('express'); 165 | var Fbbot = require('fbbot'); 166 | 167 | var app = express(); 168 | var fbbot = new Fbbot({token: '...', secret: '...'}); 169 | 170 | // plug-in fbbot 171 | app.all('/webhook', fbbot.requestHandler); 172 | // assuming HTTPS is terminated elsewhere, 173 | // or you can use standard express https capabilities 174 | app.listen(8080); 175 | 176 | // "standalone" send function 177 | // send reguar text message 178 | fbbot.send(1234567890, fbbot.TEXT, 'Hi there!', function(error, response) 179 | { 180 | // error