Repository: botmasterai/botmaster Branch: master Commit: 295f87e31c69 Files: 29 Total size: 162.0 KB Directory structure: gitextract_ta8ndqm7/ ├── .gitignore ├── .travis.yml ├── LICENSE ├── Readme.md ├── api-reference/ │ ├── base-bot.md │ ├── botmaster.md │ └── outgoing-message.md ├── lib/ │ ├── .eslintrc.js │ ├── base_bot.js │ ├── botmaster.js │ ├── errors.js │ ├── index.js │ ├── middleware.js │ └── outgoing_message.js ├── package.json └── tests/ ├── .eslintrc.js ├── _mock_bot.js ├── base_bot/ │ ├── applySettings.js │ ├── emitUpdate.js │ ├── get_user_info.js │ └── send_message.js ├── botmaster/ │ ├── add_bot.js │ ├── constructor.js │ ├── get_bot.js │ └── remove_bot.js ├── index.js ├── middleware/ │ ├── use.js │ └── use_wrapped.js └── outgoing_message.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Folder view configuration files .DS_Store Desktop.ini # Thumbnail cache files ._* Thumbs.db # Files that might appear on external disks .Spotlight-V100 .Trashes example_jd.js slack_button.html testing_creds.md tests/config.js tests/_config.js # Application specific files node_modules npm-debug.log .sass-cache /logs jsconfig.json .vscode coverage .nyc_output jsdoc ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - '9' - '8' - '7' - '6' - '4' after_success: npm run coveralls ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2016, 2017 IBM Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Readme.md ================================================ # Botmaster [![Build Status](https://travis-ci.org/botmasterai/botmaster.svg?branch=master)](https://travis-ci.org/botmasterai/botmaster) [![Coverage Status](https://coveralls.io/repos/github/botmasterai/botmaster/badge.svg?branch=master)](https://coveralls.io/github/botmasterai/botmaster?branch=master) [![Dependency Status](https://gemnasium.com/badges/github.com/botmasterai/botmaster.svg)](https://gemnasium.com/github.com/botmasterai/botmaster) [![npm-version](https://img.shields.io/npm/v/botmaster.svg)](https://www.npmjs.com/package/botmaster) [![license](https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000)](LICENSE) Botmaster v3 out. --- Botmaster v3 is virtually a complete rewrite of the framework. A lot of the syntax remains the same, but there are quite a few breaking changes that were necessary in order to get the framework to where we wanted it to be. It is now truly a micro-framework. With only 2 dependencies and without relying on express anymore, Botmaster v3 is the only JS bot framework that gives as much control as possible to the developer without losing its ease of use. A migration documentation can be found at: https://github.com/botmasterai/botmasterai.github.io/blob/master/docs/changelog.md#major-308 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. ## Documentation Find the whole documentation for the framework here: https://github.com/botmasterai/botmasterai.github.io/tree/master/docs ## License This library is licensed under the MIT [license](LICENSE) ================================================ FILE: api-reference/base-bot.md ================================================ ## BaseBot The class from which all Bot classes mus inherit. It contains all the base methods that are accessible via all bot classes. Classes that inherit from BaseBot and want to make implementation specific methods available have to prepend the method name with an underscore; e.g. in botmaster-messenger: _getGetStartedButton **Kind**: global class * [BaseBot](#BaseBot) * [new BaseBot(settings)](#new_BaseBot_new) * _instance_ * [.createOutgoingMessage(message)](#BaseBot+createOutgoingMessage) ⇒ OutgoingMessage * [.createOutgoingMessageFor(recipientId)](#BaseBot+createOutgoingMessageFor) ⇒ OutgoingMessage * [.sendMessage(message, [sendOptions])](#BaseBot+sendMessage) ⇒ Promise * [.sendMessageTo(message, recipientId, [sendOptions])](#BaseBot+sendMessageTo) ⇒ Promise * [.sendTextMessageTo(text, recipientId, [sendOptions])](#BaseBot+sendTextMessageTo) ⇒ Promise * [.reply(incomingUpdate, text, [sendOptions])](#BaseBot+reply) ⇒ Promise * [.sendAttachmentTo(attachment, recipientId, [sendOptions])](#BaseBot+sendAttachmentTo) ⇒ Promise * [.sendAttachmentFromUrlTo(type, url, recipientId, [sendOptions])](#BaseBot+sendAttachmentFromUrlTo) ⇒ Promise * [.sendDefaultButtonMessageTo(buttonTitles, textOrAttachment, recipientId, [sendOptions])](#BaseBot+sendDefaultButtonMessageTo) ⇒ Promise * [.sendIsTypingMessageTo(recipientId, [sendOptions])](#BaseBot+sendIsTypingMessageTo) ⇒ Promise * [.sendCascade(messageArray, [sendOptions])](#BaseBot+sendCascade) ⇒ Promise * [.sendTextCascadeTo(textArray, recipientId, [sendOptions])](#BaseBot+sendTextCascadeTo) ⇒ Promise * [.sendRawMessage(rawMessage)](#BaseBot+sendRawMessage) ⇒ Promise * [.getUserInfo(userId)](#BaseBot+getUserInfo) ⇒ Promise * _static_ * [.createOutgoingMessage(message)](#BaseBot.createOutgoingMessage) ⇒ OutgoingMessage * [.createOutgoingMessageFor(recipientId)](#BaseBot.createOutgoingMessageFor) ⇒ OutgoingMessage ### new BaseBot(settings) Constructor to the BaseBot class from which all the bot classes inherit. A set a basic functionalities are defined here that have to be implemented in the subclasses in order for them to work. | Param | Type | Description | | --- | --- | --- | | settings | object | inheritors of BaseBot take a settings object as first param. | ### baseBot.createOutgoingMessage(message) ⇒ OutgoingMessage createOutgoingMessage exposes the OutgoingMessage constructor via BaseBot. This simply means one can create their own OutgoingMessage object using any bot object. They can then compose it with all its helper functions This is the instance version of this method **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: OutgoingMessage - outgoingMessage. The same object passed in with all the helper functions from OutgoingMessage | Param | Type | Description | | --- | --- | --- | | message | object | base object that the outgoing Message should be based on | ### baseBot.createOutgoingMessageFor(recipientId) ⇒ OutgoingMessage same as #createOutgoingMessage, creates empty outgoingMessage with id of the recipient set. Again, this is jut sugar syntax for creating a new outgoingMessage object This is the instance version of this method **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: OutgoingMessage - outgoingMessage. A valid OutgoingMessage object with recipient set. | Param | Type | Description | | --- | --- | --- | | recipientId | string | id of the recipient the message is for | ### baseBot.sendMessage(message, [sendOptions]) ⇒ Promise sendMessage() falls back to the sendMessage implementation of whatever subclass inherits form BaseBot. The expected format is normally any type of message object that could be sent on to messenger **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise that resolves with a body object (see example) | Param | Type | Description | | --- | --- | --- | | message | object | | | [sendOptions] | boolean | an object containing options regarding the sending of the message. Currently the only valid options is: `ignoreMiddleware`. | **Example** ```js const outgoingMessage = bot.createOutgoingMessageFor(update.sender.id); outgoingMessage.addText('Hello world'); bot.sendMessage(outgoingMessage); ``` **Example** ```js // The returned promise for all sendMessage type events resolves with // a body that looks something like this: { sentOutgoingMessage: // the OutgoingMessage instance before being formatted sentRawMessage: // the OutgoingMessage object after being formatted for the platforms raw: rawBody, // the raw response from the platforms received from sending the message recipient_id: , message_id: } // Some platforms may not have either of these parameters. If that's the case, // the value assigned will be a falsy value ``` ### baseBot.sendMessageTo(message, recipientId, [sendOptions]) ⇒ Promise sendMessageTo() Just makes it easier to send a message without as much structure. **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise that resolves with a body object (see `sendMessage` example) | Param | Type | Description | | --- | --- | --- | | message | object | NOT an instance of OutgoingMessage. Use #sendMessage if you want to send instances of OutgoingMessage | | recipientId | string | a string representing the id of the user to whom you want to send the message. | | [sendOptions] | object | see `sendOptions` for `sendMessage` | **Example** ```js // message object can look something like this: // as you can see, this is not an OutgoingMessage instance const message = { text: 'Some random text' } bot.sendMessageTo(message, update.sender.id); ``` ### baseBot.sendTextMessageTo(text, recipientId, [sendOptions]) ⇒ Promise sendTextMessageTo() Just makes it easier to send a text message with minimal structure. **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise that resolves with a body object (see `sendMessage` example) | Param | Type | Description | | --- | --- | --- | | text | string | | | recipientId | string | a string representing the id of the user to whom you want to send the message. | | [sendOptions] | object | see `sendOptions` for `sendMessage` | **Example** ```js bot.sendTextMessageTo('something super important', update.sender.id); ``` ### baseBot.reply(incomingUpdate, text, [sendOptions]) ⇒ Promise reply() Another way to easily send a text message. In this case, we just send the update that came in as is and then the text we want to send as a reply. **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise that resolves with a body object (see `sendMessage` example) | Param | Type | Description | | --- | --- | --- | | incomingUpdate | object | | | text | string | text to send to the user associated with the received update | | [sendOptions] | object | see `sendOptions` for `sendMessage` | **Example** ```js bot.reply(update, 'something super important!'); ``` ### baseBot.sendAttachmentTo(attachment, recipientId, [sendOptions]) ⇒ Promise sendAttachmentTo() makes it easier to send an attachment message with less structure. **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise that resolves with a body object (see `sendMessage` example) | Param | Type | Description | | --- | --- | --- | | attachment | object | a valid Messenger style attachment. See [here](https://developers.facebook.com/docs/messenger-platform/send-api-reference) for more on that. | | recipientId | string | a string representing the id of the user to whom you want to send the message. | | [sendOptions] | object | see `sendOptions` for `sendMessage` | **Example** ```js // attachment object typically looks something like this: const attachment = { type: 'image', payload: { url: "some_valid_url_of_some_image" }, }; bot.sendAttachmentTo(attachment, update.sender.id); ``` ### baseBot.sendAttachmentFromUrlTo(type, url, recipientId, [sendOptions]) ⇒ Promise sendAttachmentFromUrlTo() makes it easier to send an attachment message with minimal structure. **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise that resolves with a body object (see `sendMessage` example) | Param | Type | Description | | --- | --- | --- | | type | string | string representing the type of attachment (audio, video, image or file) | | url | string | the url to your file | | recipientId | string | a string representing the id of the user to whom you want to send the message. | | [sendOptions] | object | see `sendOptions` for `sendMessage` | **Example** ```js bot.sendAttachmentFromUrlTo('image', "some image url you've got", update.sender.id); ``` ### baseBot.sendDefaultButtonMessageTo(buttonTitles, textOrAttachment, recipientId, [sendOptions]) ⇒ Promise sendDefaultButtonMessageTo() makes it easier to send a default set of buttons. The default button type is the Messenger quick_replies, where the payload is the same as the button title and the content_type is text. **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise that resolves with a body object (see `sendMessage` example) | Param | Type | Description | | --- | --- | --- | | buttonTitles | Array | array of button titles (no longer than 10 in size). | | 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:'. | | recipientId | string | a string representing the id of the user to whom you want to send the message. | | [sendOptions] | object | see `sendOptions` for `sendMessage` | **Example** ```js const buttonArray = ['button1', 'button2']; bot.sendDefaultButtonMessageTo(buttonArray, 'Please select "button1" or "button2"', update.sender.id,); ``` ### baseBot.sendIsTypingMessageTo(recipientId, [sendOptions]) ⇒ Promise sendIsTypingMessageTo() just sets the is typing status to the platform if available. **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise that resolves with a body object (see `sendMessage` example) | Param | Type | Description | | --- | --- | --- | | recipientId | string | a string representing the id of the user to whom you want to send the message. | | [sendOptions] | object | see `sendOptions` for `sendMessage` | **Example** ```js bot.sendIsTypingMessageTo(update.sender.id); // the returned value is different from the standard one. it won't have a message_id ``` ### baseBot.sendCascade(messageArray, [sendOptions]) ⇒ Promise sendCascade() allows developers to send a cascade of messages in a sequence. All types of messages can be sent (including raw messages). **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise that resolves with an array of body objects (see `sendMessage` example for one said object) | Param | Type | Description | | --- | --- | --- | | messageArray | Array | of messages in a format as such: [{raw: someRawObject}, {message: some valid outgoingMessage}] | | [sendOptions] | object | see `sendOptions` for `sendMessage`. will only apply to non rawMessages. (remember that for rawMessages, outgoing middleware is bypassed anyways). | **Example** ```js const rawMessage1 = { nonStandard: 'message1', recipient: { id: 'user_id', }, }; const message2 = bot.createOutgoingMessageFor(update.sender.id); message2.addText('some text'); const messageArray = [{ raw: rawMessage1 }, { message: message2 }]; bot.sendCascade(messageArray); ``` ### baseBot.sendTextCascadeTo(textArray, recipientId, [sendOptions]) ⇒ Promise sendTextCascadeTo() is simply a helper function around sendCascadeTo. It allows developers to send a cascade of text messages more easily. **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise that resolves with an array of body objects (see `sendMessage` example for one said object) | Param | Type | Description | | --- | --- | --- | | textArray | Array | of messages. | | recipientId | string | a string representing the id of the user to whom you want to send the message. | | [sendOptions] | object | see `sendOptions` for `sendMessage` | **Example** ```js bot.sendTextCascadeTo(['message1', 'message2'], user.sender.id); ``` ### baseBot.sendRawMessage(rawMessage) ⇒ Promise sendRawMessage() simply sends a raw platform dependent message. This method calls __sendMessage in each botClass without calling formatOutgoingMessage before. It's really just sugar around __sendMessage which shouldn't be used directly. **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise | Param | Type | | --- | --- | | rawMessage | Object | ### baseBot.getUserInfo(userId) ⇒ Promise Retrieves the basic user info from a user if platform supports it **Kind**: instance method of [BaseBot](#BaseBot) **Returns**: Promise - promise that resolves into the user info or an empty object by default | Param | Type | | --- | --- | | userId | string | ### BaseBot.createOutgoingMessage(message) ⇒ OutgoingMessage createOutgoingMessage exposes the OutgoingMessage constructor via BaseBot. This simply means one can create their own OutgoingMessage object using any bot object. They can then compose it with all its helper functions This is the static version of this method **Kind**: static method of [BaseBot](#BaseBot) **Returns**: OutgoingMessage - outgoingMessage. The same object passed in with all the helper functions from OutgoingMessage | Param | Type | Description | | --- | --- | --- | | message | object | base object that the outgoing Message should be based on | ### BaseBot.createOutgoingMessageFor(recipientId) ⇒ OutgoingMessage same as #createOutgoingMessage, creates empty outgoingMessage with id of the recipient set. Again, this is jut sugar syntax for creating a new outgoingMessage object This is the static version of this method **Kind**: static method of [BaseBot](#BaseBot) **Returns**: OutgoingMessage - outgoingMessage. A valid OutgoingMessage object with recipient set. | Param | Type | Description | | --- | --- | --- | | recipientId | string | id of the recipient the message is for | ================================================ FILE: api-reference/botmaster.md ================================================ ## Botmaster The Botmaster class to rule them all **Kind**: global class * [Botmaster](#Botmaster) * [new Botmaster(settings)](#new_Botmaster_new) * [.addBot(bot)](#Botmaster+addBot) ⇒ [Botmaster](#Botmaster) * [.getBot(options)](#Botmaster+getBot) ⇒ BaseBot * [.getBots(botType)](#Botmaster+getBots) ⇒ Array * [.removeBot(bot)](#Botmaster+removeBot) ⇒ [Botmaster](#Botmaster) * [.use(middleware)](#Botmaster+use) ⇒ [Botmaster](#Botmaster) * [.useWrapped(incomingMiddleware, outgoingMiddleware)](#Botmaster+useWrapped) ⇒ [Botmaster](#Botmaster) ### new Botmaster(settings) sets up a botmaster object attached to the correct server if one is set as a parameter. If not, it creates its own http server | Param | Type | | --- | --- | | settings | object | ### botmaster.addBot(bot) ⇒ [Botmaster](#Botmaster) Add an existing bot to this instance of Botmaster **Kind**: instance method of [Botmaster](#Botmaster) **Returns**: [Botmaster](#Botmaster) - returns the botmaster object for chaining | Param | Type | Description | | --- | --- | --- | | bot | BaseBot | the bot object to add to botmaster. Must be from a subclass of BaseBot | ### botmaster.getBot(options) ⇒ BaseBot Extract First bot of given type or provided id. **Kind**: instance method of [Botmaster](#Botmaster) **Returns**: BaseBot - The bot found of a class that inherits of BaseBot | Param | Type | Description | | --- | --- | --- | | options | object | must be { type: 'someBotType} or { id: someBotId }. | ### botmaster.getBots(botType) ⇒ Array Extract all bots of given type. **Kind**: instance method of [Botmaster](#Botmaster) **Returns**: Array - Array of bots found | Param | Type | Description | | --- | --- | --- | | botType | string | (there can be multiple bots of a same type) | ### botmaster.removeBot(bot) ⇒ [Botmaster](#Botmaster) Remove an existing bot from this instance of Botmaster **Kind**: instance method of [Botmaster](#Botmaster) **Returns**: [Botmaster](#Botmaster) - returns the botmaster object for chaining | Param | Type | | --- | --- | | bot | Object | ### botmaster.use(middleware) ⇒ [Botmaster](#Botmaster) Add middleware to this botmaster object This function is just sugar for `middleware.__use` in them **Kind**: instance method of [Botmaster](#Botmaster) **Returns**: [Botmaster](#Botmaster) - returns the botmaster object so you can chain middleware | Param | Type | | --- | --- | | middleware | object | **Example** ```js // The middleware param object is something that looks like this for incoming: { type: 'incoming', name: 'my-incoming-middleware', controller: (bot, update, next) => { // do stuff with update, // call next (or return a promise) }, // includeEcho: true (defaults to false), opt-in to get echo updates // includeDelivery: true (defaults to false), opt-in to get delivery updates // includeRead: true (defaults to false), opt-in to get user read updates } // and like this for outgoing middleware { type: 'outgoing', name: 'my-outgoing-middleware', controller: (bot, update, message, next) => { // do stuff with message, // call next (or return a promise) } } ``` ### botmaster.useWrapped(incomingMiddleware, outgoingMiddleware) ⇒ [Botmaster](#Botmaster) Add wrapped middleware to this botmaster instance. Wrapped middleware places the incoming middleware at beginning of incoming stack and the outgoing middleware at end of outgoing stack. This function is just sugar `middleware.useWrapped`. **Kind**: instance method of [Botmaster](#Botmaster) **Returns**: [Botmaster](#Botmaster) - returns the botmaster object so you can chain middleware | Param | Type | Description | | --- | --- | --- | | incomingMiddleware | object | | | outgoingMiddleware | object | The middleware objects are as you'd expect them to be (see use) | ================================================ FILE: api-reference/outgoing-message.md ================================================ ## OutgoingMessage This class will help you compose sendable message objects. **Kind**: global class * [OutgoingMessage](#OutgoingMessage) * [new OutgoingMessage([message])](#new_OutgoingMessage_new) * [.addRecipientById(id)](#OutgoingMessage+addRecipientById) ⇒ OutgoinMessage * [.addRecipientByPhoneNumber(phoneNumber)](#OutgoingMessage+addRecipientByPhoneNumber) ⇒ OutgoinMessage * [.removeRecipient()](#OutgoingMessage+removeRecipient) ⇒ OutgoinMessage * [.addText(text)](#OutgoingMessage+addText) ⇒ OutgoinMessage * [.removeText()](#OutgoingMessage+removeText) ⇒ OutgoinMessage * [.addAttachment(attachment)](#OutgoingMessage+addAttachment) ⇒ OutgoinMessage * [.addAttachmentFromUrl(type, url)](#OutgoingMessage+addAttachmentFromUrl) ⇒ OutgoinMessage * [.removeAttachment()](#OutgoingMessage+removeAttachment) ⇒ OutgoinMessage * [.addQuickReplies(quickReplies)](#OutgoingMessage+addQuickReplies) ⇒ OutgoinMessage * [.addPayloadLessQuickReplies(quickRepliesTitles)](#OutgoingMessage+addPayloadLessQuickReplies) ⇒ OutgoinMessage * [.addLocationQuickReply()](#OutgoingMessage+addLocationQuickReply) ⇒ OutgoinMessage * [.removeQuickReplies()](#OutgoingMessage+removeQuickReplies) ⇒ OutgoinMessage * [.addSenderAction(senderAction)](#OutgoingMessage+addSenderAction) ⇒ OutgoinMessage * [.addTypingOnSenderAction()](#OutgoingMessage+addTypingOnSenderAction) ⇒ OutgoinMessage * [.addTypingOffSenderAction()](#OutgoingMessage+addTypingOffSenderAction) ⇒ OutgoinMessage * [.addMarkSeenSenderAction()](#OutgoingMessage+addMarkSeenSenderAction) ⇒ OutgoinMessage * [.removeSenderAction()](#OutgoingMessage+removeSenderAction) ⇒ OutgoinMessage ### new OutgoingMessage([message]) Constructor to the OutgoingMessage class. Takes in an optional message object that it will use as its base to add the OutgoingMessage methods to. This constructor is not actually exposed in the public API. In order to instantiate an OutgoingMessage object, you'll need to use the createOutgoingMessage and createOutgoingMessageFor methods provided with all classes that inherit from BaseBot. There are static and non-static versions of both methods to make sure you can do so wherever as you wish | Param | Type | Description | | --- | --- | --- | | [message] | object | the base object to convert into an OutgoingMessage object | ### outgoingMessage.addRecipientById(id) ⇒ OutgoinMessage Adds `recipient.id` param to the OutgoingMessage object. This is most likely what you will want to do to add a recipient. Alternatively, you Can use addRecipientByPhoneNumber if the platform you are sending the message to supports that. **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. | Param | Type | Description | | --- | --- | --- | | id | string | the id to add to the OutgoingMessage object | ### outgoingMessage.addRecipientByPhoneNumber(phoneNumber) ⇒ OutgoinMessage Adds `recipient.phone_number` param to the OutgoingMessage object. You might prefer to add a recipient by id rather. This is achieved via addRecipientById **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. | Param | Type | Description | | --- | --- | --- | | phoneNumber | string | the phone number to add to the OutgoingMessage object | ### outgoingMessage.removeRecipient() ⇒ OutgoinMessage removes the `recipient` param from the OutgoingMessage object. This will remove the object wether it was set with a phone number or an id **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. ### outgoingMessage.addText(text) ⇒ OutgoinMessage Adds `message.text` to the OutgoingMessage **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. | Param | Type | Description | | --- | --- | --- | | text | string | the text to add to the OutgoingMessage object | ### outgoingMessage.removeText() ⇒ OutgoinMessage Removes the `message.text` param from the OutgoingMessage object. **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. ### outgoingMessage.addAttachment(attachment) ⇒ OutgoinMessage Adds `message.attachment` to the OutgoingMessage. If you want to add an attachment simply from a type and a url, have a look at: addAttachmentFromUrl **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. | Param | Type | Description | | --- | --- | --- | | attachment | object | valid messenger type attachment that can be formatted by the platforms your bot uses | ### outgoingMessage.addAttachmentFromUrl(type, url) ⇒ OutgoinMessage Adds `message.attachment` from a type and url without requiring you to provide the whole attachment object. If you want to add an attachment using a full object, use addAttachment. **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. | Param | Type | Description | | --- | --- | --- | | type | string | the attachment type (audio, video, image, file) | | url | string | the url of the attachment. | ### outgoingMessage.removeAttachment() ⇒ OutgoinMessage Removes `message.attachment` param from the OutgoingMessage object. **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. ### outgoingMessage.addQuickReplies(quickReplies) ⇒ OutgoinMessage Adds `message.quick_replies` to the OutgoinMessage object. Use addPayloadLessQuickReplies if you just want to add quick replies from an array of titles **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. | Param | Type | Description | | --- | --- | --- | | quickReplies | Array | The quick replies objects to add to the OutgoingMessage | ### outgoingMessage.addPayloadLessQuickReplies(quickRepliesTitles) ⇒ OutgoinMessage Adds `message.quick_replies` to the OutgoinMessage object from a simple array of quick replies titles.Use addQuickReplies if want to add quick replies from an quick reply objects **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. | Param | Type | Description | | --- | --- | --- | | quickRepliesTitles | Array | The titles of the quick replies objects to add to the OutgoingMessage | ### outgoingMessage.addLocationQuickReply() ⇒ OutgoinMessage Adds a `content_type: location` message.quick_replies to the OutgoingMessage. Use this if the platform the bot class you are using is based on supports asking for the location to its users. **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. ### outgoingMessage.removeQuickReplies() ⇒ OutgoinMessage Removes `message.quick_replies` param from the OutgoingMessage object. **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. ### outgoingMessage.addSenderAction(senderAction) ⇒ OutgoinMessage Adds an arbitrary `sender_action` to the OutgoinMessage **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. | Param | Type | Description | | --- | --- | --- | | senderAction | string | Arbitrary sender action (typing_on, typing_off or mark_seens) | ### outgoingMessage.addTypingOnSenderAction() ⇒ OutgoinMessage Adds `sender_action: typing_on` to the OutgoinMessage **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. ### outgoingMessage.addTypingOffSenderAction() ⇒ OutgoinMessage Adds `sender_action: typing_off` to the OutgoinMessage **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. ### outgoingMessage.addMarkSeenSenderAction() ⇒ OutgoinMessage Adds `sender_action: mark_seen` to the OutgoinMessage **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. ### outgoingMessage.removeSenderAction() ⇒ OutgoinMessage Removes `sender_action` param from the OutgoingMessage object. **Kind**: instance method of [OutgoingMessage](#OutgoingMessage) **Returns**: OutgoinMessage - returns this object to allow for chaining of methods. ================================================ FILE: lib/.eslintrc.js ================================================ module.exports = { "extends": "airbnb", "rules": { "no-underscore-dangle": "off", "prefer-rest-params": "off", "no-restricted-syntax": "off", "no-param-reassign": "off", "class-methods-use-this": "off", "strict": "off", }, }; ================================================ FILE: lib/base_bot.js ================================================ 'use strict'; const EventEmitter = require('events'); const OutgoingMessage = require('./outgoing_message'); const get = require('lodash').get; const TwoDotXError = require('./errors').TwoDotXError; const SendMessageTypeError = require('./errors').SendMessageTypeError; /** * The class from which all Bot classes mus inherit. It contains all the base * methods that are accessible via all bot classes. Classes that inherit from * BaseBot and want to make implementation specific methods available have to * prepend the method name with an underscore; e.g. in botmaster-messenger: * _getGetStartedButton */ class BaseBot extends EventEmitter { /** * Constructor to the BaseBot class from which all the bot classes inherit. * A set a basic functionalities are defined here that have to be implemented * in the subclasses in order for them to work. * * @param {object} settings - inheritors of BaseBot take a settings * object as first param. * @example * // In general however, one can instantiate a bot object like this: * const bot = new BaseBotSubClass({ // e.g. MessengerBot * credentials: , * webhookEnpoint: 'someEndpoint' // only if class requires them * }) */ constructor() { super(); this.type = 'baseBot'; // just being explicit about what subclasses can send and receive. // anything else they want to implement has to be done in raw mode. // I.e. using bot class events and upon receiving and sendRawMessage for sending. this.receives = { text: false, attachment: { audio: false, file: false, image: false, video: false, location: false, // can occur in FB messenger when user sends a message which only contains a URL // most platforms won't support that fallback: false, }, echo: false, read: false, delivery: false, postback: false, // in FB Messenger, this will exist whenever a user clicks on // a quick_reply button. It will contain the payload set by the developer // when sending the outgoing message. Bot classes should only set this // value to true if the platform they are building for has an equivalent // to this. quickReply: false, }; this.sends = { text: false, quickReply: false, locationQuickReply: false, senderAction: { typingOn: false, typingOff: false, markSeen: false, }, attachment: { audio: false, file: false, image: false, video: false, }, }; this.retrievesUserInfo = false; this.requiresWebhook = false; this.requiredCredentials = []; } /** * Just validating the settings and throwing errors or warnings * where appropriate. * @ignore * @param {object} settings */ __applySettings(settings) { if (typeof settings !== 'object') { throw new TypeError(`settings must be object, got ${typeof settings}`); } if (this.requiredCredentials.length > 0) { if (!settings.credentials) { throw new Error(`no credentials specified for bot of type '${this.type}'`); } else { this.credentials = settings.credentials; } for (const credentialName of this.requiredCredentials) { if (!this.credentials[credentialName]) { throw new Error(`bots of type '${this.type}' are expected to have '${credentialName}' credentials`); } } } if (this.requiresWebhook) { if (!settings.webhookEndpoint) { throw new Error(`bots of type '${this.type}' must be defined with webhookEndpoint in their settings`); } else { this.webhookEndpoint = settings.webhookEndpoint; } } else if (settings.webhookEndpoint) { throw new Error(`bots of type '${this.type}' do not require webhookEndpoint in their settings`); } } /** * sets up the app if needed. * As in sets up the endpoints that the bot can get called onto * see code in bot classes packages to see examples of this in action * Should not return anything * * __createMountPoints() {} */ /** * Format the update gotten from the bot source (telegram, messenger etc..). * Returns an update in a standard format * * @param {object} rawUpdate * @return {object} update * * __formatUpdate(rawUpdate) {} */ /** * createOutgoingMessage exposes the OutgoingMessage constructor * via BaseBot. This simply means one can create their own * OutgoingMessage object using any bot object. They can then compose * it with all its helper functions * * This is the static version of this method * * @param {object} message base object that the outgoing Message should be based on * * @return {OutgoingMessage} outgoingMessage. The same object passed in with * all the helper functions from OutgoingMessage */ static createOutgoingMessage(message) { return new OutgoingMessage(message); } /** * createOutgoingMessage exposes the OutgoingMessage constructor * via BaseBot. This simply means one can create their own * OutgoingMessage object using any bot object. They can then compose * it with all its helper functions * * This is the instance version of this method * * @param {object} message base object that the outgoing Message should be based on * * @return {OutgoingMessage} outgoingMessage. The same object passed in with * all the helper functions from OutgoingMessage */ createOutgoingMessage(message) { return BaseBot.createOutgoingMessage(message); } /** * same as #createOutgoingMessage, creates empty outgoingMessage with * id of the recipient set. Again, this is jut sugar syntax for creating a * new outgoingMessage object * * This is the static version of this method * * @param {string} recipientId id of the recipient the message is for * * @return {OutgoingMessage} outgoingMessage. A valid OutgoingMessage object with recipient set. */ static createOutgoingMessageFor(recipientId) { return new OutgoingMessage().addRecipientById(recipientId); } /** * same as #createOutgoingMessage, creates empty outgoingMessage with * id of the recipient set. Again, this is jut sugar syntax for creating a * new outgoingMessage object * * This is the instance version of this method * * @param {string} recipientId id of the recipient the message is for * * @return {OutgoingMessage} outgoingMessage. A valid OutgoingMessage object with recipient set. */ createOutgoingMessageFor(recipientId) { return BaseBot.createOutgoingMessageFor(recipientId); } /** * sendMessage() falls back to the sendMessage implementation of whatever * subclass inherits form BaseBot. The expected format is normally any type of * message object that could be sent on to messenger * @param {object} message * @param {boolean} [sendOptions] an object containing options regarding the * sending of the message. Currently the only valid options is: `ignoreMiddleware`. * * @return {Promise} promise that resolves with a body object (see example) * * @example * const outgoingMessage = bot.createOutgoingMessageFor(update.sender.id); * outgoingMessage.addText('Hello world'); * * bot.sendMessage(outgoingMessage); * * @example * // The returned promise for all sendMessage type events resolves with * // a body that looks something like this: * { * sentOutgoingMessage: // the OutgoingMessage instance before being formatted * sentRawMessage: // the OutgoingMessage object after being formatted for the platforms * raw: rawBody, // the raw response from the platforms received from sending the message * recipient_id: , * message_id: * } * * // Some platforms may not have either of these parameters. If that's the case, * // the value assigned will be a falsy value * */ sendMessage(message, sendOptions) { sendOptions = sendOptions || {}; // empty object if undefined const outgoingMessage = !(message instanceof OutgoingMessage) ? new OutgoingMessage(message) : message; const responseBody = {}; return this.__validateSendOptions(sendOptions) .then(() => { let outgoingMiddlewarePromise; if (this.master && !sendOptions.ignoreMiddleware) { outgoingMiddlewarePromise = this.master.middleware.__runOutgoingMiddleware( this, this.__associatedUpdate, outgoingMessage); } else { // don't actually go through middleware outgoingMiddlewarePromise = Promise.resolve(outgoingMessage); } return outgoingMiddlewarePromise; }) .then(() => { responseBody.sentOutgoingMessage = outgoingMessage; return this.__formatOutgoingMessage(outgoingMessage, sendOptions); }) .then((rawMessage) => { responseBody.sentRawMessage = rawMessage; return this.__sendMessage(rawMessage, sendOptions); }) .then((rawBody) => { responseBody.raw = rawBody; return this.__createStandardBodyResponseComponents( responseBody.sentOutgoingMessage, responseBody.sentRawMessage, responseBody.raw); }) .then((StandardBodyResponseComponents) => { responseBody.recipient_id = StandardBodyResponseComponents.recipient_id; responseBody.message_id = StandardBodyResponseComponents.message_id; return responseBody; }) .catch((err) => { if (err === 'cancel') { return 'cancelled'; } throw err; }); } /** * Bot class implementation of the __formatOutgoingMessage function. Each Bot class * has to implement this in order to be able to send outgoing messages that start * off as valid Messenger message objects (i.e. OutgoingMessage objects). * * @param {OutgoingMessage} outgoingMessage The outgoingMessage object that * needs to be formatted to the platform standard (formatted out). * @return {Promise} promise that resolves in the body of the response from the platform * * __formatOutgoingMessage(outgoingMessage) {} */ /** * Bot class implementation of the __sendMessage function. Each Bot class * has to implement this in order to be able to send outgoing messages. * * @param {object} message * @return {Promise} promise that resolves in the body of the response from the platform * * __sendMessage(rawUpdate) {} */ /** * Bot class implementation of the __createStandardBodyResponseComponents * function. Each Bot class has to implement this in order to be able to * send outgoing messages using sendMessage. This function returns the standard * recipient_id and message_id we can expect from using sendMessage * * @param {OutgoingMessage} sentOutgoingMessage The OutgoingMessage object * before formatting * @param {object} sentRawMessage The raw message that was actually sent to * the platform after __formatOutgoingMessage was called * @param {object} rawPlatformBody the raw body response from the platform * * @return {Promise} promise that resolves in an object that contains * both the recipient_id and message_id fields * * __createStandardBodyResponseComponents( * sentOutgoingMessage, sentRawMessage, rawPlatformBody) {} */ /** * sendMessageTo() Just makes it easier to send a message without as much * structure. * @param {object} message NOT an instance of OutgoingMessage. Use * #sendMessage if you want to send instances of OutgoingMessage * @param {string} recipientId a string representing the id of the user to * whom you want to send the message. * @param {object} [sendOptions] see `sendOptions` for `sendMessage` * * @return {Promise} promise that resolves with a body object * (see `sendMessage` example) * * @example * * // message object can look something like this: * // as you can see, this is not an OutgoingMessage instance * const message = { * text: 'Some random text' * } * * bot.sendMessageTo(message, update.sender.id); * */ sendMessageTo(message, recipientId, sendOptions) { const outgoingMessage = this.createOutgoingMessage({ message, }); outgoingMessage.addRecipientById(recipientId); return this.sendMessage(outgoingMessage, sendOptions); } /** * sendTextMessageTo() Just makes it easier to send a text message with * minimal structure. * @param {string} text * @param {string} recipientId a string representing the id of the user to * whom you want to send the message. * @param {object} [sendOptions] see `sendOptions` for `sendMessage` * * @return {Promise} promise that resolves with a body object * (see `sendMessage` example) * * @example * bot.sendTextMessageTo('something super important', update.sender.id); */ sendTextMessageTo(text, recipientId, sendOptions) { if (!get(this, 'sends.text')) { return Promise.reject(new SendMessageTypeError(this.type, 'text')); } const outgoingMessage = this.createOutgoingMessage() .addRecipientById(recipientId) .addText(text); return this.sendMessage(outgoingMessage, sendOptions); } /** * reply() Another way to easily send a text message. In this case, * we just send the update that came in as is and then the text we * want to send as a reply. * @param {object} incomingUpdate * @param {string} text text to send to the user associated with the received update * @param {object} [sendOptions] see `sendOptions` for `sendMessage` * @return {Promise} promise that resolves with a body object * (see `sendMessage` example) * * @example * bot.reply(update, 'something super important!'); */ reply(incomingUpdate, text, sendOptions) { return this.sendTextMessageTo(text, incomingUpdate.sender.id, sendOptions); } /** * sendAttachmentTo() makes it easier to send an attachment message with * less structure. * @param {object} attachment a valid Messenger style attachment. * See [here](https://developers.facebook.com/docs/messenger-platform/send-api-reference) * for more on that. * * @param {string} recipientId a string representing the id of the user to * whom you want to send the message. * @param {object} [sendOptions] see `sendOptions` for `sendMessage` * * @return {Promise} promise that resolves with a body object * (see `sendMessage` example) * @example * // attachment object typically looks something like this: * const attachment = { * type: 'image', * payload: { * url: "some_valid_url_of_some_image" * }, * }; * * bot.sendAttachmentTo(attachment, update.sender.id); */ sendAttachmentTo(attachment, recipientId, sendOptions) { if (!get(this, 'sends.attachment')) { return Promise.reject(new SendMessageTypeError(this.type, 'attachment')); } const outgoingMessage = this.createOutgoingMessage() .addRecipientById(recipientId) .addAttachment(attachment); return this.sendMessage(outgoingMessage, sendOptions); } /** * sendAttachmentFromUrlTo() makes it easier to send an attachment message with * minimal structure. * @param {string} type string representing the type of attachment * (audio, video, image or file) * @param {string} url the url to your file * @param {string} recipientId a string representing the id of the user to * whom you want to send the message. * @param {object} [sendOptions] see `sendOptions` for `sendMessage` * * @return {Promise} promise that resolves with a body object * (see `sendMessage` example) * * @example * bot.sendAttachmentFromUrlTo('image', "some image url you've got", update.sender.id); */ sendAttachmentFromUrlTo(type, url, recipientId, sendOptions) { if (!get(this, `sends.attachment.${type}`)) { let cantThrowErrorMessageType = `${type} attachment`; if (!get(this, 'sends.attachment')) { cantThrowErrorMessageType = 'attachment'; } return Promise.reject(new SendMessageTypeError(this.type, cantThrowErrorMessageType)); } const attachment = { type, payload: { url, }, }; return this.sendAttachmentTo(attachment, recipientId, sendOptions); } /** * sendDefaultButtonMessageTo() makes it easier to send a default set of * buttons. The default button type is the Messenger quick_replies, where * the payload is the same as the button title and the content_type is text. * * @param {Array} buttonTitles array of button titles (no longer than 10 in size). * @param {string_OR_object} textOrAttachment 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:'. * @param {string} recipientId a string representing the id of the user to * whom you want to send the message. * @param {object} [sendOptions] see `sendOptions` for `sendMessage` * * @return {Promise} promise that resolves with a body object * (see `sendMessage` example) * * @example * const buttonArray = ['button1', 'button2']; * bot.sendDefaultButtonMessageTo(buttonArray, * 'Please select "button1" or "button2"', update.sender.id,); */ sendDefaultButtonMessageTo(buttonTitles, textOrAttachment, recipientId) { const validateSendDefaultButtonMessageToArguments = () => { let err = null; if (!this.sends.quickReply) { err = new SendMessageTypeError(this.type, 'quick replies'); } else if (buttonTitles.length > 10) { err = new RangeError('buttonTitles must be of length 10 or less'); } if (textOrAttachment) { if (textOrAttachment.constructor === String) { if (!this.sends.text) { err = new SendMessageTypeError(this.type, 'text'); } } else if (textOrAttachment.constructor === Object && textOrAttachment.type) { if (!this.sends.attachment) { err = new SendMessageTypeError(this.type, 'attachment'); } else if (!this.sends.attachment[textOrAttachment.type]) { err = new SendMessageTypeError(this.type, `${textOrAttachment.type} attachment`); } } else { err = new TypeError('third argument must be a "String", an ' + 'attachment "Object" or absent'); } } return err; }; const potentialError = validateSendDefaultButtonMessageToArguments(); if (potentialError) { return Promise.reject(potentialError); } // ////////////////////////////////////////////////////// // actual code after validating with // validateSendDefaultButtonMessageToArguments function // ////////////////////////////////////////////////////// const outgoingMessage = this.createOutgoingMessage(); outgoingMessage.addRecipientById(recipientId); // deal with textOrAttachment if (!textOrAttachment && this.sends.text) { outgoingMessage.addText('Please select one of:'); } else if (textOrAttachment.constructor === String) { outgoingMessage.addText(textOrAttachment); } else { // it must be an attachment or an error would have been thrown outgoingMessage.addAttachment(textOrAttachment); } const quickReplies = []; for (const buttonTitle of buttonTitles) { quickReplies.push({ content_type: 'text', title: buttonTitle, payload: buttonTitle, // indeed, in default mode payload is buttonTitle }); } outgoingMessage.addQuickReplies(quickReplies); return this.sendMessage(outgoingMessage, arguments[3]); } /** * sendIsTypingMessageTo() just sets the is typing status to the platform * if available. * * @param {string} recipientId a string representing the id of the user to * whom you want to send the message. * @param {object} [sendOptions] see `sendOptions` for `sendMessage` * * @return {Promise} promise that resolves with a body object * (see `sendMessage` example) * * @example * bot.sendIsTypingMessageTo(update.sender.id); * // the returned value is different from the standard one. it won't have a message_id */ sendIsTypingMessageTo(recipientId, sendOptions) { if (!get(this, 'sends.senderAction.typingOn')) { return Promise.reject(new SendMessageTypeError(this.type, 'typing_on sender action')); } const isTypingMessage = { recipient: { id: recipientId, }, sender_action: 'typing_on', }; return this.sendMessage(isTypingMessage, sendOptions); } /** * sendCascade() allows developers to send a cascade of messages * in a sequence. All types of messages can be sent (including raw messages). * * @param {Array} messageArray of messages in a format as such: * [{raw: someRawObject}, {message: some valid outgoingMessage}] * @param {object} [sendOptions] see `sendOptions` for `sendMessage`. will * only apply to non rawMessages. (remember that for rawMessages, outgoing * middleware is bypassed anyways). * * @return {Promise} promise that resolves with an array of body objects * (see `sendMessage` example for one said object) * * @example * const rawMessage1 = { * nonStandard: 'message1', * recipient: { * id: 'user_id', * }, * }; * const message2 = bot.createOutgoingMessageFor(update.sender.id); * message2.addText('some text'); * * const messageArray = [{ raw: rawMessage1 }, { message: message2 }]; * * bot.sendCascade(messageArray); */ sendCascade(messageArray, sendOptions) { const returnedBodies = []; let promiseCascade = Promise.resolve(); for (const messageObject of messageArray) { promiseCascade = promiseCascade.then((body) => { if (body) { returnedBodies.push(body); } if (messageObject.raw) { return this.sendRawMessage(messageObject.raw); } else if (messageObject.message) { return this.sendMessage(messageObject.message, sendOptions); } throw new Error('No valid message options specified'); }); } return promiseCascade .then((body) => { // add last body and deal with potential callback returnedBodies.push(body); return returnedBodies; }); } /** * sendTextCascadeTo() is simply a helper function around sendCascadeTo. * It allows developers to send a cascade of text messages more easily. * * @param {Array} textArray of messages. * @param {string} recipientId a string representing the id of the user to * whom you want to send the message. * @param {object} [sendOptions] see `sendOptions` for `sendMessage` * * @return {Promise} promise that resolves with an array of body objects * (see `sendMessage` example for one said object) * * @example * bot.sendTextCascadeTo(['message1', 'message2'], user.sender.id); */ sendTextCascadeTo(textArray, recipientId, sendOptions) { const cascadeArray = textArray.map((text) => { const outgoingMessage = this.createOutgoingMessageFor(recipientId) .addText(text); return { message: outgoingMessage }; }); return this.sendCascade(cascadeArray, sendOptions); } /** * sendRawMessage() simply sends a raw platform dependent message. This method * calls __sendMessage in each botClass without calling formatOutgoingMessage * before. It's really just sugar around __sendMessage which shouldn't be used * directly. * * @param {Object} rawMessage * * @return {Promise} promise * */ sendRawMessage(rawMessage) { return this.__sendMessage(rawMessage); } /** * __validateSendOptions() is simply an internal helper function to validate * wether sendOptions is valid * @ignore * @param {function} [sendOptions] * * @return {object} with cb and sendOptions as parameters * */ __validateSendOptions(sendOptions) { return new Promise((resolve, reject) => { let err = null; if (typeof sendOptions === 'function') { err = new TwoDotXError('Using botmaster sendMessage type methods ' + 'with callback functions is no longer supported in botmaster 3. '); } else if (typeof sendOptions !== 'object') { err = new TypeError('sendOptions must be of type ' + `object. Got ${typeof sendOptions} instead`); } if (err) { return reject(err); } return resolve(); }); } /** * __emitUpdate() emits an update after going through the * incoming middleware based on the passed in update. Note that we patched * the bot object with the update, so that it is available in the outgoing * middleware too. * @ignore * @param {object} update */ __emitUpdate(update) { if (!this.master) { return Promise.reject(new Error('bot needs to be added to a botmaster ' + 'instance in order to emit received updates')); } return this.master.middleware.__runIncomingMiddleware(this, update) .catch((err) => { // doing this, to make sure all errors (even ones rejected from // promises within incoming middleware) can be retrieved somewhere; if (err === 'cancel') { return 'cancelled'; } if (err && err.message) { err.message = `"${err.message}". This is most probably on your end.`; } this.emit('error', err || 'empty error object', update); return err; }); } /** * Retrieves the basic user info from a user if platform supports it * * @param {string} userId * * @return {Promise} promise that resolves into the user info or an empty * object by default */ getUserInfo(userId, options) { if (!this.retrievesUserInfo) { return Promise.reject(TypeError( `Bots of type ${this.type} don't provide access to user info.`)); } return this.__getUserInfo(userId, options); } /** * __createBotPatchedWithUpdate is used to create a new bot * instance that on sendMessage sends the update as a sendOption. * This is important, because we want to have access to the update object * even within outgoing middleware. This allows us to always have access * to it. * @ignore * @param {object} update - update to be patched to sendMessage * @returns {object} bot */ __createBotPatchedWithUpdate(update) { const newBot = Object.create(this); newBot.__associatedUpdate = update; return newBot; } } module.exports = BaseBot; ================================================ FILE: lib/botmaster.js ================================================ 'use strict'; const http = require('http'); const EventEmitter = require('events'); const find = require('lodash').find; const remove = require('lodash').remove; const has = require('lodash').has; const debug = require('debug')('botmaster:botmaster'); const TwoDotXError = require('./errors').TwoDotXError; const Middleware = require('./middleware'); /** * The Botmaster class to rule them all */ class Botmaster extends EventEmitter { /** * sets up a botmaster object attached to the correct server if one is set * as a parameter. If not, it creates its own http server * * @param {object} settings * * @example * // attach the botmaster generated server to port 5000 rather than the default 3000 * const botmaster = new Botmaster({ * port: 5000, * }); * * @example * const http = require('http'); * * const myServer = http.createServer() * // use my own server rather than letting botmaster creat its own. * const botmaster = new Botmaster({ * server: myServer, * }); */ constructor(settings) { super(); this.settings = settings || {}; this.__throwPotentialUnsupportedSettingsErrors(); this.__setupServer(); this.middleware = new Middleware(this); // this is used for mounting routes onto bot classes "mini-apps"" this.__serverRequestListeners = {}; // default useDefaultMountPathPrepend to true if (this.settings.useDefaultMountPathPrepend === undefined) { this.settings.useDefaultMountPathPrepend = true; } this.bots = []; } __throwPotentialUnsupportedSettingsErrors() { const unsupportedSettings = ['botsSettings', 'app']; for (const settingName of unsupportedSettings) { if (this.settings[settingName]) { throw new TwoDotXError( `Starting botmaster with ${settingName} ` + 'is no longer supported.'); } } } __setupServer() { if (this.settings.server && this.settings.port) { throw new Error( 'IncompatibleArgumentsError: Please specify only ' + 'one of port and server'); } if (this.settings.server) { this.server = this.settings.server; } else { const port = has(this, 'settings.port') ? this.settings.port : 3000; this.server = this.__listen(port); } this.__setupServersRequestListeners(); } __setupServersRequestListeners() { const nonBotmasterListeners = this.server.listeners('request').slice(0); this.server.removeAllListeners('request'); this.server.on('request', (req, res) => { // run botmaster requestListeners first for (const path in this.__serverRequestListeners) { if (req.url.indexOf(path) === 0) { const requestListener = this.__serverRequestListeners[path]; return requestListener.call(this.server, req, res); } } // then run the non-botmaster ones if (nonBotmasterListeners.length > 0) { for (const requestListener of nonBotmasterListeners) { requestListener.call(this.server, req, res); } } else { // just return a 404 res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ message: `Couldn't ${req.method} ${req.url}` })); } }); } __listen(port) { const server = http.createServer(); server.listen(port, '0.0.0.0', () => { // running it for the public const serverMsg = `server parameter not specified. Running new server on port: ${port}`; debug(serverMsg); this.emit('listening', serverMsg); }); return server; } /** * Add an existing bot to this instance of Botmaster * * @param {BaseBot} bot the bot object to add to botmaster. Must be from * a subclass of BaseBot * * @return {Botmaster} returns the botmaster object for chaining */ addBot(bot) { if (bot.requiresWebhook) { const path = this.__getBotWebhookPath(bot); this.__serverRequestListeners[path] = bot.requestListener; } bot.master = this; this.bots.push(bot); bot.on('error', (err, update) => { debug(err.message); this.emit('error', bot, err, update); }); debug(`added bot of type: ${bot.type} with id: ${bot.id}`); return this; } __getBotWebhookPath(bot) { const webhookEndpoint = bot.webhookEndpoint.replace(/^\/|\/$/g, ''); const path = this.settings.useDefaultMountPathPrepend ? `/${bot.type}/${webhookEndpoint}` : `/${webhookEndpoint}`; return path; } /** * Extract First bot of given type or provided id. * * @param {object} options must be { type: 'someBotType} or { id: someBotId }. * * @return {BaseBot} The bot found of a class that inherits of BaseBot */ getBot(options) { if (!options || (!options.type && !options.id) || (options.type && options.id)) { throw new Error('\'getBot\' needs exactly one of type or id'); } if (options.id) { return find(this.bots, { id: options.id }); } return find(this.bots, { type: options.type }); } /** * Extract all bots of given type. * * @param {string} botType (there can be multiple bots of a same type) * * @return {Array} Array of bots found */ getBots(botType) { if (typeof botType !== 'string' && !(botType instanceof String)) { throw new Error('\'getBots\' takes in a string as only parameter'); } const foundBots = []; for (const bot of this.bots) { if (bot.type === botType) { foundBots.push(bot); } } return foundBots; } /** * Remove an existing bot from this instance of Botmaster * * @param {Object} bot * * @return {Botmaster} returns the botmaster object for chaining */ removeBot(bot) { if (bot.requiresWebhook) { const path = this.__getBotWebhookPath(bot); delete this.__serverRequestListeners[path]; } remove(this.bots, bot); bot.removeAllListeners(); debug(`removed bot of type: ${bot.type} with id: ${bot.id}`); return this; } /** * Add middleware to this botmaster object * This function is just sugar for `middleware.__use` in them * * @param {object} middleware * * @example * * // The middleware param object is something that looks like this for incoming: * { * type: 'incoming', * name: 'my-incoming-middleware', * controller: (bot, update, next) => { * // do stuff with update, * // call next (or return a promise) * }, * // includeEcho: true (defaults to false), opt-in to get echo updates * // includeDelivery: true (defaults to false), opt-in to get delivery updates * // includeRead: true (defaults to false), opt-in to get user read updates * } * * // and like this for outgoing middleware * * { * type: 'outgoing', * name: 'my-outgoing-middleware', * controller: (bot, update, message, next) => { * // do stuff with message, * // call next (or return a promise) * } * } * * @return {Botmaster} returns the botmaster object so you can chain middleware * */ use(middleware) { this.middleware.__use(middleware); return this; } /** * Add wrapped middleware to this botmaster instance. Wrapped middleware * places the incoming middleware at beginning of incoming stack and * the outgoing middleware at end of outgoing stack. * This function is just sugar `middleware.useWrapped`. * * @param {object} incomingMiddleware * @param {object} outgoingMiddleware * * The middleware objects are as you'd expect them to be (see use) * * @return {Botmaster} returns the botmaster object so you can chain middleware */ useWrapped(incomingMiddleware, outgoingMiddleware) { this.middleware.__useWrapped(incomingMiddleware, outgoingMiddleware); return this; } } module.exports = Botmaster; ================================================ FILE: lib/errors.js ================================================ 'use strict'; const debugBase = require('debug'); class TwoDotXError extends Error { constructor(message) { super(message); this.message += 'See the latest documentation ' + 'at http://botmasterai.com to see the preferred syntax. ' + 'Alternatively, you can downgrade botmaster to 2.x.x by doing: ' + '"npm install --save botmaster@2.x.x" or "yarn add botmaster@2.x.x"'; } } class SendMessageTypeError extends Error { constructor(botType, messageType) { super(`Bots of type ${botType} can't send` + ` messages with ${messageType}`); const debug = debugBase(`botmaster:${botType}`); debug(`Tried sending message of type ${messageType} to bot of ` + `type ${botType} that do not support this message type`); } } module.exports = { TwoDotXError, SendMessageTypeError, }; ================================================ FILE: lib/index.js ================================================ 'use strict'; const Botmaster = require('./botmaster'); Botmaster.BaseBot = require('./base_bot'); module.exports = Botmaster; ================================================ FILE: lib/middleware.js ================================================ 'use strict'; const get = require('lodash').get; const debug = require('debug')('botmaster:middleware'); class Middleware { /** * Singleton Middleware class every botmaster instance should own one of * incomingMiddleware and * outgoingMiddleware variables; * * This class is not part of the exposed API. Use botmaster.use instead * @ignore */ constructor() { this.incomingMiddlewareStack = []; this.outgoingMiddlewareStack = []; } /** * Add middleware. * See botmaster #use for more info. * @ignore */ __use(middleware) { this.__validateMiddleware(middleware); if (middleware.type === 'incoming') { this.incomingMiddlewareStack.push(middleware); debug(`added ${middleware.name || 'nameless'} incoming middleware`); } else { this.outgoingMiddlewareStack.push(middleware); debug(`added ${middleware.name || 'nameless'} outgoing middleware`); } return this; } /** * Add Wrapped middleware * See botmaster #useWrapped for more info. * @ignore * @param {object} params */ __useWrapped(incomingMiddleware, outgoingMiddleware) { if (!incomingMiddleware || !outgoingMiddleware) { throw new Error('useWrapped should be called with both an' + ' incoming and an outgoing middleware'); } this.__validateMiddleware(incomingMiddleware); this.__validateMiddleware(outgoingMiddleware); if (incomingMiddleware.type === 'outgoing') { throw new TypeError('first argument of "useWrapped" should be an' + ' incoming middleware'); } else if (outgoingMiddleware.type === 'incoming') { throw new TypeError('second argument of "useWrapped" should be an' + ' outgoing middleware'); } this.incomingMiddlewareStack.unshift(incomingMiddleware); this.outgoingMiddlewareStack.push(outgoingMiddleware); debug(`added wrapped ${incomingMiddleware.name || 'nameless'} incoming middleware`); debug(`added wrapped ${outgoingMiddleware.name || 'nameless'} outgoing middleware`); return this; } __validateMiddleware(middleware) { if (typeof middleware !== 'object') { throw new Error(`middleware should be an object. Not ${typeof middleware}`); } const middlewareController = middleware.controller; if (middleware.type !== 'incoming' && middleware.type !== 'outgoing') { throw new TypeError('invalid middleware type. Type should be either ' + '\'incoming\' or \'outgoing\''); } if (typeof middlewareController !== 'function') { throw new TypeError('middleware controller can\'t be of type ' + `${typeof middlewareController}. It needs to be a function`); } } __runIncomingMiddleware(bot, update) { return this.__runMiddlewareStack({ bot, update, middlewareStack: this.incomingMiddlewareStack, }); } __runOutgoingMiddleware(bot, associatedUpdate, message) { return this.__runMiddlewareStack({ bot, update: associatedUpdate, message, middlewareStack: this.outgoingMiddlewareStack, }); } __runMiddlewareStack(context) { const bot = context.bot; const update = context.update; const patchedBot = bot.__createBotPatchedWithUpdate(update); const message = context.message; const middlewareStack = context.middlewareStack; let middlewarePromiseStack = Promise.resolve(); const throwThrowableResolvedValue = (resolvedValue) => { if (resolvedValue === 'cancel' || resolvedValue === 'skip') { throw resolvedValue; } }; for (const middleware of middlewareStack) { middlewarePromiseStack = middlewarePromiseStack .then((resolvedValue) => { throwThrowableResolvedValue(resolvedValue); // otherwise, do nothing with resolvedValue if (this.__shouldRun(middleware, context)) { let innerPromise; return new Promise((resolve, reject) => { // next is a patched reject so that we can determine if // next was called within a returned promise, which is not allowed const next = err => reject({ err, nextRejection: true, }); if (middlewareStack === this.incomingMiddlewareStack) { innerPromise = middleware.controller( patchedBot, update, next); } else { innerPromise = middleware.controller( patchedBot, update, message, next); } if (innerPromise && innerPromise.constructor === Promise) { innerPromise.then(resolve).catch(reject); } }).catch((err) => { if (err && err.nextRejection) { if (innerPromise && innerPromise.constructor === Promise) { throw new Error('next can\'t be called if middleware ' + 'returns a promise/is an async function'); } else if (err.err) { throw err.err; } else { return; } } throw err; }); } // otherwise, return nothing return Promise.resolve(); }); } return middlewarePromiseStack .then((resolvedValue) => { throwThrowableResolvedValue(resolvedValue); }) .catch((err) => { if (err === 'skip') { return Promise.resolve(); } throw err; }); } /** * Simply returns true or false based on whether this middleware function * should be run for this object. * @ignore * @param {object} options * * @example * // options is an object that can contain any of: * { * includeEcho, // opt-in to get echo updates * includeDelivery, // opt-in to get delivery updates * includeRead, // opt-in to get read updates * } */ __shouldRun(middleware, context) { if (middleware.type === 'outgoing') { // for now, no condition to not run outgoing middleware return true; } // we are de facto dealing with incoming middleware const ignoreReceivedEchoUpdate = !middleware.includeEcho && get(context.update, 'message.is_echo'); const ignoreReceivedDeliveryUpdate = !middleware.includeDelivery && get(context.update, 'delivery'); const ignoreReceivedReadUpdate = !middleware.includeRead && get(context.update, 'read'); if (ignoreReceivedEchoUpdate || ignoreReceivedDeliveryUpdate || ignoreReceivedReadUpdate) { return false; } return true; } } module.exports = Middleware; ================================================ FILE: lib/outgoing_message.js ================================================ 'use strict'; const assign = require('lodash').assign; const has = require('lodash').has; const set = require('lodash').set; const unset = require('lodash').unset; /** * This class will help you compose sendable message objects. */ class OutgoingMessage { /** * Constructor to the OutgoingMessage class. Takes in an optional * message object that it will use as its base to add the OutgoingMessage * methods to. This constructor is not actually exposed in the public API. * In order to instantiate an OutgoingMessage object, you'll need to use the * createOutgoingMessage and createOutgoingMessageFor methods provided with * all classes that inherit from BaseBot. There are static and non-static * versions of both methods to make sure you can do so wherever as you wish * * @private * @param {object} [message] the base object to convert into an OutgoingMessage object */ constructor(message) { if (!message) { message = {}; } if (typeof message !== 'object') { throw new TypeError('OutgoingMessage constructor takes in an object as param'); } assign(this, message); return this; } __addProperty(path, nameForError, value) { if (!value) { throw new Error(`${nameForError} must have a value. Can't be ${value}`); } else if (has(this, path)) { throw new Error(`Can't add ${nameForError} to outgoingMessage that already has ${nameForError}`); } set(this, path, value); return this; } __removeProperty(path, nameForError) { if (!has(this, path)) { throw new Error(`Can't remove ${nameForError} from outgoingMessage that doesn't have any ${nameForError}`); } unset(this, path); return this; } /** * Adds `recipient.id` param to the OutgoingMessage object. This is most * likely what you will want to do to add a recipient. Alternatively, you Can * use addRecipientByPhoneNumber if the platform you are sending the message to * supports that. * * @param {string} id the id to add to the OutgoingMessage object * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addRecipientById(id) { const recipient = { id, }; return this.__addProperty('recipient', 'recipient', recipient); } /** * Adds `recipient.phone_number` param to the OutgoingMessage object. * You might prefer to add a recipient by id rather. This is achieved via * addRecipientById * * @param {string} phoneNumber the phone number to add to the OutgoingMessage object * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addRecipientByPhoneNumber(phoneNumber) { const recipient = { phone_number: phoneNumber, }; return this.__addProperty('recipient', 'recipient', recipient); } /** * removes the `recipient` param from the OutgoingMessage object. * This will remove the object wether it was set with a phone number or an id * * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ removeRecipient() { return this.__removeProperty('recipient', 'recipient'); } /** * Adds `message.text` to the OutgoingMessage * * @param {string} text the text to add to the OutgoingMessage object * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addText(text) { return this.__addProperty('message.text', 'text', text); } /** * Removes the `message.text` param from the OutgoingMessage object. * * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ removeText() { return this.__removeProperty('message.text', 'text'); } /** * Adds `message.attachment` to the OutgoingMessage. If you want to add * an attachment simply from a type and a url, have a look at: * addAttachmentFromUrl * * @param {object} attachment valid messenger type attachment that can be * formatted by the platforms your bot uses * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addAttachment(attachment) { return this.__addProperty('message.attachment', 'attachment', attachment); } /** * Adds `message.attachment` from a type and url without requiring you to * provide the whole attachment object. If you want to add an attachment using * a full object, use addAttachment. * * @param {string} type the attachment type (audio, video, image, file) * @param {string} url the url of the attachment. * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addAttachmentFromUrl(type, url) { if (!type || !url) { throw new Error('addAttachmentFromUrl must be called with truthy "type" and "url" arguments'); } if (typeof type !== 'string' || typeof url !== 'string') { throw new TypeError('addAttachmentFromUrl must be called with "type" and "url" arguments of type string'); } const attachment = { type, payload: { url, }, }; return this.addAttachment(attachment); } /** * Removes `message.attachment` param from the OutgoingMessage object. * * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ removeAttachment() { return this.__removeProperty('message.attachment', 'attachment'); } /** * Adds `message.quick_replies` to the OutgoinMessage object. Use * addPayloadLessQuickReplies if you just want to add quick replies from an * array of titles * * @param {Array} quickReplies The quick replies objects to add to the * OutgoingMessage * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addQuickReplies(quickReplies) { return this.__addProperty('message.quick_replies', 'quick_replies', quickReplies); } /** * Adds `message.quick_replies` to the OutgoinMessage object from a simple array * of quick replies titles.Use addQuickReplies if want to add quick replies * from an quick reply objects * * @param {Array} quickRepliesTitles The titles of the quick replies objects to add to the * OutgoingMessage * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addPayloadLessQuickReplies(quickRepliesTitles) { const errorText = 'addPayloadLessQuickReplies needs to be passed in an array of strings as first argument'; if (!(quickRepliesTitles instanceof Array)) { throw new TypeError(errorText); } const quickReplies = []; for (const title of quickRepliesTitles) { if (typeof title !== 'string') { throw new TypeError(errorText); } const quickReply = { title, payload: title, content_type: 'text', }; quickReplies.push(quickReply); } return this.addQuickReplies(quickReplies); } /** * Adds a `content_type: location` message.quick_replies to the OutgoingMessage. * Use this if the platform the bot class you are using is based on supports * asking for the location to its users. * * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addLocationQuickReply() { const locationQuickReply = [ { content_type: 'location', }, ]; return this.addQuickReplies(locationQuickReply); } /** * Removes `message.quick_replies` param from the OutgoingMessage object. * * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ removeQuickReplies() { return this.__removeProperty('message.quick_replies', 'quick_replies'); } /** * Adds an arbitrary `sender_action` to the OutgoinMessage * @param {string} senderAction Arbitrary sender action * (typing_on, typing_off or mark_seens) * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addSenderAction(senderAction) { return this.__addProperty('sender_action', 'sender_action', senderAction); } /** * Adds `sender_action: typing_on` to the OutgoinMessage * * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addTypingOnSenderAction() { return this.__addProperty('sender_action', 'sender_action', 'typing_on'); } /** * Adds `sender_action: typing_off` to the OutgoinMessage * * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addTypingOffSenderAction() { return this.__addProperty('sender_action', 'sender_action', 'typing_off'); } /** * Adds `sender_action: mark_seen` to the OutgoinMessage * * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ addMarkSeenSenderAction() { return this.__addProperty('sender_action', 'sender_action', 'mark_seen'); } /** * Removes `sender_action` param from the OutgoingMessage object. * * @return {OutgoinMessage} returns this object to allow for chaining of methods. */ removeSenderAction() { return this.__removeProperty('sender_action', 'sender_action'); } } module.exports = OutgoingMessage; ================================================ FILE: package.json ================================================ { "name": "botmaster", "version": "3.2.0", "description": "Framework allowing developers to write bots that are agnostic with respect to the channel used by their users (messenger, telegram etc...)", "main": "./lib/index.js", "scripts": { "test": "export NODE_ENV=test; nyc --reporter=lcov --reporter=html ava; nyc report", "test-debug": "export NODE_ENV=test DEBUG=botmaster:*; nyc --reporter=lcov --reporter=html ava", "test-watch": "export NODE_ENV=test; ava --watch", "coveralls": "cat ./coverage/lcov.info | coveralls", "postversion": "git push && git push --tags", "report": "nyc report", "botmaster-docs": "jsdoc2md lib/botmaster.js > api-reference/botmaster.md", "base-bot-docs": "jsdoc2md lib/base_bot.js > api-reference/base-bot.md", "outgoing-message-docs": "jsdoc2md lib/outgoing_message.js > api-reference/outgoing-message.md", "docs": "mkdir -p api-reference; yarn botmaster-docs; yarn base-bot-docs; yarn outgoing-message-docs", "docs-deploy": "yarn docs && cp -r api-reference ../botmasterai.github.io/docs" }, "ava": { "files": [ "tests/**/*.js", "!**/index.js" ], "source": [], "match": [], "serial": true, "verbose": true, "failFast": true, "tap": false, "powerAssert": false }, "nyc": { "check-coverage": true, "lines": 100, "statements": 100, "functions": 100, "branches": 100, "exclude": [ "tests" ] }, "repository": { "type": "git", "url": "https://github.com/jdwuarin/botmaster" }, "bugs": { "url": "https://github.com/jdwuarin/botmaster/issues" }, "keywords": [ "bot", "framework", "toolkit", "botmaster", "slack", "messenger", "telegram", "twitter", "bot-library" ], "dependencies": { "debug": "^3.1.0", "lodash": "^4.17.4" }, "engines": { "node": "4.x.x || >=6.x.x" }, "devDependencies": { "ava": "^0.19.1", "body-parser": "^1.18.2", "botmaster-test-fixtures": "^2.1.0", "coveralls": "^3.0.0", "eslint": "^4.16.0", "eslint-config-airbnb": "^16.1.0", "eslint-plugin-ava": "^4.5.0", "eslint-plugin-import": "^2.8.0", "eslint-plugin-jsx-a11y": "^6.0.3", "eslint-plugin-react": "^7.6.0", "express": "^4.16.2", "jsdoc-to-markdown": "^4.0.1", "koa": "^2.4.1", "nyc": "^11.4.1", "request-promise": "^4.2.2" }, "author": "JD Wuarin ", "license": "MIT" } ================================================ FILE: tests/.eslintrc.js ================================================ module.exports = { extends: 'airbnb', plugins: [ 'ava', ], rules: { 'import/no-extraneous-dependencies': 'off', 'no-underscore-dangle': 'off', semi: [2, 'always'], 'no-param-reassign': 'off', 'no-restricted-syntax': 'off', 'strict': 'off', }, }; ================================================ FILE: tests/_mock_bot.js ================================================ 'use strict'; const BaseBot = require('../lib/base_bot'); const express = require('express'); const expressBodyParser = require('body-parser'); const Koa = require('koa'); const assign = require('lodash').assign; const get = require('lodash').get; const merge = require('lodash').merge; class MockBot extends BaseBot { /** * Bot class that allows testers to create instances of a large number * of different bot instances with various different settings. * * @param {object} settings */ constructor(settings) { super(settings); if (!settings) { settings = {}; } this.type = settings.type || 'mock'; // the following settings would be hard coded in a standard // bot class implementation. this.requiresWebhook = settings.requiresWebhook || false; this.requiredCredentials = settings.requiredCredentials || []; this.receives = settings.receives || { text: true, attachment: { audio: true, file: true, image: true, video: true, location: true, // can occur in FB messenger when user sends a message which only contains a URL // most platforms won't support that fallback: true, }, echo: true, read: true, delivery: true, postback: true, quickReply: true, }; this.sends = settings.sends || { text: true, quickReply: true, locationQuickReply: true, senderAction: { typingOn: true, typingOff: true, markSeen: true, }, attachment: { audio: true, file: true, image: true, video: true, }, }; this.retrievesUserInfo = settings.retrievesUserInfo || false; this.id = settings.id || 'mockId'; this.__applySettings(settings); if (this.webhookEndpoint) { if (this.webhookEndpoint.indexOf('koa') > -1) { this.__createKoaMountPoints(); } else { // default to express this.__createExpressMountPoints(); } } } // Note how neither of those classes uses webhookEndpoint. // This is because I can now count on botmaster to make sure that requests // meant to go to this bot are indeed routed to this bot. __createExpressMountPoints() { const app = express(); this.requestListener = app; // for parsing application/json app.use(expressBodyParser.json()); app.post('*', (req, res) => { const update = this.__formatRawUpdate(req.body); this.__emitUpdate(update); res.sendStatus(200); }); } __createKoaMountPoints() { const app = new Koa(); this.requestListener = app.callback(); app.use((ctx) => { let bodyString = ''; ctx.req.on('data', (chunk) => { bodyString += chunk; }); ctx.req.on('end', () => { const body = JSON.parse(bodyString); const update = this.__formatRawUpdate(body); this.__emitUpdate(update); }); ctx.status = 200; }); } __formatRawUpdate(rawUpdate) { const timestamp = Math.floor(Date.now()); const recipientId = get('recipient.id', rawUpdate, 'update_id'); const update = { raw: rawUpdate, sender: { id: recipientId, }, recipient: { id: this.id, }, timestamp, message: { mid: `${this.id}.${recipientId}.${String(timestamp)}.`, seq: null, }, }; merge(update, rawUpdate); return update; } // doesn't actually do anything in mock_bot __formatOutgoingMessage(outgoingMessage) { const rawMessage = assign({}, outgoingMessage); return Promise.resolve(rawMessage); } __sendMessage(rawMessage) { const responseBody = { nonStandard: 'responseBody', }; return Promise.resolve(responseBody); } __createStandardBodyResponseComponents(sentOutgoingMessage, sentRawMessage, raw) { const timestamp = Math.floor(Date.now()); return Promise.resolve({ recipient_id: sentRawMessage.recipient.id, message_id: `${this.id}.${sentRawMessage.recipient.id}.${String(timestamp)}`, }); } __getUserInfo(userId) { return Promise.resolve({ first_name: 'Peter', last_name: 'Chang', 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', locale: 'en_US', timezone: -7, gender: 'male', }); } } module.exports = MockBot; ================================================ FILE: tests/base_bot/applySettings.js ================================================ import test from 'ava'; import MockBot from '../_mock_bot'; const errorTestTitleBase = 'should throw an error when controller is called'; const successTestTitleBase = 'should not throw an error when controller is called'; test(`${errorTestTitleBase} with a string`, (t) => { t.plan(1); const botSettings = 'invalid'; try { const bot = new MockBot(botSettings); } catch (err) { t.is(err.message.indexOf('settings must be object') > -1, true); } }); test(`${errorTestTitleBase} with no credentials, although class requires some`, (t) => { t.plan(1); const botSettings = { requiredCredentials: ['token', 'password'], }; try { const bot = new MockBot(botSettings); } catch (err) { t.is(err.message.indexOf('no credentials specified') > -1, true); } }); test(`${errorTestTitleBase} with misnamed credentials`, (t) => { t.plan(1); const botSettings = { requiredCredentials: ['token', 'password'], credentials: { token: 'something', pass: 'something else', }, }; try { const bot = new MockBot(botSettings); } catch (err) { console.log(err.message); t.is(err.message.indexOf('are expected to have \'password\' credentials') > -1, true); } }); test(`${successTestTitleBase} with correctly named credentials`, (t) => { t.plan(1); const botSettings = { requiredCredentials: ['token', 'password'], credentials: { token: 'something', password: 'something else', }, }; const bot = new MockBot(botSettings); t.pass(); }); test(`${errorTestTitleBase} with no webhookEndpoint although it requires one`, (t) => { t.plan(1); const botSettings = { requiresWebhook: true, }; try { const bot = new MockBot(botSettings); } catch (err) { t.is(err.message.indexOf('must be defined with webhookEndpoint') > -1, true); } }); test(`${successTestTitleBase} with webhookEndpoint and it needs one`, (t) => { t.plan(1); const botSettings = { requiresWebhook: true, webhookEndpoint: 'webhook', }; const bot = new MockBot(botSettings); t.pass(); }); test(`${errorTestTitleBase} with a webhookEndpoint although it does not requires one`, (t) => { t.plan(1); const botSettings = { webhookEndpoint: 'webhook', }; try { const bot = new MockBot(botSettings); } catch (err) { t.is(err.message.indexOf('do not require webhookEndpoint in') > -1, true); } }); ================================================ FILE: tests/base_bot/emitUpdate.js ================================================ import test from 'ava'; import MockBot from '../_mock_bot'; test('Emits error when called from non owned bot', (t) => { t.plan(1); const bot = new MockBot(); return bot.__emitUpdate({}) .catch((err) => { t.is(err.message, 'bot needs to be added to a botmaster instance ' + 'in order to emit received updates'); }); }); ================================================ FILE: tests/base_bot/get_user_info.js ================================================ import test from 'ava'; import MockBot from '../_mock_bot'; test('throws error when bot type does not support retrieving user is', async (t) => { t.plan(1); const bot = new MockBot(); try { await bot.getUserInfo('user_id'); t.fail('Error not returned'); } catch (err) { t.is(err.message, 'Bots of type mock don\'t provide access to user info.', 'Error message is not same as expected'); } }); test('works when bot type supports retrieving the info', async (t) => { t.plan(1); const bot = new MockBot({ retrievesUserInfo: true, }); const userInfo = await bot.getUserInfo('user_id'); t.is(userInfo.first_name, 'Peter', 'userInfo is not same as expected'); }); ================================================ FILE: tests/base_bot/send_message.js ================================================ import test from 'ava'; import { outgoingMessageFixtures, incomingUpdateFixtures, attachmentFixtures } from 'botmaster-test-fixtures'; import { assign } from 'lodash'; import MockBot from '../_mock_bot'; const sendMessageMacro = async (t, params) => { t.plan(5); // test using promises const body = await params.sendMessageMethod(); t.deepEqual(assign({}, body.sentOutgoingMessage), params.expectedSentMessage, 'sentOutgoingMessage is not same as message'); t.deepEqual(body.sentRawMessage, params.expectedSentMessage, 'sentRawMessage is not same as expected'); t.deepEqual(body.raw, { nonStandard: 'responseBody' }, 'raw is not same as expected raw body response'); t.truthy(body.recipient_id, 'recipient_id not present'); t.truthy(body.message_id, 'message_id not present'); }; const sendRawMessageMacro = async (t, params) => { t.plan(1); const body = await params.sendMessageMethod(); t.deepEqual(body, { nonStandard: 'responseBody' }, 'body is not same as expected raw body response'); }; const sendCascadeMessageMacro = async (t, params) => { t.plan(params.planFor); const bodies = await params.sendMessageMethod(); for (let i = 0; i < bodies.length; i += 1) { const body = bodies[i]; if (body.raw) { const expectedSentMessage = params.expectedSentMessages[i]; t.deepEqual(assign({}, body.sentOutgoingMessage), expectedSentMessage, 'sentOutgoingMessage is not same as message'); t.deepEqual(body.sentRawMessage, expectedSentMessage, 'sentRawMessage is not same as expected'); t.deepEqual(body.raw, { nonStandard: 'responseBody' }, 'raw is not same as expected raw body response'); t.truthy(body.recipient_id, 'recipient_id not present'); t.truthy(body.message_id, 'message_id not present'); } else { t.deepEqual(body, { nonStandard: 'responseBody' }, 'body is not same as expected raw body response'); } } }; const sendMessageErrorMacro = async (t, params) => { t.plan(1); try { await params.sendMessageMethod(); t.false(true, 'Error should have been returned, but didn\'t get any'); } catch (err) { t.deepEqual(err.message, params.expectedErrorMessage, 'Error message is not same as expected'); } }; // All tests are isolated in own scopes in order to be properly setup { const bot = new MockBot(); const messageToSend = outgoingMessageFixtures.audioMessage(); test('#sendMessage works', sendMessageMacro, { sendMessageMethod: bot.sendMessage.bind(bot, messageToSend), expectedSentMessage: outgoingMessageFixtures.audioMessage(), }); } { const bot = new MockBot(); // patching bot just to see if that works too with callbacks const patchedBot = bot.__createBotPatchedWithUpdate({}); const messageToSend = outgoingMessageFixtures.audioMessage(); test('#sendMessage throws error when sendOptions is not of valid type on a patched bot', sendMessageErrorMacro, { sendMessageMethod: patchedBot.sendMessage.bind(patchedBot, messageToSend, 'Should not be valid'), expectedErrorMessage: 'sendOptions must be of type object. Got string instead', }); } { const bot = new MockBot(); const messageToSend = outgoingMessageFixtures.audioMessage(); test('#sendMessage throws error when sendOptions is not of valid type on a non patched bot', sendMessageErrorMacro, { sendMessageMethod: bot.sendMessage.bind(bot, messageToSend, 'Should not be valid'), expectedErrorMessage: 'sendOptions must be of type object. Got string instead', }); } { const bot = new MockBot(); const patchedBot = bot.__createBotPatchedWithUpdate({}); const messageToSend = outgoingMessageFixtures.audioMessage(); test('#sendMessage throws error when tried to use with callback', sendMessageErrorMacro, { sendMessageMethod: bot.sendMessage.bind(bot, messageToSend, () => {}), expectedErrorMessage: 'Using botmaster sendMessage type methods ' + 'with callback functions is no longer supported in botmaster 3. ' + 'See the latest documentation ' + 'at http://botmasterai.com to see the preferred syntax. ' + 'Alternatively, you can downgrade botmaster to 2.x.x by doing: ' + '"npm install --save botmaster@2.x.x" or "yarn add botmaster@2.x.x"', }); test('#sendMessage throws error when cb is not of valid type on a patched bot', sendMessageErrorMacro, { sendMessageMethod: patchedBot.sendMessage.bind(patchedBot, messageToSend, () => {}), expectedErrorMessage: 'Using botmaster sendMessage type methods ' + 'with callback functions is no longer supported in botmaster 3. ' + 'See the latest documentation ' + 'at http://botmasterai.com to see the preferred syntax. ' + 'Alternatively, you can downgrade botmaster to 2.x.x by doing: ' + '"npm install --save botmaster@2.x.x" or "yarn add botmaster@2.x.x"', }); } { const bot = new MockBot(); const messageToSend = outgoingMessageFixtures.audioMessage(); test('#sendRawMessage works', sendRawMessageMacro, { sendMessageMethod: bot.sendRawMessage.bind(bot, messageToSend), expectedSentMessage: outgoingMessageFixtures.audioMessage(), }); } { const bot = new MockBot(); const messageToSend = outgoingMessageFixtures.audioMessage(); test('#sendMessage works with sendOptions', sendMessageMacro, { sendMessageMethod: bot.sendMessage.bind(bot, messageToSend, { ignoreMiddleware: true }), expectedSentMessage: outgoingMessageFixtures.audioMessage(), }); } { const bot = new MockBot(); const subMessagePart = { text: 'Hello World!', }; test('#sendMessageTo works', sendMessageMacro, { sendMessageMethod: bot.sendMessageTo.bind(bot, subMessagePart, 'user_id'), expectedSentMessage: outgoingMessageFixtures.textMessage(), }); } { const bot = new MockBot({ sends: { text: false, }, }); test('#sendTextMessageTo throws error if bot class does not support text', sendMessageErrorMacro, { sendMessageMethod: bot.sendTextMessageTo.bind(bot, 'Hello World!', 'user_id'), expectedErrorMessage: 'Bots of type mock can\'t send messages with text', }); } { const bot = new MockBot(); test('#sendTextMessageTo works', sendMessageMacro, { sendMessageMethod: bot.sendTextMessageTo.bind(bot, 'Hello World!', 'user_id'), expectedSentMessage: outgoingMessageFixtures.textMessage(), }); } { const bot = new MockBot({ sends: { text: false, }, }); const updateToReplyTo = incomingUpdateFixtures.textUpdate(); test('#reply throws error if bot class does not support text', sendMessageErrorMacro, { sendMessageMethod: bot.sendTextMessageTo.bind(bot, updateToReplyTo, 'Hello World!'), expectedErrorMessage: 'Bots of type mock can\'t send messages with text', }); } { const bot = new MockBot(); const updateToReplyTo = incomingUpdateFixtures.textUpdate(); // patching bot just to see if that works too with callbacks const patchedBot = bot.__createBotPatchedWithUpdate(updateToReplyTo); test('#reply works', sendMessageMacro, { sendMessageMethod: patchedBot.reply.bind(patchedBot, updateToReplyTo, 'Hello World!'), expectedSentMessage: outgoingMessageFixtures.textMessage(), }); } { const bot = new MockBot({ sends: { attachment: false, }, }); const attachment = attachmentFixtures.audioAttachment(); test('#sendAttachmentTo throws error if bot class does not support attachment', sendMessageErrorMacro, { sendMessageMethod: bot.sendAttachmentTo.bind(bot, attachment, 'user_id'), expectedErrorMessage: 'Bots of type mock can\'t send messages with attachment', }); } { const bot = new MockBot(); const attachment = attachmentFixtures.audioAttachment(); test('#sendAttachmentTo works', sendMessageMacro, { sendMessageMethod: bot.sendAttachmentTo.bind(bot, attachment, 'user_id'), expectedSentMessage: outgoingMessageFixtures.audioMessage(), }); } { const bot = new MockBot({ sends: { attachment: false, }, }); test('#sendAttachmentFromUrlTo throws error if bot class does not support attachment', sendMessageErrorMacro, { sendMessageMethod: bot.sendAttachmentFromUrlTo.bind( bot, 'audio', 'SOME_AUDIO_URL', 'user_id'), expectedErrorMessage: 'Bots of type mock can\'t send messages with attachment', }); } { const bot = new MockBot({ sends: { attachment: { audio: false, image: true, }, }, }); test('#sendAttachmentFromUrlTo throws error if bot class does not support attachment of specific type', sendMessageErrorMacro, { sendMessageMethod: bot.sendAttachmentFromUrlTo.bind( bot, 'audio', 'SOME_AUDIO_URL', 'user_id'), expectedErrorMessage: 'Bots of type mock can\'t send messages with audio attachment', }); } { const bot = new MockBot(); test('#sendAttachmentFromUrlTo works', sendMessageMacro, { sendMessageMethod: bot.sendAttachmentFromUrlTo.bind( bot, 'audio', 'SOME_AUDIO_URL', 'user_id'), expectedSentMessage: outgoingMessageFixtures.audioMessage(), }); } { const bot = new MockBot({ sends: { quickReply: false, }, }); test('#sendDefaultButtonMessageTo throws error if bot class does not support quickReply', sendMessageErrorMacro, { sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( bot, [], undefined, 'user_id'), expectedErrorMessage: 'Bots of type mock can\'t send messages with quick replies', }); } { const bot = new MockBot(); const buttonTitles = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; test('#sendDefaultButtonMessageTo throws error if button count is larger than 10', sendMessageErrorMacro, { sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( bot, buttonTitles, undefined, 'user_id'), expectedErrorMessage: 'buttonTitles must be of length 10 or less', }); } { const bot = new MockBot({ sends: { text: false, quickReply: true, }, }); test('#sendDefaultButtonMessageTo throws error if bot class does not support text and text is set', sendMessageErrorMacro, { sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( bot, [], 'Click on one of', 'user_id'), expectedErrorMessage: 'Bots of type mock can\'t send messages with text', }); } { const bot = new MockBot({ sends: { attachment: false, quickReply: true, }, }); test('#sendDefaultButtonMessageTo throws error if bot class does not support attachment and attachment is set', sendMessageErrorMacro, { sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( bot, [], attachmentFixtures.imageAttachment(), 'user_id'), expectedErrorMessage: 'Bots of type mock can\'t send messages with attachment', }); } { const bot = new MockBot({ sends: { attachment: { image: false, }, quickReply: true, }, }); test('#sendDefaultButtonMessageTo throws error if bot class does not support image attachment and image attachment is set', sendMessageErrorMacro, { sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( bot, [], attachmentFixtures.imageAttachment(), 'user_id'), expectedErrorMessage: 'Bots of type mock can\'t send messages with image attachment', }); } { const bot = new MockBot(); const buttonTitles = ['B1', 'B2']; test('#sendDefaultButtonMessageTo throws error if textOrAttachment is not valid', sendMessageErrorMacro, { sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( bot, buttonTitles, new MockBot(), 'user_id'), expectedErrorMessage: 'third argument must be a "String", an attachment "Object" or absent', }); } { const bot = new MockBot(); const buttonTitles = ['B1', 'B2']; const quickReplies = [ { content_type: 'text', title: 'B1', payload: 'B1', }, { content_type: 'text', title: 'B2', payload: 'B2', }, ]; const expectedSentMessage = outgoingMessageFixtures.textOnlyQuickReplyMessage(); expectedSentMessage.message.quick_replies = quickReplies; test('#sendDefaultButtonMessageTo works with falsy textOrAttachment', sendMessageMacro, { expectedSentMessage, sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( bot, buttonTitles, undefined, 'user_id'), }); } { const bot = new MockBot(); const buttonTitles = ['B1', 'B2']; const quickReplies = [ { content_type: 'text', title: 'B1', payload: 'B1', }, { content_type: 'text', title: 'B2', payload: 'B2', }, ]; const expectedSentMessage = outgoingMessageFixtures.textOnlyQuickReplyMessage(); expectedSentMessage.message.quick_replies = quickReplies; expectedSentMessage.message.text = 'Click one of:'; test('#sendDefaultButtonMessageTo works with text type textOrAttachment', sendMessageMacro, { expectedSentMessage, sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( bot, buttonTitles, 'Click one of:', 'user_id'), }); } { const bot = new MockBot(); const buttonTitles = ['B1', 'B2']; const quickReplies = [ { content_type: 'text', title: 'B1', payload: 'B1', }, { content_type: 'text', title: 'B2', payload: 'B2', }, ]; const expectedSentMessage = outgoingMessageFixtures.textOnlyQuickReplyMessage(); expectedSentMessage.message.quick_replies = quickReplies; delete expectedSentMessage.message.text; expectedSentMessage.message.attachment = attachmentFixtures.imageAttachment(); test('#sendDefaultButtonMessageTo works with object type textOrAttachment', sendMessageMacro, { expectedSentMessage, sendMessageMethod: bot.sendDefaultButtonMessageTo.bind( bot, buttonTitles, attachmentFixtures.imageAttachment(), 'user_id'), }); } { const bot = new MockBot({ sends: { text: false, }, }); test('#sendIsTypingMessageTo throws error if bot class does not support typing_on sender action', sendMessageErrorMacro, { sendMessageMethod: bot.sendIsTypingMessageTo.bind(bot, 'user_id'), expectedErrorMessage: 'Bots of type mock can\'t send messages with typing_on sender action', }); } { const bot = new MockBot(); test('#sendIsTypingMessageTo works', sendMessageMacro, { sendMessageMethod: bot.sendIsTypingMessageTo.bind(bot, 'user_id'), expectedSentMessage: outgoingMessageFixtures.typingOnMessage(), }); } { const bot = new MockBot(); test('#sendCascade throws error when used with no valid params', sendMessageErrorMacro, { sendMessageMethod: bot.sendCascade.bind(bot, [{}]), expectedErrorMessage: 'No valid message options specified', }); } { const bot = new MockBot(); const rawMessage1 = { nonStandard: 'message1', recipient: { id: 'user_id', }, }; const rawMessage2 = { nonStandard: 'message2', recipient: { id: 'user_id', }, }; const messageArray = [{ raw: rawMessage1 }, { raw: rawMessage2 }]; test('#sendCascade works with raw messages', sendCascadeMessageMacro, { sendMessageMethod: bot.sendCascade.bind(bot, messageArray), planFor: 2, // num assertions to plan for }); } { const bot = new MockBot(); const message1 = outgoingMessageFixtures.textOnlyQuickReplyMessage(); const message2 = outgoingMessageFixtures.imageMessage(); const messageArray = [{ message: message1 }, { message: message2 }]; const expectedSentMessages = [message1, message2]; test('#sendCascade works with valid messages', sendCascadeMessageMacro, { sendMessageMethod: bot.sendCascade.bind(bot, messageArray), planFor: 10, expectedSentMessages, }); } { const bot = new MockBot(); const message1 = outgoingMessageFixtures.textOnlyQuickReplyMessage(); const messageArray = [{ message: message1 }]; const expectedSentMessages = [message1]; test('#sendCascade works with single valid messages', sendCascadeMessageMacro, { sendMessageMethod: bot.sendCascade.bind(bot, messageArray), planFor: 5, expectedSentMessages, }); } { const bot = new MockBot(); const rawMessage1 = { nonStandard: 'message1', recipient: { id: 'user_id', }, }; const message2 = outgoingMessageFixtures.imageMessage(); const messageArray = [{ raw: rawMessage1 }, { message: message2 }]; const expectedSentMessages = [rawMessage1, message2]; test('#sendCascade works with mixed raw and botmaster messages', sendCascadeMessageMacro, { sendMessageMethod: bot.sendCascade.bind(bot, messageArray), planFor: 6, expectedSentMessages, }); } { const bot = new MockBot(); const textArray = ['Hello World!', 'Goodbye World!']; const secondExpectedMessage = outgoingMessageFixtures.textMessage(); secondExpectedMessage.message.text = 'Goodbye World!'; const expectedSentMessages = [ outgoingMessageFixtures.textMessage(), secondExpectedMessage, ]; test('#sendTextCascadeTo works', sendCascadeMessageMacro, { sendMessageMethod: bot.sendTextCascadeTo.bind(bot, textArray, 'user_id'), planFor: 10, expectedSentMessages, }); } ================================================ FILE: tests/botmaster/add_bot.js ================================================ import test from 'ava'; import http from 'http'; import express from 'express'; import request from 'request-promise'; import Botmaster from '../../lib'; import MockBot from '../_mock_bot'; test('works with a bot that doesn\'t require webhhooks', (t) => { t.plan(3); return new Promise((resolve) => { const botmaster = new Botmaster(); botmaster.on('listening', () => { const bot = new MockBot(); botmaster.addBot(bot); t.is(Object.keys(botmaster.__serverRequestListeners).length, 0); t.is(botmaster.bots.length, 1); t.is(botmaster.bots[0], bot); botmaster.server.close(resolve); }); }); }); const arbitraryBotMacro = (t, { botmasterSettings, botSettings }) => { t.plan(3); return new Promise((resolve) => { const botmaster = new Botmaster(botmasterSettings); botmaster.on('listening', () => { const bot = new MockBot(botSettings); botmaster.addBot(bot); t.is(Object.keys(botmaster.__serverRequestListeners).length, 1); t.is(botmaster.bots.length, 1); const uri = botmaster.settings.useDefaultMountPathPrepend ? `http://localhost:3000/${botSettings.type}/webhook/endpoint` : 'http://localhost:3000/webhook/endpoint'; const updateToSend = { text: 'Hello world' }; const requestOptions = { method: 'POST', uri, json: updateToSend, }; request(requestOptions); botmaster.use({ type: 'incoming', controller: (onUpdateBot, update) => { t.deepEqual(update.raw, updateToSend); botmaster.server.close(resolve); }, }); botmaster.on('error', () => { botmaster.server.close(resolve); }); }); }); }; test('works with an express bot', arbitraryBotMacro, { botSettings: { requiresWebhook: true, webhookEndpoint: 'webhook/endpoint', type: 'express', }, }); test('works with a koa bot', arbitraryBotMacro, { botSettings: { requiresWebhook: true, webhookEndpoint: 'webhook/endpoint', type: 'koa', }, }); test('works with a webhook that has slash bot', arbitraryBotMacro, { botSettings: { requiresWebhook: true, webhookEndpoint: '/webhook/endpoint/', type: 'express', }, }); // this test could also have been in constructor. As it spans over both constructor and bot adding test('should accept requests where expected when useDefaultMountPathPrepend is truthy', arbitraryBotMacro, { botmasterSettings: { useDefaultMountPathPrepend: false, }, botSettings: { requiresWebhook: true, webhookEndpoint: 'webhook/endpoint', type: 'express', }, }); test('works with an express server AND both an express and a koa bot', (t) => { t.plan(6); return new Promise((resolve) => { // just dev's personal app stuff const app = express(); const appResponse = { text: 'wadup?', }; app.use('/someRoute', (req, res) => { res.json(appResponse); }); // /////////////////////////////// const myServer = http.createServer(app); const botmaster = new Botmaster({ server: myServer, }); myServer.listen(3000, () => { // creating and adding bots const koaBotSettings = { requiresWebhook: true, webhookEndpoint: 'webhook', type: 'koa', }; const expressBotSettings = { requiresWebhook: true, webhookEndpoint: 'webhook', type: 'express', }; const koaBot = new MockBot(koaBotSettings); const expressBot = new MockBot(expressBotSettings); botmaster.addBot(koaBot); botmaster.addBot(expressBot); t.is(Object.keys(botmaster.__serverRequestListeners).length, 2); t.is(botmaster.bots.length, 2); // /////////////////////////////// // send requests to bots const updateToSendToKoaBot = { text: 'Hello Koa Bot' }; const updateToSendToExpressBot = { text: 'Hello express Bot' }; const koaBotRequestOptions = { method: 'POST', uri: 'http://localhost:3000/koa/webhook', json: updateToSendToKoaBot, }; const expressBotRequestOptions = { method: 'POST', uri: 'http://localhost:3000/express/webhook', json: updateToSendToExpressBot, }; request(koaBotRequestOptions) .then(() => request(expressBotRequestOptions)); // //////////////////////////// // catch update events let receivedUpdatesCount = 0; botmaster.use({ type: 'incoming', controller: ('update', (onUpdateBot, update) => { receivedUpdatesCount += 1; if (update.raw.text.indexOf('Koa') > -1) { t.deepEqual(update.raw, updateToSendToKoaBot); } else if (update.raw.text.indexOf('express') > -1) { t.deepEqual(update.raw, updateToSendToExpressBot); } if (receivedUpdatesCount === 2) { const appRequestOptions = { uri: 'http://localhost:3000/someRoute', json: true, }; request.get(appRequestOptions) .then((body) => { t.deepEqual(appResponse, body); t.is(botmaster.server, myServer); botmaster.server.close(resolve); }); } }), }); // //////////////////////////// }); }); }); ================================================ FILE: tests/botmaster/constructor.js ================================================ import test from 'ava'; import http from 'http'; import express from 'express'; import Koa from 'koa'; import request from 'request-promise'; import MockBot from '../_mock_bot'; import Botmaster from '../../lib'; // just this code to make sure unhandled exceptions are printed to // the console when developing. process.on('unhandledRejection', (err) => { console.error('UNHANDLED REJECTION', err.stack); }); // const request = require('request-promise'); // const JsonFileStore = require('jfs'); test('shouldn\'t throw any error if settings aren\'t specified', (t) => { t.plan(2); return new Promise((resolve) => { const botmaster = new Botmaster(); botmaster.on('listening', () => { t.is(botmaster.server.address().port, 3000); botmaster.server.close(() => { t.pass(); resolve(); }); }); }); }); test('should throw any error if settings.botsSetting are specified', (t) => { t.plan(1); const settings = { botsSettings: 'something', }; try { const botmaster = new Botmaster(settings); } catch (e) { t.is(e.message.indexOf( 'Starting botmaster with botsSettings is no longer supported') > -1, true, e.message); } }); test('should throw any error if settings.app are specified', (t) => { t.plan(1); const settings = { app: 'something', }; try { const botmaster = new Botmaster(settings); } catch (e) { t.is(e.message.indexOf( 'Starting botmaster with app is no longer') > -1, true, e.message); } }); test('should use my server when passed in settings', (t) => { t.plan(2); const myServer = http.createServer(); const settings = { server: myServer, }; const botmaster = new Botmaster(settings); t.is(botmaster.server === myServer, true); t.is(botmaster.server, myServer); }); test('should correctly set port when passed in settings', (t) => { t.plan(1); return new Promise((resolve) => { const settings = { port: 5000, }; const botmaster = new Botmaster(settings); botmaster.on('listening', () => { t.is(botmaster.server.address().port, 5000); botmaster.server.close(resolve); }); }); }); test('should throw and error when server and port passed in settings', (t) => { t.plan(1); const myServer = http.createServer(); try { const settings = { server: myServer, port: 4000, }; const botmaster = new Botmaster(settings); } catch (e) { t.is(e.message.indexOf('IncompatibleArgumentsError') > -1, true); } }); test('when used with default botmaster server,' + 'requestListener should return 404s to unfound routes', (t) => { t.plan(1); return new Promise((resolve) => { const botmaster = new Botmaster(); botmaster.on('listening', () => { const options = { uri: 'http://localhost:3000/someRoute', json: true, }; request.get(options) .catch((err) => { t.is(err.error.message, 'Couldn\'t GET /someRoute'); botmaster.server.close(resolve); }); }); }); }); test('when used with a server created with an express app' + 'requestListener should route non botmaster requests to express app', (t) => { t.plan(2); const app = express(); const appResponse = { text: 'wadup?', }; app.use('/someRoute', (req, res) => { res.json(appResponse); }); return new Promise((resolve) => { const myServer = app.listen(3000); const botmaster = new Botmaster({ server: myServer }); myServer.on('listening', () => { const options = { uri: 'http://localhost:3000/someRoute', json: true, }; request.get(options) .then((body) => { t.deepEqual(appResponse, body); t.is(botmaster.server, myServer); botmaster.server.close(resolve); }); }); }); }); test('when used with a server created with a koa app' + 'requestListener should route non botmaster requests to koa app', (t) => { t.plan(2); const app = new Koa(); const appResponse = { text: 'wadup?', }; app.use((ctx) => { if (ctx.request.url === '/someRoute') { ctx.body = appResponse; } }); return new Promise((resolve) => { const myServer = app.listen(3000); const botmaster = new Botmaster({ server: myServer }); myServer.on('listening', () => { const options = { uri: 'http://localhost:3000/someRoute', json: true, }; request.get(options) .then((body) => { t.deepEqual(body, appResponse); t.is(botmaster.server, myServer); botmaster.server.close(() => { resolve(); }); }); }); }); }); ================================================ FILE: tests/botmaster/get_bot.js ================================================ import test from 'ava'; import Botmaster from '../../lib'; import MockBot from '../_mock_bot'; const testTitleBase = 'Botmaster #getBot'; let botmaster; let botOne; let botTwo; let botThree; test.before(() => { const botOneOptions = { type: 'platformOne', id: 'botOne', }; botOne = new MockBot(botOneOptions); const botTwoOptions = { type: 'platformOne', // same type as botOne (but added after) id: 'botTwo', }; botTwo = new MockBot(botTwoOptions); const botThreeOptions = { type: 'platformThree', id: 'botThree', }; botThree = new MockBot(botThreeOptions); // just using createServer here so I don't have to close it after. // i.e. no need for before and after hooks botmaster = new Botmaster(); botmaster.addBot(botOne); botmaster.addBot(botTwo); botmaster.addBot(botThree); }); test(`${testTitleBase} should throw an error when getting called without any options `, (t) => { t.plan(1); try { botmaster.getBot(); } catch (err) { t.is(err.message.indexOf('needs exactly one of') > -1, true); } }); test(`${testTitleBase} should throw an error when getting called without two options`, (t) => { t.plan(1); try { botmaster.getBot({ type: 'platformOne', id: 'botOne', }); } catch (err) { t.is(err.message.indexOf('needs exactly one of') > -1, true); } }); test(`${testTitleBase} should work when getting called with only id option`, (t) => { t.plan(1); const bot = botmaster.getBot({ id: 'botOne', }); t.is(bot, botOne); }); test(`${testTitleBase} should work when getting called with only type option`, (t) => { t.plan(1); const bot = botmaster.getBot({ type: 'platformOne', }); t.is(bot, botOne); }); test(`${testTitleBase}s should work when getting called with only type option`, (t) => { t.plan(1); try { botmaster.getBots({ type: 'platformOne', }); } catch (err) { t.is(err.message.indexOf('takes in a string as') > -1, true); } }); test(`${testTitleBase}s should return bots of a certain type when requested`, (t) => { t.plan(6); const platformOneBots = botmaster.getBots('platformOne'); t.is(platformOneBots.length, 2); t.is(platformOneBots[0].type, 'platformOne'); t.is(platformOneBots[1].type, 'platformOne'); const platformTwoBots = botmaster.getBots('platformTwo'); t.is(platformTwoBots.length, 0); const platformThreeBots = botmaster.getBots('platformThree'); t.is(platformThreeBots.length, 1); t.is(platformThreeBots[0].type, 'platformThree'); }); test.after(() => { return new Promise((resolve) => { botmaster.server.close(resolve); }); }); ================================================ FILE: tests/botmaster/remove_bot.js ================================================ import test from 'ava'; import request from 'request-promise'; import Botmaster from '../../lib'; import MockBot from '../_mock_bot'; test('works with a bot that doesn\'t require webhhooks', (t) => { t.plan(2); return new Promise((resolve) => { const botmaster = new Botmaster(); botmaster.on('listening', () => { const bot = new MockBot(); botmaster.addBot(bot); botmaster.removeBot(bot); t.is(Object.keys(botmaster.__serverRequestListeners).length, 0); t.is(botmaster.bots.length, 0); botmaster.server.close(resolve); }); }); }); const arbitraryBotMacro = (t, { botmasterSettings, botSettings }) => { t.plan(3); console.log(botSettings); return new Promise((resolve) => { const botmaster = new Botmaster(botmasterSettings); botmaster.on('listening', () => { const bot = new MockBot(botSettings); botmaster.addBot(bot); botmaster.removeBot(bot); t.is(Object.keys(botmaster.__serverRequestListeners).length, 0); t.is(botmaster.bots.length, 0); const updateToSend = { text: 'Hello world' }; const requestOptions = { method: 'POST', uri: `http://localhost:3000/mock/${botSettings.webhookEndpoint}`, json: updateToSend, }; request(requestOptions) .catch((err) => { t.is(err.error.message, `Couldn't POST /mock/${botSettings.webhookEndpoint}`); botmaster.server.close(resolve); }); }); }); }; test('works with an express bot', arbitraryBotMacro, { botSettings: { requiresWebhook: true, webhookEndpoint: 'express', }, }); test('works with a koa bot', arbitraryBotMacro, { botSettings: { requiresWebhook: true, webhookEndpoint: 'koa', }, }); test('Removes path if useDefaultMountPathPrepend is false', arbitraryBotMacro, { botmasterSettings: { useDefaultMountPathPrepend: false, }, botSettings: { requiresWebhook: true, webhookEndpoint: '/express/', }, }); ================================================ FILE: tests/index.js ================================================ 'use strict'; const MockBot = require('./_mock_bot'); // if using MockBot in your library, just do a require('botmaster/tests').MockBot // and make sure the following packages are either in your dependencies exports // dev-dependencies: /** * express * koa * body-parser */ module.exports = { MockBot, }; ================================================ FILE: tests/middleware/use.js ================================================ import test from 'ava'; import request from 'request-promise'; import { assign } from 'lodash'; import { outgoingMessageFixtures, incomingUpdateFixtures } from 'botmaster-test-fixtures'; import Botmaster from '../../lib'; import MockBot from '../_mock_bot'; test.beforeEach((t) => { return new Promise((resolve) => { t.context.botmaster = new Botmaster(); t.context.bot = new MockBot({ requiresWebhook: true, webhookEndpoint: 'webhook', type: 'express', }); t.context.botmaster.addBot(t.context.bot); t.context.baseRequestOptions = { method: 'POST', uri: 'http://localhost:3000/express/webhook', body: {}, json: true, resolveWithFullResponse: true, }; t.context.botmaster.on('listening', resolve); }); }); test.afterEach((t) => { return new Promise((resolve) => { t.context.botmaster.server.close(resolve); }); }); test('throws an error middleware is not an object', (t) => { t.plan(1); try { t.context.botmaster.use('something'); } catch (err) { t.is(err.message, 'middleware should be an object. Not string', 'Error message is not the same as expected'); } }); test('throws an error if type is not incoming or outgoing', (t) => { t.plan(1); try { t.context.botmaster.use({ type: 'something', }); } catch (err) { t.is(err.message, 'invalid middleware type. Type should be either \'incoming\' or \'outgoing\'', 'Error message is not the same as expected'); } }); test('throws an error if controller is not defined', (t) => { t.plan(1); try { t.context.botmaster.use({ type: 'incoming', }); } catch (err) { t.is(err.message, 'middleware controller can\'t be of type undefined. It needs to be a function', 'Error message is not the same as expected'); } }); test('throws an error if middlewareCallback is not a function', (t) => { t.plan(1); try { t.context.botmaster.use({ type: 'incoming', controller: 'not valid', }); } catch (err) { t.is(err.message, 'middleware controller can\'t be of type string. It needs to be a function', 'Error message is not the same as expected'); } }); const incomingMiddlewareErrorMacro = (t, controller) => { t.plan(1); return new Promise((resolve) => { const botmaster = t.context.botmaster; botmaster.use({ controller, type: 'incoming', }); botmaster.use({ type: 'incoming', controller: () => { t.fail('this middleware should not get hit'); resolve(); }, }); botmaster.on('error', (bot, err) => { t.is(err.message, '"update.blop is not a function". This is most probably on your end.', 'Error message did not match'); resolve(); }); request(t.context.baseRequestOptions); }); }; incomingMiddlewareErrorMacro.title = customTitlePart => `Errors in incoming middleware are emitted correctly ${customTitlePart}`; test('in synchronous middleware', incomingMiddlewareErrorMacro, (bot, update) => { update.blop(); }); test('using next', incomingMiddlewareErrorMacro, (bot, update, next) => { process.nextTick(() => { try { update.blop(); } catch (err) { next(err); } }); }); test('using promises', incomingMiddlewareErrorMacro, (bot, update) => { return new Promise((resolve, reject) => { process.nextTick(() => { try { update.blop(); } catch (err) { reject(err); } }); }); }); test('using async function', incomingMiddlewareErrorMacro, async (bot, update) => { // just a function that returns a promise const somePromise = () => new Promise((resolve) => { process.nextTick(() => { resolve(); }); }); await somePromise(); update.blop(); }); test('Error is emitted if error is thrown by user and does not inherit from Error', (t) => { t.plan(1); return new Promise((resolve) => { const botmaster = t.context.botmaster; botmaster.use({ controller: async () => { const err = 'not expected'; throw err; }, type: 'incoming', }); botmaster.use({ type: 'incoming', controller: () => { t.fail('this middleware should not get hit'); resolve(); }, }); botmaster.on('error', (bot, err) => { t.is(err, 'not expected', 'Error message did not match'); resolve(); }); request(t.context.baseRequestOptions); }); }); test('Error is emitted if error is thrown by user and is falsy', (t) => { t.plan(1); return new Promise((resolve) => { const botmaster = t.context.botmaster; botmaster.use({ controller: () => Promise.reject(), type: 'incoming', }); botmaster.use({ type: 'incoming', controller: () => { t.fail('this middleware should not get hit'); resolve(); }, }); botmaster.on('error', (bot, err) => { t.is(err, 'empty error object', 'Error message did not match'); resolve(); }); request(t.context.baseRequestOptions); }); }); test('Emits error if next is used within returned promise', (t) => { t.plan(1); return new Promise((resolve) => { const botmaster = t.context.botmaster; botmaster.use({ type: 'incoming', controller: async (bot, update, next) => { next('skip'); }, }); botmaster.use({ type: 'incoming', controller: () => { t.fail('this middleware should not get hit'); resolve(); }, }); botmaster.on('error', (bot, err) => { t.is(err.message, '"next can\'t be called if middleware returns a promise/is an async ' + 'function". This is most probably on your end.', 'Error message did not match'); resolve(); }); request(t.context.baseRequestOptions); }); }); test('sets up the incoming middleware function specified if good params' + ' passed. Does not call any outgoing middleware when going through', (t) => { t.plan(1); return new Promise((resolve) => { const botmaster = t.context.botmaster; botmaster.use({ type: 'incoming', controller: async (bot, update) => { update.message.text = 'Hello World!'; }, }); botmaster.use({ type: 'outgoing', controller: () => { t.fail('outgoing middleware should not be called'); }, }); botmaster.use({ type: 'incoming', controller: (bot, update) => { t.is(update.message.text, 'Hello World!', 'update object did not match'); resolve(); }, }); request(t.context.baseRequestOptions); }); }); test('sets up the incoming middleware and calls them using __emitUpdate', (t) => { t.plan(1); return new Promise((resolve) => { t.context.botmaster.use({ type: 'incoming', controller: (bot, update, next) => { update.text = 'Hello World!'; next(); }, }); t.context.botmaster.use({ type: 'incoming', controller: (bot, update) => { t.is(update.text, 'Hello World!', 'update object did not match'); resolve(); }, }); t.context.bot.__emitUpdate({}); }); }); test('sets up the incoming middleware in order of declaration', (t) => { t.plan(1); return new Promise((resolve) => { t.context.botmaster.use({ type: 'incoming', controller: (bot, update, next) => { update.text = 'Hello '; next(); }, }); t.context.botmaster.use({ type: 'incoming', controller: (bot, update) => { update.text += 'World!'; return Promise.resolve(); }, }); t.context.botmaster.use({ type: 'incoming', controller: async (bot, update) => { update.text += ' And others'; }, }); t.context.botmaster.use({ type: 'incoming', controller: (bot, update) => { t.is(update.text, 'Hello World! And others', 'update object did not match'); resolve(); }, }); t.context.bot.__emitUpdate({}); }); }); const incomingMiddlewareChainBreakerMacro = (t, controller) => { t.plan(1); return new Promise(async (resolve) => { t.context.botmaster.use({ type: 'incoming', controller, }); t.context.botmaster.use({ type: 'incoming', controller: () => { t.fail('this middleware should not get hit'); resolve(); }, }); const val = await t.context.bot.__emitUpdate({}); if (val) { t.is(val, 'cancelled'); } else { t.pass(); } resolve(); }); }; incomingMiddlewareChainBreakerMacro.title = customTitlePart => `using middleware chain breakers in incoming middleware works as expected ${customTitlePart}`; test('using next skip', incomingMiddlewareChainBreakerMacro, (bot, update, next) => { next('skip'); }); test('using promise skip', incomingMiddlewareChainBreakerMacro, () => Promise.resolve('skip')); test('using async skip', incomingMiddlewareChainBreakerMacro, async () => 'skip'); test('using next cancel', incomingMiddlewareChainBreakerMacro, (bot, update, next) => { next('cancel'); }); test('using promise cancel', incomingMiddlewareChainBreakerMacro, () => Promise.resolve('cancel')); test('using async cancel', incomingMiddlewareChainBreakerMacro, async () => 'cancel'); test('echo, read and delivery are not included by default', (t) => { t.plan(3); return new Promise((resolve) => { t.context.botmaster.use({ type: 'incoming', controller: () => { t.fail('this middleware should never get hit in this test'); resolve(); }, }); let hitMiddlewareCount = 0; const resolveWhenNeeded = () => { hitMiddlewareCount += 1; if (hitMiddlewareCount === 3) { resolve(); } }; t.context.botmaster.use({ type: 'incoming', includeEcho: true, controller: (bot, update, next) => { t.truthy(update.message.is_echo, 'message is not an echo'); resolveWhenNeeded(); next(); }, }); t.context.botmaster.use({ type: 'incoming', includeDelivery: true, controller: (bot, update) => { t.truthy(update.delivery, 'message is not a delivery confirmation'); resolveWhenNeeded(); return Promise.resolve(); }, }); t.context.botmaster.use({ type: 'incoming', includeRead: true, controller: async (bot, update) => { t.truthy(update.read, 'message is not a read confirmation'); resolveWhenNeeded(); }, }); t.context.bot.__emitUpdate(incomingUpdateFixtures.echoUpdate()); t.context.bot.__emitUpdate(incomingUpdateFixtures.messageReadUpdate()); t.context.bot.__emitUpdate(incomingUpdateFixtures.messageDeliveredUpdate()); }); }); const outgoingMiddlewareErrorMacro = (t, controller) => { t.plan(1); return new Promise((resolve) => { const botmaster = t.context.botmaster; botmaster.use({ controller, type: 'outgoing', }); botmaster.use({ type: 'outgoing', controller: () => { t.fail('this middleware should not get hit'); resolve(); }, }); botmaster.bots[0].sendMessage({}) .catch((err) => { t.is(err.message, 'message.blop is not a function', 'Error message did not match'); resolve(); }); }); }; outgoingMiddlewareErrorMacro.title = customTitlePart => `Errors in outgoing middleware are thrown correctly ${customTitlePart}`; test('in synchronous middleware', outgoingMiddlewareErrorMacro, (bot, update, message) => { message.blop(); }); test('using next', outgoingMiddlewareErrorMacro, (bot, update, message, next) => { process.nextTick(() => { try { message.blop(); } catch (err) { next(err); } }); }); test('using promises', outgoingMiddlewareErrorMacro, (bot, update, message) => { return new Promise((resolve, reject) => { process.nextTick(() => { try { message.blop(); } catch (err) { reject(err); } }); }); }); test('using async function', outgoingMiddlewareErrorMacro, async (bot, update, message) => { // just a function that returns a promise const somePromise = () => new Promise((resolve) => { process.nextTick(() => { resolve(); }); }); await somePromise(); message.blop(); }); test('sets up the outgoing middleware in order of declaration. ' + 'Then calls them when prompted without calling incoming middleware', (t) => { t.plan(1); return new Promise((resolve) => { const botmaster = t.context.botmaster; botmaster.use({ type: 'incoming', controller: (bot, update, next) => { t.fail('Called incoming middleware, although should not'); next(); }, }); botmaster.use({ type: 'outgoing', controller: async (bot, update, message) => { message.removeText(); }, }); botmaster.use({ type: 'outgoing', controller: (bot, update, message, next) => { message.addText('Goodbye Worlds!'); next(); }, }); botmaster.bots[0].sendMessage(outgoingMessageFixtures.textMessage()) .then((body) => { t.is(body.sentOutgoingMessage.message.text, 'Goodbye Worlds!', 'sent message did not match'); resolve(); }) .catch((err) => { t.fail(err.message); resolve(); }); }); }); const skipOutgoingMiddlewareMacro = (t, controller) => { t.plan(2); return new Promise(async (resolve) => { t.context.botmaster.use({ type: 'outgoing', controller: (bot, update, message, next) => { t.pass(); return controller(bot, update, message, next); }, }); t.context.botmaster.use({ type: 'outgoing', controller: () => { t.fail('this middleware should not get hit'); resolve(); }, }); const body = await t.context.bot.sendMessage( outgoingMessageFixtures.textMessage()); t.deepEqual(body.sentRawMessage, outgoingMessageFixtures.textMessage()); resolve(); }); }; skipOutgoingMiddlewareMacro.title = customTitlePart => `using middleware skip in outgoing middleware works as expected ${customTitlePart}`; test('using next skip', skipOutgoingMiddlewareMacro, (bot, update, message, next) => { next('skip'); }); test('using promise skip', skipOutgoingMiddlewareMacro, () => Promise.resolve('skip')); test('using async skip', skipOutgoingMiddlewareMacro, async () => 'skip'); const cancelOutgoingMiddlewareMacro = async (t, controller) => { t.plan(2); return new Promise(async (resolve) => { t.context.botmaster.use({ type: 'outgoing', controller: (bot, update, message, next) => { t.pass(); return controller(bot, update, message, next); }, }); t.context.botmaster.use({ type: 'outgoing', controller: () => { t.fail('this middleware should not get hit'); resolve(); }, }); const body = await t.context.bot.sendMessage( outgoingMessageFixtures.textMessage()); t.is(body, 'cancelled'); resolve(); }); }; cancelOutgoingMiddlewareMacro.title = customTitlePart => `using middleware cancel in outgoing middleware works as expected ${customTitlePart}`; test('using next cancel', cancelOutgoingMiddlewareMacro, (bot, update, message, next) => { next('cancel'); }); test('using promise cancel', cancelOutgoingMiddlewareMacro, () => Promise.resolve('cancel')); test('using async cancel', cancelOutgoingMiddlewareMacro, async () => 'cancel'); test('sets up the outgoing middleware which is ignored if specified so in sendOptions.', (t) => { t.plan(2); return new Promise(async (resolve) => { t.context.botmaster.use({ type: 'outgoing', controller: () => { t.fail('this middleware should not get hit'); resolve(); }, }); const bot = t.context.bot; try { await bot.sendMessage( outgoingMessageFixtures.textMessage(), { ignoreMiddleware: true }); await bot.reply( incomingUpdateFixtures.textUpdate(), 'wadup?', { ignoreMiddleware: true }); await bot.sendAttachmentFromUrlTo( 'image', 'some_link', 'user_id', { ignoreMiddleware: true }); await bot.sendDefaultButtonMessageTo( ['b1', 'b2'], undefined, 'user_id', { ignoreMiddleware: true }); await bot.sendIsTypingMessageTo( 'user_id', { ignoreMiddleware: true }); const bodies = await bot.sendTextCascadeTo( ['message1', 'message2'], 'user_id', { ignoreMiddleware: true }); t.is(bodies[0].sentOutgoingMessage.message.text, 'message1', 'sentOutgoingMessage was not as expected'); t.is(bodies[1].sentOutgoingMessage.message.text, 'message2', 'sentOutgoingMessage was not as expected'); resolve(); } catch (err) { t.fail(err.message); resolve(); } }); }); test('sets up the outgoing middleware which is aware of update when manually set using __createBotPatchedWithUpdate', (t) => { t.plan(2); return new Promise(async (resolve) => { const botmaster = t.context.botmaster; const mockUpdate = { id: 1 }; botmaster.use({ type: 'outgoing', controller: (bot, update, message, next) => { t.is(update, mockUpdate, 'associated update is not the same'); t.deepEqual(assign({}, message), outgoingMessageFixtures.textMessage(), 'Message is not the same'); next(); }, }); const bot = botmaster.bots[0]; try { // with a patchedBot const patchedBot = bot.__createBotPatchedWithUpdate(mockUpdate); await patchedBot.sendMessage(outgoingMessageFixtures.textMessage()); botmaster.server.close(resolve); } catch (err) { t.fail(err.message); botmaster.server.close(resolve); } }); }); test('sets up the outgoing middleware which is aware of update when sending message from incoming middleware', (t) => { t.plan(3); return new Promise((resolve) => { const botmaster = t.context.botmaster; botmaster.use({ type: 'incoming', controller: async (bot, update) => { const body = await bot.reply(update, 'Hello World!'); t.is(body.sentOutgoingMessage.message.text, 'Hello World!'); resolve(); }, }); botmaster.use({ type: 'outgoing', controller: (bot, update, message, next) => { t.deepEqual(assign({}, update), incomingUpdateFixtures.textUpdate(), 'associated update is not the same'); t.deepEqual(assign({}, message), outgoingMessageFixtures.textMessage(), 'Message is not the same'); next(); }, }); const bot = botmaster.bots[0]; bot.__emitUpdate(incomingUpdateFixtures.textUpdate()); }); }); test('sets up the outgoing middleware which is aware of update on the second pass when sending a message in outgoing middleware', (t) => { t.plan(8); return new Promise((resolve) => { const botmaster = t.context.botmaster; const receivedUpdate = incomingUpdateFixtures.textUpdate(); let pass = 1; botmaster.use({ type: 'outgoing', controller: async (bot, update, message) => { if (pass === 1) { t.is(message.message.text, 'Hello World!', 'message text is not as expected on first pass'); t.is(update.newProp, 1, 'newProp is not the expected value on first pass'); t.is(update, receivedUpdate, 'Reference to update is not the same'); update.newProp = 2; pass += 1; const body = await bot.reply(update, 'Goodbye World!'); t.is(body.sentRawMessage.message.text, 'Goodbye World!'); } else if (pass === 2) { t.is(message.message.text, 'Goodbye World!', 'message text is not as expected on second pass'); t.is(update.newProp, 2, 'newProp is not the expected value on second pass'); t.is(update, receivedUpdate, 'Reference to update is not the same'); } }, }); botmaster.use({ type: 'incoming', controller: async (bot, update) => { update.newProp = 1; const body = await bot.reply(update, 'Hello World!'); t.is(body.sentRawMessage.message.text, 'Hello World!'); resolve(); }, }); t.context.bot.__emitUpdate(receivedUpdate); }); }); ================================================ FILE: tests/middleware/use_wrapped.js ================================================ import test from 'ava'; import Botmaster from '../../lib'; test.beforeEach((t) => { return new Promise((resolve) => { t.context.botmaster = new Botmaster(); t.context.botmaster.on('listening', resolve); }); }); test.afterEach((t) => { return new Promise((resolve) => { t.context.botmaster.server.close(resolve); }); }); const errorThrowingMacro = (t, params) => { t.plan(1); const botmaster = t.context.botmaster; try { botmaster.useWrapped(params.incomingMiddleware, params.outgoingMiddleware); } catch (err) { t.is(err.message, params.errorMessage, 'Error message is not the same as expected'); } }; errorThrowingMacro.title = customTitlePart => `throws an error if ${customTitlePart}`; test('called with no params', errorThrowingMacro, { errorMessage: 'useWrapped should be called with both an' + ' incoming and an outgoing middleware', }); test('called with no outgoing middleware', errorThrowingMacro, { incomingMiddleware: { type: 'incoming', controller: __ => __, }, errorMessage: 'useWrapped should be called with both an' + ' incoming and an outgoing middleware', }); test('called with no incoming middleware', errorThrowingMacro, { outgoingMiddleware: { type: 'outgoing', controller: __ => __, }, errorMessage: 'useWrapped should be called with both an' + ' incoming and an outgoing middleware', }); test('called with two incoming middlewares', errorThrowingMacro, { incomingMiddleware: { type: 'outgoing', controller: __ => __, }, outgoingMiddleware: { type: 'outgoing', controller: __ => __, }, errorMessage: 'first argument of "useWrapped" should be an' + ' incoming middleware', }); test('called with two incoming middlewares', errorThrowingMacro, { incomingMiddleware: { type: 'incoming', controller: __ => __, }, outgoingMiddleware: { type: 'incoming', controller: __ => __, }, errorMessage: 'second argument of "useWrapped" should be an' + ' outgoing middleware', }); test('middleware gets added where expected', (t) => { t.plan(4); const botmaster = t.context.botmaster; const useIncomingController = __ => __; botmaster.use({ type: 'incoming', controller: useIncomingController, }); const useOutgoingController = __ => __; botmaster.use({ type: 'outgoing', controller: useOutgoingController, }); const useWrappedIncomingController = __ => __; const useWrappedOutgoingController = __ => __; botmaster.useWrapped({ type: 'incoming', controller: useWrappedIncomingController, }, { type: 'outgoing', controller: useWrappedOutgoingController, }); t.is(botmaster.middleware.incomingMiddlewareStack.length, 2); t.is(botmaster.middleware.outgoingMiddlewareStack.length, 2); t.is(botmaster.middleware.incomingMiddlewareStack[0].controller, useWrappedIncomingController); t.is(botmaster.middleware.outgoingMiddlewareStack[1].controller, useWrappedOutgoingController); }); ================================================ FILE: tests/outgoing_message.js ================================================ import test from 'ava'; import { outgoingMessageFixtures, attachmentFixtures } from 'botmaster-test-fixtures'; import { assign } from 'lodash'; import MockBot from './_mock_bot'; import OutgoingMessage from '../lib/outgoing_message'; const createBaseOutgoingMessage = () => { const outgoingMessage = { recipient: { id: 'user_id', }, }; return new OutgoingMessage(outgoingMessage); }; test('Instantiating an OutgoingMessage object via a bot\'s createOutgoingMessage works', (t) => { t.plan(1); const bot = new MockBot(); const botOutgoingMessage = bot.createOutgoingMessage({}); t.deepEqual(botOutgoingMessage, new OutgoingMessage()); }); test('Instantiating an OutgoingMessage object via a bot class\'s createOutgoingMessage works', (t) => { t.plan(1); const botOutgoingMessage = MockBot.createOutgoingMessage({}); t.deepEqual(botOutgoingMessage, new OutgoingMessage()); }); test('Instantiating an OutgoingMessage starter object via a bot\'s createOutgoingMessageFor works', (t) => { t.plan(1); const bot = new MockBot(); const botOutgoingMessage = bot.createOutgoingMessageFor('user_id'); t.deepEqual(botOutgoingMessage, createBaseOutgoingMessage()); }); test('Instantiating an OutgoingMessage starter object via a bot class\'s createOutgoingMessageFor works', (t) => { t.plan(1); const botOutgoingMessage = MockBot.createOutgoingMessageFor('user_id'); t.deepEqual(botOutgoingMessage, createBaseOutgoingMessage()); }); test('#constructor does not throw an error when initialized without argument', (t) => { t.plan(1); const m = new OutgoingMessage(); t.pass(); }); test('throws an error when argument passed is not an object', (t) => { t.plan(1); try { const m = new OutgoingMessage('not an object'); } catch (err) { t.is(err.message, 'OutgoingMessage constructor takes in an object as param'); } }); test('#constructor properly assigns passed in object', (t) => { t.plan(1); const message = outgoingMessageFixtures.textMessage(); const outgoingMessage = new OutgoingMessage(message); // assign is used here and in all the subsequent tests, in order // to make sure that the deepEqual passes. Otherwise, it is comparing an // instance of OutgoingMessage with Object, which won't work! t.deepEqual(assign({}, outgoingMessage), message); }); test('#__addPropery throws error when trying to add property with falsy value', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); try { outgoingMessage.__addProperty('arbitrary', 'arbitrary', undefined); } catch (err) { t.is(err.message, 'arbitrary must have a value. Can\'t be undefined'); } }); test('#__addPropery throws error when trying to add property that already has a value', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.arbitrary = 'some value'; try { outgoingMessage.__addProperty('arbitrary', 'arbitrary', 'some other value'); } catch (err) { t.is(err.message, 'Can\'t add arbitrary to outgoingMessage that already has arbitrary'); } }); test('#__removePropery throws error when trying to remove property that doesn\'t have a value', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); try { outgoingMessage.__removeProperty('arbitrary.arb', 'arbitrary'); } catch (err) { t.is(err.message, 'Can\'t remove arbitrary from outgoingMessage that doesn\'t have any arbitrary'); } }); test('#addRecipientId properly works', (t) => { t.plan(1); const outgoingMessage = new OutgoingMessage().addRecipientById('user_id'); t.deepEqual(outgoingMessage, createBaseOutgoingMessage()); }); test('#addRecipientPhone properly works', (t) => { t.plan(1); const outgoingMessage = new OutgoingMessage().addRecipientByPhoneNumber('phoneNumber'); const expectedMessage = { recipient: { phone_number: 'phoneNumber', }, }; t.deepEqual(assign({}, outgoingMessage), expectedMessage); }); test('#removeRecipient properly works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.removeRecipient(); t.deepEqual(outgoingMessage, new OutgoingMessage()); }); test('#addText properly works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.addText('Hello World!'); t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.textMessage()); }); test('#removeText properly works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.addText('Hello World!').removeText(); const expectedOutgoingMessage = createBaseOutgoingMessage(); expectedOutgoingMessage.message = {}; t.deepEqual(outgoingMessage, expectedOutgoingMessage); }); test('#addAttachment works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.addAttachment(attachmentFixtures.audioAttachment()); t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.audioMessage()); }); test('#addAttachmentFromUrl throws error if not passed in strings', (t) => { t.plan(2); const outgoingMessage = createBaseOutgoingMessage(); try { outgoingMessage.addAttachmentFromUrl({ type: 'audio' }, 'SOME_AUDIO_URL'); } catch (err) { t.is(err.message, 'addAttachmentFromUrl must be called with "type" and "url" arguments of type string'); } try { outgoingMessage.addAttachmentFromUrl('audio', { url: 'SOME_AUDIO_URL' }); } catch (err) { t.is(err.message, 'addAttachmentFromUrl must be called with "type" and "url" arguments of type string'); } }); test('#addAttachmentFromUrl throws error if not passed in both type and url', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); try { outgoingMessage.addAttachmentFromUrl('audio'); } catch (err) { t.is(err.message, 'addAttachmentFromUrl must be called with truthy "type" and "url" arguments'); } }); test('#addAttachmentFromUrl works when using the right arguments', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.addAttachmentFromUrl('audio', 'SOME_AUDIO_URL'); t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.audioMessage()); }); test('#removeAttachment works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage .addAttachment(attachmentFixtures.audioAttachment()) .removeAttachment(); const expectedOutgoingMessage = createBaseOutgoingMessage(); expectedOutgoingMessage.message = {}; t.deepEqual(outgoingMessage, expectedOutgoingMessage); }); test('#addQuickReplies works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); const quickReplies = outgoingMessageFixtures.textOnlyQuickReplyMessage().message.quick_replies; outgoingMessage.addQuickReplies(quickReplies); outgoingMessage.addText('Please select one of:'); t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.textOnlyQuickReplyMessage()); }); test('#addPayloadLessQuickReplies throws error if not passed in array of strings', (t) => { t.plan(2); const outgoingMessage = createBaseOutgoingMessage(); try { outgoingMessage.addPayloadLessQuickReplies('not an array'); } catch (err) { t.is(err.message, 'addPayloadLessQuickReplies needs to be passed in an array of strings as first argument'); } try { outgoingMessage.addPayloadLessQuickReplies(['not an array of strings', {}]); } catch (err) { t.is(err.message, 'addPayloadLessQuickReplies needs to be passed in an array of strings as first argument'); } }); test('#addPayloadLessQuickReplies properly works when passed array of strings', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.addPayloadLessQuickReplies(['B1', 'B2']); outgoingMessage.addText('Please select one of:'); const quickReplies = [ { content_type: 'text', title: 'B1', payload: 'B1', }, { content_type: 'text', title: 'B2', payload: 'B2', }, ]; const expectedOutgoingMessage = outgoingMessageFixtures.textOnlyQuickReplyMessage(); expectedOutgoingMessage.message.quick_replies = quickReplies; t.deepEqual(assign({}, outgoingMessage), expectedOutgoingMessage); }); test('#addLocationQuickReply properly works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.addLocationQuickReply(); outgoingMessage.addText('Please share your location:'); t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.locationQuickReplyMessage()); }); test('#removeQuickReplies works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage .addLocationQuickReply() .removeQuickReplies(); const expectedOutgoingMessage = createBaseOutgoingMessage(); expectedOutgoingMessage.message = {}; t.deepEqual(outgoingMessage, expectedOutgoingMessage); }); test('#addSenderAction properly works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.addSenderAction('typing_on'); t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.typingOnMessage()); }); test('#removeSenderAction properly works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.addSenderAction('some_action').removeSenderAction(); const expectedOutgoingMessage = createBaseOutgoingMessage(); t.deepEqual(outgoingMessage, expectedOutgoingMessage); }); test('#addTypingOnSenderAction properly works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.addTypingOnSenderAction(); t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.typingOnMessage()); }); test('#addTypingOffSenderAction properly works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.addTypingOffSenderAction(); t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.typingOffMessage()); }); test('#addMarkSeenSenderAction properly works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage(); outgoingMessage.addMarkSeenSenderAction(); t.deepEqual(assign({}, outgoingMessage), outgoingMessageFixtures.markSeenMessage()); }); test('chaining of all methods works', (t) => { t.plan(1); const outgoingMessage = createBaseOutgoingMessage() .removeRecipient() .addRecipientByPhoneNumber('phoneNumber') .removeRecipient() .addRecipientById('user_id') .addText('Hello') .removeText() .addAttachment({}) .removeAttachment() .addAttachmentFromUrl('image', 'someUrl') .removeAttachment() .addQuickReplies([]) .removeQuickReplies() .addPayloadLessQuickReplies(['B1', 'B2'], 'select one of') .removeQuickReplies() .addLocationQuickReply() .removeQuickReplies() .addSenderAction('some_abstract_action') .removeSenderAction() .addTypingOnSenderAction() .removeSenderAction() .addTypingOffSenderAction() .removeSenderAction() .addMarkSeenSenderAction() .removeSenderAction(); const expectedOutgoingMessage = createBaseOutgoingMessage(); expectedOutgoingMessage.message = {}; t.deepEqual(outgoingMessage, expectedOutgoingMessage); });