├── .travis.yml ├── lib ├── index.js ├── .eslintrc.js ├── errors.js ├── middleware.js ├── botmaster.js ├── outgoing_message.js └── base_bot.js ├── tests ├── .eslintrc.js ├── index.js ├── base_bot │ ├── emitUpdate.js │ ├── get_user_info.js │ ├── applySettings.js │ └── send_message.js ├── botmaster │ ├── remove_bot.js │ ├── get_bot.js │ ├── constructor.js │ └── add_bot.js ├── middleware │ ├── use_wrapped.js │ └── use.js ├── _mock_bot.js └── outgoing_message.js ├── .gitignore ├── LICENSE ├── Readme.md ├── package.json └── api-reference ├── botmaster.md ├── outgoing-message.md └── base-bot.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '9' 4 | - '8' 5 | - '7' 6 | - '6' 7 | - '4' 8 | 9 | after_success: npm run coveralls 10 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Botmaster = require('./botmaster'); 4 | Botmaster.BaseBot = require('./base_bot'); 5 | 6 | module.exports = Botmaster; 7 | -------------------------------------------------------------------------------- /lib/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "airbnb", 3 | "rules": { 4 | "no-underscore-dangle": "off", 5 | "prefer-rest-params": "off", 6 | "no-restricted-syntax": "off", 7 | "no-param-reassign": "off", 8 | "class-methods-use-this": "off", 9 | "strict": "off", 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /tests/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'airbnb', 3 | plugins: [ 4 | 'ava', 5 | ], 6 | rules: { 7 | 'import/no-extraneous-dependencies': 'off', 8 | 'no-underscore-dangle': 'off', 9 | semi: [2, 'always'], 10 | 'no-param-reassign': 'off', 11 | 'no-restricted-syntax': 'off', 12 | 'strict': 'off', 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const MockBot = require('./_mock_bot'); 4 | 5 | // if using MockBot in your library, just do a require('botmaster/tests').MockBot 6 | // and make sure the following packages are either in your dependencies exports 7 | // dev-dependencies: 8 | 9 | /** 10 | * express 11 | * koa 12 | * body-parser 13 | */ 14 | 15 | module.exports = { 16 | MockBot, 17 | }; 18 | -------------------------------------------------------------------------------- /tests/base_bot/emitUpdate.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import MockBot from '../_mock_bot'; 4 | 5 | test('Emits error when called from non owned bot', (t) => { 6 | t.plan(1); 7 | 8 | const bot = new MockBot(); 9 | 10 | return bot.__emitUpdate({}) 11 | .catch((err) => { 12 | t.is(err.message, 'bot needs to be added to a botmaster instance ' + 13 | 'in order to emit received updates'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Folder view configuration files 2 | .DS_Store 3 | Desktop.ini 4 | 5 | # Thumbnail cache files 6 | ._* 7 | Thumbs.db 8 | 9 | # Files that might appear on external disks 10 | .Spotlight-V100 11 | .Trashes 12 | 13 | example_jd.js 14 | slack_button.html 15 | testing_creds.md 16 | tests/config.js 17 | tests/_config.js 18 | 19 | # Application specific files 20 | node_modules 21 | npm-debug.log 22 | .sass-cache 23 | 24 | /logs 25 | jsconfig.json 26 | .vscode 27 | coverage 28 | .nyc_output 29 | jsdoc -------------------------------------------------------------------------------- /tests/base_bot/get_user_info.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import MockBot from '../_mock_bot'; 4 | 5 | test('throws error when bot type does not support retrieving user is', async (t) => { 6 | t.plan(1); 7 | 8 | const bot = new MockBot(); 9 | try { 10 | await bot.getUserInfo('user_id'); 11 | t.fail('Error not returned'); 12 | } catch (err) { 13 | t.is(err.message, 'Bots of type mock don\'t provide access to user info.', 14 | 'Error message is not same as expected'); 15 | } 16 | }); 17 | 18 | test('works when bot type supports retrieving the info', async (t) => { 19 | t.plan(1); 20 | 21 | const bot = new MockBot({ 22 | retrievesUserInfo: true, 23 | }); 24 | 25 | const userInfo = await bot.getUserInfo('user_id'); 26 | t.is(userInfo.first_name, 'Peter', 'userInfo is not same as expected'); 27 | }); 28 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const debugBase = require('debug'); 4 | 5 | class TwoDotXError extends Error { 6 | constructor(message) { 7 | super(message); 8 | 9 | this.message += 'See the latest documentation ' + 10 | 'at http://botmasterai.com to see the preferred syntax. ' + 11 | 'Alternatively, you can downgrade botmaster to 2.x.x by doing: ' + 12 | '"npm install --save botmaster@2.x.x" or "yarn add botmaster@2.x.x"'; 13 | } 14 | 15 | } 16 | 17 | class SendMessageTypeError extends Error { 18 | constructor(botType, messageType) { 19 | super(`Bots of type ${botType} can't send` + 20 | ` messages with ${messageType}`); 21 | 22 | const debug = debugBase(`botmaster:${botType}`); 23 | debug(`Tried sending message of type ${messageType} to bot of ` + 24 | `type ${botType} that do not support this message type`); 25 | } 26 | } 27 | 28 | module.exports = { 29 | TwoDotXError, 30 | SendMessageTypeError, 31 | }; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016, 2017 IBM 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Botmaster 2 | 3 | [![Build Status](https://travis-ci.org/botmasterai/botmaster.svg?branch=master)](https://travis-ci.org/botmasterai/botmaster) 4 | [![Coverage Status](https://coveralls.io/repos/github/botmasterai/botmaster/badge.svg?branch=master)](https://coveralls.io/github/botmasterai/botmaster?branch=master) 5 | [![Dependency Status](https://gemnasium.com/badges/github.com/botmasterai/botmaster.svg)](https://gemnasium.com/github.com/botmasterai/botmaster) 6 | [![npm-version](https://img.shields.io/npm/v/botmaster.svg)](https://www.npmjs.com/package/botmaster) 7 | [![license](https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000)](LICENSE) 8 | 9 | Botmaster v3 out. 10 | --- 11 | 12 | Botmaster v3 is virtually a complete rewrite of the framework. A lot of the syntax remains the same, 13 | but there are quite a few breaking changes that were necessary in order to get the framework 14 | to where we wanted it to be. It is now truly a micro-framework. With only 2 dependencies and without 15 | relying on express anymore, Botmaster v3 is the only JS bot framework that gives as much control 16 | as possible to the developer without losing its ease of use. 17 | 18 | A migration documentation can be found at: https://github.com/botmasterai/botmasterai.github.io/blob/master/docs/changelog.md#major-308 19 | 20 | Botmaster is a lightweight chatbot framework. Its purpose is to integrate your existing chatbot into a variety of messaging channels - currently Facebook Messenger, Twitter DM and Telegram. 21 | 22 | ## Documentation 23 | 24 | Find the whole documentation for the framework here: https://github.com/botmasterai/botmasterai.github.io/tree/master/docs 25 | 26 | ## License 27 | 28 | This library is licensed under the MIT [license](LICENSE) 29 | -------------------------------------------------------------------------------- /tests/botmaster/remove_bot.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import request from 'request-promise'; 3 | 4 | import Botmaster from '../../lib'; 5 | import MockBot from '../_mock_bot'; 6 | 7 | test('works with a bot that doesn\'t require webhhooks', (t) => { 8 | t.plan(2); 9 | 10 | return new Promise((resolve) => { 11 | const botmaster = new Botmaster(); 12 | 13 | botmaster.on('listening', () => { 14 | const bot = new MockBot(); 15 | 16 | botmaster.addBot(bot); 17 | botmaster.removeBot(bot); 18 | t.is(Object.keys(botmaster.__serverRequestListeners).length, 0); 19 | t.is(botmaster.bots.length, 0); 20 | botmaster.server.close(resolve); 21 | }); 22 | }); 23 | }); 24 | 25 | const arbitraryBotMacro = (t, { botmasterSettings, botSettings }) => { 26 | t.plan(3); 27 | console.log(botSettings); 28 | return new Promise((resolve) => { 29 | const botmaster = new Botmaster(botmasterSettings); 30 | 31 | botmaster.on('listening', () => { 32 | const bot = new MockBot(botSettings); 33 | 34 | botmaster.addBot(bot); 35 | botmaster.removeBot(bot); 36 | t.is(Object.keys(botmaster.__serverRequestListeners).length, 0); 37 | t.is(botmaster.bots.length, 0); 38 | 39 | const updateToSend = { text: 'Hello world' }; 40 | const requestOptions = { 41 | method: 'POST', 42 | uri: `http://localhost:3000/mock/${botSettings.webhookEndpoint}`, 43 | json: updateToSend, 44 | }; 45 | 46 | request(requestOptions) 47 | 48 | .catch((err) => { 49 | t.is(err.error.message, 50 | `Couldn't POST /mock/${botSettings.webhookEndpoint}`); 51 | botmaster.server.close(resolve); 52 | }); 53 | }); 54 | }); 55 | }; 56 | 57 | test('works with an express bot', arbitraryBotMacro, { 58 | botSettings: { 59 | requiresWebhook: true, 60 | webhookEndpoint: 'express', 61 | }, 62 | }); 63 | 64 | test('works with a koa bot', arbitraryBotMacro, { 65 | botSettings: { 66 | requiresWebhook: true, 67 | webhookEndpoint: 'koa', 68 | }, 69 | }); 70 | 71 | test('Removes path if useDefaultMountPathPrepend is false', arbitraryBotMacro, { 72 | botmasterSettings: { 73 | useDefaultMountPathPrepend: false, 74 | }, 75 | botSettings: { 76 | requiresWebhook: true, 77 | webhookEndpoint: '/express/', 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "botmaster", 3 | "version": "3.2.0", 4 | "description": "Framework allowing developers to write bots that are agnostic with respect to the channel used by their users (messenger, telegram etc...)", 5 | "main": "./lib/index.js", 6 | "scripts": { 7 | "test": "export NODE_ENV=test; nyc --reporter=lcov --reporter=html ava; nyc report", 8 | "test-debug": "export NODE_ENV=test DEBUG=botmaster:*; nyc --reporter=lcov --reporter=html ava", 9 | "test-watch": "export NODE_ENV=test; ava --watch", 10 | "coveralls": "cat ./coverage/lcov.info | coveralls", 11 | "postversion": "git push && git push --tags", 12 | "report": "nyc report", 13 | "botmaster-docs": "jsdoc2md lib/botmaster.js > api-reference/botmaster.md", 14 | "base-bot-docs": "jsdoc2md lib/base_bot.js > api-reference/base-bot.md", 15 | "outgoing-message-docs": "jsdoc2md lib/outgoing_message.js > api-reference/outgoing-message.md", 16 | "docs": "mkdir -p api-reference; yarn botmaster-docs; yarn base-bot-docs; yarn outgoing-message-docs", 17 | "docs-deploy": "yarn docs && cp -r api-reference ../botmasterai.github.io/docs" 18 | }, 19 | "ava": { 20 | "files": [ 21 | "tests/**/*.js", 22 | "!**/index.js" 23 | ], 24 | "source": [], 25 | "match": [], 26 | "serial": true, 27 | "verbose": true, 28 | "failFast": true, 29 | "tap": false, 30 | "powerAssert": false 31 | }, 32 | "nyc": { 33 | "check-coverage": true, 34 | "lines": 100, 35 | "statements": 100, 36 | "functions": 100, 37 | "branches": 100, 38 | "exclude": [ 39 | "tests" 40 | ] 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "https://github.com/jdwuarin/botmaster" 45 | }, 46 | "bugs": { 47 | "url": "https://github.com/jdwuarin/botmaster/issues" 48 | }, 49 | "keywords": [ 50 | "bot", 51 | "framework", 52 | "toolkit", 53 | "botmaster", 54 | "slack", 55 | "messenger", 56 | "telegram", 57 | "twitter", 58 | "bot-library" 59 | ], 60 | "dependencies": { 61 | "debug": "^3.1.0", 62 | "lodash": "^4.17.4" 63 | }, 64 | "engines": { 65 | "node": "4.x.x || >=6.x.x" 66 | }, 67 | "devDependencies": { 68 | "ava": "^0.19.1", 69 | "body-parser": "^1.18.2", 70 | "botmaster-test-fixtures": "^2.1.0", 71 | "coveralls": "^3.0.0", 72 | "eslint": "^4.16.0", 73 | "eslint-config-airbnb": "^16.1.0", 74 | "eslint-plugin-ava": "^4.5.0", 75 | "eslint-plugin-import": "^2.8.0", 76 | "eslint-plugin-jsx-a11y": "^6.0.3", 77 | "eslint-plugin-react": "^7.6.0", 78 | "express": "^4.16.2", 79 | "jsdoc-to-markdown": "^4.0.1", 80 | "koa": "^2.4.1", 81 | "nyc": "^11.4.1", 82 | "request-promise": "^4.2.2" 83 | }, 84 | "author": "JD Wuarin ", 85 | "license": "MIT" 86 | } 87 | -------------------------------------------------------------------------------- /tests/base_bot/applySettings.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import MockBot from '../_mock_bot'; 4 | 5 | const errorTestTitleBase = 'should throw an error when controller is called'; 6 | const successTestTitleBase = 'should not throw an error when controller is called'; 7 | 8 | test(`${errorTestTitleBase} with a string`, (t) => { 9 | t.plan(1); 10 | 11 | const botSettings = 'invalid'; 12 | 13 | try { 14 | const bot = new MockBot(botSettings); 15 | } catch (err) { 16 | t.is(err.message.indexOf('settings must be object') > -1, true); 17 | } 18 | }); 19 | 20 | test(`${errorTestTitleBase} with no credentials, although class requires some`, (t) => { 21 | t.plan(1); 22 | 23 | const botSettings = { 24 | requiredCredentials: ['token', 'password'], 25 | }; 26 | 27 | try { 28 | const bot = new MockBot(botSettings); 29 | } catch (err) { 30 | t.is(err.message.indexOf('no credentials specified') > -1, true); 31 | } 32 | }); 33 | 34 | test(`${errorTestTitleBase} with misnamed credentials`, (t) => { 35 | t.plan(1); 36 | 37 | const botSettings = { 38 | requiredCredentials: ['token', 'password'], 39 | credentials: { 40 | token: 'something', 41 | pass: 'something else', 42 | }, 43 | }; 44 | 45 | try { 46 | const bot = new MockBot(botSettings); 47 | } catch (err) { 48 | console.log(err.message); 49 | t.is(err.message.indexOf('are expected to have \'password\' credentials') > -1, true); 50 | } 51 | }); 52 | 53 | test(`${successTestTitleBase} with correctly named credentials`, (t) => { 54 | t.plan(1); 55 | 56 | const botSettings = { 57 | requiredCredentials: ['token', 'password'], 58 | credentials: { 59 | token: 'something', 60 | password: 'something else', 61 | }, 62 | }; 63 | const bot = new MockBot(botSettings); 64 | t.pass(); 65 | }); 66 | 67 | test(`${errorTestTitleBase} with no webhookEndpoint although it requires one`, (t) => { 68 | t.plan(1); 69 | 70 | const botSettings = { 71 | requiresWebhook: true, 72 | }; 73 | 74 | try { 75 | const bot = new MockBot(botSettings); 76 | } catch (err) { 77 | t.is(err.message.indexOf('must be defined with webhookEndpoint') > -1, true); 78 | } 79 | }); 80 | 81 | test(`${successTestTitleBase} with webhookEndpoint and it needs one`, (t) => { 82 | t.plan(1); 83 | 84 | const botSettings = { 85 | requiresWebhook: true, 86 | webhookEndpoint: 'webhook', 87 | }; 88 | 89 | const bot = new MockBot(botSettings); 90 | t.pass(); 91 | }); 92 | 93 | test(`${errorTestTitleBase} with a webhookEndpoint although it does not requires one`, (t) => { 94 | t.plan(1); 95 | 96 | const botSettings = { 97 | webhookEndpoint: 'webhook', 98 | }; 99 | 100 | try { 101 | const bot = new MockBot(botSettings); 102 | } catch (err) { 103 | t.is(err.message.indexOf('do not require webhookEndpoint in') > -1, true); 104 | } 105 | }); 106 | -------------------------------------------------------------------------------- /tests/botmaster/get_bot.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import Botmaster from '../../lib'; 4 | import MockBot from '../_mock_bot'; 5 | 6 | const testTitleBase = 'Botmaster #getBot'; 7 | 8 | let botmaster; 9 | let botOne; 10 | let botTwo; 11 | let botThree; 12 | 13 | test.before(() => { 14 | const botOneOptions = { 15 | type: 'platformOne', 16 | id: 'botOne', 17 | }; 18 | botOne = new MockBot(botOneOptions); 19 | 20 | const botTwoOptions = { 21 | type: 'platformOne', // same type as botOne (but added after) 22 | id: 'botTwo', 23 | }; 24 | botTwo = new MockBot(botTwoOptions); 25 | 26 | const botThreeOptions = { 27 | type: 'platformThree', 28 | id: 'botThree', 29 | }; 30 | botThree = new MockBot(botThreeOptions); 31 | 32 | // just using createServer here so I don't have to close it after. 33 | // i.e. no need for before and after hooks 34 | botmaster = new Botmaster(); 35 | 36 | botmaster.addBot(botOne); 37 | botmaster.addBot(botTwo); 38 | botmaster.addBot(botThree); 39 | }); 40 | 41 | test(`${testTitleBase} should throw an error when getting called without any options `, (t) => { 42 | t.plan(1); 43 | 44 | try { 45 | botmaster.getBot(); 46 | } catch (err) { 47 | t.is(err.message.indexOf('needs exactly one of') > -1, true); 48 | } 49 | }); 50 | 51 | test(`${testTitleBase} should throw an error when getting called without two options`, (t) => { 52 | t.plan(1); 53 | 54 | try { 55 | botmaster.getBot({ 56 | type: 'platformOne', 57 | id: 'botOne', 58 | }); 59 | } catch (err) { 60 | t.is(err.message.indexOf('needs exactly one of') > -1, true); 61 | } 62 | }); 63 | 64 | test(`${testTitleBase} should work when getting called with only id option`, (t) => { 65 | t.plan(1); 66 | 67 | const bot = botmaster.getBot({ 68 | id: 'botOne', 69 | }); 70 | t.is(bot, botOne); 71 | }); 72 | 73 | test(`${testTitleBase} should work when getting called with only type option`, (t) => { 74 | t.plan(1); 75 | 76 | const bot = botmaster.getBot({ 77 | type: 'platformOne', 78 | }); 79 | t.is(bot, botOne); 80 | }); 81 | 82 | test(`${testTitleBase}s should work when getting called with only type option`, (t) => { 83 | t.plan(1); 84 | 85 | try { 86 | botmaster.getBots({ 87 | type: 'platformOne', 88 | }); 89 | } catch (err) { 90 | t.is(err.message.indexOf('takes in a string as') > -1, true); 91 | } 92 | }); 93 | 94 | test(`${testTitleBase}s should return bots of a certain type when requested`, (t) => { 95 | t.plan(6); 96 | 97 | const platformOneBots = botmaster.getBots('platformOne'); 98 | t.is(platformOneBots.length, 2); 99 | t.is(platformOneBots[0].type, 'platformOne'); 100 | t.is(platformOneBots[1].type, 'platformOne'); 101 | 102 | const platformTwoBots = botmaster.getBots('platformTwo'); 103 | t.is(platformTwoBots.length, 0); 104 | 105 | const platformThreeBots = botmaster.getBots('platformThree'); 106 | t.is(platformThreeBots.length, 1); 107 | t.is(platformThreeBots[0].type, 'platformThree'); 108 | }); 109 | 110 | test.after(() => { 111 | return new Promise((resolve) => { 112 | botmaster.server.close(resolve); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /tests/middleware/use_wrapped.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import Botmaster from '../../lib'; 4 | 5 | test.beforeEach((t) => { 6 | return new Promise((resolve) => { 7 | t.context.botmaster = new Botmaster(); 8 | t.context.botmaster.on('listening', resolve); 9 | }); 10 | }); 11 | 12 | test.afterEach((t) => { 13 | return new Promise((resolve) => { 14 | t.context.botmaster.server.close(resolve); 15 | }); 16 | }); 17 | 18 | const errorThrowingMacro = (t, params) => { 19 | t.plan(1); 20 | 21 | const botmaster = t.context.botmaster; 22 | try { 23 | botmaster.useWrapped(params.incomingMiddleware, params.outgoingMiddleware); 24 | } catch (err) { 25 | t.is(err.message, 26 | params.errorMessage, 27 | 'Error message is not the same as expected'); 28 | } 29 | }; 30 | 31 | errorThrowingMacro.title = customTitlePart => 32 | `throws an error if ${customTitlePart}`; 33 | 34 | test('called with no params', errorThrowingMacro, { 35 | errorMessage: 'useWrapped should be called with both an' + 36 | ' incoming and an outgoing middleware', 37 | }); 38 | 39 | test('called with no outgoing middleware', errorThrowingMacro, { 40 | incomingMiddleware: { 41 | type: 'incoming', 42 | controller: __ => __, 43 | }, 44 | errorMessage: 'useWrapped should be called with both an' + 45 | ' incoming and an outgoing middleware', 46 | }); 47 | 48 | test('called with no incoming middleware', errorThrowingMacro, { 49 | outgoingMiddleware: { 50 | type: 'outgoing', 51 | controller: __ => __, 52 | }, 53 | errorMessage: 'useWrapped should be called with both an' + 54 | ' incoming and an outgoing middleware', 55 | }); 56 | 57 | test('called with two incoming middlewares', errorThrowingMacro, { 58 | incomingMiddleware: { 59 | type: 'outgoing', 60 | controller: __ => __, 61 | }, 62 | outgoingMiddleware: { 63 | type: 'outgoing', 64 | controller: __ => __, 65 | }, 66 | errorMessage: 'first argument of "useWrapped" should be an' + 67 | ' incoming middleware', 68 | }); 69 | 70 | test('called with two incoming middlewares', errorThrowingMacro, { 71 | incomingMiddleware: { 72 | type: 'incoming', 73 | controller: __ => __, 74 | }, 75 | outgoingMiddleware: { 76 | type: 'incoming', 77 | controller: __ => __, 78 | }, 79 | errorMessage: 'second argument of "useWrapped" should be an' + 80 | ' outgoing middleware', 81 | }); 82 | 83 | test('middleware gets added where expected', (t) => { 84 | t.plan(4); 85 | 86 | const botmaster = t.context.botmaster; 87 | 88 | const useIncomingController = __ => __; 89 | botmaster.use({ 90 | type: 'incoming', 91 | controller: useIncomingController, 92 | }); 93 | 94 | const useOutgoingController = __ => __; 95 | botmaster.use({ 96 | type: 'outgoing', 97 | controller: useOutgoingController, 98 | }); 99 | 100 | const useWrappedIncomingController = __ => __; 101 | const useWrappedOutgoingController = __ => __; 102 | 103 | botmaster.useWrapped({ 104 | type: 'incoming', 105 | controller: useWrappedIncomingController, 106 | }, { 107 | type: 'outgoing', 108 | controller: useWrappedOutgoingController, 109 | }); 110 | 111 | t.is(botmaster.middleware.incomingMiddlewareStack.length, 2); 112 | t.is(botmaster.middleware.outgoingMiddlewareStack.length, 2); 113 | t.is(botmaster.middleware.incomingMiddlewareStack[0].controller, 114 | useWrappedIncomingController); 115 | t.is(botmaster.middleware.outgoingMiddlewareStack[1].controller, 116 | useWrappedOutgoingController); 117 | }); 118 | -------------------------------------------------------------------------------- /api-reference/botmaster.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Botmaster 4 | The Botmaster class to rule them all 5 | 6 | **Kind**: global class 7 | 8 | * [Botmaster](#Botmaster) 9 | * [new Botmaster(settings)](#new_Botmaster_new) 10 | * [.addBot(bot)](#Botmaster+addBot) ⇒ [Botmaster](#Botmaster) 11 | * [.getBot(options)](#Botmaster+getBot) ⇒ BaseBot 12 | * [.getBots(botType)](#Botmaster+getBots) ⇒ Array 13 | * [.removeBot(bot)](#Botmaster+removeBot) ⇒ [Botmaster](#Botmaster) 14 | * [.use(middleware)](#Botmaster+use) ⇒ [Botmaster](#Botmaster) 15 | * [.useWrapped(incomingMiddleware, outgoingMiddleware)](#Botmaster+useWrapped) ⇒ [Botmaster](#Botmaster) 16 | 17 | 18 | 19 | ### new Botmaster(settings) 20 | sets up a botmaster object attached to the correct server if one is set 21 | as a parameter. If not, it creates its own http server 22 | 23 | 24 | | Param | Type | 25 | | --- | --- | 26 | | settings | object | 27 | 28 | 29 | 30 | ### botmaster.addBot(bot) ⇒ [Botmaster](#Botmaster) 31 | Add an existing bot to this instance of Botmaster 32 | 33 | **Kind**: instance method of [Botmaster](#Botmaster) 34 | **Returns**: [Botmaster](#Botmaster) - returns the botmaster object for chaining 35 | 36 | | Param | Type | Description | 37 | | --- | --- | --- | 38 | | bot | BaseBot | the bot object to add to botmaster. Must be from a subclass of BaseBot | 39 | 40 | 41 | 42 | ### botmaster.getBot(options) ⇒ BaseBot 43 | Extract First bot of given type or provided id. 44 | 45 | **Kind**: instance method of [Botmaster](#Botmaster) 46 | **Returns**: BaseBot - The bot found of a class that inherits of BaseBot 47 | 48 | | Param | Type | Description | 49 | | --- | --- | --- | 50 | | options | object | must be { type: 'someBotType} or { id: someBotId }. | 51 | 52 | 53 | 54 | ### botmaster.getBots(botType) ⇒ Array 55 | Extract all bots of given type. 56 | 57 | **Kind**: instance method of [Botmaster](#Botmaster) 58 | **Returns**: Array - Array of bots found 59 | 60 | | Param | Type | Description | 61 | | --- | --- | --- | 62 | | botType | string | (there can be multiple bots of a same type) | 63 | 64 | 65 | 66 | ### botmaster.removeBot(bot) ⇒ [Botmaster](#Botmaster) 67 | Remove an existing bot from this instance of Botmaster 68 | 69 | **Kind**: instance method of [Botmaster](#Botmaster) 70 | **Returns**: [Botmaster](#Botmaster) - returns the botmaster object for chaining 71 | 72 | | Param | Type | 73 | | --- | --- | 74 | | bot | Object | 75 | 76 | 77 | 78 | ### botmaster.use(middleware) ⇒ [Botmaster](#Botmaster) 79 | Add middleware to this botmaster object 80 | This function is just sugar for `middleware.__use` in them 81 | 82 | **Kind**: instance method of [Botmaster](#Botmaster) 83 | **Returns**: [Botmaster](#Botmaster) - returns the botmaster object so you can chain middleware 84 | 85 | | Param | Type | 86 | | --- | --- | 87 | | middleware | object | 88 | 89 | **Example** 90 | ```js 91 | // The middleware param object is something that looks like this for incoming: 92 | { 93 | type: 'incoming', 94 | name: 'my-incoming-middleware', 95 | controller: (bot, update, next) => { 96 | // do stuff with update, 97 | // call next (or return a promise) 98 | }, 99 | // includeEcho: true (defaults to false), opt-in to get echo updates 100 | // includeDelivery: true (defaults to false), opt-in to get delivery updates 101 | // includeRead: true (defaults to false), opt-in to get user read updates 102 | } 103 | 104 | // and like this for outgoing middleware 105 | 106 | { 107 | type: 'outgoing', 108 | name: 'my-outgoing-middleware', 109 | controller: (bot, update, message, next) => { 110 | // do stuff with message, 111 | // call next (or return a promise) 112 | } 113 | } 114 | ``` 115 | 116 | 117 | ### botmaster.useWrapped(incomingMiddleware, outgoingMiddleware) ⇒ [Botmaster](#Botmaster) 118 | Add wrapped middleware to this botmaster instance. Wrapped middleware 119 | places the incoming middleware at beginning of incoming stack and 120 | the outgoing middleware at end of outgoing stack. 121 | This function is just sugar `middleware.useWrapped`. 122 | 123 | **Kind**: instance method of [Botmaster](#Botmaster) 124 | **Returns**: [Botmaster](#Botmaster) - returns the botmaster object so you can chain middleware 125 | 126 | | Param | Type | Description | 127 | | --- | --- | --- | 128 | | incomingMiddleware | object | | 129 | | outgoingMiddleware | object | The middleware objects are as you'd expect them to be (see use) | 130 | 131 | -------------------------------------------------------------------------------- /tests/_mock_bot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseBot = require('../lib/base_bot'); 4 | const express = require('express'); 5 | const expressBodyParser = require('body-parser'); 6 | const Koa = require('koa'); 7 | const assign = require('lodash').assign; 8 | const get = require('lodash').get; 9 | const merge = require('lodash').merge; 10 | 11 | class MockBot extends BaseBot { 12 | 13 | /** 14 | * Bot class that allows testers to create instances of a large number 15 | * of different bot instances with various different settings. 16 | * 17 | * @param {object} settings 18 | */ 19 | constructor(settings) { 20 | super(settings); 21 | if (!settings) { 22 | settings = {}; 23 | } 24 | this.type = settings.type || 'mock'; 25 | // the following settings would be hard coded in a standard 26 | // bot class implementation. 27 | this.requiresWebhook = settings.requiresWebhook || false; 28 | this.requiredCredentials = settings.requiredCredentials || []; 29 | 30 | this.receives = settings.receives || { 31 | text: true, 32 | attachment: { 33 | audio: true, 34 | file: true, 35 | image: true, 36 | video: true, 37 | location: true, 38 | // can occur in FB messenger when user sends a message which only contains a URL 39 | // most platforms won't support that 40 | fallback: true, 41 | }, 42 | echo: true, 43 | read: true, 44 | delivery: true, 45 | postback: true, 46 | quickReply: true, 47 | }; 48 | 49 | this.sends = settings.sends || { 50 | text: true, 51 | quickReply: true, 52 | locationQuickReply: true, 53 | senderAction: { 54 | typingOn: true, 55 | typingOff: true, 56 | markSeen: true, 57 | }, 58 | attachment: { 59 | audio: true, 60 | file: true, 61 | image: true, 62 | video: true, 63 | }, 64 | }; 65 | 66 | this.retrievesUserInfo = settings.retrievesUserInfo || false; 67 | this.id = settings.id || 'mockId'; 68 | 69 | this.__applySettings(settings); 70 | if (this.webhookEndpoint) { 71 | if (this.webhookEndpoint.indexOf('koa') > -1) { 72 | this.__createKoaMountPoints(); 73 | } else { 74 | // default to express 75 | this.__createExpressMountPoints(); 76 | } 77 | } 78 | } 79 | 80 | // Note how neither of those classes uses webhookEndpoint. 81 | // This is because I can now count on botmaster to make sure that requests 82 | // meant to go to this bot are indeed routed to this bot. 83 | __createExpressMountPoints() { 84 | const app = express(); 85 | this.requestListener = app; 86 | 87 | // for parsing application/json 88 | app.use(expressBodyParser.json()); 89 | 90 | app.post('*', (req, res) => { 91 | const update = this.__formatRawUpdate(req.body); 92 | this.__emitUpdate(update); 93 | 94 | res.sendStatus(200); 95 | }); 96 | } 97 | 98 | __createKoaMountPoints() { 99 | const app = new Koa(); 100 | this.requestListener = app.callback(); 101 | 102 | app.use((ctx) => { 103 | let bodyString = ''; 104 | ctx.req.on('data', (chunk) => { 105 | bodyString += chunk; 106 | }); 107 | 108 | ctx.req.on('end', () => { 109 | const body = JSON.parse(bodyString); 110 | const update = this.__formatRawUpdate(body); 111 | this.__emitUpdate(update); 112 | }); 113 | 114 | ctx.status = 200; 115 | }); 116 | } 117 | 118 | 119 | __formatRawUpdate(rawUpdate) { 120 | const timestamp = Math.floor(Date.now()); 121 | const recipientId = get('recipient.id', rawUpdate, 'update_id'); 122 | 123 | const update = { 124 | raw: rawUpdate, 125 | sender: { 126 | id: recipientId, 127 | }, 128 | recipient: { 129 | id: this.id, 130 | }, 131 | timestamp, 132 | message: { 133 | mid: `${this.id}.${recipientId}.${String(timestamp)}.`, 134 | seq: null, 135 | }, 136 | }; 137 | 138 | merge(update, rawUpdate); 139 | 140 | return update; 141 | } 142 | 143 | // doesn't actually do anything in mock_bot 144 | __formatOutgoingMessage(outgoingMessage) { 145 | const rawMessage = assign({}, outgoingMessage); 146 | return Promise.resolve(rawMessage); 147 | } 148 | 149 | __sendMessage(rawMessage) { 150 | const responseBody = { 151 | nonStandard: 'responseBody', 152 | }; 153 | 154 | return Promise.resolve(responseBody); 155 | } 156 | 157 | __createStandardBodyResponseComponents(sentOutgoingMessage, sentRawMessage, raw) { 158 | const timestamp = Math.floor(Date.now()); 159 | 160 | return Promise.resolve({ 161 | recipient_id: sentRawMessage.recipient.id, 162 | message_id: `${this.id}.${sentRawMessage.recipient.id}.${String(timestamp)}`, 163 | }); 164 | } 165 | 166 | __getUserInfo(userId) { 167 | return Promise.resolve({ 168 | first_name: 'Peter', 169 | last_name: 'Chang', 170 | profile_pic: 'https://fbcdn-profile-a.akamaihd.net/hprofile-ak-xpf1/v/t1.0-1/p200x200/13055603_10105219398495383_8237637584159975445_n.jpg?oh=1d241d4b6d4dac50eaf9bb73288ea192&oe=57AF5C03&__gda__=1470213755_ab17c8c8e3a0a447fed3f272fa2179ce', 171 | locale: 'en_US', 172 | timezone: -7, 173 | gender: 'male', 174 | }); 175 | } 176 | 177 | } 178 | 179 | module.exports = MockBot; 180 | -------------------------------------------------------------------------------- /tests/botmaster/constructor.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import http from 'http'; 3 | import express from 'express'; 4 | import Koa from 'koa'; 5 | import request from 'request-promise'; 6 | 7 | import MockBot from '../_mock_bot'; 8 | import Botmaster from '../../lib'; 9 | 10 | // just this code to make sure unhandled exceptions are printed to 11 | // the console when developing. 12 | process.on('unhandledRejection', (err) => { 13 | console.error('UNHANDLED REJECTION', err.stack); 14 | }); 15 | 16 | // const request = require('request-promise'); 17 | // const JsonFileStore = require('jfs'); 18 | 19 | test('shouldn\'t throw any error if settings aren\'t specified', (t) => { 20 | t.plan(2); 21 | 22 | return new Promise((resolve) => { 23 | const botmaster = new Botmaster(); 24 | 25 | botmaster.on('listening', () => { 26 | t.is(botmaster.server.address().port, 3000); 27 | botmaster.server.close(() => { 28 | t.pass(); 29 | resolve(); 30 | }); 31 | }); 32 | }); 33 | }); 34 | 35 | test('should throw any error if settings.botsSetting are specified', (t) => { 36 | t.plan(1); 37 | 38 | const settings = { 39 | botsSettings: 'something', 40 | }; 41 | try { 42 | const botmaster = new Botmaster(settings); 43 | } catch (e) { 44 | t.is(e.message.indexOf( 45 | 'Starting botmaster with botsSettings is no longer supported') > -1, 46 | true, e.message); 47 | } 48 | }); 49 | 50 | test('should throw any error if settings.app are specified', (t) => { 51 | t.plan(1); 52 | 53 | const settings = { 54 | app: 'something', 55 | }; 56 | try { 57 | const botmaster = new Botmaster(settings); 58 | } catch (e) { 59 | t.is(e.message.indexOf( 60 | 'Starting botmaster with app is no longer') > -1, true, 61 | e.message); 62 | } 63 | }); 64 | 65 | test('should use my server when passed in settings', (t) => { 66 | t.plan(2); 67 | const myServer = http.createServer(); 68 | 69 | const settings = { 70 | server: myServer, 71 | }; 72 | const botmaster = new Botmaster(settings); 73 | t.is(botmaster.server === myServer, true); 74 | t.is(botmaster.server, myServer); 75 | }); 76 | 77 | test('should correctly set port when passed in settings', (t) => { 78 | t.plan(1); 79 | 80 | return new Promise((resolve) => { 81 | const settings = { 82 | port: 5000, 83 | }; 84 | const botmaster = new Botmaster(settings); 85 | 86 | botmaster.on('listening', () => { 87 | t.is(botmaster.server.address().port, 5000); 88 | botmaster.server.close(resolve); 89 | }); 90 | }); 91 | }); 92 | 93 | test('should throw and error when server and port passed in settings', (t) => { 94 | t.plan(1); 95 | 96 | const myServer = http.createServer(); 97 | 98 | try { 99 | const settings = { 100 | server: myServer, 101 | port: 4000, 102 | }; 103 | const botmaster = new Botmaster(settings); 104 | } catch (e) { 105 | t.is(e.message.indexOf('IncompatibleArgumentsError') > -1, true); 106 | } 107 | }); 108 | 109 | test('when used with default botmaster server,' + 110 | 'requestListener should return 404s to unfound routes', (t) => { 111 | t.plan(1); 112 | 113 | return new Promise((resolve) => { 114 | const botmaster = new Botmaster(); 115 | 116 | botmaster.on('listening', () => { 117 | const options = { 118 | uri: 'http://localhost:3000/someRoute', 119 | json: true, 120 | }; 121 | request.get(options) 122 | 123 | .catch((err) => { 124 | t.is(err.error.message, 'Couldn\'t GET /someRoute'); 125 | botmaster.server.close(resolve); 126 | }); 127 | }); 128 | }); 129 | }); 130 | 131 | test('when used with a server created with an express app' + 132 | 'requestListener should route non botmaster requests to express app', (t) => { 133 | t.plan(2); 134 | const app = express(); 135 | const appResponse = { 136 | text: 'wadup?', 137 | }; 138 | 139 | app.use('/someRoute', (req, res) => { 140 | res.json(appResponse); 141 | }); 142 | 143 | return new Promise((resolve) => { 144 | const myServer = app.listen(3000); 145 | const botmaster = new Botmaster({ server: myServer }); 146 | 147 | myServer.on('listening', () => { 148 | const options = { 149 | uri: 'http://localhost:3000/someRoute', 150 | json: true, 151 | }; 152 | request.get(options) 153 | 154 | .then((body) => { 155 | t.deepEqual(appResponse, body); 156 | t.is(botmaster.server, myServer); 157 | botmaster.server.close(resolve); 158 | }); 159 | }); 160 | }); 161 | }); 162 | 163 | test('when used with a server created with a koa app' + 164 | 'requestListener should route non botmaster requests to koa app', (t) => { 165 | t.plan(2); 166 | const app = new Koa(); 167 | const appResponse = { 168 | text: 'wadup?', 169 | }; 170 | 171 | app.use((ctx) => { 172 | if (ctx.request.url === '/someRoute') { 173 | ctx.body = appResponse; 174 | } 175 | }); 176 | 177 | return new Promise((resolve) => { 178 | const myServer = app.listen(3000); 179 | const botmaster = new Botmaster({ server: myServer }); 180 | 181 | myServer.on('listening', () => { 182 | const options = { 183 | uri: 'http://localhost:3000/someRoute', 184 | json: true, 185 | }; 186 | request.get(options) 187 | 188 | .then((body) => { 189 | t.deepEqual(body, appResponse); 190 | t.is(botmaster.server, myServer); 191 | botmaster.server.close(() => { 192 | resolve(); 193 | }); 194 | }); 195 | }); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /tests/botmaster/add_bot.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import http from 'http'; 3 | import express from 'express'; 4 | import request from 'request-promise'; 5 | 6 | import Botmaster from '../../lib'; 7 | import MockBot from '../_mock_bot'; 8 | 9 | test('works with a bot that doesn\'t require webhhooks', (t) => { 10 | t.plan(3); 11 | 12 | return new Promise((resolve) => { 13 | const botmaster = new Botmaster(); 14 | 15 | botmaster.on('listening', () => { 16 | const bot = new MockBot(); 17 | 18 | botmaster.addBot(bot); 19 | t.is(Object.keys(botmaster.__serverRequestListeners).length, 0); 20 | t.is(botmaster.bots.length, 1); 21 | t.is(botmaster.bots[0], bot); 22 | botmaster.server.close(resolve); 23 | }); 24 | }); 25 | }); 26 | 27 | const arbitraryBotMacro = (t, { botmasterSettings, botSettings }) => { 28 | t.plan(3); 29 | return new Promise((resolve) => { 30 | const botmaster = new Botmaster(botmasterSettings); 31 | 32 | botmaster.on('listening', () => { 33 | const bot = new MockBot(botSettings); 34 | 35 | botmaster.addBot(bot); 36 | t.is(Object.keys(botmaster.__serverRequestListeners).length, 1); 37 | t.is(botmaster.bots.length, 1); 38 | 39 | const uri = botmaster.settings.useDefaultMountPathPrepend 40 | ? `http://localhost:3000/${botSettings.type}/webhook/endpoint` 41 | : 'http://localhost:3000/webhook/endpoint'; 42 | 43 | const updateToSend = { text: 'Hello world' }; 44 | const requestOptions = { 45 | method: 'POST', 46 | uri, 47 | json: updateToSend, 48 | }; 49 | 50 | request(requestOptions); 51 | 52 | botmaster.use({ 53 | type: 'incoming', 54 | controller: (onUpdateBot, update) => { 55 | t.deepEqual(update.raw, updateToSend); 56 | botmaster.server.close(resolve); 57 | }, 58 | }); 59 | 60 | botmaster.on('error', () => { 61 | botmaster.server.close(resolve); 62 | }); 63 | }); 64 | }); 65 | }; 66 | 67 | test('works with an express bot', arbitraryBotMacro, { 68 | botSettings: { 69 | requiresWebhook: true, 70 | webhookEndpoint: 'webhook/endpoint', 71 | type: 'express', 72 | }, 73 | }); 74 | 75 | test('works with a koa bot', arbitraryBotMacro, { 76 | botSettings: { 77 | requiresWebhook: true, 78 | webhookEndpoint: 'webhook/endpoint', 79 | type: 'koa', 80 | }, 81 | }); 82 | 83 | test('works with a webhook that has slash bot', arbitraryBotMacro, { 84 | botSettings: { 85 | requiresWebhook: true, 86 | webhookEndpoint: '/webhook/endpoint/', 87 | type: 'express', 88 | }, 89 | }); 90 | 91 | // this test could also have been in constructor. As it spans over both constructor and bot adding 92 | test('should accept requests where expected when useDefaultMountPathPrepend is truthy', arbitraryBotMacro, { 93 | botmasterSettings: { 94 | useDefaultMountPathPrepend: false, 95 | }, 96 | botSettings: { 97 | requiresWebhook: true, 98 | webhookEndpoint: 'webhook/endpoint', 99 | type: 'express', 100 | }, 101 | }); 102 | 103 | test('works with an express server AND both an express and a koa bot', (t) => { 104 | t.plan(6); 105 | 106 | return new Promise((resolve) => { 107 | // just dev's personal app stuff 108 | const app = express(); 109 | 110 | const appResponse = { 111 | text: 'wadup?', 112 | }; 113 | 114 | app.use('/someRoute', (req, res) => { 115 | res.json(appResponse); 116 | }); 117 | // /////////////////////////////// 118 | 119 | const myServer = http.createServer(app); 120 | 121 | const botmaster = new Botmaster({ 122 | server: myServer, 123 | }); 124 | 125 | myServer.listen(3000, () => { 126 | // creating and adding bots 127 | const koaBotSettings = { 128 | requiresWebhook: true, 129 | webhookEndpoint: 'webhook', 130 | type: 'koa', 131 | }; 132 | 133 | const expressBotSettings = { 134 | requiresWebhook: true, 135 | webhookEndpoint: 'webhook', 136 | type: 'express', 137 | }; 138 | const koaBot = new MockBot(koaBotSettings); 139 | const expressBot = new MockBot(expressBotSettings); 140 | 141 | botmaster.addBot(koaBot); 142 | botmaster.addBot(expressBot); 143 | t.is(Object.keys(botmaster.__serverRequestListeners).length, 2); 144 | t.is(botmaster.bots.length, 2); 145 | // /////////////////////////////// 146 | 147 | // send requests to bots 148 | const updateToSendToKoaBot = { text: 'Hello Koa Bot' }; 149 | const updateToSendToExpressBot = { text: 'Hello express Bot' }; 150 | 151 | const koaBotRequestOptions = { 152 | method: 'POST', 153 | uri: 'http://localhost:3000/koa/webhook', 154 | json: updateToSendToKoaBot, 155 | }; 156 | 157 | const expressBotRequestOptions = { 158 | method: 'POST', 159 | uri: 'http://localhost:3000/express/webhook', 160 | json: updateToSendToExpressBot, 161 | }; 162 | 163 | request(koaBotRequestOptions) 164 | .then(() => request(expressBotRequestOptions)); 165 | // //////////////////////////// 166 | 167 | // catch update events 168 | let receivedUpdatesCount = 0; 169 | botmaster.use({ 170 | type: 'incoming', 171 | controller: ('update', (onUpdateBot, update) => { 172 | receivedUpdatesCount += 1; 173 | if (update.raw.text.indexOf('Koa') > -1) { 174 | t.deepEqual(update.raw, updateToSendToKoaBot); 175 | } else if (update.raw.text.indexOf('express') > -1) { 176 | t.deepEqual(update.raw, updateToSendToExpressBot); 177 | } 178 | if (receivedUpdatesCount === 2) { 179 | const appRequestOptions = { 180 | uri: 'http://localhost:3000/someRoute', 181 | json: true, 182 | }; 183 | request.get(appRequestOptions) 184 | 185 | .then((body) => { 186 | t.deepEqual(appResponse, body); 187 | t.is(botmaster.server, myServer); 188 | botmaster.server.close(resolve); 189 | }); 190 | } 191 | }), 192 | }); 193 | // //////////////////////////// 194 | }); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /lib/middleware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const get = require('lodash').get; 4 | const debug = require('debug')('botmaster:middleware'); 5 | 6 | class Middleware { 7 | /** 8 | * Singleton Middleware class every botmaster instance should own one of 9 | * incomingMiddleware and 10 | * outgoingMiddleware variables; 11 | * 12 | * This class is not part of the exposed API. Use botmaster.use instead 13 | * @ignore 14 | */ 15 | constructor() { 16 | this.incomingMiddlewareStack = []; 17 | this.outgoingMiddlewareStack = []; 18 | } 19 | 20 | /** 21 | * Add middleware. 22 | * See botmaster #use for more info. 23 | * @ignore 24 | */ 25 | __use(middleware) { 26 | this.__validateMiddleware(middleware); 27 | 28 | if (middleware.type === 'incoming') { 29 | this.incomingMiddlewareStack.push(middleware); 30 | debug(`added ${middleware.name || 'nameless'} incoming middleware`); 31 | } else { 32 | this.outgoingMiddlewareStack.push(middleware); 33 | debug(`added ${middleware.name || 'nameless'} outgoing middleware`); 34 | } 35 | 36 | return this; 37 | } 38 | 39 | /** 40 | * Add Wrapped middleware 41 | * See botmaster #useWrapped for more info. 42 | * @ignore 43 | * @param {object} params 44 | */ 45 | __useWrapped(incomingMiddleware, outgoingMiddleware) { 46 | if (!incomingMiddleware || !outgoingMiddleware) { 47 | throw new Error('useWrapped should be called with both an' + 48 | ' incoming and an outgoing middleware'); 49 | } 50 | this.__validateMiddleware(incomingMiddleware); 51 | this.__validateMiddleware(outgoingMiddleware); 52 | 53 | if (incomingMiddleware.type === 'outgoing') { 54 | throw new TypeError('first argument of "useWrapped" should be an' + 55 | ' incoming middleware'); 56 | } else if (outgoingMiddleware.type === 'incoming') { 57 | throw new TypeError('second argument of "useWrapped" should be an' + 58 | ' outgoing middleware'); 59 | } 60 | 61 | this.incomingMiddlewareStack.unshift(incomingMiddleware); 62 | this.outgoingMiddlewareStack.push(outgoingMiddleware); 63 | debug(`added wrapped ${incomingMiddleware.name || 'nameless'} incoming middleware`); 64 | debug(`added wrapped ${outgoingMiddleware.name || 'nameless'} outgoing middleware`); 65 | 66 | return this; 67 | } 68 | 69 | __validateMiddleware(middleware) { 70 | if (typeof middleware !== 'object') { 71 | throw new Error(`middleware should be an object. Not ${typeof middleware}`); 72 | } 73 | 74 | const middlewareController = middleware.controller; 75 | 76 | if (middleware.type !== 'incoming' && middleware.type !== 'outgoing') { 77 | throw new TypeError('invalid middleware type. Type should be either ' + 78 | '\'incoming\' or \'outgoing\''); 79 | } 80 | if (typeof middlewareController !== 'function') { 81 | throw new TypeError('middleware controller can\'t be of type ' + 82 | `${typeof middlewareController}. It needs to be a function`); 83 | } 84 | } 85 | 86 | __runIncomingMiddleware(bot, update) { 87 | return this.__runMiddlewareStack({ 88 | bot, 89 | update, 90 | middlewareStack: this.incomingMiddlewareStack, 91 | }); 92 | } 93 | 94 | __runOutgoingMiddleware(bot, associatedUpdate, message) { 95 | return this.__runMiddlewareStack({ 96 | bot, 97 | update: associatedUpdate, 98 | message, 99 | middlewareStack: this.outgoingMiddlewareStack, 100 | }); 101 | } 102 | 103 | __runMiddlewareStack(context) { 104 | const bot = context.bot; 105 | const update = context.update; 106 | const patchedBot = bot.__createBotPatchedWithUpdate(update); 107 | const message = context.message; 108 | const middlewareStack = context.middlewareStack; 109 | 110 | let middlewarePromiseStack = Promise.resolve(); 111 | 112 | const throwThrowableResolvedValue = (resolvedValue) => { 113 | if (resolvedValue === 'cancel' || resolvedValue === 'skip') { 114 | throw resolvedValue; 115 | } 116 | }; 117 | 118 | for (const middleware of middlewareStack) { 119 | middlewarePromiseStack = middlewarePromiseStack 120 | 121 | .then((resolvedValue) => { 122 | throwThrowableResolvedValue(resolvedValue); 123 | // otherwise, do nothing with resolvedValue 124 | if (this.__shouldRun(middleware, context)) { 125 | let innerPromise; 126 | return new Promise((resolve, reject) => { 127 | // next is a patched reject so that we can determine if 128 | // next was called within a returned promise, which is not allowed 129 | const next = err => reject({ 130 | err, 131 | nextRejection: true, 132 | }); 133 | if (middlewareStack === this.incomingMiddlewareStack) { 134 | innerPromise = middleware.controller( 135 | patchedBot, update, next); 136 | } else { 137 | innerPromise = middleware.controller( 138 | patchedBot, update, message, next); 139 | } 140 | 141 | if (innerPromise && innerPromise.constructor === Promise) { 142 | innerPromise.then(resolve).catch(reject); 143 | } 144 | }).catch((err) => { 145 | if (err && err.nextRejection) { 146 | if (innerPromise && innerPromise.constructor === Promise) { 147 | throw new Error('next can\'t be called if middleware ' + 148 | 'returns a promise/is an async function'); 149 | } else if (err.err) { 150 | throw err.err; 151 | } else { 152 | return; 153 | } 154 | } 155 | 156 | throw err; 157 | }); 158 | } 159 | // otherwise, return nothing 160 | return Promise.resolve(); 161 | }); 162 | } 163 | 164 | return middlewarePromiseStack 165 | 166 | .then((resolvedValue) => { 167 | throwThrowableResolvedValue(resolvedValue); 168 | }) 169 | .catch((err) => { 170 | if (err === 'skip') { 171 | return Promise.resolve(); 172 | } 173 | 174 | throw err; 175 | }); 176 | } 177 | 178 | /** 179 | * Simply returns true or false based on whether this middleware function 180 | * should be run for this object. 181 | * @ignore 182 | * @param {object} options 183 | * 184 | * @example 185 | * // options is an object that can contain any of: 186 | * { 187 | * includeEcho, // opt-in to get echo updates 188 | * includeDelivery, // opt-in to get delivery updates 189 | * includeRead, // opt-in to get read updates 190 | * } 191 | */ 192 | __shouldRun(middleware, context) { 193 | if (middleware.type === 'outgoing') { 194 | // for now, no condition to not run outgoing middleware 195 | return true; 196 | } 197 | // we are de facto dealing with incoming middleware 198 | const ignoreReceivedEchoUpdate = !middleware.includeEcho && 199 | get(context.update, 'message.is_echo'); 200 | const ignoreReceivedDeliveryUpdate = !middleware.includeDelivery && 201 | get(context.update, 'delivery'); 202 | const ignoreReceivedReadUpdate = !middleware.includeRead && 203 | get(context.update, 'read'); 204 | 205 | if (ignoreReceivedEchoUpdate || 206 | ignoreReceivedDeliveryUpdate || 207 | ignoreReceivedReadUpdate) { 208 | return false; 209 | } 210 | 211 | return true; 212 | } 213 | } 214 | 215 | module.exports = Middleware; 216 | -------------------------------------------------------------------------------- /lib/botmaster.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('http'); 4 | const EventEmitter = require('events'); 5 | const find = require('lodash').find; 6 | const remove = require('lodash').remove; 7 | const has = require('lodash').has; 8 | const debug = require('debug')('botmaster:botmaster'); 9 | const TwoDotXError = require('./errors').TwoDotXError; 10 | const Middleware = require('./middleware'); 11 | 12 | /** 13 | * The Botmaster class to rule them all 14 | */ 15 | 16 | class Botmaster extends EventEmitter { 17 | /** 18 | * sets up a botmaster object attached to the correct server if one is set 19 | * as a parameter. If not, it creates its own http server 20 | * 21 | * @param {object} settings 22 | * 23 | * @example 24 | * // attach the botmaster generated server to port 5000 rather than the default 3000 25 | * const botmaster = new Botmaster({ 26 | * port: 5000, 27 | * }); 28 | * 29 | * @example 30 | * const http = require('http'); 31 | * 32 | * const myServer = http.createServer() 33 | * // use my own server rather than letting botmaster creat its own. 34 | * const botmaster = new Botmaster({ 35 | * server: myServer, 36 | * }); 37 | */ 38 | 39 | constructor(settings) { 40 | super(); 41 | this.settings = settings || {}; 42 | this.__throwPotentialUnsupportedSettingsErrors(); 43 | this.__setupServer(); 44 | this.middleware = new Middleware(this); 45 | 46 | // this is used for mounting routes onto bot classes "mini-apps"" 47 | this.__serverRequestListeners = {}; 48 | // default useDefaultMountPathPrepend to true 49 | if (this.settings.useDefaultMountPathPrepend === undefined) { 50 | this.settings.useDefaultMountPathPrepend = true; 51 | } 52 | this.bots = []; 53 | } 54 | 55 | __throwPotentialUnsupportedSettingsErrors() { 56 | const unsupportedSettings = ['botsSettings', 'app']; 57 | 58 | for (const settingName of unsupportedSettings) { 59 | if (this.settings[settingName]) { 60 | throw new TwoDotXError( 61 | `Starting botmaster with ${settingName} ` + 62 | 'is no longer supported.'); 63 | } 64 | } 65 | } 66 | 67 | __setupServer() { 68 | if (this.settings.server && this.settings.port) { 69 | throw new Error( 70 | 'IncompatibleArgumentsError: Please specify only ' + 71 | 'one of port and server'); 72 | } 73 | if (this.settings.server) { 74 | this.server = this.settings.server; 75 | } else { 76 | const port = has(this, 'settings.port') 77 | ? this.settings.port 78 | : 3000; 79 | this.server = this.__listen(port); 80 | } 81 | this.__setupServersRequestListeners(); 82 | } 83 | 84 | __setupServersRequestListeners() { 85 | const nonBotmasterListeners = this.server.listeners('request').slice(0); 86 | this.server.removeAllListeners('request'); 87 | 88 | this.server.on('request', (req, res) => { 89 | // run botmaster requestListeners first 90 | for (const path in this.__serverRequestListeners) { 91 | if (req.url.indexOf(path) === 0) { 92 | const requestListener = this.__serverRequestListeners[path]; 93 | return requestListener.call(this.server, req, res); 94 | } 95 | } 96 | // then run the non-botmaster ones 97 | if (nonBotmasterListeners.length > 0) { 98 | for (const requestListener of nonBotmasterListeners) { 99 | requestListener.call(this.server, req, res); 100 | } 101 | } else { 102 | // just return a 404 103 | res.writeHead(404, { 'Content-Type': 'application/json' }); 104 | res.end(JSON.stringify({ message: `Couldn't ${req.method} ${req.url}` })); 105 | } 106 | }); 107 | } 108 | 109 | __listen(port) { 110 | const server = http.createServer(); 111 | server.listen(port, '0.0.0.0', () => { // running it for the public 112 | const serverMsg = `server parameter not specified. Running new server on port: ${port}`; 113 | debug(serverMsg); 114 | this.emit('listening', serverMsg); 115 | }); 116 | 117 | return server; 118 | } 119 | 120 | /** 121 | * Add an existing bot to this instance of Botmaster 122 | * 123 | * @param {BaseBot} bot the bot object to add to botmaster. Must be from 124 | * a subclass of BaseBot 125 | * 126 | * @return {Botmaster} returns the botmaster object for chaining 127 | */ 128 | addBot(bot) { 129 | if (bot.requiresWebhook) { 130 | const path = this.__getBotWebhookPath(bot); 131 | this.__serverRequestListeners[path] = bot.requestListener; 132 | } 133 | bot.master = this; 134 | this.bots.push(bot); 135 | bot.on('error', (err, update) => { 136 | debug(err.message); 137 | this.emit('error', bot, err, update); 138 | }); 139 | 140 | debug(`added bot of type: ${bot.type} with id: ${bot.id}`); 141 | 142 | return this; 143 | } 144 | 145 | __getBotWebhookPath(bot) { 146 | const webhookEndpoint = bot.webhookEndpoint.replace(/^\/|\/$/g, ''); 147 | 148 | const path = this.settings.useDefaultMountPathPrepend 149 | ? `/${bot.type}/${webhookEndpoint}` 150 | : `/${webhookEndpoint}`; 151 | 152 | return path; 153 | } 154 | 155 | /** 156 | * Extract First bot of given type or provided id. 157 | * 158 | * @param {object} options must be { type: 'someBotType} or { id: someBotId }. 159 | * 160 | * @return {BaseBot} The bot found of a class that inherits of BaseBot 161 | */ 162 | getBot(options) { 163 | if (!options || 164 | (!options.type && !options.id) || 165 | (options.type && options.id)) { 166 | throw new Error('\'getBot\' needs exactly one of type or id'); 167 | } 168 | 169 | if (options.id) { 170 | return find(this.bots, { id: options.id }); 171 | } 172 | 173 | return find(this.bots, { type: options.type }); 174 | } 175 | 176 | /** 177 | * Extract all bots of given type. 178 | * 179 | * @param {string} botType (there can be multiple bots of a same type) 180 | * 181 | * @return {Array} Array of bots found 182 | */ 183 | getBots(botType) { 184 | if (typeof botType !== 'string' && !(botType instanceof String)) { 185 | throw new Error('\'getBots\' takes in a string as only parameter'); 186 | } 187 | 188 | const foundBots = []; 189 | for (const bot of this.bots) { 190 | if (bot.type === botType) { 191 | foundBots.push(bot); 192 | } 193 | } 194 | 195 | return foundBots; 196 | } 197 | 198 | /** 199 | * Remove an existing bot from this instance of Botmaster 200 | * 201 | * @param {Object} bot 202 | * 203 | * @return {Botmaster} returns the botmaster object for chaining 204 | */ 205 | removeBot(bot) { 206 | if (bot.requiresWebhook) { 207 | const path = this.__getBotWebhookPath(bot); 208 | delete this.__serverRequestListeners[path]; 209 | } 210 | remove(this.bots, bot); 211 | bot.removeAllListeners(); 212 | 213 | debug(`removed bot of type: ${bot.type} with id: ${bot.id}`); 214 | 215 | return this; 216 | } 217 | 218 | /** 219 | * Add middleware to this botmaster object 220 | * This function is just sugar for `middleware.__use` in them 221 | * 222 | * @param {object} middleware 223 | * 224 | * @example 225 | * 226 | * // The middleware param object is something that looks like this for incoming: 227 | * { 228 | * type: 'incoming', 229 | * name: 'my-incoming-middleware', 230 | * controller: (bot, update, next) => { 231 | * // do stuff with update, 232 | * // call next (or return a promise) 233 | * }, 234 | * // includeEcho: true (defaults to false), opt-in to get echo updates 235 | * // includeDelivery: true (defaults to false), opt-in to get delivery updates 236 | * // includeRead: true (defaults to false), opt-in to get user read updates 237 | * } 238 | * 239 | * // and like this for outgoing middleware 240 | * 241 | * { 242 | * type: 'outgoing', 243 | * name: 'my-outgoing-middleware', 244 | * controller: (bot, update, message, next) => { 245 | * // do stuff with message, 246 | * // call next (or return a promise) 247 | * } 248 | * } 249 | * 250 | * @return {Botmaster} returns the botmaster object so you can chain middleware 251 | * 252 | */ 253 | use(middleware) { 254 | this.middleware.__use(middleware); 255 | 256 | return this; 257 | } 258 | 259 | /** 260 | * Add wrapped middleware to this botmaster instance. Wrapped middleware 261 | * places the incoming middleware at beginning of incoming stack and 262 | * the outgoing middleware at end of outgoing stack. 263 | * This function is just sugar `middleware.useWrapped`. 264 | * 265 | * @param {object} incomingMiddleware 266 | * @param {object} outgoingMiddleware 267 | * 268 | * The middleware objects are as you'd expect them to be (see use) 269 | * 270 | * @return {Botmaster} returns the botmaster object so you can chain middleware 271 | */ 272 | useWrapped(incomingMiddleware, outgoingMiddleware) { 273 | this.middleware.__useWrapped(incomingMiddleware, outgoingMiddleware); 274 | 275 | return this; 276 | } 277 | } 278 | 279 | module.exports = Botmaster; 280 | -------------------------------------------------------------------------------- /lib/outgoing_message.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assign = require('lodash').assign; 4 | const has = require('lodash').has; 5 | const set = require('lodash').set; 6 | const unset = require('lodash').unset; 7 | 8 | /** 9 | * This class will help you compose sendable message objects. 10 | */ 11 | 12 | class OutgoingMessage { 13 | 14 | /** 15 | * Constructor to the OutgoingMessage class. Takes in an optional 16 | * message object that it will use as its base to add the OutgoingMessage 17 | * methods to. This constructor is not actually exposed in the public API. 18 | * In order to instantiate an OutgoingMessage object, you'll need to use the 19 | * createOutgoingMessage and createOutgoingMessageFor methods provided with 20 | * all classes that inherit from BaseBot. There are static and non-static 21 | * versions of both methods to make sure you can do so wherever as you wish 22 | * 23 | * @private 24 | * @param {object} [message] the base object to convert into an OutgoingMessage object 25 | */ 26 | constructor(message) { 27 | if (!message) { 28 | message = {}; 29 | } 30 | if (typeof message !== 'object') { 31 | throw new TypeError('OutgoingMessage constructor takes in an object as param'); 32 | } 33 | assign(this, message); 34 | 35 | return this; 36 | } 37 | 38 | __addProperty(path, nameForError, value) { 39 | if (!value) { 40 | throw new Error(`${nameForError} must have a value. Can't be ${value}`); 41 | } else if (has(this, path)) { 42 | throw new Error(`Can't add ${nameForError} to outgoingMessage that already has ${nameForError}`); 43 | } 44 | set(this, path, value); 45 | 46 | return this; 47 | } 48 | 49 | __removeProperty(path, nameForError) { 50 | if (!has(this, path)) { 51 | throw new Error(`Can't remove ${nameForError} from outgoingMessage that doesn't have any ${nameForError}`); 52 | } 53 | unset(this, path); 54 | 55 | return this; 56 | } 57 | 58 | /** 59 | * Adds `recipient.id` param to the OutgoingMessage object. This is most 60 | * likely what you will want to do to add a recipient. Alternatively, you Can 61 | * use addRecipientByPhoneNumber if the platform you are sending the message to 62 | * supports that. 63 | * 64 | * @param {string} id the id to add to the OutgoingMessage object 65 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 66 | */ 67 | addRecipientById(id) { 68 | const recipient = { 69 | id, 70 | }; 71 | return this.__addProperty('recipient', 'recipient', recipient); 72 | } 73 | 74 | /** 75 | * Adds `recipient.phone_number` param to the OutgoingMessage object. 76 | * You might prefer to add a recipient by id rather. This is achieved via 77 | * addRecipientById 78 | * 79 | * @param {string} phoneNumber the phone number to add to the OutgoingMessage object 80 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 81 | */ 82 | addRecipientByPhoneNumber(phoneNumber) { 83 | const recipient = { 84 | phone_number: phoneNumber, 85 | }; 86 | return this.__addProperty('recipient', 'recipient', recipient); 87 | } 88 | 89 | /** 90 | * removes the `recipient` param from the OutgoingMessage object. 91 | * This will remove the object wether it was set with a phone number or an id 92 | * 93 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 94 | */ 95 | removeRecipient() { 96 | return this.__removeProperty('recipient', 'recipient'); 97 | } 98 | 99 | /** 100 | * Adds `message.text` to the OutgoingMessage 101 | * 102 | * @param {string} text the text to add to the OutgoingMessage object 103 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 104 | */ 105 | addText(text) { 106 | return this.__addProperty('message.text', 'text', text); 107 | } 108 | 109 | /** 110 | * Removes the `message.text` param from the OutgoingMessage object. 111 | * 112 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 113 | */ 114 | removeText() { 115 | return this.__removeProperty('message.text', 'text'); 116 | } 117 | 118 | /** 119 | * Adds `message.attachment` to the OutgoingMessage. If you want to add 120 | * an attachment simply from a type and a url, have a look at: 121 | * addAttachmentFromUrl 122 | * 123 | * @param {object} attachment valid messenger type attachment that can be 124 | * formatted by the platforms your bot uses 125 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 126 | */ 127 | addAttachment(attachment) { 128 | return this.__addProperty('message.attachment', 'attachment', attachment); 129 | } 130 | 131 | /** 132 | * Adds `message.attachment` from a type and url without requiring you to 133 | * provide the whole attachment object. If you want to add an attachment using 134 | * a full object, use addAttachment. 135 | * 136 | * @param {string} type the attachment type (audio, video, image, file) 137 | * @param {string} url the url of the attachment. 138 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 139 | */ 140 | addAttachmentFromUrl(type, url) { 141 | if (!type || !url) { 142 | throw new Error('addAttachmentFromUrl must be called with truthy "type" and "url" arguments'); 143 | } 144 | if (typeof type !== 'string' || typeof url !== 'string') { 145 | throw new TypeError('addAttachmentFromUrl must be called with "type" and "url" arguments of type string'); 146 | } 147 | const attachment = { 148 | type, 149 | payload: { 150 | url, 151 | }, 152 | }; 153 | 154 | return this.addAttachment(attachment); 155 | } 156 | 157 | /** 158 | * Removes `message.attachment` param from the OutgoingMessage object. 159 | * 160 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 161 | */ 162 | removeAttachment() { 163 | return this.__removeProperty('message.attachment', 'attachment'); 164 | } 165 | 166 | /** 167 | * Adds `message.quick_replies` to the OutgoinMessage object. Use 168 | * addPayloadLessQuickReplies if you just want to add quick replies from an 169 | * array of titles 170 | * 171 | * @param {Array} quickReplies The quick replies objects to add to the 172 | * OutgoingMessage 173 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 174 | */ 175 | addQuickReplies(quickReplies) { 176 | return this.__addProperty('message.quick_replies', 'quick_replies', quickReplies); 177 | } 178 | 179 | /** 180 | * Adds `message.quick_replies` to the OutgoinMessage object from a simple array 181 | * of quick replies titles.Use addQuickReplies if want to add quick replies 182 | * from an quick reply objects 183 | * 184 | * @param {Array} quickRepliesTitles The titles of the quick replies objects to add to the 185 | * OutgoingMessage 186 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 187 | */ 188 | addPayloadLessQuickReplies(quickRepliesTitles) { 189 | const errorText = 'addPayloadLessQuickReplies needs to be passed in an array of strings as first argument'; 190 | if (!(quickRepliesTitles instanceof Array)) { 191 | throw new TypeError(errorText); 192 | } 193 | const quickReplies = []; 194 | for (const title of quickRepliesTitles) { 195 | if (typeof title !== 'string') { 196 | throw new TypeError(errorText); 197 | } 198 | const quickReply = { 199 | title, 200 | payload: title, 201 | content_type: 'text', 202 | }; 203 | quickReplies.push(quickReply); 204 | } 205 | 206 | return this.addQuickReplies(quickReplies); 207 | } 208 | 209 | /** 210 | * Adds a `content_type: location` message.quick_replies to the OutgoingMessage. 211 | * Use this if the platform the bot class you are using is based on supports 212 | * asking for the location to its users. 213 | * 214 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 215 | */ 216 | addLocationQuickReply() { 217 | const locationQuickReply = [ 218 | { 219 | content_type: 'location', 220 | }, 221 | ]; 222 | 223 | return this.addQuickReplies(locationQuickReply); 224 | } 225 | 226 | /** 227 | * Removes `message.quick_replies` param from the OutgoingMessage object. 228 | * 229 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 230 | */ 231 | removeQuickReplies() { 232 | return this.__removeProperty('message.quick_replies', 'quick_replies'); 233 | } 234 | 235 | /** 236 | * Adds an arbitrary `sender_action` to the OutgoinMessage 237 | * @param {string} senderAction Arbitrary sender action 238 | * (typing_on, typing_off or mark_seens) 239 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 240 | */ 241 | addSenderAction(senderAction) { 242 | return this.__addProperty('sender_action', 'sender_action', senderAction); 243 | } 244 | 245 | /** 246 | * Adds `sender_action: typing_on` to the OutgoinMessage 247 | * 248 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 249 | */ 250 | addTypingOnSenderAction() { 251 | return this.__addProperty('sender_action', 'sender_action', 'typing_on'); 252 | } 253 | 254 | /** 255 | * Adds `sender_action: typing_off` to the OutgoinMessage 256 | * 257 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 258 | */ 259 | addTypingOffSenderAction() { 260 | return this.__addProperty('sender_action', 'sender_action', 'typing_off'); 261 | } 262 | 263 | /** 264 | * Adds `sender_action: mark_seen` to the OutgoinMessage 265 | * 266 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 267 | */ 268 | addMarkSeenSenderAction() { 269 | return this.__addProperty('sender_action', 'sender_action', 'mark_seen'); 270 | } 271 | 272 | /** 273 | * Removes `sender_action` param from the OutgoingMessage object. 274 | * 275 | * @return {OutgoinMessage} returns this object to allow for chaining of methods. 276 | */ 277 | removeSenderAction() { 278 | return this.__removeProperty('sender_action', 'sender_action'); 279 | } 280 | } 281 | 282 | module.exports = OutgoingMessage; 283 | -------------------------------------------------------------------------------- /api-reference/outgoing-message.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## OutgoingMessage 4 | This class will help you compose sendable message objects. 5 | 6 | **Kind**: global class 7 | 8 | * [OutgoingMessage](#OutgoingMessage) 9 | * [new OutgoingMessage([message])](#new_OutgoingMessage_new) 10 | * [.addRecipientById(id)](#OutgoingMessage+addRecipientById) ⇒ OutgoinMessage 11 | * [.addRecipientByPhoneNumber(phoneNumber)](#OutgoingMessage+addRecipientByPhoneNumber) ⇒ OutgoinMessage 12 | * [.removeRecipient()](#OutgoingMessage+removeRecipient) ⇒ OutgoinMessage 13 | * [.addText(text)](#OutgoingMessage+addText) ⇒ OutgoinMessage 14 | * [.removeText()](#OutgoingMessage+removeText) ⇒ OutgoinMessage 15 | * [.addAttachment(attachment)](#OutgoingMessage+addAttachment) ⇒ OutgoinMessage 16 | * [.addAttachmentFromUrl(type, url)](#OutgoingMessage+addAttachmentFromUrl) ⇒ OutgoinMessage 17 | * [.removeAttachment()](#OutgoingMessage+removeAttachment) ⇒ OutgoinMessage 18 | * [.addQuickReplies(quickReplies)](#OutgoingMessage+addQuickReplies) ⇒ OutgoinMessage 19 | * [.addPayloadLessQuickReplies(quickRepliesTitles)](#OutgoingMessage+addPayloadLessQuickReplies) ⇒ OutgoinMessage 20 | * [.addLocationQuickReply()](#OutgoingMessage+addLocationQuickReply) ⇒ OutgoinMessage 21 | * [.removeQuickReplies()](#OutgoingMessage+removeQuickReplies) ⇒ OutgoinMessage 22 | * [.addSenderAction(senderAction)](#OutgoingMessage+addSenderAction) ⇒ OutgoinMessage 23 | * [.addTypingOnSenderAction()](#OutgoingMessage+addTypingOnSenderAction) ⇒ OutgoinMessage 24 | * [.addTypingOffSenderAction()](#OutgoingMessage+addTypingOffSenderAction) ⇒ OutgoinMessage 25 | * [.addMarkSeenSenderAction()](#OutgoingMessage+addMarkSeenSenderAction) ⇒ OutgoinMessage 26 | * [.removeSenderAction()](#OutgoingMessage+removeSenderAction) ⇒ OutgoinMessage 27 | 28 | 29 | 30 | ### new OutgoingMessage([message]) 31 | Constructor to the OutgoingMessage class. Takes in an optional 32 | message object that it will use as its base to add the OutgoingMessage 33 | methods to. This constructor is not actually exposed in the public API. 34 | In order to instantiate an OutgoingMessage object, you'll need to use the 35 | createOutgoingMessage and createOutgoingMessageFor methods provided with 36 | all classes that inherit from BaseBot. There are static and non-static 37 | versions of both methods to make sure you can do so wherever as you wish 38 | 39 | 40 | | Param | Type | Description | 41 | | --- | --- | --- | 42 | | [message] | object | the base object to convert into an OutgoingMessage object | 43 | 44 | 45 | 46 | ### outgoingMessage.addRecipientById(id) ⇒ OutgoinMessage 47 | Adds `recipient.id` param to the OutgoingMessage object. This is most 48 | likely what you will want to do to add a recipient. Alternatively, you Can 49 | use addRecipientByPhoneNumber if the platform you are sending the message to 50 | supports that. 51 | 52 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 53 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 54 | 55 | | Param | Type | Description | 56 | | --- | --- | --- | 57 | | id | string | the id to add to the OutgoingMessage object | 58 | 59 | 60 | 61 | ### outgoingMessage.addRecipientByPhoneNumber(phoneNumber) ⇒ OutgoinMessage 62 | Adds `recipient.phone_number` param to the OutgoingMessage object. 63 | You might prefer to add a recipient by id rather. This is achieved via 64 | addRecipientById 65 | 66 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 67 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 68 | 69 | | Param | Type | Description | 70 | | --- | --- | --- | 71 | | phoneNumber | string | the phone number to add to the OutgoingMessage object | 72 | 73 | 74 | 75 | ### outgoingMessage.removeRecipient() ⇒ OutgoinMessage 76 | removes the `recipient` param from the OutgoingMessage object. 77 | This will remove the object wether it was set with a phone number or an id 78 | 79 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 80 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 81 | 82 | 83 | ### outgoingMessage.addText(text) ⇒ OutgoinMessage 84 | Adds `message.text` to the OutgoingMessage 85 | 86 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 87 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 88 | 89 | | Param | Type | Description | 90 | | --- | --- | --- | 91 | | text | string | the text to add to the OutgoingMessage object | 92 | 93 | 94 | 95 | ### outgoingMessage.removeText() ⇒ OutgoinMessage 96 | Removes the `message.text` param from the OutgoingMessage object. 97 | 98 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 99 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 100 | 101 | 102 | ### outgoingMessage.addAttachment(attachment) ⇒ OutgoinMessage 103 | Adds `message.attachment` to the OutgoingMessage. If you want to add 104 | an attachment simply from a type and a url, have a look at: 105 | addAttachmentFromUrl 106 | 107 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 108 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 109 | 110 | | Param | Type | Description | 111 | | --- | --- | --- | 112 | | attachment | object | valid messenger type attachment that can be formatted by the platforms your bot uses | 113 | 114 | 115 | 116 | ### outgoingMessage.addAttachmentFromUrl(type, url) ⇒ OutgoinMessage 117 | Adds `message.attachment` from a type and url without requiring you to 118 | provide the whole attachment object. If you want to add an attachment using 119 | a full object, use addAttachment. 120 | 121 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 122 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 123 | 124 | | Param | Type | Description | 125 | | --- | --- | --- | 126 | | type | string | the attachment type (audio, video, image, file) | 127 | | url | string | the url of the attachment. | 128 | 129 | 130 | 131 | ### outgoingMessage.removeAttachment() ⇒ OutgoinMessage 132 | Removes `message.attachment` param from the OutgoingMessage object. 133 | 134 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 135 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 136 | 137 | 138 | ### outgoingMessage.addQuickReplies(quickReplies) ⇒ OutgoinMessage 139 | Adds `message.quick_replies` to the OutgoinMessage object. Use 140 | addPayloadLessQuickReplies if you just want to add quick replies from an 141 | array of titles 142 | 143 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 144 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 145 | 146 | | Param | Type | Description | 147 | | --- | --- | --- | 148 | | quickReplies | Array | The quick replies objects to add to the OutgoingMessage | 149 | 150 | 151 | 152 | ### outgoingMessage.addPayloadLessQuickReplies(quickRepliesTitles) ⇒ OutgoinMessage 153 | Adds `message.quick_replies` to the OutgoinMessage object from a simple array 154 | of quick replies titles.Use addQuickReplies if want to add quick replies 155 | from an quick reply objects 156 | 157 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 158 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 159 | 160 | | Param | Type | Description | 161 | | --- | --- | --- | 162 | | quickRepliesTitles | Array | The titles of the quick replies objects to add to the OutgoingMessage | 163 | 164 | 165 | 166 | ### outgoingMessage.addLocationQuickReply() ⇒ OutgoinMessage 167 | Adds a `content_type: location` message.quick_replies to the OutgoingMessage. 168 | Use this if the platform the bot class you are using is based on supports 169 | asking for the location to its users. 170 | 171 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 172 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 173 | 174 | 175 | ### outgoingMessage.removeQuickReplies() ⇒ OutgoinMessage 176 | Removes `message.quick_replies` param from the OutgoingMessage object. 177 | 178 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 179 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 180 | 181 | 182 | ### outgoingMessage.addSenderAction(senderAction) ⇒ OutgoinMessage 183 | Adds an arbitrary `sender_action` to the OutgoinMessage 184 | 185 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 186 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 187 | 188 | | Param | Type | Description | 189 | | --- | --- | --- | 190 | | senderAction | string | Arbitrary sender action (typing_on, typing_off or mark_seens) | 191 | 192 | 193 | 194 | ### outgoingMessage.addTypingOnSenderAction() ⇒ OutgoinMessage 195 | Adds `sender_action: typing_on` to the OutgoinMessage 196 | 197 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 198 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 199 | 200 | 201 | ### outgoingMessage.addTypingOffSenderAction() ⇒ OutgoinMessage 202 | Adds `sender_action: typing_off` to the OutgoinMessage 203 | 204 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 205 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 206 | 207 | 208 | ### outgoingMessage.addMarkSeenSenderAction() ⇒ OutgoinMessage 209 | Adds `sender_action: mark_seen` to the OutgoinMessage 210 | 211 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 212 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 213 | 214 | 215 | ### outgoingMessage.removeSenderAction() ⇒ OutgoinMessage 216 | Removes `sender_action` param from the OutgoingMessage object. 217 | 218 | **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) 219 | **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. 220 | -------------------------------------------------------------------------------- /tests/outgoing_message.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { outgoingMessageFixtures, attachmentFixtures } from 'botmaster-test-fixtures'; 3 | import { assign } from 'lodash'; 4 | import MockBot from './_mock_bot'; 5 | 6 | import OutgoingMessage from '../lib/outgoing_message'; 7 | 8 | const createBaseOutgoingMessage = () => { 9 | const outgoingMessage = { 10 | recipient: { 11 | id: 'user_id', 12 | }, 13 | }; 14 | 15 | return new OutgoingMessage(outgoingMessage); 16 | }; 17 | 18 | test('Instantiating an OutgoingMessage object via a bot\'s createOutgoingMessage works', (t) => { 19 | t.plan(1); 20 | 21 | const bot = new MockBot(); 22 | const botOutgoingMessage = bot.createOutgoingMessage({}); 23 | 24 | t.deepEqual(botOutgoingMessage, new OutgoingMessage()); 25 | }); 26 | 27 | test('Instantiating an OutgoingMessage object via a bot class\'s createOutgoingMessage works', (t) => { 28 | t.plan(1); 29 | 30 | const botOutgoingMessage = MockBot.createOutgoingMessage({}); 31 | 32 | t.deepEqual(botOutgoingMessage, new OutgoingMessage()); 33 | }); 34 | 35 | test('Instantiating an OutgoingMessage starter object via a bot\'s createOutgoingMessageFor works', (t) => { 36 | t.plan(1); 37 | 38 | const bot = new MockBot(); 39 | const botOutgoingMessage = bot.createOutgoingMessageFor('user_id'); 40 | 41 | t.deepEqual(botOutgoingMessage, createBaseOutgoingMessage()); 42 | }); 43 | 44 | test('Instantiating an OutgoingMessage starter object via a bot class\'s createOutgoingMessageFor works', (t) => { 45 | t.plan(1); 46 | 47 | const botOutgoingMessage = MockBot.createOutgoingMessageFor('user_id'); 48 | 49 | t.deepEqual(botOutgoingMessage, createBaseOutgoingMessage()); 50 | }); 51 | 52 | test('#constructor does not throw an error when initialized without argument', (t) => { 53 | t.plan(1); 54 | 55 | const m = new OutgoingMessage(); 56 | t.pass(); 57 | }); 58 | 59 | test('throws an error when argument passed is not an object', (t) => { 60 | t.plan(1); 61 | 62 | try { 63 | const m = new OutgoingMessage('not an object'); 64 | } catch (err) { 65 | t.is(err.message, 66 | 'OutgoingMessage constructor takes in an object as param'); 67 | } 68 | }); 69 | 70 | test('#constructor properly assigns passed in object', (t) => { 71 | t.plan(1); 72 | 73 | const message = outgoingMessageFixtures.textMessage(); 74 | const outgoingMessage = new OutgoingMessage(message); 75 | 76 | // assign is used here and in all the subsequent tests, in order 77 | // to make sure that the deepEqual passes. Otherwise, it is comparing an 78 | // instance of OutgoingMessage with Object, which won't work! 79 | t.deepEqual(assign({}, outgoingMessage), message); 80 | }); 81 | 82 | test('#__addPropery throws error when trying to add property with falsy value', (t) => { 83 | t.plan(1); 84 | 85 | const outgoingMessage = createBaseOutgoingMessage(); 86 | 87 | try { 88 | outgoingMessage.__addProperty('arbitrary', 'arbitrary', undefined); 89 | } catch (err) { 90 | t.is(err.message, 91 | 'arbitrary must have a value. Can\'t be undefined'); 92 | } 93 | }); 94 | 95 | test('#__addPropery throws error when trying to add property that already has a value', (t) => { 96 | t.plan(1); 97 | 98 | const outgoingMessage = createBaseOutgoingMessage(); 99 | outgoingMessage.arbitrary = 'some value'; 100 | 101 | try { 102 | outgoingMessage.__addProperty('arbitrary', 'arbitrary', 'some other value'); 103 | } catch (err) { 104 | t.is(err.message, 105 | 'Can\'t add arbitrary to outgoingMessage that already has arbitrary'); 106 | } 107 | }); 108 | 109 | test('#__removePropery throws error when trying to remove property that doesn\'t have a value', (t) => { 110 | t.plan(1); 111 | 112 | const outgoingMessage = createBaseOutgoingMessage(); 113 | 114 | try { 115 | outgoingMessage.__removeProperty('arbitrary.arb', 'arbitrary'); 116 | } catch (err) { 117 | t.is(err.message, 118 | 'Can\'t remove arbitrary from outgoingMessage that doesn\'t have any arbitrary'); 119 | } 120 | }); 121 | 122 | test('#addRecipientId properly works', (t) => { 123 | t.plan(1); 124 | 125 | const outgoingMessage = new OutgoingMessage().addRecipientById('user_id'); 126 | 127 | t.deepEqual(outgoingMessage, createBaseOutgoingMessage()); 128 | }); 129 | 130 | test('#addRecipientPhone properly works', (t) => { 131 | t.plan(1); 132 | 133 | const outgoingMessage = new OutgoingMessage().addRecipientByPhoneNumber('phoneNumber'); 134 | 135 | const expectedMessage = { 136 | recipient: { 137 | phone_number: 'phoneNumber', 138 | }, 139 | }; 140 | 141 | t.deepEqual(assign({}, outgoingMessage), expectedMessage); 142 | }); 143 | 144 | test('#removeRecipient properly works', (t) => { 145 | t.plan(1); 146 | 147 | const outgoingMessage = createBaseOutgoingMessage(); 148 | outgoingMessage.removeRecipient(); 149 | 150 | t.deepEqual(outgoingMessage, new OutgoingMessage()); 151 | }); 152 | 153 | test('#addText properly works', (t) => { 154 | t.plan(1); 155 | 156 | const outgoingMessage = createBaseOutgoingMessage(); 157 | outgoingMessage.addText('Hello World!'); 158 | 159 | t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.textMessage()); 160 | }); 161 | 162 | test('#removeText properly works', (t) => { 163 | t.plan(1); 164 | 165 | const outgoingMessage = createBaseOutgoingMessage(); 166 | outgoingMessage.addText('Hello World!').removeText(); 167 | 168 | const expectedOutgoingMessage = createBaseOutgoingMessage(); 169 | expectedOutgoingMessage.message = {}; 170 | 171 | t.deepEqual(outgoingMessage, expectedOutgoingMessage); 172 | }); 173 | 174 | test('#addAttachment works', (t) => { 175 | t.plan(1); 176 | 177 | const outgoingMessage = createBaseOutgoingMessage(); 178 | outgoingMessage.addAttachment(attachmentFixtures.audioAttachment()); 179 | 180 | t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.audioMessage()); 181 | }); 182 | 183 | test('#addAttachmentFromUrl throws error if not passed in strings', (t) => { 184 | t.plan(2); 185 | 186 | const outgoingMessage = createBaseOutgoingMessage(); 187 | try { 188 | outgoingMessage.addAttachmentFromUrl({ type: 'audio' }, 'SOME_AUDIO_URL'); 189 | } catch (err) { 190 | t.is(err.message, 191 | 'addAttachmentFromUrl must be called with "type" and "url" arguments of type string'); 192 | } 193 | 194 | try { 195 | outgoingMessage.addAttachmentFromUrl('audio', { url: 'SOME_AUDIO_URL' }); 196 | } catch (err) { 197 | t.is(err.message, 198 | 'addAttachmentFromUrl must be called with "type" and "url" arguments of type string'); 199 | } 200 | }); 201 | 202 | test('#addAttachmentFromUrl throws error if not passed in both type and url', (t) => { 203 | t.plan(1); 204 | 205 | const outgoingMessage = createBaseOutgoingMessage(); 206 | try { 207 | outgoingMessage.addAttachmentFromUrl('audio'); 208 | } catch (err) { 209 | t.is(err.message, 210 | 'addAttachmentFromUrl must be called with truthy "type" and "url" arguments'); 211 | } 212 | }); 213 | 214 | test('#addAttachmentFromUrl works when using the right arguments', (t) => { 215 | t.plan(1); 216 | 217 | const outgoingMessage = createBaseOutgoingMessage(); 218 | outgoingMessage.addAttachmentFromUrl('audio', 'SOME_AUDIO_URL'); 219 | 220 | t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.audioMessage()); 221 | }); 222 | 223 | test('#removeAttachment works', (t) => { 224 | t.plan(1); 225 | 226 | const outgoingMessage = createBaseOutgoingMessage(); 227 | outgoingMessage 228 | .addAttachment(attachmentFixtures.audioAttachment()) 229 | .removeAttachment(); 230 | 231 | const expectedOutgoingMessage = createBaseOutgoingMessage(); 232 | expectedOutgoingMessage.message = {}; 233 | 234 | t.deepEqual(outgoingMessage, expectedOutgoingMessage); 235 | }); 236 | 237 | test('#addQuickReplies works', (t) => { 238 | t.plan(1); 239 | 240 | const outgoingMessage = createBaseOutgoingMessage(); 241 | const quickReplies = outgoingMessageFixtures.textOnlyQuickReplyMessage().message.quick_replies; 242 | outgoingMessage.addQuickReplies(quickReplies); 243 | outgoingMessage.addText('Please select one of:'); 244 | 245 | t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.textOnlyQuickReplyMessage()); 246 | }); 247 | 248 | test('#addPayloadLessQuickReplies throws error if not passed in array of strings', (t) => { 249 | t.plan(2); 250 | 251 | const outgoingMessage = createBaseOutgoingMessage(); 252 | try { 253 | outgoingMessage.addPayloadLessQuickReplies('not an array'); 254 | } catch (err) { 255 | t.is(err.message, 256 | 'addPayloadLessQuickReplies needs to be passed in an array of strings as first argument'); 257 | } 258 | 259 | try { 260 | outgoingMessage.addPayloadLessQuickReplies(['not an array of strings', {}]); 261 | } catch (err) { 262 | t.is(err.message, 263 | 'addPayloadLessQuickReplies needs to be passed in an array of strings as first argument'); 264 | } 265 | }); 266 | 267 | test('#addPayloadLessQuickReplies properly works when passed array of strings', (t) => { 268 | t.plan(1); 269 | 270 | const outgoingMessage = createBaseOutgoingMessage(); 271 | outgoingMessage.addPayloadLessQuickReplies(['B1', 'B2']); 272 | outgoingMessage.addText('Please select one of:'); 273 | 274 | const quickReplies = [ 275 | { 276 | content_type: 'text', 277 | title: 'B1', 278 | payload: 'B1', 279 | }, 280 | { 281 | content_type: 'text', 282 | title: 'B2', 283 | payload: 'B2', 284 | }, 285 | ]; 286 | 287 | const expectedOutgoingMessage = outgoingMessageFixtures.textOnlyQuickReplyMessage(); 288 | expectedOutgoingMessage.message.quick_replies = quickReplies; 289 | 290 | t.deepEqual(assign({}, outgoingMessage), expectedOutgoingMessage); 291 | }); 292 | 293 | test('#addLocationQuickReply properly works', (t) => { 294 | t.plan(1); 295 | 296 | const outgoingMessage = createBaseOutgoingMessage(); 297 | outgoingMessage.addLocationQuickReply(); 298 | outgoingMessage.addText('Please share your location:'); 299 | 300 | t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.locationQuickReplyMessage()); 301 | }); 302 | 303 | test('#removeQuickReplies works', (t) => { 304 | t.plan(1); 305 | 306 | const outgoingMessage = createBaseOutgoingMessage(); 307 | outgoingMessage 308 | .addLocationQuickReply() 309 | .removeQuickReplies(); 310 | 311 | const expectedOutgoingMessage = createBaseOutgoingMessage(); 312 | expectedOutgoingMessage.message = {}; 313 | 314 | t.deepEqual(outgoingMessage, expectedOutgoingMessage); 315 | }); 316 | 317 | test('#addSenderAction properly works', (t) => { 318 | t.plan(1); 319 | 320 | const outgoingMessage = createBaseOutgoingMessage(); 321 | outgoingMessage.addSenderAction('typing_on'); 322 | 323 | t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.typingOnMessage()); 324 | }); 325 | 326 | test('#removeSenderAction properly works', (t) => { 327 | t.plan(1); 328 | 329 | const outgoingMessage = createBaseOutgoingMessage(); 330 | outgoingMessage.addSenderAction('some_action').removeSenderAction(); 331 | 332 | const expectedOutgoingMessage = createBaseOutgoingMessage(); 333 | 334 | t.deepEqual(outgoingMessage, expectedOutgoingMessage); 335 | }); 336 | 337 | test('#addTypingOnSenderAction properly works', (t) => { 338 | t.plan(1); 339 | 340 | const outgoingMessage = createBaseOutgoingMessage(); 341 | outgoingMessage.addTypingOnSenderAction(); 342 | 343 | t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.typingOnMessage()); 344 | }); 345 | 346 | test('#addTypingOffSenderAction properly works', (t) => { 347 | t.plan(1); 348 | 349 | const outgoingMessage = createBaseOutgoingMessage(); 350 | outgoingMessage.addTypingOffSenderAction(); 351 | 352 | t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.typingOffMessage()); 353 | }); 354 | 355 | test('#addMarkSeenSenderAction properly works', (t) => { 356 | t.plan(1); 357 | 358 | const outgoingMessage = createBaseOutgoingMessage(); 359 | outgoingMessage.addMarkSeenSenderAction(); 360 | 361 | t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.markSeenMessage()); 362 | }); 363 | 364 | test('chaining of all methods works', (t) => { 365 | t.plan(1); 366 | 367 | const outgoingMessage = createBaseOutgoingMessage() 368 | .removeRecipient() 369 | .addRecipientByPhoneNumber('phoneNumber') 370 | .removeRecipient() 371 | .addRecipientById('user_id') 372 | .addText('Hello') 373 | .removeText() 374 | .addAttachment({}) 375 | .removeAttachment() 376 | .addAttachmentFromUrl('image', 'someUrl') 377 | .removeAttachment() 378 | .addQuickReplies([]) 379 | .removeQuickReplies() 380 | .addPayloadLessQuickReplies(['B1', 'B2'], 'select one of') 381 | .removeQuickReplies() 382 | .addLocationQuickReply() 383 | .removeQuickReplies() 384 | .addSenderAction('some_abstract_action') 385 | .removeSenderAction() 386 | .addTypingOnSenderAction() 387 | .removeSenderAction() 388 | .addTypingOffSenderAction() 389 | .removeSenderAction() 390 | .addMarkSeenSenderAction() 391 | .removeSenderAction(); 392 | 393 | const expectedOutgoingMessage = createBaseOutgoingMessage(); 394 | expectedOutgoingMessage.message = {}; 395 | 396 | t.deepEqual(outgoingMessage, expectedOutgoingMessage); 397 | }); 398 | -------------------------------------------------------------------------------- /api-reference/base-bot.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## BaseBot 4 | The class from which all Bot classes mus inherit. It contains all the base 5 | methods that are accessible via all bot classes. Classes that inherit from 6 | BaseBot and want to make implementation specific methods available have to 7 | prepend the method name with an underscore; e.g. in botmaster-messenger: 8 | _getGetStartedButton 9 | 10 | **Kind**: global class 11 | 12 | * [BaseBot](#BaseBot) 13 | * [new BaseBot(settings)](#new_BaseBot_new) 14 | * _instance_ 15 | * [.createOutgoingMessage(message)](#BaseBot+createOutgoingMessage) ⇒ OutgoingMessage 16 | * [.createOutgoingMessageFor(recipientId)](#BaseBot+createOutgoingMessageFor) ⇒ OutgoingMessage 17 | * [.sendMessage(message, [sendOptions])](#BaseBot+sendMessage) ⇒ Promise 18 | * [.sendMessageTo(message, recipientId, [sendOptions])](#BaseBot+sendMessageTo) ⇒ Promise 19 | * [.sendTextMessageTo(text, recipientId, [sendOptions])](#BaseBot+sendTextMessageTo) ⇒ Promise 20 | * [.reply(incomingUpdate, text, [sendOptions])](#BaseBot+reply) ⇒ Promise 21 | * [.sendAttachmentTo(attachment, recipientId, [sendOptions])](#BaseBot+sendAttachmentTo) ⇒ Promise 22 | * [.sendAttachmentFromUrlTo(type, url, recipientId, [sendOptions])](#BaseBot+sendAttachmentFromUrlTo) ⇒ Promise 23 | * [.sendDefaultButtonMessageTo(buttonTitles, textOrAttachment, recipientId, [sendOptions])](#BaseBot+sendDefaultButtonMessageTo) ⇒ Promise 24 | * [.sendIsTypingMessageTo(recipientId, [sendOptions])](#BaseBot+sendIsTypingMessageTo) ⇒ Promise 25 | * [.sendCascade(messageArray, [sendOptions])](#BaseBot+sendCascade) ⇒ Promise 26 | * [.sendTextCascadeTo(textArray, recipientId, [sendOptions])](#BaseBot+sendTextCascadeTo) ⇒ Promise 27 | * [.sendRawMessage(rawMessage)](#BaseBot+sendRawMessage) ⇒ Promise 28 | * [.getUserInfo(userId)](#BaseBot+getUserInfo) ⇒ Promise 29 | * _static_ 30 | * [.createOutgoingMessage(message)](#BaseBot.createOutgoingMessage) ⇒ OutgoingMessage 31 | * [.createOutgoingMessageFor(recipientId)](#BaseBot.createOutgoingMessageFor) ⇒ OutgoingMessage 32 | 33 | 34 | 35 | ### new BaseBot(settings) 36 | Constructor to the BaseBot class from which all the bot classes inherit. 37 | A set a basic functionalities are defined here that have to be implemented 38 | in the subclasses in order for them to work. 39 | 40 | 41 | | Param | Type | Description | 42 | | --- | --- | --- | 43 | | settings | object | inheritors of BaseBot take a settings object as first param. | 44 | 45 | 46 | 47 | ### baseBot.createOutgoingMessage(message) ⇒ OutgoingMessage 48 | createOutgoingMessage exposes the OutgoingMessage constructor 49 | via BaseBot. This simply means one can create their own 50 | OutgoingMessage object using any bot object. They can then compose 51 | it with all its helper functions 52 | 53 | This is the instance version of this method 54 | 55 | **Kind**: instance method of [BaseBot](#BaseBot) 56 | **Returns**: OutgoingMessage - outgoingMessage. The same object passed in with 57 | all the helper functions from OutgoingMessage 58 | 59 | | Param | Type | Description | 60 | | --- | --- | --- | 61 | | message | object | base object that the outgoing Message should be based on | 62 | 63 | 64 | 65 | ### baseBot.createOutgoingMessageFor(recipientId) ⇒ OutgoingMessage 66 | same as #createOutgoingMessage, creates empty outgoingMessage with 67 | id of the recipient set. Again, this is jut sugar syntax for creating a 68 | new outgoingMessage object 69 | 70 | This is the instance version of this method 71 | 72 | **Kind**: instance method of [BaseBot](#BaseBot) 73 | **Returns**: OutgoingMessage - outgoingMessage. A valid OutgoingMessage object with recipient set. 74 | 75 | | Param | Type | Description | 76 | | --- | --- | --- | 77 | | recipientId | string | id of the recipient the message is for | 78 | 79 | 80 | 81 | ### baseBot.sendMessage(message, [sendOptions]) ⇒ Promise 82 | sendMessage() falls back to the sendMessage implementation of whatever 83 | subclass inherits form BaseBot. The expected format is normally any type of 84 | message object that could be sent on to messenger 85 | 86 | **Kind**: instance method of [BaseBot](#BaseBot) 87 | **Returns**: Promise - promise that resolves with a body object (see example) 88 | 89 | | Param | Type | Description | 90 | | --- | --- | --- | 91 | | message | object | | 92 | | [sendOptions] | boolean | an object containing options regarding the sending of the message. Currently the only valid options is: `ignoreMiddleware`. | 93 | 94 | **Example** 95 | ```js 96 | const outgoingMessage = bot.createOutgoingMessageFor(update.sender.id); 97 | outgoingMessage.addText('Hello world'); 98 | 99 | bot.sendMessage(outgoingMessage); 100 | ``` 101 | **Example** 102 | ```js 103 | // The returned promise for all sendMessage type events resolves with 104 | // a body that looks something like this: 105 | { 106 | sentOutgoingMessage: // the OutgoingMessage instance before being formatted 107 | sentRawMessage: // the OutgoingMessage object after being formatted for the platforms 108 | raw: rawBody, // the raw response from the platforms received from sending the message 109 | recipient_id: , 110 | message_id: 111 | } 112 | 113 | // Some platforms may not have either of these parameters. If that's the case, 114 | // the value assigned will be a falsy value 115 | ``` 116 | 117 | 118 | ### baseBot.sendMessageTo(message, recipientId, [sendOptions]) ⇒ Promise 119 | sendMessageTo() Just makes it easier to send a message without as much 120 | structure. 121 | 122 | **Kind**: instance method of [BaseBot](#BaseBot) 123 | **Returns**: Promise - promise that resolves with a body object 124 | (see `sendMessage` example) 125 | 126 | | Param | Type | Description | 127 | | --- | --- | --- | 128 | | message | object | NOT an instance of OutgoingMessage. Use #sendMessage if you want to send instances of OutgoingMessage | 129 | | recipientId | string | a string representing the id of the user to whom you want to send the message. | 130 | | [sendOptions] | object | see `sendOptions` for `sendMessage` | 131 | 132 | **Example** 133 | ```js 134 | // message object can look something like this: 135 | // as you can see, this is not an OutgoingMessage instance 136 | const message = { 137 | text: 'Some random text' 138 | } 139 | 140 | bot.sendMessageTo(message, update.sender.id); 141 | ``` 142 | 143 | 144 | ### baseBot.sendTextMessageTo(text, recipientId, [sendOptions]) ⇒ Promise 145 | sendTextMessageTo() Just makes it easier to send a text message with 146 | minimal structure. 147 | 148 | **Kind**: instance method of [BaseBot](#BaseBot) 149 | **Returns**: Promise - promise that resolves with a body object 150 | (see `sendMessage` example) 151 | 152 | | Param | Type | Description | 153 | | --- | --- | --- | 154 | | text | string | | 155 | | recipientId | string | a string representing the id of the user to whom you want to send the message. | 156 | | [sendOptions] | object | see `sendOptions` for `sendMessage` | 157 | 158 | **Example** 159 | ```js 160 | bot.sendTextMessageTo('something super important', update.sender.id); 161 | ``` 162 | 163 | 164 | ### baseBot.reply(incomingUpdate, text, [sendOptions]) ⇒ Promise 165 | reply() Another way to easily send a text message. In this case, 166 | we just send the update that came in as is and then the text we 167 | want to send as a reply. 168 | 169 | **Kind**: instance method of [BaseBot](#BaseBot) 170 | **Returns**: Promise - promise that resolves with a body object 171 | (see `sendMessage` example) 172 | 173 | | Param | Type | Description | 174 | | --- | --- | --- | 175 | | incomingUpdate | object | | 176 | | text | string | text to send to the user associated with the received update | 177 | | [sendOptions] | object | see `sendOptions` for `sendMessage` | 178 | 179 | **Example** 180 | ```js 181 | bot.reply(update, 'something super important!'); 182 | ``` 183 | 184 | 185 | ### baseBot.sendAttachmentTo(attachment, recipientId, [sendOptions]) ⇒ Promise 186 | sendAttachmentTo() makes it easier to send an attachment message with 187 | less structure. 188 | 189 | **Kind**: instance method of [BaseBot](#BaseBot) 190 | **Returns**: Promise - promise that resolves with a body object 191 | (see `sendMessage` example) 192 | 193 | | Param | Type | Description | 194 | | --- | --- | --- | 195 | | attachment | object | a valid Messenger style attachment. See [here](https://developers.facebook.com/docs/messenger-platform/send-api-reference) for more on that. | 196 | | recipientId | string | a string representing the id of the user to whom you want to send the message. | 197 | | [sendOptions] | object | see `sendOptions` for `sendMessage` | 198 | 199 | **Example** 200 | ```js 201 | // attachment object typically looks something like this: 202 | const attachment = { 203 | type: 'image', 204 | payload: { 205 | url: "some_valid_url_of_some_image" 206 | }, 207 | }; 208 | 209 | bot.sendAttachmentTo(attachment, update.sender.id); 210 | ``` 211 | 212 | 213 | ### baseBot.sendAttachmentFromUrlTo(type, url, recipientId, [sendOptions]) ⇒ Promise 214 | sendAttachmentFromUrlTo() makes it easier to send an attachment message with 215 | minimal structure. 216 | 217 | **Kind**: instance method of [BaseBot](#BaseBot) 218 | **Returns**: Promise - promise that resolves with a body object 219 | (see `sendMessage` example) 220 | 221 | | Param | Type | Description | 222 | | --- | --- | --- | 223 | | type | string | string representing the type of attachment (audio, video, image or file) | 224 | | url | string | the url to your file | 225 | | recipientId | string | a string representing the id of the user to whom you want to send the message. | 226 | | [sendOptions] | object | see `sendOptions` for `sendMessage` | 227 | 228 | **Example** 229 | ```js 230 | bot.sendAttachmentFromUrlTo('image', "some image url you've got", update.sender.id); 231 | ``` 232 | 233 | 234 | ### baseBot.sendDefaultButtonMessageTo(buttonTitles, textOrAttachment, recipientId, [sendOptions]) ⇒ Promise 235 | sendDefaultButtonMessageTo() makes it easier to send a default set of 236 | buttons. The default button type is the Messenger quick_replies, where 237 | the payload is the same as the button title and the content_type is text. 238 | 239 | **Kind**: instance method of [BaseBot](#BaseBot) 240 | **Returns**: Promise - promise that resolves with a body object 241 | (see `sendMessage` example) 242 | 243 | | Param | Type | Description | 244 | | --- | --- | --- | 245 | | buttonTitles | Array | array of button titles (no longer than 10 in size). | 246 | | textOrAttachment | string_OR_object | a string or an attachment object similar to the ones required in `bot.sendAttachmentTo`. This is meant to provide context to the buttons. I.e. why are there buttons here. A piece of text or an attachment could detail that. If falsy, text will be added that reads: 'Please select one of:'. | 247 | | recipientId | string | a string representing the id of the user to whom you want to send the message. | 248 | | [sendOptions] | object | see `sendOptions` for `sendMessage` | 249 | 250 | **Example** 251 | ```js 252 | const buttonArray = ['button1', 'button2']; 253 | bot.sendDefaultButtonMessageTo(buttonArray, 254 | 'Please select "button1" or "button2"', update.sender.id,); 255 | ``` 256 | 257 | 258 | ### baseBot.sendIsTypingMessageTo(recipientId, [sendOptions]) ⇒ Promise 259 | sendIsTypingMessageTo() just sets the is typing status to the platform 260 | if available. 261 | 262 | **Kind**: instance method of [BaseBot](#BaseBot) 263 | **Returns**: Promise - promise that resolves with a body object 264 | (see `sendMessage` example) 265 | 266 | | Param | Type | Description | 267 | | --- | --- | --- | 268 | | recipientId | string | a string representing the id of the user to whom you want to send the message. | 269 | | [sendOptions] | object | see `sendOptions` for `sendMessage` | 270 | 271 | **Example** 272 | ```js 273 | bot.sendIsTypingMessageTo(update.sender.id); 274 | // the returned value is different from the standard one. it won't have a message_id 275 | ``` 276 | 277 | 278 | ### baseBot.sendCascade(messageArray, [sendOptions]) ⇒ Promise 279 | sendCascade() allows developers to send a cascade of messages 280 | in a sequence. All types of messages can be sent (including raw messages). 281 | 282 | **Kind**: instance method of [BaseBot](#BaseBot) 283 | **Returns**: Promise - promise that resolves with an array of body objects 284 | (see `sendMessage` example for one said object) 285 | 286 | | Param | Type | Description | 287 | | --- | --- | --- | 288 | | messageArray | Array | of messages in a format as such: [{raw: someRawObject}, {message: some valid outgoingMessage}] | 289 | | [sendOptions] | object | see `sendOptions` for `sendMessage`. will only apply to non rawMessages. (remember that for rawMessages, outgoing middleware is bypassed anyways). | 290 | 291 | **Example** 292 | ```js 293 | const rawMessage1 = { 294 | nonStandard: 'message1', 295 | recipient: { 296 | id: 'user_id', 297 | }, 298 | }; 299 | const message2 = bot.createOutgoingMessageFor(update.sender.id); 300 | message2.addText('some text'); 301 | 302 | const messageArray = [{ raw: rawMessage1 }, { message: message2 }]; 303 | 304 | bot.sendCascade(messageArray); 305 | ``` 306 | 307 | 308 | ### baseBot.sendTextCascadeTo(textArray, recipientId, [sendOptions]) ⇒ Promise 309 | sendTextCascadeTo() is simply a helper function around sendCascadeTo. 310 | It allows developers to send a cascade of text messages more easily. 311 | 312 | **Kind**: instance method of [BaseBot](#BaseBot) 313 | **Returns**: Promise - promise that resolves with an array of body objects 314 | (see `sendMessage` example for one said object) 315 | 316 | | Param | Type | Description | 317 | | --- | --- | --- | 318 | | textArray | Array | of messages. | 319 | | recipientId | string | a string representing the id of the user to whom you want to send the message. | 320 | | [sendOptions] | object | see `sendOptions` for `sendMessage` | 321 | 322 | **Example** 323 | ```js 324 | bot.sendTextCascadeTo(['message1', 'message2'], user.sender.id); 325 | ``` 326 | 327 | 328 | ### baseBot.sendRawMessage(rawMessage) ⇒ Promise 329 | sendRawMessage() simply sends a raw platform dependent message. This method 330 | calls __sendMessage in each botClass without calling formatOutgoingMessage 331 | before. It's really just sugar around __sendMessage which shouldn't be used 332 | directly. 333 | 334 | **Kind**: instance method of [BaseBot](#BaseBot) 335 | **Returns**: Promise - promise 336 | 337 | | Param | Type | 338 | | --- | --- | 339 | | rawMessage | Object | 340 | 341 | 342 | 343 | ### baseBot.getUserInfo(userId) ⇒ Promise 344 | Retrieves the basic user info from a user if platform supports it 345 | 346 | **Kind**: instance method of [BaseBot](#BaseBot) 347 | **Returns**: Promise - promise that resolves into the user info or an empty 348 | object by default 349 | 350 | | Param | Type | 351 | | --- | --- | 352 | | userId | string | 353 | 354 | 355 | 356 | ### BaseBot.createOutgoingMessage(message) ⇒ OutgoingMessage 357 | createOutgoingMessage exposes the OutgoingMessage constructor 358 | via BaseBot. This simply means one can create their own 359 | OutgoingMessage object using any bot object. They can then compose 360 | it with all its helper functions 361 | 362 | This is the static version of this method 363 | 364 | **Kind**: static method of [BaseBot](#BaseBot) 365 | **Returns**: OutgoingMessage - outgoingMessage. The same object passed in with 366 | all the helper functions from OutgoingMessage 367 | 368 | | Param | Type | Description | 369 | | --- | --- | --- | 370 | | message | object | base object that the outgoing Message should be based on | 371 | 372 | 373 | 374 | ### BaseBot.createOutgoingMessageFor(recipientId) ⇒ OutgoingMessage 375 | same as #createOutgoingMessage, creates empty outgoingMessage with 376 | id of the recipient set. Again, this is jut sugar syntax for creating a 377 | new outgoingMessage object 378 | 379 | This is the static version of this method 380 | 381 | **Kind**: static method of [BaseBot](#BaseBot) 382 | **Returns**: OutgoingMessage - outgoingMessage. A valid OutgoingMessage object with recipient set. 383 | 384 | | Param | Type | Description | 385 | | --- | --- | --- | 386 | | recipientId | string | id of the recipient the message is for | 387 | 388 | -------------------------------------------------------------------------------- /tests/base_bot/send_message.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { outgoingMessageFixtures, 3 | incomingUpdateFixtures, 4 | attachmentFixtures } from 'botmaster-test-fixtures'; 5 | import { assign } from 'lodash'; 6 | 7 | import MockBot from '../_mock_bot'; 8 | 9 | const sendMessageMacro = async (t, params) => { 10 | t.plan(5); 11 | // test using promises 12 | const body = await params.sendMessageMethod(); 13 | 14 | t.deepEqual(assign({}, body.sentOutgoingMessage), params.expectedSentMessage, 15 | 'sentOutgoingMessage is not same as message'); 16 | t.deepEqual(body.sentRawMessage, params.expectedSentMessage, 17 | 'sentRawMessage is not same as expected'); 18 | t.deepEqual(body.raw, { nonStandard: 'responseBody' }, 19 | 'raw is not same as expected raw body response'); 20 | t.truthy(body.recipient_id, 'recipient_id not present'); 21 | t.truthy(body.message_id, 'message_id not present'); 22 | }; 23 | 24 | const sendRawMessageMacro = async (t, params) => { 25 | t.plan(1); 26 | 27 | const body = await params.sendMessageMethod(); 28 | 29 | t.deepEqual(body, { nonStandard: 'responseBody' }, 30 | 'body is not same as expected raw body response'); 31 | }; 32 | 33 | const sendCascadeMessageMacro = async (t, params) => { 34 | t.plan(params.planFor); 35 | 36 | const bodies = await params.sendMessageMethod(); 37 | 38 | for (let i = 0; i < bodies.length; i += 1) { 39 | const body = bodies[i]; 40 | if (body.raw) { 41 | const expectedSentMessage = params.expectedSentMessages[i]; 42 | t.deepEqual(assign({}, body.sentOutgoingMessage), expectedSentMessage, 43 | 'sentOutgoingMessage is not same as message'); 44 | t.deepEqual(body.sentRawMessage, expectedSentMessage, 45 | 'sentRawMessage is not same as expected'); 46 | t.deepEqual(body.raw, { nonStandard: 'responseBody' }, 47 | 'raw is not same as expected raw body response'); 48 | t.truthy(body.recipient_id, 'recipient_id not present'); 49 | t.truthy(body.message_id, 'message_id not present'); 50 | } else { 51 | t.deepEqual(body, { nonStandard: 'responseBody' }, 52 | 'body is not same as expected raw body response'); 53 | } 54 | } 55 | }; 56 | 57 | const sendMessageErrorMacro = async (t, params) => { 58 | t.plan(1); 59 | 60 | try { 61 | await params.sendMessageMethod(); 62 | t.false(true, 'Error should have been returned, but didn\'t get any'); 63 | } catch (err) { 64 | t.deepEqual(err.message, params.expectedErrorMessage, 65 | 'Error message is not same as expected'); 66 | } 67 | }; 68 | 69 | 70 | // All tests are isolated in own scopes in order to be properly setup 71 | { 72 | const bot = new MockBot(); 73 | const messageToSend = outgoingMessageFixtures.audioMessage(); 74 | 75 | test('#sendMessage works', sendMessageMacro, { 76 | sendMessageMethod: bot.sendMessage.bind(bot, messageToSend), 77 | expectedSentMessage: outgoingMessageFixtures.audioMessage(), 78 | }); 79 | } 80 | 81 | { 82 | const bot = new MockBot(); 83 | 84 | // patching bot just to see if that works too with callbacks 85 | const patchedBot = bot.__createBotPatchedWithUpdate({}); 86 | const messageToSend = outgoingMessageFixtures.audioMessage(); 87 | 88 | test('#sendMessage throws error when sendOptions is not of valid type on a patched bot', sendMessageErrorMacro, { 89 | sendMessageMethod: patchedBot.sendMessage.bind(patchedBot, messageToSend, 'Should not be valid'), 90 | expectedErrorMessage: 'sendOptions must be of type object. Got string instead', 91 | }); 92 | } 93 | 94 | { 95 | const bot = new MockBot(); 96 | 97 | const messageToSend = outgoingMessageFixtures.audioMessage(); 98 | 99 | test('#sendMessage throws error when sendOptions is not of valid type on a non patched bot', sendMessageErrorMacro, { 100 | sendMessageMethod: bot.sendMessage.bind(bot, messageToSend, 'Should not be valid'), 101 | expectedErrorMessage: 'sendOptions must be of type object. Got string instead', 102 | }); 103 | } 104 | 105 | { 106 | const bot = new MockBot(); 107 | 108 | const patchedBot = bot.__createBotPatchedWithUpdate({}); 109 | const messageToSend = outgoingMessageFixtures.audioMessage(); 110 | 111 | test('#sendMessage throws error when tried to use with callback', sendMessageErrorMacro, { 112 | sendMessageMethod: bot.sendMessage.bind(bot, messageToSend, () => {}), 113 | expectedErrorMessage: 'Using botmaster sendMessage type methods ' + 114 | 'with callback functions is no longer supported in botmaster 3. ' + 115 | 'See the latest documentation ' + 116 | 'at http://botmasterai.com to see the preferred syntax. ' + 117 | 'Alternatively, you can downgrade botmaster to 2.x.x by doing: ' + 118 | '"npm install --save botmaster@2.x.x" or "yarn add botmaster@2.x.x"', 119 | }); 120 | 121 | test('#sendMessage throws error when cb is not of valid type on a patched bot', sendMessageErrorMacro, { 122 | sendMessageMethod: patchedBot.sendMessage.bind(patchedBot, messageToSend, () => {}), 123 | expectedErrorMessage: 'Using botmaster sendMessage type methods ' + 124 | 'with callback functions is no longer supported in botmaster 3. ' + 125 | 'See the latest documentation ' + 126 | 'at http://botmasterai.com to see the preferred syntax. ' + 127 | 'Alternatively, you can downgrade botmaster to 2.x.x by doing: ' + 128 | '"npm install --save botmaster@2.x.x" or "yarn add botmaster@2.x.x"', 129 | }); 130 | } 131 | 132 | { 133 | const bot = new MockBot(); 134 | const messageToSend = outgoingMessageFixtures.audioMessage(); 135 | 136 | test('#sendRawMessage works', sendRawMessageMacro, { 137 | sendMessageMethod: bot.sendRawMessage.bind(bot, messageToSend), 138 | expectedSentMessage: outgoingMessageFixtures.audioMessage(), 139 | }); 140 | } 141 | 142 | { 143 | const bot = new MockBot(); 144 | const messageToSend = outgoingMessageFixtures.audioMessage(); 145 | 146 | test('#sendMessage works with sendOptions', sendMessageMacro, { 147 | sendMessageMethod: bot.sendMessage.bind(bot, messageToSend, { ignoreMiddleware: true }), 148 | expectedSentMessage: outgoingMessageFixtures.audioMessage(), 149 | }); 150 | } 151 | 152 | { 153 | const bot = new MockBot(); 154 | const subMessagePart = { 155 | text: 'Hello World!', 156 | }; 157 | 158 | test('#sendMessageTo works', sendMessageMacro, { 159 | sendMessageMethod: bot.sendMessageTo.bind(bot, subMessagePart, 'user_id'), 160 | expectedSentMessage: outgoingMessageFixtures.textMessage(), 161 | }); 162 | } 163 | 164 | { 165 | const bot = new MockBot({ 166 | sends: { 167 | text: false, 168 | }, 169 | }); 170 | 171 | test('#sendTextMessageTo throws error if bot class does not support text', sendMessageErrorMacro, { 172 | sendMessageMethod: bot.sendTextMessageTo.bind(bot, 'Hello World!', 'user_id'), 173 | expectedErrorMessage: 'Bots of type mock can\'t send messages with text', 174 | }); 175 | } 176 | 177 | { 178 | const bot = new MockBot(); 179 | 180 | test('#sendTextMessageTo works', sendMessageMacro, { 181 | sendMessageMethod: bot.sendTextMessageTo.bind(bot, 'Hello World!', 'user_id'), 182 | expectedSentMessage: outgoingMessageFixtures.textMessage(), 183 | }); 184 | } 185 | 186 | { 187 | const bot = new MockBot({ 188 | sends: { 189 | text: false, 190 | }, 191 | }); 192 | 193 | const updateToReplyTo = incomingUpdateFixtures.textUpdate(); 194 | 195 | test('#reply throws error if bot class does not support text', sendMessageErrorMacro, { 196 | sendMessageMethod: bot.sendTextMessageTo.bind(bot, updateToReplyTo, 'Hello World!'), 197 | expectedErrorMessage: 'Bots of type mock can\'t send messages with text', 198 | }); 199 | } 200 | 201 | { 202 | const bot = new MockBot(); 203 | 204 | const updateToReplyTo = incomingUpdateFixtures.textUpdate(); 205 | // patching bot just to see if that works too with callbacks 206 | const patchedBot = bot.__createBotPatchedWithUpdate(updateToReplyTo); 207 | 208 | test('#reply works', sendMessageMacro, { 209 | sendMessageMethod: patchedBot.reply.bind(patchedBot, updateToReplyTo, 'Hello World!'), 210 | expectedSentMessage: outgoingMessageFixtures.textMessage(), 211 | }); 212 | } 213 | 214 | { 215 | const bot = new MockBot({ 216 | sends: { 217 | attachment: false, 218 | }, 219 | }); 220 | 221 | const attachment = attachmentFixtures.audioAttachment(); 222 | 223 | test('#sendAttachmentTo throws error if bot class does not support attachment', sendMessageErrorMacro, { 224 | sendMessageMethod: bot.sendAttachmentTo.bind(bot, attachment, 'user_id'), 225 | expectedErrorMessage: 'Bots of type mock can\'t send messages with attachment', 226 | }); 227 | } 228 | 229 | { 230 | const bot = new MockBot(); 231 | 232 | const attachment = attachmentFixtures.audioAttachment(); 233 | 234 | test('#sendAttachmentTo works', sendMessageMacro, { 235 | sendMessageMethod: bot.sendAttachmentTo.bind(bot, attachment, 'user_id'), 236 | expectedSentMessage: outgoingMessageFixtures.audioMessage(), 237 | }); 238 | } 239 | 240 | { 241 | const bot = new MockBot({ 242 | sends: { 243 | attachment: false, 244 | }, 245 | }); 246 | 247 | test('#sendAttachmentFromUrlTo throws error if bot class does not support attachment', sendMessageErrorMacro, { 248 | sendMessageMethod: bot.sendAttachmentFromUrlTo.bind( 249 | bot, 'audio', 'SOME_AUDIO_URL', 'user_id'), 250 | expectedErrorMessage: 'Bots of type mock can\'t send messages with attachment', 251 | }); 252 | } 253 | 254 | { 255 | const bot = new MockBot({ 256 | sends: { 257 | attachment: { 258 | audio: false, 259 | image: true, 260 | }, 261 | }, 262 | }); 263 | 264 | test('#sendAttachmentFromUrlTo throws error if bot class does not support attachment of specific type', sendMessageErrorMacro, { 265 | sendMessageMethod: bot.sendAttachmentFromUrlTo.bind( 266 | bot, 'audio', 'SOME_AUDIO_URL', 'user_id'), 267 | expectedErrorMessage: 'Bots of type mock can\'t send messages with audio attachment', 268 | }); 269 | } 270 | 271 | { 272 | const bot = new MockBot(); 273 | 274 | test('#sendAttachmentFromUrlTo works', sendMessageMacro, { 275 | sendMessageMethod: bot.sendAttachmentFromUrlTo.bind( 276 | bot, 'audio', 'SOME_AUDIO_URL', 'user_id'), 277 | expectedSentMessage: outgoingMessageFixtures.audioMessage(), 278 | }); 279 | } 280 | 281 | { 282 | const bot = new MockBot({ 283 | sends: { 284 | quickReply: false, 285 | }, 286 | }); 287 | 288 | test('#sendDefaultButtonMessageTo throws error if bot class does not support quickReply', sendMessageErrorMacro, { 289 | sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( 290 | bot, [], undefined, 'user_id'), 291 | expectedErrorMessage: 'Bots of type mock can\'t send messages with quick replies', 292 | }); 293 | } 294 | 295 | { 296 | const bot = new MockBot(); 297 | const buttonTitles = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; 298 | 299 | test('#sendDefaultButtonMessageTo throws error if button count is larger than 10', sendMessageErrorMacro, { 300 | sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( 301 | bot, buttonTitles, undefined, 'user_id'), 302 | expectedErrorMessage: 'buttonTitles must be of length 10 or less', 303 | }); 304 | } 305 | 306 | { 307 | const bot = new MockBot({ 308 | sends: { 309 | text: false, 310 | quickReply: true, 311 | }, 312 | }); 313 | 314 | test('#sendDefaultButtonMessageTo throws error if bot class does not support text and text is set', sendMessageErrorMacro, { 315 | sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( 316 | bot, [], 'Click on one of', 'user_id'), 317 | expectedErrorMessage: 'Bots of type mock can\'t send messages with text', 318 | }); 319 | } 320 | 321 | { 322 | const bot = new MockBot({ 323 | sends: { 324 | attachment: false, 325 | quickReply: true, 326 | }, 327 | }); 328 | 329 | test('#sendDefaultButtonMessageTo throws error if bot class does not support attachment and attachment is set', sendMessageErrorMacro, { 330 | sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( 331 | bot, [], attachmentFixtures.imageAttachment(), 'user_id'), 332 | expectedErrorMessage: 'Bots of type mock can\'t send messages with attachment', 333 | }); 334 | } 335 | 336 | { 337 | const bot = new MockBot({ 338 | sends: { 339 | attachment: { 340 | image: false, 341 | }, 342 | quickReply: true, 343 | }, 344 | }); 345 | 346 | test('#sendDefaultButtonMessageTo throws error if bot class does not support image attachment and image attachment is set', sendMessageErrorMacro, { 347 | sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( 348 | bot, [], attachmentFixtures.imageAttachment(), 'user_id'), 349 | expectedErrorMessage: 'Bots of type mock can\'t send messages with image attachment', 350 | }); 351 | } 352 | 353 | { 354 | const bot = new MockBot(); 355 | const buttonTitles = ['B1', 'B2']; 356 | 357 | test('#sendDefaultButtonMessageTo throws error if textOrAttachment is not valid', sendMessageErrorMacro, { 358 | sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( 359 | bot, buttonTitles, new MockBot(), 'user_id'), 360 | expectedErrorMessage: 'third argument must be a "String", an attachment "Object" or absent', 361 | }); 362 | } 363 | 364 | { 365 | const bot = new MockBot(); 366 | const buttonTitles = ['B1', 'B2']; 367 | 368 | const quickReplies = [ 369 | { 370 | content_type: 'text', 371 | title: 'B1', 372 | payload: 'B1', 373 | }, 374 | { 375 | content_type: 'text', 376 | title: 'B2', 377 | payload: 'B2', 378 | }, 379 | ]; 380 | 381 | const expectedSentMessage = outgoingMessageFixtures.textOnlyQuickReplyMessage(); 382 | expectedSentMessage.message.quick_replies = quickReplies; 383 | 384 | test('#sendDefaultButtonMessageTo works with falsy textOrAttachment', sendMessageMacro, { 385 | expectedSentMessage, 386 | sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( 387 | bot, buttonTitles, undefined, 'user_id'), 388 | }); 389 | } 390 | 391 | { 392 | const bot = new MockBot(); 393 | const buttonTitles = ['B1', 'B2']; 394 | 395 | const quickReplies = [ 396 | { 397 | content_type: 'text', 398 | title: 'B1', 399 | payload: 'B1', 400 | }, 401 | { 402 | content_type: 'text', 403 | title: 'B2', 404 | payload: 'B2', 405 | }, 406 | ]; 407 | 408 | const expectedSentMessage = outgoingMessageFixtures.textOnlyQuickReplyMessage(); 409 | expectedSentMessage.message.quick_replies = quickReplies; 410 | expectedSentMessage.message.text = 'Click one of:'; 411 | 412 | test('#sendDefaultButtonMessageTo works with text type textOrAttachment', sendMessageMacro, { 413 | expectedSentMessage, 414 | sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( 415 | bot, buttonTitles, 'Click one of:', 'user_id'), 416 | }); 417 | } 418 | 419 | { 420 | const bot = new MockBot(); 421 | const buttonTitles = ['B1', 'B2']; 422 | 423 | const quickReplies = [ 424 | { 425 | content_type: 'text', 426 | title: 'B1', 427 | payload: 'B1', 428 | }, 429 | { 430 | content_type: 'text', 431 | title: 'B2', 432 | payload: 'B2', 433 | }, 434 | ]; 435 | 436 | const expectedSentMessage = outgoingMessageFixtures.textOnlyQuickReplyMessage(); 437 | expectedSentMessage.message.quick_replies = quickReplies; 438 | delete expectedSentMessage.message.text; 439 | expectedSentMessage.message.attachment = attachmentFixtures.imageAttachment(); 440 | 441 | test('#sendDefaultButtonMessageTo works with object type textOrAttachment', sendMessageMacro, { 442 | expectedSentMessage, 443 | sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( 444 | bot, buttonTitles, attachmentFixtures.imageAttachment(), 'user_id'), 445 | }); 446 | } 447 | 448 | { 449 | const bot = new MockBot({ 450 | sends: { 451 | text: false, 452 | }, 453 | }); 454 | 455 | test('#sendIsTypingMessageTo throws error if bot class does not support typing_on sender action', sendMessageErrorMacro, { 456 | sendMessageMethod: bot.sendIsTypingMessageTo.bind(bot, 'user_id'), 457 | expectedErrorMessage: 'Bots of type mock can\'t send messages with typing_on sender action', 458 | }); 459 | } 460 | 461 | { 462 | const bot = new MockBot(); 463 | 464 | test('#sendIsTypingMessageTo works', sendMessageMacro, { 465 | sendMessageMethod: bot.sendIsTypingMessageTo.bind(bot, 'user_id'), 466 | expectedSentMessage: outgoingMessageFixtures.typingOnMessage(), 467 | }); 468 | } 469 | 470 | { 471 | const bot = new MockBot(); 472 | 473 | test('#sendCascade throws error when used with no valid params', sendMessageErrorMacro, { 474 | sendMessageMethod: bot.sendCascade.bind(bot, [{}]), 475 | expectedErrorMessage: 'No valid message options specified', 476 | }); 477 | } 478 | 479 | { 480 | const bot = new MockBot(); 481 | 482 | const rawMessage1 = { 483 | nonStandard: 'message1', 484 | recipient: { 485 | id: 'user_id', 486 | }, 487 | }; 488 | const rawMessage2 = { 489 | nonStandard: 'message2', 490 | recipient: { 491 | id: 'user_id', 492 | }, 493 | }; 494 | 495 | const messageArray = [{ raw: rawMessage1 }, { raw: rawMessage2 }]; 496 | 497 | test('#sendCascade works with raw messages', sendCascadeMessageMacro, { 498 | sendMessageMethod: bot.sendCascade.bind(bot, messageArray), 499 | planFor: 2, // num assertions to plan for 500 | }); 501 | } 502 | 503 | { 504 | const bot = new MockBot(); 505 | 506 | const message1 = outgoingMessageFixtures.textOnlyQuickReplyMessage(); 507 | const message2 = outgoingMessageFixtures.imageMessage(); 508 | 509 | const messageArray = [{ message: message1 }, { message: message2 }]; 510 | const expectedSentMessages = [message1, message2]; 511 | 512 | test('#sendCascade works with valid messages', sendCascadeMessageMacro, { 513 | sendMessageMethod: bot.sendCascade.bind(bot, messageArray), 514 | planFor: 10, 515 | expectedSentMessages, 516 | }); 517 | } 518 | 519 | { 520 | const bot = new MockBot(); 521 | 522 | const message1 = outgoingMessageFixtures.textOnlyQuickReplyMessage(); 523 | 524 | const messageArray = [{ message: message1 }]; 525 | const expectedSentMessages = [message1]; 526 | 527 | test('#sendCascade works with single valid messages', sendCascadeMessageMacro, { 528 | sendMessageMethod: bot.sendCascade.bind(bot, messageArray), 529 | planFor: 5, 530 | expectedSentMessages, 531 | }); 532 | } 533 | 534 | { 535 | const bot = new MockBot(); 536 | 537 | const rawMessage1 = { 538 | nonStandard: 'message1', 539 | recipient: { 540 | id: 'user_id', 541 | }, 542 | }; 543 | const message2 = outgoingMessageFixtures.imageMessage(); 544 | 545 | const messageArray = [{ raw: rawMessage1 }, { message: message2 }]; 546 | const expectedSentMessages = [rawMessage1, message2]; 547 | 548 | test('#sendCascade works with mixed raw and botmaster messages', sendCascadeMessageMacro, { 549 | sendMessageMethod: bot.sendCascade.bind(bot, messageArray), 550 | planFor: 6, 551 | expectedSentMessages, 552 | }); 553 | } 554 | 555 | 556 | { 557 | const bot = new MockBot(); 558 | 559 | const textArray = ['Hello World!', 'Goodbye World!']; 560 | const secondExpectedMessage = outgoingMessageFixtures.textMessage(); 561 | secondExpectedMessage.message.text = 'Goodbye World!'; 562 | const expectedSentMessages = [ 563 | outgoingMessageFixtures.textMessage(), 564 | secondExpectedMessage, 565 | ]; 566 | 567 | test('#sendTextCascadeTo works', sendCascadeMessageMacro, { 568 | sendMessageMethod: bot.sendTextCascadeTo.bind(bot, textArray, 'user_id'), 569 | planFor: 10, 570 | expectedSentMessages, 571 | }); 572 | } 573 | -------------------------------------------------------------------------------- /tests/middleware/use.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import request from 'request-promise'; 3 | import { assign } from 'lodash'; 4 | import { outgoingMessageFixtures, 5 | incomingUpdateFixtures } from 'botmaster-test-fixtures'; 6 | 7 | import Botmaster from '../../lib'; 8 | import MockBot from '../_mock_bot'; 9 | 10 | test.beforeEach((t) => { 11 | return new Promise((resolve) => { 12 | t.context.botmaster = new Botmaster(); 13 | t.context.bot = new MockBot({ 14 | requiresWebhook: true, 15 | webhookEndpoint: 'webhook', 16 | type: 'express', 17 | }); 18 | t.context.botmaster.addBot(t.context.bot); 19 | t.context.baseRequestOptions = { 20 | method: 'POST', 21 | uri: 'http://localhost:3000/express/webhook', 22 | body: {}, 23 | json: true, 24 | resolveWithFullResponse: true, 25 | }; 26 | t.context.botmaster.on('listening', resolve); 27 | }); 28 | }); 29 | 30 | test.afterEach((t) => { 31 | return new Promise((resolve) => { 32 | t.context.botmaster.server.close(resolve); 33 | }); 34 | }); 35 | 36 | test('throws an error middleware is not an object', (t) => { 37 | t.plan(1); 38 | 39 | try { 40 | t.context.botmaster.use('something'); 41 | } catch (err) { 42 | t.is(err.message, 43 | 'middleware should be an object. Not string', 44 | 'Error message is not the same as expected'); 45 | } 46 | }); 47 | 48 | test('throws an error if type is not incoming or outgoing', (t) => { 49 | t.plan(1); 50 | 51 | try { 52 | t.context.botmaster.use({ 53 | type: 'something', 54 | }); 55 | } catch (err) { 56 | t.is(err.message, 57 | 'invalid middleware type. Type should be either \'incoming\' or \'outgoing\'', 58 | 'Error message is not the same as expected'); 59 | } 60 | }); 61 | 62 | test('throws an error if controller is not defined', (t) => { 63 | t.plan(1); 64 | 65 | try { 66 | t.context.botmaster.use({ 67 | type: 'incoming', 68 | }); 69 | } catch (err) { 70 | t.is(err.message, 71 | 'middleware controller can\'t be of type undefined. It needs to be a function', 72 | 'Error message is not the same as expected'); 73 | } 74 | }); 75 | 76 | test('throws an error if middlewareCallback is not a function', (t) => { 77 | t.plan(1); 78 | 79 | try { 80 | t.context.botmaster.use({ 81 | type: 'incoming', 82 | controller: 'not valid', 83 | }); 84 | } catch (err) { 85 | t.is(err.message, 86 | 'middleware controller can\'t be of type string. It needs to be a function', 87 | 'Error message is not the same as expected'); 88 | } 89 | }); 90 | 91 | const incomingMiddlewareErrorMacro = (t, controller) => { 92 | t.plan(1); 93 | 94 | return new Promise((resolve) => { 95 | const botmaster = t.context.botmaster; 96 | 97 | botmaster.use({ 98 | controller, 99 | type: 'incoming', 100 | }); 101 | 102 | botmaster.use({ 103 | type: 'incoming', 104 | controller: () => { 105 | t.fail('this middleware should not get hit'); 106 | resolve(); 107 | }, 108 | }); 109 | 110 | botmaster.on('error', (bot, err) => { 111 | t.is(err.message, 112 | '"update.blop is not a function". This is most probably on your end.', 113 | 'Error message did not match'); 114 | resolve(); 115 | }); 116 | 117 | request(t.context.baseRequestOptions); 118 | }); 119 | }; 120 | 121 | incomingMiddlewareErrorMacro.title = customTitlePart => 122 | `Errors in incoming middleware are emitted correctly ${customTitlePart}`; 123 | 124 | test('in synchronous middleware', incomingMiddlewareErrorMacro, 125 | (bot, update) => { 126 | update.blop(); 127 | }); 128 | 129 | test('using next', incomingMiddlewareErrorMacro, 130 | (bot, update, next) => { 131 | process.nextTick(() => { 132 | try { 133 | update.blop(); 134 | } catch (err) { 135 | next(err); 136 | } 137 | }); 138 | }); 139 | 140 | test('using promises', incomingMiddlewareErrorMacro, 141 | (bot, update) => { 142 | return new Promise((resolve, reject) => { 143 | process.nextTick(() => { 144 | try { 145 | update.blop(); 146 | } catch (err) { 147 | reject(err); 148 | } 149 | }); 150 | }); 151 | }); 152 | 153 | test('using async function', incomingMiddlewareErrorMacro, 154 | async (bot, update) => { 155 | // just a function that returns a promise 156 | const somePromise = () => new Promise((resolve) => { 157 | process.nextTick(() => { 158 | resolve(); 159 | }); 160 | }); 161 | 162 | await somePromise(); 163 | update.blop(); 164 | }); 165 | 166 | test('Error is emitted if error is thrown by user and does not inherit from Error', (t) => { 167 | t.plan(1); 168 | 169 | return new Promise((resolve) => { 170 | const botmaster = t.context.botmaster; 171 | 172 | botmaster.use({ 173 | controller: async () => { 174 | const err = 'not expected'; 175 | throw err; 176 | }, 177 | type: 'incoming', 178 | }); 179 | 180 | botmaster.use({ 181 | type: 'incoming', 182 | controller: () => { 183 | t.fail('this middleware should not get hit'); 184 | resolve(); 185 | }, 186 | }); 187 | 188 | botmaster.on('error', (bot, err) => { 189 | t.is(err, 190 | 'not expected', 191 | 'Error message did not match'); 192 | resolve(); 193 | }); 194 | 195 | request(t.context.baseRequestOptions); 196 | }); 197 | }); 198 | 199 | test('Error is emitted if error is thrown by user and is falsy', (t) => { 200 | t.plan(1); 201 | 202 | return new Promise((resolve) => { 203 | const botmaster = t.context.botmaster; 204 | 205 | botmaster.use({ 206 | controller: () => Promise.reject(), 207 | type: 'incoming', 208 | }); 209 | 210 | botmaster.use({ 211 | type: 'incoming', 212 | controller: () => { 213 | t.fail('this middleware should not get hit'); 214 | resolve(); 215 | }, 216 | }); 217 | 218 | botmaster.on('error', (bot, err) => { 219 | t.is(err, 220 | 'empty error object', 221 | 'Error message did not match'); 222 | resolve(); 223 | }); 224 | 225 | request(t.context.baseRequestOptions); 226 | }); 227 | }); 228 | 229 | test('Emits error if next is used within returned promise', (t) => { 230 | t.plan(1); 231 | 232 | return new Promise((resolve) => { 233 | const botmaster = t.context.botmaster; 234 | 235 | botmaster.use({ 236 | type: 'incoming', 237 | controller: async (bot, update, next) => { 238 | next('skip'); 239 | }, 240 | }); 241 | 242 | botmaster.use({ 243 | type: 'incoming', 244 | controller: () => { 245 | t.fail('this middleware should not get hit'); 246 | resolve(); 247 | }, 248 | }); 249 | 250 | botmaster.on('error', (bot, err) => { 251 | t.is(err.message, 252 | '"next can\'t be called if middleware returns a promise/is an async ' + 253 | 'function". This is most probably on your end.', 254 | 'Error message did not match'); 255 | resolve(); 256 | }); 257 | 258 | request(t.context.baseRequestOptions); 259 | }); 260 | }); 261 | 262 | test('sets up the incoming middleware function specified if good params' + 263 | ' passed. Does not call any outgoing middleware when going through', (t) => { 264 | t.plan(1); 265 | 266 | return new Promise((resolve) => { 267 | const botmaster = t.context.botmaster; 268 | 269 | botmaster.use({ 270 | type: 'incoming', 271 | controller: async (bot, update) => { 272 | update.message.text = 'Hello World!'; 273 | }, 274 | }); 275 | 276 | botmaster.use({ 277 | type: 'outgoing', 278 | controller: () => { 279 | t.fail('outgoing middleware should not be called'); 280 | }, 281 | }); 282 | 283 | botmaster.use({ 284 | type: 'incoming', 285 | controller: (bot, update) => { 286 | t.is(update.message.text, 'Hello World!', 'update object did not match'); 287 | resolve(); 288 | }, 289 | }); 290 | 291 | request(t.context.baseRequestOptions); 292 | }); 293 | }); 294 | 295 | 296 | test('sets up the incoming middleware and calls them using __emitUpdate', (t) => { 297 | t.plan(1); 298 | 299 | return new Promise((resolve) => { 300 | t.context.botmaster.use({ 301 | type: 'incoming', 302 | controller: (bot, update, next) => { 303 | update.text = 'Hello World!'; 304 | next(); 305 | }, 306 | }); 307 | 308 | t.context.botmaster.use({ 309 | type: 'incoming', 310 | controller: (bot, update) => { 311 | t.is(update.text, 'Hello World!', 'update object did not match'); 312 | resolve(); 313 | }, 314 | }); 315 | 316 | t.context.bot.__emitUpdate({}); 317 | }); 318 | }); 319 | 320 | 321 | test('sets up the incoming middleware in order of declaration', (t) => { 322 | t.plan(1); 323 | 324 | return new Promise((resolve) => { 325 | t.context.botmaster.use({ 326 | type: 'incoming', 327 | controller: (bot, update, next) => { 328 | update.text = 'Hello '; 329 | next(); 330 | }, 331 | }); 332 | 333 | t.context.botmaster.use({ 334 | type: 'incoming', 335 | controller: (bot, update) => { 336 | update.text += 'World!'; 337 | return Promise.resolve(); 338 | }, 339 | }); 340 | 341 | t.context.botmaster.use({ 342 | type: 'incoming', 343 | controller: async (bot, update) => { 344 | update.text += ' And others'; 345 | }, 346 | }); 347 | 348 | t.context.botmaster.use({ 349 | type: 'incoming', 350 | controller: (bot, update) => { 351 | t.is(update.text, 'Hello World! And others', 'update object did not match'); 352 | resolve(); 353 | }, 354 | }); 355 | 356 | t.context.bot.__emitUpdate({}); 357 | }); 358 | }); 359 | 360 | const incomingMiddlewareChainBreakerMacro = (t, controller) => { 361 | t.plan(1); 362 | 363 | return new Promise(async (resolve) => { 364 | t.context.botmaster.use({ 365 | type: 'incoming', 366 | controller, 367 | }); 368 | 369 | t.context.botmaster.use({ 370 | type: 'incoming', 371 | controller: () => { 372 | t.fail('this middleware should not get hit'); 373 | resolve(); 374 | }, 375 | }); 376 | 377 | const val = await t.context.bot.__emitUpdate({}); 378 | if (val) { 379 | t.is(val, 'cancelled'); 380 | } else { 381 | t.pass(); 382 | } 383 | resolve(); 384 | }); 385 | }; 386 | 387 | incomingMiddlewareChainBreakerMacro.title = customTitlePart => 388 | `using middleware chain breakers in incoming middleware works as expected ${customTitlePart}`; 389 | 390 | test('using next skip', incomingMiddlewareChainBreakerMacro, (bot, update, next) => { 391 | next('skip'); 392 | }); 393 | 394 | test('using promise skip', incomingMiddlewareChainBreakerMacro, 395 | () => Promise.resolve('skip')); 396 | 397 | test('using async skip', incomingMiddlewareChainBreakerMacro, 398 | async () => 'skip'); 399 | 400 | test('using next cancel', incomingMiddlewareChainBreakerMacro, (bot, update, next) => { 401 | next('cancel'); 402 | }); 403 | 404 | test('using promise cancel', incomingMiddlewareChainBreakerMacro, 405 | () => Promise.resolve('cancel')); 406 | 407 | test('using async cancel', incomingMiddlewareChainBreakerMacro, 408 | async () => 'cancel'); 409 | 410 | 411 | test('echo, read and delivery are not included by default', (t) => { 412 | t.plan(3); 413 | 414 | return new Promise((resolve) => { 415 | t.context.botmaster.use({ 416 | type: 'incoming', 417 | controller: () => { 418 | t.fail('this middleware should never get hit in this test'); 419 | resolve(); 420 | }, 421 | }); 422 | 423 | let hitMiddlewareCount = 0; 424 | const resolveWhenNeeded = () => { 425 | hitMiddlewareCount += 1; 426 | if (hitMiddlewareCount === 3) { 427 | resolve(); 428 | } 429 | }; 430 | 431 | t.context.botmaster.use({ 432 | type: 'incoming', 433 | includeEcho: true, 434 | controller: (bot, update, next) => { 435 | t.truthy(update.message.is_echo, 'message is not an echo'); 436 | resolveWhenNeeded(); 437 | next(); 438 | }, 439 | }); 440 | 441 | t.context.botmaster.use({ 442 | type: 'incoming', 443 | includeDelivery: true, 444 | controller: (bot, update) => { 445 | t.truthy(update.delivery, 'message is not a delivery confirmation'); 446 | resolveWhenNeeded(); 447 | return Promise.resolve(); 448 | }, 449 | }); 450 | 451 | t.context.botmaster.use({ 452 | type: 'incoming', 453 | includeRead: true, 454 | controller: async (bot, update) => { 455 | t.truthy(update.read, 'message is not a read confirmation'); 456 | resolveWhenNeeded(); 457 | }, 458 | }); 459 | 460 | t.context.bot.__emitUpdate(incomingUpdateFixtures.echoUpdate()); 461 | t.context.bot.__emitUpdate(incomingUpdateFixtures.messageReadUpdate()); 462 | t.context.bot.__emitUpdate(incomingUpdateFixtures.messageDeliveredUpdate()); 463 | }); 464 | }); 465 | 466 | const outgoingMiddlewareErrorMacro = (t, controller) => { 467 | t.plan(1); 468 | 469 | return new Promise((resolve) => { 470 | const botmaster = t.context.botmaster; 471 | 472 | botmaster.use({ 473 | controller, 474 | type: 'outgoing', 475 | }); 476 | 477 | botmaster.use({ 478 | type: 'outgoing', 479 | controller: () => { 480 | t.fail('this middleware should not get hit'); 481 | resolve(); 482 | }, 483 | }); 484 | 485 | botmaster.bots[0].sendMessage({}) 486 | .catch((err) => { 487 | t.is(err.message, 488 | 'message.blop is not a function', 489 | 'Error message did not match'); 490 | resolve(); 491 | }); 492 | }); 493 | }; 494 | 495 | outgoingMiddlewareErrorMacro.title = customTitlePart => 496 | `Errors in outgoing middleware are thrown correctly ${customTitlePart}`; 497 | 498 | test('in synchronous middleware', outgoingMiddlewareErrorMacro, 499 | (bot, update, message) => { 500 | message.blop(); 501 | }); 502 | 503 | test('using next', outgoingMiddlewareErrorMacro, 504 | (bot, update, message, next) => { 505 | process.nextTick(() => { 506 | try { 507 | message.blop(); 508 | } catch (err) { 509 | next(err); 510 | } 511 | }); 512 | }); 513 | 514 | test('using promises', outgoingMiddlewareErrorMacro, 515 | (bot, update, message) => { 516 | return new Promise((resolve, reject) => { 517 | process.nextTick(() => { 518 | try { 519 | message.blop(); 520 | } catch (err) { 521 | reject(err); 522 | } 523 | }); 524 | }); 525 | }); 526 | 527 | test('using async function', outgoingMiddlewareErrorMacro, 528 | async (bot, update, message) => { 529 | // just a function that returns a promise 530 | const somePromise = () => new Promise((resolve) => { 531 | process.nextTick(() => { 532 | resolve(); 533 | }); 534 | }); 535 | 536 | await somePromise(); 537 | message.blop(); 538 | }); 539 | 540 | test('sets up the outgoing middleware in order of declaration. ' + 541 | 'Then calls them when prompted without calling incoming middleware', (t) => { 542 | t.plan(1); 543 | 544 | return new Promise((resolve) => { 545 | const botmaster = t.context.botmaster; 546 | 547 | botmaster.use({ 548 | type: 'incoming', 549 | controller: (bot, update, next) => { 550 | t.fail('Called incoming middleware, although should not'); 551 | next(); 552 | }, 553 | }); 554 | 555 | botmaster.use({ 556 | type: 'outgoing', 557 | controller: async (bot, update, message) => { 558 | message.removeText(); 559 | }, 560 | }); 561 | 562 | botmaster.use({ 563 | type: 'outgoing', 564 | controller: (bot, update, message, next) => { 565 | message.addText('Goodbye Worlds!'); 566 | next(); 567 | }, 568 | }); 569 | 570 | botmaster.bots[0].sendMessage(outgoingMessageFixtures.textMessage()) 571 | .then((body) => { 572 | t.is(body.sentOutgoingMessage.message.text, 'Goodbye Worlds!', 'sent message did not match'); 573 | resolve(); 574 | }) 575 | .catch((err) => { 576 | t.fail(err.message); 577 | resolve(); 578 | }); 579 | }); 580 | }); 581 | 582 | const skipOutgoingMiddlewareMacro = (t, controller) => { 583 | t.plan(2); 584 | 585 | return new Promise(async (resolve) => { 586 | t.context.botmaster.use({ 587 | type: 'outgoing', 588 | controller: (bot, update, message, next) => { 589 | t.pass(); 590 | return controller(bot, update, message, next); 591 | }, 592 | }); 593 | 594 | t.context.botmaster.use({ 595 | type: 'outgoing', 596 | controller: () => { 597 | t.fail('this middleware should not get hit'); 598 | resolve(); 599 | }, 600 | }); 601 | 602 | const body = await t.context.bot.sendMessage( 603 | outgoingMessageFixtures.textMessage()); 604 | t.deepEqual(body.sentRawMessage, outgoingMessageFixtures.textMessage()); 605 | resolve(); 606 | }); 607 | }; 608 | 609 | skipOutgoingMiddlewareMacro.title = customTitlePart => 610 | `using middleware skip in outgoing middleware works as expected ${customTitlePart}`; 611 | 612 | test('using next skip', skipOutgoingMiddlewareMacro, (bot, update, message, next) => { 613 | next('skip'); 614 | }); 615 | 616 | test('using promise skip', skipOutgoingMiddlewareMacro, 617 | () => Promise.resolve('skip')); 618 | 619 | test('using async skip', skipOutgoingMiddlewareMacro, 620 | async () => 'skip'); 621 | 622 | 623 | const cancelOutgoingMiddlewareMacro = async (t, controller) => { 624 | t.plan(2); 625 | 626 | return new Promise(async (resolve) => { 627 | t.context.botmaster.use({ 628 | type: 'outgoing', 629 | controller: (bot, update, message, next) => { 630 | t.pass(); 631 | return controller(bot, update, message, next); 632 | }, 633 | }); 634 | 635 | t.context.botmaster.use({ 636 | type: 'outgoing', 637 | controller: () => { 638 | t.fail('this middleware should not get hit'); 639 | resolve(); 640 | }, 641 | }); 642 | 643 | const body = await t.context.bot.sendMessage( 644 | outgoingMessageFixtures.textMessage()); 645 | t.is(body, 'cancelled'); 646 | resolve(); 647 | }); 648 | }; 649 | 650 | cancelOutgoingMiddlewareMacro.title = customTitlePart => 651 | `using middleware cancel in outgoing middleware works as expected ${customTitlePart}`; 652 | 653 | test('using next cancel', cancelOutgoingMiddlewareMacro, (bot, update, message, next) => { 654 | next('cancel'); 655 | }); 656 | 657 | test('using promise cancel', cancelOutgoingMiddlewareMacro, 658 | () => Promise.resolve('cancel')); 659 | 660 | test('using async cancel', cancelOutgoingMiddlewareMacro, 661 | async () => 'cancel'); 662 | 663 | test('sets up the outgoing middleware which is ignored if specified so in sendOptions.', (t) => { 664 | t.plan(2); 665 | 666 | return new Promise(async (resolve) => { 667 | t.context.botmaster.use({ 668 | type: 'outgoing', 669 | controller: () => { 670 | t.fail('this middleware should not get hit'); 671 | resolve(); 672 | }, 673 | }); 674 | 675 | const bot = t.context.bot; 676 | try { 677 | await bot.sendMessage( 678 | outgoingMessageFixtures.textMessage(), { ignoreMiddleware: true }); 679 | await bot.reply( 680 | incomingUpdateFixtures.textUpdate(), 'wadup?', { ignoreMiddleware: true }); 681 | await bot.sendAttachmentFromUrlTo( 682 | 'image', 'some_link', 'user_id', { ignoreMiddleware: true }); 683 | await bot.sendDefaultButtonMessageTo( 684 | ['b1', 'b2'], undefined, 'user_id', { ignoreMiddleware: true }); 685 | await bot.sendIsTypingMessageTo( 686 | 'user_id', { ignoreMiddleware: true }); 687 | const bodies = await bot.sendTextCascadeTo( 688 | ['message1', 'message2'], 'user_id', { ignoreMiddleware: true }); 689 | 690 | t.is(bodies[0].sentOutgoingMessage.message.text, 'message1', 691 | 'sentOutgoingMessage was not as expected'); 692 | t.is(bodies[1].sentOutgoingMessage.message.text, 'message2', 693 | 'sentOutgoingMessage was not as expected'); 694 | 695 | resolve(); 696 | } catch (err) { 697 | t.fail(err.message); 698 | resolve(); 699 | } 700 | }); 701 | }); 702 | 703 | test('sets up the outgoing middleware which is aware of update when manually set using __createBotPatchedWithUpdate', (t) => { 704 | t.plan(2); 705 | 706 | return new Promise(async (resolve) => { 707 | const botmaster = t.context.botmaster; 708 | 709 | const mockUpdate = { id: 1 }; 710 | botmaster.use({ 711 | type: 'outgoing', 712 | controller: (bot, update, message, next) => { 713 | t.is(update, mockUpdate, 'associated update is not the same'); 714 | t.deepEqual(assign({}, message), outgoingMessageFixtures.textMessage(), 715 | 'Message is not the same'); 716 | next(); 717 | }, 718 | }); 719 | 720 | const bot = botmaster.bots[0]; 721 | try { 722 | // with a patchedBot 723 | const patchedBot = bot.__createBotPatchedWithUpdate(mockUpdate); 724 | await patchedBot.sendMessage(outgoingMessageFixtures.textMessage()); 725 | 726 | botmaster.server.close(resolve); 727 | } catch (err) { 728 | t.fail(err.message); 729 | botmaster.server.close(resolve); 730 | } 731 | }); 732 | }); 733 | 734 | test('sets up the outgoing middleware which is aware of update when sending message from incoming middleware', (t) => { 735 | t.plan(3); 736 | 737 | return new Promise((resolve) => { 738 | const botmaster = t.context.botmaster; 739 | 740 | botmaster.use({ 741 | type: 'incoming', 742 | controller: async (bot, update) => { 743 | const body = await bot.reply(update, 'Hello World!'); 744 | t.is(body.sentOutgoingMessage.message.text, 'Hello World!'); 745 | resolve(); 746 | }, 747 | }); 748 | 749 | botmaster.use({ 750 | type: 'outgoing', 751 | controller: (bot, update, message, next) => { 752 | t.deepEqual(assign({}, update), incomingUpdateFixtures.textUpdate(), 'associated update is not the same'); 753 | t.deepEqual(assign({}, message), outgoingMessageFixtures.textMessage(), 'Message is not the same'); 754 | next(); 755 | }, 756 | }); 757 | 758 | const bot = botmaster.bots[0]; 759 | bot.__emitUpdate(incomingUpdateFixtures.textUpdate()); 760 | }); 761 | }); 762 | 763 | test('sets up the outgoing middleware which is aware of update on the second pass when sending a message in outgoing middleware', (t) => { 764 | t.plan(8); 765 | 766 | return new Promise((resolve) => { 767 | const botmaster = t.context.botmaster; 768 | 769 | const receivedUpdate = incomingUpdateFixtures.textUpdate(); 770 | 771 | let pass = 1; 772 | botmaster.use({ 773 | type: 'outgoing', 774 | controller: async (bot, update, message) => { 775 | if (pass === 1) { 776 | t.is(message.message.text, 'Hello World!', 'message text is not as expected on first pass'); 777 | t.is(update.newProp, 1, 'newProp is not the expected value on first pass'); 778 | t.is(update, receivedUpdate, 'Reference to update is not the same'); 779 | update.newProp = 2; 780 | pass += 1; 781 | 782 | const body = await bot.reply(update, 'Goodbye World!'); 783 | t.is(body.sentRawMessage.message.text, 'Goodbye World!'); 784 | } else if (pass === 2) { 785 | t.is(message.message.text, 'Goodbye World!', 'message text is not as expected on second pass'); 786 | t.is(update.newProp, 2, 'newProp is not the expected value on second pass'); 787 | t.is(update, receivedUpdate, 'Reference to update is not the same'); 788 | } 789 | }, 790 | }); 791 | 792 | botmaster.use({ 793 | type: 'incoming', 794 | controller: async (bot, update) => { 795 | update.newProp = 1; 796 | const body = await bot.reply(update, 'Hello World!'); 797 | t.is(body.sentRawMessage.message.text, 'Hello World!'); 798 | resolve(); 799 | }, 800 | }); 801 | 802 | t.context.bot.__emitUpdate(receivedUpdate); 803 | }); 804 | }); 805 | -------------------------------------------------------------------------------- /lib/base_bot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventEmitter = require('events'); 4 | const OutgoingMessage = require('./outgoing_message'); 5 | const get = require('lodash').get; 6 | const TwoDotXError = require('./errors').TwoDotXError; 7 | const SendMessageTypeError = require('./errors').SendMessageTypeError; 8 | 9 | /** 10 | * The class from which all Bot classes mus inherit. It contains all the base 11 | * methods that are accessible via all bot classes. Classes that inherit from 12 | * BaseBot and want to make implementation specific methods available have to 13 | * prepend the method name with an underscore; e.g. in botmaster-messenger: 14 | * _getGetStartedButton 15 | */ 16 | 17 | class BaseBot extends EventEmitter { 18 | /** 19 | * Constructor to the BaseBot class from which all the bot classes inherit. 20 | * A set a basic functionalities are defined here that have to be implemented 21 | * in the subclasses in order for them to work. 22 | * 23 | * @param {object} settings - inheritors of BaseBot take a settings 24 | * object as first param. 25 | * @example 26 | * // In general however, one can instantiate a bot object like this: 27 | * const bot = new BaseBotSubClass({ // e.g. MessengerBot 28 | * credentials: , 29 | * webhookEnpoint: 'someEndpoint' // only if class requires them 30 | * }) 31 | */ 32 | constructor() { 33 | super(); 34 | this.type = 'baseBot'; 35 | 36 | // just being explicit about what subclasses can send and receive. 37 | // anything else they want to implement has to be done in raw mode. 38 | // I.e. using bot class events and upon receiving and sendRawMessage for sending. 39 | 40 | this.receives = { 41 | text: false, 42 | attachment: { 43 | audio: false, 44 | file: false, 45 | image: false, 46 | video: false, 47 | location: false, 48 | // can occur in FB messenger when user sends a message which only contains a URL 49 | // most platforms won't support that 50 | fallback: false, 51 | }, 52 | echo: false, 53 | read: false, 54 | delivery: false, 55 | postback: false, 56 | // in FB Messenger, this will exist whenever a user clicks on 57 | // a quick_reply button. It will contain the payload set by the developer 58 | // when sending the outgoing message. Bot classes should only set this 59 | // value to true if the platform they are building for has an equivalent 60 | // to this. 61 | quickReply: false, 62 | }; 63 | 64 | this.sends = { 65 | text: false, 66 | quickReply: false, 67 | locationQuickReply: false, 68 | senderAction: { 69 | typingOn: false, 70 | typingOff: false, 71 | markSeen: false, 72 | }, 73 | attachment: { 74 | audio: false, 75 | file: false, 76 | image: false, 77 | video: false, 78 | }, 79 | }; 80 | 81 | this.retrievesUserInfo = false; 82 | 83 | this.requiresWebhook = false; 84 | this.requiredCredentials = []; 85 | } 86 | 87 | /** 88 | * Just validating the settings and throwing errors or warnings 89 | * where appropriate. 90 | * @ignore 91 | * @param {object} settings 92 | */ 93 | __applySettings(settings) { 94 | if (typeof settings !== 'object') { 95 | throw new TypeError(`settings must be object, got ${typeof settings}`); 96 | } 97 | 98 | if (this.requiredCredentials.length > 0) { 99 | if (!settings.credentials) { 100 | throw new Error(`no credentials specified for bot of type '${this.type}'`); 101 | } else { 102 | this.credentials = settings.credentials; 103 | } 104 | 105 | for (const credentialName of this.requiredCredentials) { 106 | if (!this.credentials[credentialName]) { 107 | throw new Error(`bots of type '${this.type}' are expected to have '${credentialName}' credentials`); 108 | } 109 | } 110 | } 111 | 112 | if (this.requiresWebhook) { 113 | if (!settings.webhookEndpoint) { 114 | throw new Error(`bots of type '${this.type}' must be defined with webhookEndpoint in their settings`); 115 | } else { 116 | this.webhookEndpoint = settings.webhookEndpoint; 117 | } 118 | } else if (settings.webhookEndpoint) { 119 | throw new Error(`bots of type '${this.type}' do not require webhookEndpoint in their settings`); 120 | } 121 | } 122 | 123 | /** 124 | * sets up the app if needed. 125 | * As in sets up the endpoints that the bot can get called onto 126 | * see code in bot classes packages to see examples of this in action 127 | * Should not return anything 128 | * 129 | * __createMountPoints() {} 130 | */ 131 | 132 | /** 133 | * Format the update gotten from the bot source (telegram, messenger etc..). 134 | * Returns an update in a standard format 135 | * 136 | * @param {object} rawUpdate 137 | * @return {object} update 138 | * 139 | * __formatUpdate(rawUpdate) {} 140 | */ 141 | 142 | /** 143 | * createOutgoingMessage exposes the OutgoingMessage constructor 144 | * via BaseBot. This simply means one can create their own 145 | * OutgoingMessage object using any bot object. They can then compose 146 | * it with all its helper functions 147 | * 148 | * This is the static version of this method 149 | * 150 | * @param {object} message base object that the outgoing Message should be based on 151 | * 152 | * @return {OutgoingMessage} outgoingMessage. The same object passed in with 153 | * all the helper functions from OutgoingMessage 154 | */ 155 | static createOutgoingMessage(message) { 156 | return new OutgoingMessage(message); 157 | } 158 | 159 | /** 160 | * createOutgoingMessage exposes the OutgoingMessage constructor 161 | * via BaseBot. This simply means one can create their own 162 | * OutgoingMessage object using any bot object. They can then compose 163 | * it with all its helper functions 164 | * 165 | * This is the instance version of this method 166 | * 167 | * @param {object} message base object that the outgoing Message should be based on 168 | * 169 | * @return {OutgoingMessage} outgoingMessage. The same object passed in with 170 | * all the helper functions from OutgoingMessage 171 | */ 172 | createOutgoingMessage(message) { 173 | return BaseBot.createOutgoingMessage(message); 174 | } 175 | 176 | /** 177 | * same as #createOutgoingMessage, creates empty outgoingMessage with 178 | * id of the recipient set. Again, this is jut sugar syntax for creating a 179 | * new outgoingMessage object 180 | * 181 | * This is the static version of this method 182 | * 183 | * @param {string} recipientId id of the recipient the message is for 184 | * 185 | * @return {OutgoingMessage} outgoingMessage. A valid OutgoingMessage object with recipient set. 186 | */ 187 | 188 | static createOutgoingMessageFor(recipientId) { 189 | return new OutgoingMessage().addRecipientById(recipientId); 190 | } 191 | 192 | /** 193 | * same as #createOutgoingMessage, creates empty outgoingMessage with 194 | * id of the recipient set. Again, this is jut sugar syntax for creating a 195 | * new outgoingMessage object 196 | * 197 | * This is the instance version of this method 198 | * 199 | * @param {string} recipientId id of the recipient the message is for 200 | * 201 | * @return {OutgoingMessage} outgoingMessage. A valid OutgoingMessage object with recipient set. 202 | */ 203 | 204 | createOutgoingMessageFor(recipientId) { 205 | return BaseBot.createOutgoingMessageFor(recipientId); 206 | } 207 | 208 | /** 209 | * sendMessage() falls back to the sendMessage implementation of whatever 210 | * subclass inherits form BaseBot. The expected format is normally any type of 211 | * message object that could be sent on to messenger 212 | * @param {object} message 213 | * @param {boolean} [sendOptions] an object containing options regarding the 214 | * sending of the message. Currently the only valid options is: `ignoreMiddleware`. 215 | * 216 | * @return {Promise} promise that resolves with a body object (see example) 217 | * 218 | * @example 219 | * const outgoingMessage = bot.createOutgoingMessageFor(update.sender.id); 220 | * outgoingMessage.addText('Hello world'); 221 | * 222 | * bot.sendMessage(outgoingMessage); 223 | * 224 | * @example 225 | * // The returned promise for all sendMessage type events resolves with 226 | * // a body that looks something like this: 227 | * { 228 | * sentOutgoingMessage: // the OutgoingMessage instance before being formatted 229 | * sentRawMessage: // the OutgoingMessage object after being formatted for the platforms 230 | * raw: rawBody, // the raw response from the platforms received from sending the message 231 | * recipient_id: , 232 | * message_id: 233 | * } 234 | * 235 | * // Some platforms may not have either of these parameters. If that's the case, 236 | * // the value assigned will be a falsy value 237 | * 238 | */ 239 | sendMessage(message, sendOptions) { 240 | sendOptions = sendOptions || {}; // empty object if undefined 241 | 242 | const outgoingMessage = !(message instanceof OutgoingMessage) 243 | ? new OutgoingMessage(message) 244 | : message; 245 | 246 | const responseBody = {}; 247 | return this.__validateSendOptions(sendOptions) 248 | 249 | .then(() => { 250 | let outgoingMiddlewarePromise; 251 | if (this.master && !sendOptions.ignoreMiddleware) { 252 | outgoingMiddlewarePromise = this.master.middleware.__runOutgoingMiddleware( 253 | this, this.__associatedUpdate, outgoingMessage); 254 | } else { 255 | // don't actually go through middleware 256 | outgoingMiddlewarePromise = Promise.resolve(outgoingMessage); 257 | } 258 | return outgoingMiddlewarePromise; 259 | }) 260 | .then(() => { 261 | responseBody.sentOutgoingMessage = outgoingMessage; 262 | return this.__formatOutgoingMessage(outgoingMessage, sendOptions); 263 | }) 264 | .then((rawMessage) => { 265 | responseBody.sentRawMessage = rawMessage; 266 | return this.__sendMessage(rawMessage, sendOptions); 267 | }) 268 | .then((rawBody) => { 269 | responseBody.raw = rawBody; 270 | return this.__createStandardBodyResponseComponents( 271 | responseBody.sentOutgoingMessage, 272 | responseBody.sentRawMessage, 273 | responseBody.raw); 274 | }) 275 | .then((StandardBodyResponseComponents) => { 276 | responseBody.recipient_id = StandardBodyResponseComponents.recipient_id; 277 | responseBody.message_id = StandardBodyResponseComponents.message_id; 278 | return responseBody; 279 | }) 280 | .catch((err) => { 281 | if (err === 'cancel') { 282 | return 'cancelled'; 283 | } 284 | 285 | throw err; 286 | }); 287 | } 288 | 289 | /** 290 | * Bot class implementation of the __formatOutgoingMessage function. Each Bot class 291 | * has to implement this in order to be able to send outgoing messages that start 292 | * off as valid Messenger message objects (i.e. OutgoingMessage objects). 293 | * 294 | * @param {OutgoingMessage} outgoingMessage The outgoingMessage object that 295 | * needs to be formatted to the platform standard (formatted out). 296 | * @return {Promise} promise that resolves in the body of the response from the platform 297 | * 298 | * __formatOutgoingMessage(outgoingMessage) {} 299 | */ 300 | 301 | /** 302 | * Bot class implementation of the __sendMessage function. Each Bot class 303 | * has to implement this in order to be able to send outgoing messages. 304 | * 305 | * @param {object} message 306 | * @return {Promise} promise that resolves in the body of the response from the platform 307 | * 308 | * __sendMessage(rawUpdate) {} 309 | */ 310 | 311 | /** 312 | * Bot class implementation of the __createStandardBodyResponseComponents 313 | * function. Each Bot class has to implement this in order to be able to 314 | * send outgoing messages using sendMessage. This function returns the standard 315 | * recipient_id and message_id we can expect from using sendMessage 316 | * 317 | * @param {OutgoingMessage} sentOutgoingMessage The OutgoingMessage object 318 | * before formatting 319 | * @param {object} sentRawMessage The raw message that was actually sent to 320 | * the platform after __formatOutgoingMessage was called 321 | * @param {object} rawPlatformBody the raw body response from the platform 322 | * 323 | * @return {Promise} promise that resolves in an object that contains 324 | * both the recipient_id and message_id fields 325 | * 326 | * __createStandardBodyResponseComponents( 327 | * sentOutgoingMessage, sentRawMessage, rawPlatformBody) {} 328 | */ 329 | 330 | /** 331 | * sendMessageTo() Just makes it easier to send a message without as much 332 | * structure. 333 | * @param {object} message NOT an instance of OutgoingMessage. Use 334 | * #sendMessage if you want to send instances of OutgoingMessage 335 | * @param {string} recipientId a string representing the id of the user to 336 | * whom you want to send the message. 337 | * @param {object} [sendOptions] see `sendOptions` for `sendMessage` 338 | * 339 | * @return {Promise} promise that resolves with a body object 340 | * (see `sendMessage` example) 341 | * 342 | * @example 343 | * 344 | * // message object can look something like this: 345 | * // as you can see, this is not an OutgoingMessage instance 346 | * const message = { 347 | * text: 'Some random text' 348 | * } 349 | * 350 | * bot.sendMessageTo(message, update.sender.id); 351 | * 352 | */ 353 | sendMessageTo(message, recipientId, sendOptions) { 354 | const outgoingMessage = this.createOutgoingMessage({ 355 | message, 356 | }); 357 | outgoingMessage.addRecipientById(recipientId); 358 | 359 | return this.sendMessage(outgoingMessage, sendOptions); 360 | } 361 | 362 | /** 363 | * sendTextMessageTo() Just makes it easier to send a text message with 364 | * minimal structure. 365 | * @param {string} text 366 | * @param {string} recipientId a string representing the id of the user to 367 | * whom you want to send the message. 368 | * @param {object} [sendOptions] see `sendOptions` for `sendMessage` 369 | * 370 | * @return {Promise} promise that resolves with a body object 371 | * (see `sendMessage` example) 372 | * 373 | * @example 374 | * bot.sendTextMessageTo('something super important', update.sender.id); 375 | */ 376 | sendTextMessageTo(text, recipientId, sendOptions) { 377 | if (!get(this, 'sends.text')) { 378 | return Promise.reject(new SendMessageTypeError(this.type, 'text')); 379 | } 380 | const outgoingMessage = this.createOutgoingMessage() 381 | .addRecipientById(recipientId) 382 | .addText(text); 383 | 384 | return this.sendMessage(outgoingMessage, sendOptions); 385 | } 386 | 387 | /** 388 | * reply() Another way to easily send a text message. In this case, 389 | * we just send the update that came in as is and then the text we 390 | * want to send as a reply. 391 | * @param {object} incomingUpdate 392 | * @param {string} text text to send to the user associated with the received update 393 | * @param {object} [sendOptions] see `sendOptions` for `sendMessage` 394 | * @return {Promise} promise that resolves with a body object 395 | * (see `sendMessage` example) 396 | * 397 | * @example 398 | * bot.reply(update, 'something super important!'); 399 | */ 400 | reply(incomingUpdate, text, sendOptions) { 401 | return this.sendTextMessageTo(text, incomingUpdate.sender.id, sendOptions); 402 | } 403 | 404 | /** 405 | * sendAttachmentTo() makes it easier to send an attachment message with 406 | * less structure. 407 | * @param {object} attachment a valid Messenger style attachment. 408 | * See [here](https://developers.facebook.com/docs/messenger-platform/send-api-reference) 409 | * for more on that. 410 | * 411 | * @param {string} recipientId a string representing the id of the user to 412 | * whom you want to send the message. 413 | * @param {object} [sendOptions] see `sendOptions` for `sendMessage` 414 | * 415 | * @return {Promise} promise that resolves with a body object 416 | * (see `sendMessage` example) 417 | * @example 418 | * // attachment object typically looks something like this: 419 | * const attachment = { 420 | * type: 'image', 421 | * payload: { 422 | * url: "some_valid_url_of_some_image" 423 | * }, 424 | * }; 425 | * 426 | * bot.sendAttachmentTo(attachment, update.sender.id); 427 | */ 428 | sendAttachmentTo(attachment, recipientId, sendOptions) { 429 | if (!get(this, 'sends.attachment')) { 430 | return Promise.reject(new SendMessageTypeError(this.type, 'attachment')); 431 | } 432 | const outgoingMessage = this.createOutgoingMessage() 433 | .addRecipientById(recipientId) 434 | .addAttachment(attachment); 435 | 436 | return this.sendMessage(outgoingMessage, sendOptions); 437 | } 438 | 439 | /** 440 | * sendAttachmentFromUrlTo() makes it easier to send an attachment message with 441 | * minimal structure. 442 | * @param {string} type string representing the type of attachment 443 | * (audio, video, image or file) 444 | * @param {string} url the url to your file 445 | * @param {string} recipientId a string representing the id of the user to 446 | * whom you want to send the message. 447 | * @param {object} [sendOptions] see `sendOptions` for `sendMessage` 448 | * 449 | * @return {Promise} promise that resolves with a body object 450 | * (see `sendMessage` example) 451 | * 452 | * @example 453 | * bot.sendAttachmentFromUrlTo('image', "some image url you've got", update.sender.id); 454 | */ 455 | sendAttachmentFromUrlTo(type, url, recipientId, sendOptions) { 456 | if (!get(this, `sends.attachment.${type}`)) { 457 | let cantThrowErrorMessageType = `${type} attachment`; 458 | if (!get(this, 'sends.attachment')) { 459 | cantThrowErrorMessageType = 'attachment'; 460 | } 461 | return Promise.reject(new SendMessageTypeError(this.type, 462 | cantThrowErrorMessageType)); 463 | } 464 | const attachment = { 465 | type, 466 | payload: { 467 | url, 468 | }, 469 | }; 470 | 471 | return this.sendAttachmentTo(attachment, recipientId, sendOptions); 472 | } 473 | 474 | /** 475 | * sendDefaultButtonMessageTo() makes it easier to send a default set of 476 | * buttons. The default button type is the Messenger quick_replies, where 477 | * the payload is the same as the button title and the content_type is text. 478 | * 479 | * @param {Array} buttonTitles array of button titles (no longer than 10 in size). 480 | * @param {string_OR_object} textOrAttachment a string or an attachment object 481 | * similar to the ones required in `bot.sendAttachmentTo`. 482 | * This is meant to provide context to the buttons. 483 | * I.e. why are there buttons here. A piece of text or an attachment 484 | * could detail that. If falsy, text will be added that reads: 485 | * 'Please select one of:'. 486 | * @param {string} recipientId a string representing the id of the user to 487 | * whom you want to send the message. 488 | * @param {object} [sendOptions] see `sendOptions` for `sendMessage` 489 | * 490 | * @return {Promise} promise that resolves with a body object 491 | * (see `sendMessage` example) 492 | * 493 | * @example 494 | * const buttonArray = ['button1', 'button2']; 495 | * bot.sendDefaultButtonMessageTo(buttonArray, 496 | * 'Please select "button1" or "button2"', update.sender.id,); 497 | */ 498 | sendDefaultButtonMessageTo(buttonTitles, textOrAttachment, recipientId) { 499 | const validateSendDefaultButtonMessageToArguments = () => { 500 | let err = null; 501 | if (!this.sends.quickReply) { 502 | err = new SendMessageTypeError(this.type, 'quick replies'); 503 | } else if (buttonTitles.length > 10) { 504 | err = new RangeError('buttonTitles must be of length 10 or less'); 505 | } 506 | 507 | if (textOrAttachment) { 508 | if (textOrAttachment.constructor === String) { 509 | if (!this.sends.text) { 510 | err = new SendMessageTypeError(this.type, 'text'); 511 | } 512 | } else if (textOrAttachment.constructor === Object && textOrAttachment.type) { 513 | if (!this.sends.attachment) { 514 | err = new SendMessageTypeError(this.type, 'attachment'); 515 | } else if (!this.sends.attachment[textOrAttachment.type]) { 516 | err = new SendMessageTypeError(this.type, 517 | `${textOrAttachment.type} attachment`); 518 | } 519 | } else { 520 | err = new TypeError('third argument must be a "String", an ' + 521 | 'attachment "Object" or absent'); 522 | } 523 | } 524 | 525 | return err; 526 | }; 527 | 528 | const potentialError = validateSendDefaultButtonMessageToArguments(); 529 | if (potentialError) { 530 | return Promise.reject(potentialError); 531 | } 532 | 533 | // ////////////////////////////////////////////////////// 534 | // actual code after validating with 535 | // validateSendDefaultButtonMessageToArguments function 536 | // ////////////////////////////////////////////////////// 537 | 538 | const outgoingMessage = this.createOutgoingMessage(); 539 | outgoingMessage.addRecipientById(recipientId); 540 | // deal with textOrAttachment 541 | if (!textOrAttachment && this.sends.text) { 542 | outgoingMessage.addText('Please select one of:'); 543 | } else if (textOrAttachment.constructor === String) { 544 | outgoingMessage.addText(textOrAttachment); 545 | } else { 546 | // it must be an attachment or an error would have been thrown 547 | outgoingMessage.addAttachment(textOrAttachment); 548 | } 549 | 550 | const quickReplies = []; 551 | for (const buttonTitle of buttonTitles) { 552 | quickReplies.push({ 553 | content_type: 'text', 554 | title: buttonTitle, 555 | payload: buttonTitle, // indeed, in default mode payload is buttonTitle 556 | }); 557 | } 558 | outgoingMessage.addQuickReplies(quickReplies); 559 | return this.sendMessage(outgoingMessage, arguments[3]); 560 | } 561 | 562 | /** 563 | * sendIsTypingMessageTo() just sets the is typing status to the platform 564 | * if available. 565 | * 566 | * @param {string} recipientId a string representing the id of the user to 567 | * whom you want to send the message. 568 | * @param {object} [sendOptions] see `sendOptions` for `sendMessage` 569 | * 570 | * @return {Promise} promise that resolves with a body object 571 | * (see `sendMessage` example) 572 | * 573 | * @example 574 | * bot.sendIsTypingMessageTo(update.sender.id); 575 | * // the returned value is different from the standard one. it won't have a message_id 576 | */ 577 | sendIsTypingMessageTo(recipientId, sendOptions) { 578 | if (!get(this, 'sends.senderAction.typingOn')) { 579 | return Promise.reject(new SendMessageTypeError(this.type, 580 | 'typing_on sender action')); 581 | } 582 | const isTypingMessage = { 583 | recipient: { 584 | id: recipientId, 585 | }, 586 | sender_action: 'typing_on', 587 | }; 588 | return this.sendMessage(isTypingMessage, sendOptions); 589 | } 590 | 591 | /** 592 | * sendCascade() allows developers to send a cascade of messages 593 | * in a sequence. All types of messages can be sent (including raw messages). 594 | * 595 | * @param {Array} messageArray of messages in a format as such: 596 | * [{raw: someRawObject}, {message: some valid outgoingMessage}] 597 | * @param {object} [sendOptions] see `sendOptions` for `sendMessage`. will 598 | * only apply to non rawMessages. (remember that for rawMessages, outgoing 599 | * middleware is bypassed anyways). 600 | * 601 | * @return {Promise} promise that resolves with an array of body objects 602 | * (see `sendMessage` example for one said object) 603 | * 604 | * @example 605 | * const rawMessage1 = { 606 | * nonStandard: 'message1', 607 | * recipient: { 608 | * id: 'user_id', 609 | * }, 610 | * }; 611 | * const message2 = bot.createOutgoingMessageFor(update.sender.id); 612 | * message2.addText('some text'); 613 | * 614 | * const messageArray = [{ raw: rawMessage1 }, { message: message2 }]; 615 | * 616 | * bot.sendCascade(messageArray); 617 | */ 618 | sendCascade(messageArray, sendOptions) { 619 | const returnedBodies = []; 620 | 621 | let promiseCascade = Promise.resolve(); 622 | 623 | for (const messageObject of messageArray) { 624 | promiseCascade = promiseCascade.then((body) => { 625 | if (body) { 626 | returnedBodies.push(body); 627 | } 628 | if (messageObject.raw) { 629 | return this.sendRawMessage(messageObject.raw); 630 | } else if (messageObject.message) { 631 | return this.sendMessage(messageObject.message, sendOptions); 632 | } 633 | throw new Error('No valid message options specified'); 634 | }); 635 | } 636 | 637 | return promiseCascade 638 | 639 | .then((body) => { 640 | // add last body and deal with potential callback 641 | returnedBodies.push(body); 642 | return returnedBodies; 643 | }); 644 | } 645 | 646 | /** 647 | * sendTextCascadeTo() is simply a helper function around sendCascadeTo. 648 | * It allows developers to send a cascade of text messages more easily. 649 | * 650 | * @param {Array} textArray of messages. 651 | * @param {string} recipientId a string representing the id of the user to 652 | * whom you want to send the message. 653 | * @param {object} [sendOptions] see `sendOptions` for `sendMessage` 654 | * 655 | * @return {Promise} promise that resolves with an array of body objects 656 | * (see `sendMessage` example for one said object) 657 | * 658 | * @example 659 | * bot.sendTextCascadeTo(['message1', 'message2'], user.sender.id); 660 | */ 661 | 662 | sendTextCascadeTo(textArray, recipientId, sendOptions) { 663 | const cascadeArray = textArray.map((text) => { 664 | const outgoingMessage = this.createOutgoingMessageFor(recipientId) 665 | .addText(text); 666 | 667 | return { message: outgoingMessage }; 668 | }); 669 | 670 | return this.sendCascade(cascadeArray, sendOptions); 671 | } 672 | 673 | /** 674 | * sendRawMessage() simply sends a raw platform dependent message. This method 675 | * calls __sendMessage in each botClass without calling formatOutgoingMessage 676 | * before. It's really just sugar around __sendMessage which shouldn't be used 677 | * directly. 678 | * 679 | * @param {Object} rawMessage 680 | * 681 | * @return {Promise} promise 682 | * 683 | */ 684 | sendRawMessage(rawMessage) { 685 | return this.__sendMessage(rawMessage); 686 | } 687 | 688 | /** 689 | * __validateSendOptions() is simply an internal helper function to validate 690 | * wether sendOptions is valid 691 | * @ignore 692 | * @param {function} [sendOptions] 693 | * 694 | * @return {object} with cb and sendOptions as parameters 695 | * 696 | */ 697 | 698 | __validateSendOptions(sendOptions) { 699 | return new Promise((resolve, reject) => { 700 | let err = null; 701 | 702 | if (typeof sendOptions === 'function') { 703 | err = new TwoDotXError('Using botmaster sendMessage type methods ' + 704 | 'with callback functions is no longer supported in botmaster 3. '); 705 | } else if (typeof sendOptions !== 'object') { 706 | err = new TypeError('sendOptions must be of type ' + 707 | `object. Got ${typeof sendOptions} instead`); 708 | } 709 | 710 | if (err) { 711 | return reject(err); 712 | } 713 | 714 | return resolve(); 715 | }); 716 | } 717 | 718 | /** 719 | * __emitUpdate() emits an update after going through the 720 | * incoming middleware based on the passed in update. Note that we patched 721 | * the bot object with the update, so that it is available in the outgoing 722 | * middleware too. 723 | * @ignore 724 | * @param {object} update 725 | */ 726 | __emitUpdate(update) { 727 | if (!this.master) { 728 | return Promise.reject(new Error('bot needs to be added to a botmaster ' + 729 | 'instance in order to emit received updates')); 730 | } 731 | 732 | return this.master.middleware.__runIncomingMiddleware(this, update) 733 | .catch((err) => { 734 | // doing this, to make sure all errors (even ones rejected from 735 | // promises within incoming middleware) can be retrieved somewhere; 736 | if (err === 'cancel') { 737 | return 'cancelled'; 738 | } 739 | if (err && err.message) { 740 | err.message = `"${err.message}". This is most probably on your end.`; 741 | } 742 | 743 | this.emit('error', err || 'empty error object', update); 744 | return err; 745 | }); 746 | } 747 | 748 | /** 749 | * Retrieves the basic user info from a user if platform supports it 750 | * 751 | * @param {string} userId 752 | * 753 | * @return {Promise} promise that resolves into the user info or an empty 754 | * object by default 755 | */ 756 | getUserInfo(userId, options) { 757 | if (!this.retrievesUserInfo) { 758 | return Promise.reject(TypeError( 759 | `Bots of type ${this.type} don't provide access to user info.`)); 760 | } 761 | return this.__getUserInfo(userId, options); 762 | } 763 | 764 | /** 765 | * __createBotPatchedWithUpdate is used to create a new bot 766 | * instance that on sendMessage sends the update as a sendOption. 767 | * This is important, because we want to have access to the update object 768 | * even within outgoing middleware. This allows us to always have access 769 | * to it. 770 | * @ignore 771 | * @param {object} update - update to be patched to sendMessage 772 | * @returns {object} bot 773 | */ 774 | __createBotPatchedWithUpdate(update) { 775 | const newBot = Object.create(this); 776 | newBot.__associatedUpdate = update; 777 | return newBot; 778 | } 779 | } 780 | 781 | module.exports = BaseBot; 782 | --------------------------------------------------------------------------------