diff --git a/.gitignore b/.gitignore index c3c36444b..74b5a7fe8 100755 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ examples/db_team_bot/ .DS_Store */.DS_Store .env +.idea +.vscode +coverage diff --git a/.jscsrc b/.jscsrc index 583005e49..3812ebd3e 100644 --- a/.jscsrc +++ b/.jscsrc @@ -3,7 +3,7 @@ "disallowKeywords": ["with"], "disallowMultipleLineBreaks": null, "disallowMultipleVarDecl": null, - "maximumLineLength": 120, + "maximumLineLength": 180, "disallowSpacesInsideObjectBrackets": null, "requireCamelCaseOrUpperCaseIdentifiers": null, "requireCurlyBraces": null, diff --git a/.npmignore b/.npmignore new file mode 100644 index 000000000..11125fc90 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +docs/provisioning/IMG diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aeeee5c42..269fef495 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,9 +21,13 @@ possible with your report. If you can, please include: ## Submitting Pull Requests +* Create, or link to an existing issue identifying the need driving your PR request. The issue can contain more details of the need for the PR as well as host debate as to which course of action the PR will take that will most serve the common good. * Include screenshots and animated GIFs in your pull request whenever possible. * Follow the JavaScript coding style with details from `.jscsrc` and `.editorconfig` files and use necessary plugins for your text editor. +* Run `npm test` before submitting and fix any issues. +* Add tests to cover any new functionality. Add and/or update tests for any updates to the code. * Write documentation in [Markdown](https://daringfireball.net/projects/markdown). +* Please follow, [JSDoc](http://usejsdoc.org/) for proper documentation. * Use short, present tense commit messages. See [Commit Message Styleguide](#git-commit-messages). ## Styleguides diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..7c594830f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM library/node:slim + +COPY . /app + +RUN cd /app \ + && npm install --production + +WORKDIR /app diff --git a/__test__/lib/Botkit.test.js b/__test__/lib/Botkit.test.js new file mode 100644 index 000000000..465fe3594 --- /dev/null +++ b/__test__/lib/Botkit.test.js @@ -0,0 +1,30 @@ +'use strict'; + +let botkit; + +jest.mock('../../lib/CoreBot', () => 'corebot'); +jest.mock('../../lib/SlackBot', () => 'slackbot'); +jest.mock('../../lib/Facebook', () => 'facebook'); +jest.mock('../../lib/TwilioIPMBot', () => 'twilioipm'); +jest.mock('../../lib/TwilioSMSBot', () => 'twiliosms'); +jest.mock('../../lib/BotFramework', () => 'botframework'); +jest.mock('../../lib/CiscoSparkbot', () => 'spark'); +jest.mock('../../lib/ConsoleBot', () => 'console'); +jest.mock('../../lib/Teams', () => 'teams'); + +beforeEach(() => { + jest.clearAllMocks(); + botkit = require('../../lib/Botkit'); +}); + +test('exports bot interfaces', () => { + expect(botkit.core).toBe('corebot'); + expect(botkit.slackbot).toBe('slackbot'); + expect(botkit.facebookbot).toBe('facebook'); + expect(botkit.twilioipmbot).toBe('twilioipm'); + expect(botkit.twiliosmsbot).toBe('twiliosms'); + expect(botkit.botframeworkbot).toBe('botframework'); + expect(botkit.sparkbot).toBe('spark'); + expect(botkit.teamsbot).toBe('teams'); + expect(botkit.consolebot).toBe('console'); +}); diff --git a/__test__/lib/Slack_web_api.test.js b/__test__/lib/Slack_web_api.test.js new file mode 100644 index 000000000..836db401c --- /dev/null +++ b/__test__/lib/Slack_web_api.test.js @@ -0,0 +1,321 @@ +'use strict'; + +let slackWebApi; +let mockRequest; +let mockResponse; +let mockBot; + +mockRequest = {}; + +jest.mock('request', () => mockRequest); + +beforeEach(() => { + mockResponse = { + statusCode: 200, + body: '{"ok": true}' + }; + + mockBot = { + config: {}, + debug: jest.fn(), + log: jest.fn(), + userAgent: jest.fn().mockReturnValue('jesting') + }; + + mockBot.log.error = jest.fn(); + + mockRequest.post = jest.fn().mockImplementation((params, cb) => { + cb(null, mockResponse, mockResponse.body); + }); + + slackWebApi = require('../../lib/Slack_web_api'); +}); + +describe('config', () => { + test('default api_root', () => { + const instance = slackWebApi(mockBot, {}); + expect(instance.api_url).toBe('https://slack.com/api/'); + }); + + test('setting api_root', () => { + mockBot.config.api_root = 'http://www.somethingelse.com'; + const instance = slackWebApi(mockBot, {}); + expect(instance.api_url).toBe('http://www.somethingelse.com/api/'); + }); +}); + +describe('callApi', () => { + let instance; + + test('uses data.token by default and post', () => { + const data = { + token: 'abc123' + }; + const cb = jest.fn(); + + instance = slackWebApi(mockBot, {}); + instance.callAPI('some.method', data, cb); + + expect(mockRequest.post).toHaveBeenCalledTimes(1); + const firstArg = mockRequest.post.mock.calls[0][0]; + expect(firstArg.form.token).toBe('abc123'); + }); + + test('uses config.token if data.token is missing', () => { + const data = {}; + const cb = jest.fn(); + + instance = slackWebApi(mockBot, { token: 'abc123' }); + instance.callAPI('some.method', data, cb); + + expect(mockRequest.post).toHaveBeenCalledTimes(1); + const firstArg = mockRequest.post.mock.calls[0][0]; + expect(firstArg.form.token).toBe('abc123'); + }); + + // this case is specific to callAPI, shared cases will be tested below + test(`handles multipart data`, () => { + const cb = jest.fn(); + instance = slackWebApi(mockBot, {}); + instance.callAPI('some.method', 'data', cb, true); + + expect(mockRequest.post).toHaveBeenCalledTimes(1); + const firstArg = mockRequest.post.mock.calls[0][0]; + + expect(firstArg.formData).toBe('data'); + expect(firstArg.form).toBeUndefined(); + expect(cb).toHaveBeenCalledWith(null, { ok: true }); + }); +}); + +describe('callApiWithoutToken', () => { + let instance; + + test('uses data values by default', () => { + const data = { + client_id: 'id', + client_secret: 'secret', + redirect_uri: 'redirectUri' + }; + const cb = jest.fn(); + + instance = slackWebApi(mockBot, {}); + instance.callAPIWithoutToken('some.method', data, cb); + + expect(mockRequest.post.mock.calls.length).toBe(1); + const firstArg = mockRequest.post.mock.calls[0][0]; + expect(firstArg.form.client_id).toBe('id'); + expect(firstArg.form.client_secret).toBe('secret'); + expect(firstArg.form.redirect_uri).toBe('redirectUri'); + }); + + test('uses config values if not set in data', () => { + const config = { + clientId: 'id', + clientSecret: 'secret', + redirectUri: 'redirectUri' + }; + const cb = jest.fn(); + + // this seems to be an API inconsistency: + // callAPIWithoutToken uses bot.config, but callAPI uses that passed config + mockBot.config = config; + + instance = slackWebApi(mockBot, {}); + instance.callAPIWithoutToken('some.method', {}, cb); + + expect(mockRequest.post.mock.calls.length).toBe(1); + const firstArg = mockRequest.post.mock.calls[0][0]; + expect(firstArg.form.client_id).toBe('id'); + expect(firstArg.form.client_secret).toBe('secret'); + expect(firstArg.form.redirect_uri).toBe('redirectUri'); + }); +}); + +describe('postForm', () => { + + ['callAPI', 'callAPIWithoutToken'].forEach((methodName) => { + let method; + let cb; + + beforeEach(() => { + const instance = slackWebApi(mockBot, {}); + method = instance[methodName]; + cb = jest.fn(); + }); + + test(`${methodName}: handles success`, () => { + method('some.action', 'data', cb); + expect(mockRequest.post).toHaveBeenCalledTimes(1); + const firstArg = mockRequest.post.mock.calls[0][0]; + + // do some thorough assertions here for a baseline + expect(firstArg.url).toMatch(/some.action$/); + expect(firstArg.form).toBe('data'); + expect(firstArg.formData).toBeUndefined(); + expect(firstArg.headers).toEqual({ 'User-Agent': 'jesting' }); + expect(cb).toHaveBeenCalledWith(null, { ok: true }); + }); + + test(`${methodName}: defaults callback`, () => { + method('some.action', 'data'); + expect(mockRequest.post).toHaveBeenCalledTimes(1); + }); + + test(`${methodName}: handles request lib error`, () => { + const error = new Error('WHOOPS!'); + mockRequest.post.mockImplementation((params, callback) => { + callback(error, null, null); + }); + + method('some.action', 'data', cb); + + expect(mockRequest.post).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(error); + }); + + test(`${methodName}: handles 429 response code`, () => { + mockRequest.post.mockImplementation((params, callback) => { + callback(null, { statusCode: 429 }, null); + }); + + method('some.action', 'data', cb); + + expect(mockRequest.post).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledTimes(1); + const firstArg = cb.mock.calls[0][0]; + expect(firstArg.message).toBe('Rate limit exceeded'); + }); + + test(`${methodName}: handles other response codes`, () => { + mockRequest.post.mockImplementation((params, callback) => { + callback(null, { statusCode: 400 }, null); + }); + + method('some.action', 'data', cb); + + expect(mockRequest.post).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledTimes(1); + const firstArg = cb.mock.calls[0][0]; + expect(firstArg.message).toBe('Invalid response'); + }); + + test(`${methodName}: handles error parsing body`, () => { + mockRequest.post.mockImplementation((params, callback) => { + callback(null, { statusCode: 200 }, '{'); + }); + + method('some.action', 'data', cb); + + expect(mockRequest.post).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledTimes(1); + const firstArg = cb.mock.calls[0][0]; + expect(firstArg).toBeInstanceOf(Error); + }); + + test(`${methodName}: handles ok.false response`, () => { + mockRequest.post.mockImplementation((params, callback) => { + callback(null, { statusCode: 200 }, '{ "ok": false, "error": "not ok"}'); + }); + + method('some.action', 'data', cb); + + expect(mockRequest.post).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith('not ok', { ok: false, error: 'not ok' }); + }); + }); +}); + +describe('api methods', () => { + let instance; + let cb; + + beforeEach(() => { + instance = slackWebApi(mockBot, {}); + cb = jest.fn(); + jest.spyOn(instance, 'callAPI'); + instance.callAPI.mockImplementation(() => { }); + }); + + afterEach(() => { + if (jest.isMockFunction(JSON.stringify)) { + JSON.stringify.mockRestore(); + } + instance.callAPI.mockRestore(); + }); + + test('spot check api methods ', () => { + // testing for all methods seems wasteful, but let's confirm the methods got built correctly and test the following scenarios + // two levels + expect(instance.auth).toBeDefined(); + expect(instance.auth.test).toBeDefined(); + + instance.auth.test('options', 'cb'); + const firstCallArgs = instance.callAPI.mock.calls[0]; + expect(firstCallArgs).toEqual(['auth.test', 'options', 'cb']); + + // three levels + expect(instance.users).toBeDefined(); + expect(instance.users.profile).toBeDefined(); + expect(instance.users.profile.get).toBeDefined(); + + instance.users.profile.get('options', 'cb'); + const secondCallArgs = instance.callAPI.mock.calls[1]; + expect(secondCallArgs).toEqual(['users.profile.get', 'options', 'cb']); + }); + + describe('special cases', () => { + + test('chat.postMessage stringifies attachments', () => { + instance.chat.postMessage({ attachments: [] }, cb); + expect(instance.callAPI).toHaveBeenCalledWith('chat.postMessage', { attachments: '[]' }, cb); + }); + + test('chat.postMessage handles attachments as Strings', () => { + jest.spyOn(JSON, 'stringify'); + instance.chat.postMessage({ attachments: 'string' }, cb); + expect(instance.callAPI).toHaveBeenCalledWith('chat.postMessage', { attachments: 'string' }, cb); + expect(JSON.stringify).not.toHaveBeenCalled(); + }); + + test('chat.postMessage handles attachments stringification errors', () => { + const error = new Error('WHOOPSIE'); + jest.spyOn(JSON, 'stringify').mockImplementation(() => { throw error; }); + instance.chat.postMessage({ attachments: [] }, cb); + expect(instance.callAPI).toHaveBeenCalledWith('chat.postMessage', {}, cb); + expect(JSON.stringify).toHaveBeenCalled(); + }); + + test('chat.update stringifies attachments', () => { + instance.chat.update({ attachments: [] }, cb); + expect(instance.callAPI).toHaveBeenCalledWith('chat.update', { attachments: '[]' }, cb); + }); + + test('chat.update handles attachments as Strings', () => { + jest.spyOn(JSON, 'stringify'); + instance.chat.update({ attachments: 'string' }, cb); + expect(instance.callAPI).toHaveBeenCalledWith('chat.update', { attachments: 'string' }, cb); + expect(JSON.stringify).not.toHaveBeenCalled(); + }); + + test('chat.postMessage handles attachments stringification errors', () => { + const error = new Error('WHOOPSIE'); + jest.spyOn(JSON, 'stringify').mockImplementation(() => { throw error; }); + instance.chat.update({ attachments: [] }, cb); + expect(instance.callAPI).toHaveBeenCalledWith('chat.update', {}, cb); + expect(JSON.stringify).toHaveBeenCalled(); + }); + + test('files.upload should not use multipart if file is false', () => { + const options = { file: false, token: 'abc123' }; + instance.files.upload(options, cb); + expect(instance.callAPI).toHaveBeenCalledWith('files.upload', options, cb, false); + }); + + test('files.upload should use multipart if file is true', () => { + const options = { file: true, token: 'abc123' }; + instance.files.upload(options, cb); + expect(instance.callAPI).toHaveBeenCalledWith('files.upload', options, cb, true); + }); + }); +}); diff --git a/changelog.md b/changelog.md new file mode 100644 index 000000000..4bb30f564 --- /dev/null +++ b/changelog.md @@ -0,0 +1,420 @@ +# Change Log + +[View the official Botkit roadmap](https://github.com/howdyai/botkit/projects/7) for upcoming changes and features. + +[Want to contribute? Read our guide!](https://github.com/howdyai/botkit/blob/master/CONTRIBUTING.md) + +## 0.6.0 + +This version features some BIG CHANGES! + +**New platform support:** + +[Microsoft Teams](docs/readme-teams.md) is now officially supported with its own connector, including new features in [Botkit Studio](https://studio.botkit.ai) like authoring of Teams-specific attachments, an app package builder and configuration manager, and [a new starter kit](https://github.com/howdyai/botkit-starter-teams). + +[Read the full docs for Botkit for Microsoft Teams](docs/readme-teams.md) + +**Major changes to message handling code:** + +[Introducing the Botkit Message Pipeline](docs/readme-pipeline.md), a formalized process for Botkit's handling of incoming and outgoing messages. All of the platform connectors have been refactored to use this new series of middleware functions, and are now implemented in a uniform way. + +Developers who make use of Botkit's middleware system should [take time to read this update](docs/readme-pipeline.md). Most current middleware functions will continue to work as expected, but mild changes may be desirable to update these functions to use Botkit's latest features. + +In addition, developers who use third party middleware plugins should carefully retest their applications after upgrading to version 0.6, as these plugins may need to be updated for full compatibility. + +**Upgrade Guide:** + +This version of Botkit deprecates the `receive_via_postback` and `interactive_replies` options +that caused button clicks to be treated as typed messages. These and other small changes to the way Botkit emits events may require minor updates to some Botkit apps. + +[Upgrading from Botkit 0.5 or lower? Read this guide!](docs/howto/upgrade_05to06.md) + + +## 0.5.8 + +Slack: Support for sending ephemeral messages with `bot.whisper()` and `bot.sendEphemeral()`. In addition, any message with `message.ephemeral` set to true will be sent with `bot.sendEphemeral()` automatically. [Read documentation here.](docs/readme-slack.md#ephemeral-messages) Thanks to [@jonchurch](https://github.com/howdyai/botkit/pull/958) + +Slack: Add support for `bot.api.files.sharedPublicURL()` method. Thanks to [@GitTristan](https://github.com/howdyai/botkit/pull/912) + +Facebook: Support for using [message tags](https://developers.facebook.com/docs/messenger-platform/message-tags). [Read documentation here.](docs/readme-facebook.md#message-tags) Thanks to [@ouadie-lahdioui](https://github.com/howdyai/botkit/pull/960) + +Facebook: Support for using Facebook's new built-in NLP tools. [Read documentation here.](docs/readme-facebook.md#built-in-nlp) Thanks to [@ouadie-lahdioui](https://github.com/howdyai/botkit/pull/943) for this one too!! + + +Twilio SMS: Add support for sending MMS messages (file attachments) via Twilio. [Read documentation here.](docs/readme-twiliosms.md#sending-media-attachments-mms) Thanks to [@krismuniz](https://github.com/howdyai/botkit/pull/951)! + +Cisco Spark: Emit a console warning when a bot receives messages from outside the allowed domain list. Thanks to [@MathRobin](https://github.com/howdyai/botkit/pull/918)! + +New: Typescript declarations! Thanks to [@uny and @naktibalda](https://github.com/howdyai/botkit/pull/953) for their work on this. + + + +## 0.5.7 + +Lock in ciscospark dependency at version 1.8.0 until further notice due to breaking changes in more recent versions. + +## 0.5.6 + +Fix for Botkit Studio-powered bots: Facebook attachments can now be added without buttons + +Fix for Cisco Spark: Bot mentions will now reliably be pruned from message, regardless of what client originated the message + +Fix for Cisco Spark: startPrivateConversationWithPersonID has been fixed. + +## 0.5.5 + +*Introducing Botkit for SMS!* Botkit bots can now send and receive messages using Twilio's Programmable SMS API! +Huge thanks to @krismuniz who spearheaded this effort! [Read all about Twilio SMS here](docs/readme-twiliosms.md) + +*New unit tests* have been added, thanks to the ongoing efforts of @colestrode, @amplicity and others. +This release includes coverage of the Botkit core library and the Slack API library. +This is an [ongoing effort](https://github.com/howdyai/botkit/projects/3), and we encourage interested developers to get involved! + +Add missing error callback to catch Slack condition where incoming messages do not match a team in the database. +[PR #887](https://github.com/howdyai/botkit/pull/887) thanks to @alecl! + +Add support for Facebook attachment upload api [PR #899](https://github.com/howdyai/botkit/pull/899) thanks @ouadie-lahdioui! +Read docs about this feature [here](docs/readme-facebook.md#attachment-upload-api) + +Fixed issue with Slack message menus. [PR #769](https://github.com/howdyai/botkit/pull/769) + +Fixed confusing parameter in JSON storage system. `delete()` methods now expect object id as first parameter. [PR #854](https://github.com/howdyai/botkit/pull/854) thanks to @mehamasum! + +All example bot scripts have been moved into the [examples/](examples/) folder. Thanks @colestrode! + +Fixes an instance where Botkit was not automatically responding to incoming webhooks from Cisco with a 200 status. [PR #843](https://github.com/howdyai/botkit/pull/843) + +Updated dependencies to latest: twilio, ciscospark, https-proxy-agent, promise + +## 0.5.4 + +Fix for [#806](https://github.com/howdyai/botkit/issues/806) - new version of websocket didn't play nice with Slack's message servers + +Support for Slack's new [rtm.connect method](https://api.slack.com/methods/rtm.connect). + +Use rtm.connect instead of rtm.start when connecting an RTM bot to Slack. This should performance during connections. + +## 0.5.3 + +Add a new [readme file](readme.md) and moved other documentation into `docs/` folder. + +Update all dependencies to their most recent versions + +Change behavior of conversation timeouts. [New behavior is documented here.](docs/readme.md#handling-conversation-timeouts) + +Support for Facebook Messenger's new "Home URL" setting [PR #793](https://github.com/howdyai/botkit/pull/793) +[New features documented here.](https://github.com/howdyai/botkit/blob/master/docs/readme-facebook.md#controllerapimessenger_profilehome_url) + +Support for including parameters in Facebook codes. [PR #790](https://github.com/howdyai/botkit/pull/790) +[Docs here.](https://github.com/howdyai/botkit/blob/master/docs/readme-facebook.md#messenger-code-api) + +Support for Facebook's new "target audience" APIs [PR #798](https://github.com/howdyai/botkit/pull/798) + +Support for additional Slack user APIs, including 'user.profile.set' and 'user.profile.get'. [PR #780](https://github.com/howdyai/botkit/pull/780) + +Add support for `createPrivateConversation()` in Slack bots [PR #586](https://github.com/howdyai/botkit/pull/586) + +*beforeThread Hooks:* + +These new hooks allow developers to execute custom functionality as a conversation transitions from one thread to another. +This enables asynchronous operations like database and API calls to be made mid-conversation, and can be used to add additional +template variables (using `convo.setVar()`), or change the direction of the conversation (using `convo.gotoThread()`). + +Add `convo.beforeThread()`, a plugin hook that fires before a conversation thread begins. [Docs](docs/readme.md#convobeforethread) + +Add `controller.studio.beforeThread()`, a plugin hook that fires before a Botkit Studio-powered conversation thread begins. [Docs](docs/readme-studio.md#controllerstudiobeforethread) + + +## 0.5.2 + +*Changes for Slack:* + +Add support for Slack's new `chat.unfurl` method for use with [App Unfurls](https://api.slack.com/docs/message-link-unfurling) + +Add additional Slack's team API methods [PR #677](https://github.com/howdyai/botkit/pull/677) + +Botkit will now store the value of the state parameter used in the oauth flow in the team's record [PR #657](https://github.com/howdyai/botkit/pull/657) + +Fixes slash commands when using internal webserver [PR #699](https://github.com/howdyai/botkit/pull/699) + +Add error logging for say and spawn.run [PR #691](https://github.com/howdyai/botkit/pull/691) + +*Changes for Facebook Messenger:* + +Updates to Facebook's Messenger Profile APIs (previously thread settings APIs) [PR #690](https://github.com/howdyai/botkit/pull/690) + +Add ability to retrieve Messenger Code image [PR #689](https://github.com/howdyai/botkit/pull/689) + +Add support for Facebook's domain whitelisting API [PR #573](https://github.com/howdyai/botkit/pull/573) + +Add tests for handleWebhookPayload in Facebook bot flow [PR #678](https://github.com/howdyai/botkit/pull/678) + +Add Facebook account linking support [PR #578](https://github.com/howdyai/botkit/pull/578) + +Add ability to customize api url for Facebook [PR #576](https://github.com/howdyai/botkit/pull/567) + +*Changes to Botkit Core:* + +Add "done" and "exit" as a utterances for "quit" [PR #498](https://github.com/howdyai/botkit/pull/498) + +*Thanks* + +Thanks to @jhsu @davidwneary @mbensch @alecl @ouadie-lahdioui @agamrafaeli @katsgeorgeek @jfairley + + +## 0.5.1 + +Fixes for Cisco Spark: + +Allow port number to be included in public_address + +Fix to issue when using retrieveFile() + +Fixes for Slack: + +Add support for `channel.replies` API + +Fixes for Facebook: + +Add support for [Facebook account linking](https://github.com/howdyai/botkit/pull/645) + +## 0.5.0 + +Add support for bots on [Cisco Spark](http://developer.ciscospark.com)! For information about getting started, and to learn about new Botkit features that are specific to this platform, [check out our new Cisco Spark readme. ](readme-ciscospark.md) + +Botkit will now send a user agent header on all requests to the Slack API + +When building for Slack, developers may now override the root URL of the API by specifying `api_root` in the configuration in order to use mocked testing endpoints or use the Slack API through a proxy. + +## 0.4.10 + +Add support for [Slack Enterprise Grid](https://slack.com/enterprise), for more information [read](https://blog.howdy.ai/beam-us-up-botkit-in-the-enterprise-e6133e0cbdf3#.o3md9lw29) + +Add Support for Slack's new thread system, including: + +[bot.replyInThread()](readme-slack.md#botreplyinthread) to create a threaded reply + +[bot.startConversationInThread()](readme-slack.md#botstartconversationinthread) to create and immediately start a conversation in a thread + +[bot.createConversationInThread()](readme-slack.md#botcreateconversationinthread) to create a conversation in a thread + +Add new `heard` middleware endpoint, which fires _after_ a pattern has been matched, but before the handler function is called. This allows developers to enrich messages with NLP tools or other plugins, but do so only when the original message matches specific criteria. + +Add new `capture` middleware endpoint, which fires _after_ a user responds to a `convo.ask` question but _before_ the related handler function is called. This allows developers to change the value that is captured, or capture additional values such as entities returned by an NLP plugin. + + +## 0.4.9 + +`controller.version()` will now report the currently installed version of Botkit. + +Change to how quick replies are rendered via Botkit Studio's API + +## 0.4.7 + +Add support for Facebook Messenger "location" quick replies [PR #568](https://github.com/howdyai/botkit/pull/568) + +Add support for Slack's new users.setPresence API [PR #562](https://github.com/howdyai/botkit/pull/562) + +Add support for Slack's new reminders API [PR #580](https://github.com/howdyai/botkit/pull/580) + + + +## 0.4.6 + +Change to controller.studio.runTrigger: Will now resolve promise regardless of whether a trigger was matched + +## 0.4.5 + +Bug fix: Fix detection of Slackbot interactive callbacks + +## 0.4.4 + +Changes: + +Add referral field to `facebook_postback` events, if set [PR #552](https://github.com/howdyai/botkit/pull/553) + +Refactor handling of incoming webhooks from Slack and Facebook in order to make it easier for developers to create custom endpoints and/or integrate Botkit into existing Express applications. + +Add `controller.handleWebhookPayload()` to process a raw webhook payload and ingest it into Botkit + +Make stale connection detection configurable [PR #505](https://github.com/howdyai/botkit/pull/505) + +DDOS Vulnerability Fix - Secure Facebook Webhook [PR #555](https://github.com/howdyai/botkit/pull/555) + + +Bug fixes: + +Fix an issue where a custom redirect_uri would be rejected by Slack's oauth verification + +Fix bot_channel_join and bot_group_join with Slack Events API [PR #514](https://github.com/howdyai/botkit/pull/514) + +Fix path to static content directory [PR #546](https://github.com/howdyai/botkit/pull/546) + +`retry` and `send_via_rtm` options are now properly associated with the controller object. + +Fix some issues pertaining to typing indicators and the slack RTM [PR #533](https://github.com/howdyai/botkit/pull/533) + + + + +## 0.4.3 + +Adds [convo.transitionTo()](readme.md#convotransitionto), a new feature for creating smoother transitions between conversation threads + +Adds support for new Facebook Messenger [thread settings APIs](readme-facebook.md#thread-settings-api) +which enable developers to set and manage the 'getting started' screen and persistent menus. + +Adds support for Facebook Messenger attachment in [Botkit Studio](https://studio.botkit.ai) + +Adds a check to ensure messages are properly received by Facebook and Slack before proceeding to next message in a conversation. + +Adds optional `require_delivery` option for Facebook and Slack bots which tells Botkit to wait to receive a delivery confirmation from the platform before sending further messages. [Slack info](readme-slack.md#require-delivery-confirmation-for-rtm-messages) [Facebook info](readme-facebook.md#require-delivery-confirmation) + +Change: Receiving `facebook_postback` events as normal "spoken" messages now requires the `{receive_via_postback:true}` option be set on the controller. [Read more](readme-facebook.md#receive-postback-button-clicks-as-typed-messages) + +## 0.4.2 + +Support for Slack's [Events API](https://api.slack.com/events-api) is here, thanks to the Botkit contributor community. [Read documentation here](https://github.com/howdyai/botkit/blob/master/readme-slack.md#events-api) + +Bug fix: + +Fixes an issue with setting the default IP address for the Express server introduced in 0.4.1 + +## 0.4.1 + +This release contains many small fixes and updates to keep up with the ever changing platforms! + +BIG CHANGES: + +Slack bots will now send messages via the Web API instead of the RTM. This behavior can be changed by passing `send_via_rtm: true` to `controller.spawn()` + +Adds [ConsoleBot](lib/ConsoleBot.js) for creating bots that work on the command line + +Adds a new [Middleware Readme](readme-middlewares.md) for documenting the existing middleware modules + +Adds an example for using quick replies in the [Facebook Example Bot](examples/facebook_bot.js) + +Adds additional fields to Facebook messages to specify if they are `facebook_postback`s or normal messages. + +Adds optional `hostname` field to constructor functions to bind Express to a specific IP. + +Fixes for Slack's files.upload API + +Merge in numerous pull requests from the community: +[PR #461](https://github.com/howdyai/botkit/pull/461) +[PR #465](https://github.com/howdyai/botkit/pull/465) +[PR #466](https://github.com/howdyai/botkit/pull/466) +[PR #469](https://github.com/howdyai/botkit/pull/469) +[PR #472](https://github.com/howdyai/botkit/pull/472) +[PR #474](https://github.com/howdyai/botkit/pull/474) +[PR #434](https://github.com/howdyai/botkit/pull/434) +[PR #435](https://github.com/howdyai/botkit/pull/435) +[PR #440](https://github.com/howdyai/botkit/pull/440) +[PR #441](https://github.com/howdyai/botkit/pull/441) +[PR #443](https://github.com/howdyai/botkit/pull/443) +[PR #446](https://github.com/howdyai/botkit/pull/446) +[PR #448](https://github.com/howdyai/botkit/pull/448) + + +## 0.4 + +Add support for Botkit Studio APIs. [More Info](readme-studio.md) + +Substantially expanded the documentation regarding Botkit's [conversation thread system](readme.md#conversation-threads). + +Add support for Microsoft Bot Framework. The [Microsoft Bot Framework](https://botframework.com) makes it easy to create a single bot that can run across a variety of messaging channels including [Skype](https://skype.com), [Group.me](https://groupme.com), [Facebook Messenger](https://messenger.com), [Slack](https://slack.com), +[Telegram](https://telegram.org/), [Kik](https://www.kik.com/), [SMS](https://www.twilio.com/), and [email](https://microsoft.office.com). [More Info](readme-botframework.md) + +Updates to Facebook Messenger connector to support features like message echoes, read receipts, and quick replies. + +Merged numerous pull requests from the community: +[PR #358](https://github.com/howdyai/botkit/pull/358) +[PR #361](https://github.com/howdyai/botkit/pull/361) +[PR #353](https://github.com/howdyai/botkit/pull/353) +[PR #363](https://github.com/howdyai/botkit/pull/363) +[PR #320](https://github.com/howdyai/botkit/pull/320) +[PR #319](https://github.com/howdyai/botkit/pull/319) +[PR #317](https://github.com/howdyai/botkit/pull/317) +[PR #299](https://github.com/howdyai/botkit/pull/299) +[PR #298](https://github.com/howdyai/botkit/pull/298) +[PR #293](https://github.com/howdyai/botkit/pull/293) +[PR #256](https://github.com/howdyai/botkit/pull/256) +[PR #403](https://github.com/howdyai/botkit/pull/403) +[PR #392](https://github.com/howdyai/botkit/pull/392) + + + +In order to learn about and better serve our user community, Botkit now sends anonymous usage stats to stats.botkit.ai. To learn about opting out of stats collection, [read here](readme.md#opt-out-of-stats). + +## 0.2.2 + +Add support for Slack Interactive Messages. + +Add example of Slack button application that provides a bot that uses interactive messages. + +New functionality in Slack bot: Botkit will track spawned Slack bots and route incoming webhooks to pre-existing RTM bots. This enables RTM bots to reply to interactive messages and slash commands. + +## 0.2.1 + +Improves Slack RTM reconnects thanks to @selfcontained [PR #274](https://github.com/howdyai/botkit/pull/274) + +## 0.2 + +Adds support for Twilio IP Messenging bots + +Add example bot: twilio_ipm_bot.js + +## 0.1.2 + +*Slack changes:* + +Adds authentication of incoming Slack webhooks if token specified. [More info](readme_slack.md#securing-outgoing-webhooks-and-slash-commands) [Thanks to [@sgud](https://github.com/howdyai/botkit/pull/167)] + +Improves support for direct_mentions of bots in Slack (Merged [PR #189](https://github.com/howdyai/botkit/pull/189)) + +Make the oauth identity available to the user of the OAuth endpoint via `req.identity` (Merged [PR #174](https://github.com/howdyai/botkit/pull/174)) + +Fix issue where single team apps had a hard time receiving slash command events without funky workaround. (closes [Issue #108](https://github.com/howdyai/botkit/issues/108)) + +Add [team_slashcommand.js](/examples/slack/team_slashcommand.js) and [team_outgoingwebhook.js](/examples/slack/team_outgoingwebhook.js) to the examples folder. + + + +*Facebook changes:* + +The `attachment` field may now be used by Facebook bots within a conversation for both convo.say and convo.ask. In addition, postback messages can now be received as the answer to a convo.ask in addition to triggering their own facebook_postback event. [Thanks to [@crummy](https://github.com/howdyai/botkit/pull/220) and [@tkornblit](https://github.com/howdyai/botkit/pull/208)] + +Include attachments field in incoming Facebook messages (Merged [PR #231](https://github.com/howdyai/botkit/pull/231)) + +Adds built-in support for opening a localtunnel.me tunnel to expose Facebook webhook endpoint while developing locally. (Merged [PR #234](https://github.com/howdyai/botkit/pull/234)) + +## 0.1.1 + +Fix issue with over-zealous try/catch in Slack_web_api.js + +## 0.1.0 + +Adds support for Facebook Messenger bots. + +Rename example bot: bot.js became slack_bot.js + +Add example bot: facebook_bot.js + +## 0.0.15 + +Changes conversation.ask to use the same pattern matching function as +is used in `hears()` + +Adds `controller.changeEars()` Developers can now globally change the +way Botkit matches patterns. + + +## 0.0.14 + +Add new middleware hooks. Developers can now change affect a message +as it is received or sent, and can also change the way Botkit matches +patterns in the `hears()` handler. + +## 0.0.~ + +Next time I promise to start a change log at v0.0.0 diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 000000000..c4bb07357 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,28 @@ + +## Included Examples + +These examples are included in the Botkit [Github repo](https://github.com/howdyai/botkit). + +[slack_bot.js](https://github.com/howdyai/botkit/blob/master/examples/slack_bot.js) An example bot that can be connected to your team. Useful as a basis for creating your first bot! + +[spark_bot.js](https://github.com/howdyai/botkit/blob/master/examples/spark_bot.js) An example bot that can be connected to Cisco Spark. Useful as a basis for creating your first bot! + +[facebook_bot.js](https://github.com/howdyai/botkit/blob/master/examples/facebook_bot.js) An example bot that can be connected to your Facebook page. Useful as a basis for creating your first bot! + +[twilio_sms_bot.js](https://github.com/howdyai/botkit/blob/master/examples/twilio_sms_bot.js) An example bot that can be connected to your Twilio SMS service. Useful as a basis for creating your first bot! + +[twilio_ipm_bot.js](https://github.com/howdyai/botkit/blob/master/examples/twilio_ipm_bot.js) An example bot that can be connected to your Twilio IP Messaging client. Useful as a basis for creating your first bot! + +[botframework_bot.js](https://github.com/howdyai/botkit/blob/master/examples/botframework_bot.js) An example bot that can be connected to the Microsoft Bot Framework network. Useful as a basis for creating your first bot! + +[examples/demo_bot.js](https://github.com/howdyai/botkit/blob/master/examples/slack/demo_bot.js) another example bot that uses different ways to send and receive messages. + +[examples/team_outgoingwebhook.js](https://github.com/howdyai/botkit/blob/master/examples/slack/team_outgoingwebhook.js) an example of a Botkit app that receives and responds to outgoing webhooks from a single team. + +[examples/team_slashcommand.js](https://github.com/howdyai/botkit/blob/master/examples/slack/team_slashcommand.js) an example of a Botkit app that receives slash commands from a single team. + +[examples/slackbutton_bot.js](https://github.com/howdyai/botkit/blob/master/examples/slack/slackbutton_bot.js) an example of using the Slack Button to offer a bot integration. + +[examples/slackbutton_incomingwebhooks.js](https://github.com/howdyai/botkit/blob/master/examples/slack/slackbutton_incomingwebhooks.js) an example of using the Slack Button to offer an incoming webhook integration. This example also includes a simple form which allows you to broadcast a message to any team who adds the integration. + +[example/sentiment_analysis.js](https://github.com/howdyai/botkit/blob/master/examples/slack/sentiment_analysis.js) a simple example of a chatbot using sentiment analysis. Keeps a running score of each user based on positive and negative keywords. Messages and thresholds can be configured. diff --git a/docs/functions.md b/docs/functions.md new file mode 100644 index 000000000..8bb3ef233 --- /dev/null +++ b/docs/functions.md @@ -0,0 +1,90 @@ +# Botkit Controller + +controller.middleware.* + +controller.changeEars + +controller.hears + +controller.on + +controller.trigger + +controller.spawn + +controller.receiveMessage + +controller.version + +controller.log + +... and then platform specific features too + + + +# Bot Object + +bot.reply + +bot.say + +bot.startConversation + +bot.createConversation + +... and lots of specific featres added by platforms + + + + +# Conversation Object + +convo.setVar + +convo.activate + +convo.isActive + +convo.deactivate + +convo.say + +convo.sayFirst + +convo.on + +convo.trigger + +convo.next + +convo.repeat + +convo.silentRepeat + +convo.addQuestion + +convo.ask + +convo.setTimeout + +convo.onTimeout + +convo.hasThread + +convo.transitionTo + +convo.gotoThread + +convo.getResponses + +convo.getResponsesAsArray + +convo.extractResponses + +convo.extractResponse + +convo.stop + +convo.successful + +convo.cloneMessage diff --git a/docs/glitch.png b/docs/glitch.png new file mode 100644 index 000000000..d5ea397fd Binary files /dev/null and b/docs/glitch.png differ diff --git a/docs/hosting-your-own-bot.md b/docs/hosting-your-own-bot.md new file mode 100644 index 000000000..672a7bb5c --- /dev/null +++ b/docs/hosting-your-own-bot.md @@ -0,0 +1,25 @@ +# How To Host Your Botkit App + +This is a placeholder for forthcoming docs. Sorry! + +## the gist + +Get a server somewhere. [Digital Ocean](https://m.do.co/c/c8d2cb189d36) is a good choice, but Botkit runs in Azure, AWS, Heroku and others. + +Get your process up and running on some port - 3000 by default. + +Set up letsencrypt or some other SSL solution. Most messaging platforms now require that your endpoint be served over SSL. + +Set up nginx to serve your ssl-enabled website and proxy traffic to the bot application on port 3000. + +Voila! You have a ssl-enabled bot. + +### Optional next steps: +* Only accept requests over https. +* Block access to port 3000 + + +## related topics + +* scaling up with multiple bot processes +* botkit and continuous integration diff --git a/docs/howto/build_connector.md b/docs/howto/build_connector.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/howto/build_middleware.md b/docs/howto/build_middleware.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/howto/upgrade_05to06.md b/docs/howto/upgrade_05to06.md new file mode 100644 index 000000000..4fe559857 --- /dev/null +++ b/docs/howto/upgrade_05to06.md @@ -0,0 +1,74 @@ +# Upgrading Botkit 0.5 to 0.6 + +With the introduction of the [Botkit Message Pipeline](../readme-pipeline.md), +a few things changed about the way message events are +emitted that may impact developers who built their +bot using an earlier version of Botkit. + +Though we've made fairly substantial under-the-hood changes to Botkit in this version, +most bots should continue to function without requiring any changes. +However, as a precaution, we urge developers to carefully test their bot with the new version of Botkit before going live. + +## How to upgrade Botkit + +Inside your Botkit application's root folder, run the command: + +``` +npm update --save botkit +``` + +This will cause your version of Botkit to be updated to the latest +available version (0.6.0), and your package.json file will be modified. + +Alternately, you can edit your package.json file by hand, changing the +version option for Botkit to '^0.6'. + +## Does my bot require any changes? + +Facebook Bots that use the `receive_via_postback` option, and Slack bots that use the `interactive_replies` option may require small updates. + +In the past, it was sometimes possible for Botkit to emit more than one event +per incoming message - in particular, when handling button click events +as typed user text. This double-event behavior was enabled by configuration +switches - one for Facebook, and one for Slack - which are now deprecated. + +As of version 0.6, incoming messages will only ever emit a single event. +These button click events can will now have the button payload value included as the `message.text` property, and can thus be treated like other types of user messages. + +## For Facebook Messenger Bots: + +The `receive_via_postback` option is no longer needed to receive button clicks as typed messages in conversations. They will now be captured automatically. + +In addition, developers can now include `facebook_postback` in the list +of events when specifying `hears()` patterns. This will allow button clicks +and other postback events to trigger heard patterns. + +Developers who previously used `receive_via_postback` may want to add `facebook_postback` to their hears() handlers to ensure no functionality is lost. + +``` +controller.hears('welcome','facebook_postback', function(bot, message) { + // respond when a user clicks a button with the payload value "welcome" +}); +``` + +## For Slack Bots: + +The `interactive_replies` is no longer needed to receive button clicks as typed messages in conversations. They will now be captured automatically. + +In addition, developers can now include `interactive_message_callback` in the list of events when specifying `hears()` patterns. This will allow button clicks to trigger heard patterns. + +Developers who previously used `interactive_replies` may want to add `interactive_message_callback` to their hears() handlers to ensure no functionality is lost. + +``` +controller.hears('welcome','interactive_message_callback', function(bot, message) { + // respond when a user clicks a button with the value set to "welcome" +}); +``` + +## Slack Starter Kit Fix + +In previous versions of the Slack Starter Kit, we included a clever skill which allowed buttons to be treated as typed text if the `name` of the button was set to "say." In addition to re-emitting the event as a type message, this skill also caused the message in Slack to be updated. + +If you use this skill and want to upgrade to the latest version of Botkit, you should replace the `skills/interactive_messages.js` file in your project with [this updated version](https://github.com/howdyai/botkit-starter-slack/blob/master/skills/interactive_messages.js). + +This new version now defines a `receive middleware` that looks for `interactive_message_callback` events and performs update to the message. It no longer needs to re-emit the message, as after the middleware completes, the message will pass naturally to any ongoing conversation or `hears` handler. diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 000000000..2c429cedd --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,55 @@ +### Writing your own logging module + +By default, your bot will log to the standard JavaScript `console` object +available in Node.js. This will synchronously print logging messages to stdout +of the running process. + +There may be some cases, such as remote debugging or rotating of large logs, +where you may want a more sophisticated logging solution. You can write your +own logging module that uses a third-party tool, like +[winston](https://github.com/winstonjs/winston) or +[Bristol](https://github.com/TomFrost/Bristol). Just create an object with a +`log` method. That method should take a severity level (such as `'error'` or +`'debug'`) as its first argument, and then any number of other arguments that +will be logged as messages. (Both Winston and Bristol create objects of this +description; it's a common interface.) + +Then, use it when you create your bot: +```javascript +var controller = Botkit.slackbot({ + logger: new winston.Logger({ + levels: winston.config.syslog.levels + transports: [ + new (winston.transports.Console)(), + new (winston.transports.File)({ filename: './bot.log' }) + ] + }) +}); +``` + + + +Note: with Winston, we must use the syslog.levels over the default or else some botkit log messages (like 'notice') will not be logged properly. + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/middleware.md b/docs/middleware.md new file mode 100644 index 000000000..3cb21dfc9 --- /dev/null +++ b/docs/middleware.md @@ -0,0 +1,203 @@ +## Middleware + +The functionality of Botkit can be extended using middleware +functions. These functions can plugin to the core bot running processes at +several useful places and make changes to both a bot's configuration and +the incoming or outgoing message. + +For information about existing middleware plugins, [see here](readme-middlewares.md) + +### Middleware Endpoints + +Botkit currently supports middleware insertion in the following places: + +* When receiving a message, before triggering any events +* When sending a message, before the message is sent to the API +* When hearing a message +* When matching patterns with `hears()`, after the pattern has been matched but before the handler function is called +* When capturing a users response to a `convo.ask()` question, after the user has answered, but before the value is stored or passed to the handler function + +Send and Receive middleware functions are added to Botkit using an Express-style "use" syntax. +Each function receives a bot parameter, a message parameter, and +a next function which must be called to continue processing the middleware stack. + +Hear middleware functions are passed in to the `controller.hears` function, +and override the built in regular expression matching. + +### Receive Middleware + +Receive middleware can be used to do things like preprocess the message +content using external natural language processing services like Wit.ai. +Additional information can be added to the message object for use down the chain. + +```javascript +controller.middleware.receive.use(function(bot, message, next) { + + // do something... + // message.extrainfo = 'foo'; + next(); + +}); +``` + + +### Send Middleware + +Send middleware can be used to do things like preprocess the message +content before it gets sent out to the messaging client. + +```javascript +controller.middleware.send.use(function(bot, message, next) { + + // do something useful... + if (message.intent == 'hi') { + message.text = 'Hello!!!'; + } + next(); + +}); +``` + + +### Hear Middleware + +Hear middleware can be used to change the way Botkit bots "hear" triggers. +It can be used to look for values in fields other than message.text, or use comparison methods other than regular expression matching. For example, a middleware function +could enable Botkit to "hear" intents added by an NLP classifier instead of string patterns. + +Hear middleware is enabled by passing a function into the `hears()` method on the Botkit controller. +When specified, the middleware function will be used instead of the built in regular expression match. + +These functions receive 2 parameters - `patterns` an array of patterns, and `message` the incoming +message. This function will be called _after_ any receive middlewares, so may use any additional +information that may have been added. A return value of `true` indicates the pattern has been +matched and the bot should respond. + +```javascript +// this example does a simple string match instead of using regular expressions +function custom_hear_middleware(patterns, message) { + + for (var p = 0; p < patterns.length; p++) { + if (patterns[p] == message.text) { + return true; + } + } + return false; +} + + +controller.hears(['hello'],'direct_message',custom_hear_middleware,function(bot, message) { + + bot.reply(message, 'I heard the EXACT string match for "hello"'); + +}); +``` + +It is possible to completely replace the built in regular expression match with +a middleware function by calling `controller.changeEars()`. This will replace the matching function used in `hears()` +as well as inside `convo.ask().` This would, for example, enable your bot to +hear only intents instead of strings. + +```javascript +controller.changeEars(function(patterns, message) { + + // ... do something + // return true or false +}); +``` + + +### Heard Middleware + +Heard middleware can be used to modify or enrich a message with additional information before it is handled by the callback function. +This can be useful for developers who want to use NLP tools, but want to limit the type and number of messages sent to be classified. +It is also useful for developers who want to mix internal application data (for example, user account information) into messages. + +Whereas the `receive middleware` will fire for every single incoming message of any type, the heard middleware only fires when a pattern has already been matched. + +Heard middleware functions fire anytime Botkit attempts to match a pre-defined pattern: when using the `hears()` feature, and also when using `convo.ask()` to capture user responses. + + +```javascript +controller.middleware.heard.use(function(bot, message, next) { + + // load internal user data and add it to the message + + mydb.users.find({id: message.user}, function(err, user_record) { + + // amend the message with a new field. + // this will now be available inside the normal handler function + message.internal_user = user_record; + + // call next or else execution will stall + next(); + + }); + +}); +``` + +### Capture Middleware + +As users respond to questions posed using `convo.ask()`, their answers will first be passed through any capture middleware endpoints. +The capture middleware can modify the message in any way, including changing the value that will be used to test pre-defined patterns +and that will ultimately be stored as the final user answer. + +This can be particularly useful when used in conjunction with a natural language processing API. NLP plugins like [IBM Watson](https://github.com/watson-developer-cloud/botkit-middleware) and [Microsoft LUIS](https://github.com/Stevenic/botkit-middleware-luis) typically provide 2 features: translation of raw user text into a pre-defined `intent`, and extraction of structured data from the raw string into `entities`. + +Another instance in which this is useful is when used in conjunction with buttons and quick replies that, in addition to displayed text may also carry a hidden payload value. Developers can use this middleware endpoint to capture the payload instead of the displayed text. + +The `capture` middleware endpoint allows developers to harness these values and capture them instead of or in addition to the raw user text. + +Please note that the signature of the `capture` middleware is slightly different than the other endpoints, as it includes a parameter for the conversation object: + +```javascript +controller.middleware.capture.use(function(bot, message, convo, next) { + + // user's raw response is in message.text + + // instead of capturing the raw response, let's capture the intent + if (message.intent) { + message.text = message.intent; + } + + // what if there is a hidden payload? let's use that instead + if (message.payload) { + message.text = message.payload; + } + + // what if there are entities too? we can use them as part of the conversation... + if (message.entities) { + for (var e = 0; e < message.entities.length; e++) { + convo.setVar(message.entities[e].name, message.entities[e].value); + } + } + + // always call next! + next(); + +}); +``` + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/provisioning/IMG/Slack-eventsenable.png b/docs/provisioning/IMG/Slack-eventsenable.png new file mode 100644 index 000000000..7453dde80 Binary files /dev/null and b/docs/provisioning/IMG/Slack-eventsenable.png differ diff --git a/docs/provisioning/IMG/botkit_icon.png b/docs/provisioning/IMG/botkit_icon.png new file mode 100644 index 000000000..abfd60078 Binary files /dev/null and b/docs/provisioning/IMG/botkit_icon.png differ diff --git a/docs/provisioning/IMG/cisco_add.png b/docs/provisioning/IMG/cisco_add.png new file mode 100644 index 000000000..9b82b879e Binary files /dev/null and b/docs/provisioning/IMG/cisco_add.png differ diff --git a/docs/provisioning/IMG/fb_app_perm.png b/docs/provisioning/IMG/fb_app_perm.png new file mode 100644 index 000000000..1f86c6af0 Binary files /dev/null and b/docs/provisioning/IMG/fb_app_perm.png differ diff --git a/docs/provisioning/IMG/fb_mess.png b/docs/provisioning/IMG/fb_mess.png new file mode 100644 index 000000000..12ee66509 Binary files /dev/null and b/docs/provisioning/IMG/fb_mess.png differ diff --git a/docs/provisioning/IMG/fb_new.png b/docs/provisioning/IMG/fb_new.png new file mode 100644 index 000000000..e60002e93 Binary files /dev/null and b/docs/provisioning/IMG/fb_new.png differ diff --git a/docs/provisioning/IMG/fb_tokengen.png b/docs/provisioning/IMG/fb_tokengen.png new file mode 100644 index 000000000..750805522 Binary files /dev/null and b/docs/provisioning/IMG/fb_tokengen.png differ diff --git a/docs/provisioning/IMG/fb_webhooks.png b/docs/provisioning/IMG/fb_webhooks.png new file mode 100644 index 000000000..85b75cf99 Binary files /dev/null and b/docs/provisioning/IMG/fb_webhooks.png differ diff --git a/docs/provisioning/IMG/fb_webhooks_com.png b/docs/provisioning/IMG/fb_webhooks_com.png new file mode 100644 index 000000000..a89a58dc4 Binary files /dev/null and b/docs/provisioning/IMG/fb_webhooks_com.png differ diff --git a/docs/provisioning/IMG/slack-botuser.png b/docs/provisioning/IMG/slack-botuser.png new file mode 100644 index 000000000..40c46592a Binary files /dev/null and b/docs/provisioning/IMG/slack-botuser.png differ diff --git a/docs/provisioning/IMG/slack-im.png b/docs/provisioning/IMG/slack-im.png new file mode 100644 index 000000000..d8006a5b9 Binary files /dev/null and b/docs/provisioning/IMG/slack-im.png differ diff --git a/docs/provisioning/IMG/slack-new.png b/docs/provisioning/IMG/slack-new.png new file mode 100644 index 000000000..87ad3827d Binary files /dev/null and b/docs/provisioning/IMG/slack-new.png differ diff --git a/docs/provisioning/IMG/slack_botevents.png b/docs/provisioning/IMG/slack_botevents.png new file mode 100644 index 000000000..3c3947943 Binary files /dev/null and b/docs/provisioning/IMG/slack_botevents.png differ diff --git a/docs/provisioning/IMG/slack_client_secret.png b/docs/provisioning/IMG/slack_client_secret.png new file mode 100644 index 000000000..52a5343fe Binary files /dev/null and b/docs/provisioning/IMG/slack_client_secret.png differ diff --git a/docs/provisioning/IMG/slack_oauth.png b/docs/provisioning/IMG/slack_oauth.png new file mode 100644 index 000000000..6d79d0009 Binary files /dev/null and b/docs/provisioning/IMG/slack_oauth.png differ diff --git a/docs/provisioning/cisco-spark.md b/docs/provisioning/cisco-spark.md new file mode 100644 index 000000000..3ac4f398b --- /dev/null +++ b/docs/provisioning/cisco-spark.md @@ -0,0 +1,44 @@ +# Configure Botkit and Cisco Spark + +Setting up a bot for Cisco Spark is one of the easiest experiences for bot developers! Follow these steps carefully to configure your bot. + +### 1. Install Botkit + +The easiest path to creating a new bot for Cisco Spark is through Botkit Studio. [Sign up for an account here](https://studio.botkit.ai/signup/). This method will provide a guided path to hosting, along with other useful tools for creating and managing your bot. + +For advanced users looking to run their own code, you will need to [install Botkit](../readme-ciscospark.md#getting-started) and run it before your bot can be configured with Cisco Spark. + +### 2. Create a new bot in the Cisco Developer portal + +Follow the instructions to create a new bot in the [Cisco Spark Developer Portal](https://developer.ciscospark.com/add-bot.html). + +![Add a bot](IMG/cisco_add.png) + +Take note of the bot username, you'll need it later. + +**Note about your icon**: Cisco requires you host an avatar for your bot before you can create it in their portal. This bot needs to be a 512x512px image icon hosted anywhere on the web. This can be changed later. + +You can copy and paste this URL for a Botkit icon you can use right away: + +https://raw.githubusercontent.com/howdyai/botkit-starter-ciscospark/master/public/default_icon.png + +### 3. Copy your access token + +Cisco will provide you an `access token` that is specific to your bot. Write this down, you won't be able to see this later (but you will be able revoke it and create a new one). + +### 4. Run your bot with variables set + + [Follow these instructions](../readme-ciscospark.md#getting-started) to run your bot locally, or by using a third-party service such as [Glitch](https://glitch.com) or [Heroku](https://heroku.com). + + You will need the following environment variables when running your bot: + + * `access_token` = Your token from Cisco Spark (**required**) + * `secret` = User-defined string to validate payloads (**required**) + * `public_address`= URL of your bot server (**required**) + * `studio_token`= [Botkit Studio](https://studio.botkit.ai) API token (optional) + +You should now be able to search your Cisco Spark team for the bot username you defined, and add it to your team! + +### Additional resources + +Read more about making bots for this platform in the [Cisco Developer Portal](https://developer.ciscospark.com/bots.html). diff --git a/docs/provisioning/facebook_messenger.md b/docs/provisioning/facebook_messenger.md new file mode 100644 index 000000000..09e9b2281 --- /dev/null +++ b/docs/provisioning/facebook_messenger.md @@ -0,0 +1,55 @@ +# Configure Botkit and Facebook Messenger + +Facebook is a constantly evolving platform, nominally you can find everything you [need to create a bot](https://developers.facebook.com/docs/messenger-platform/guides/quick-start) on their platform page, but that information is subject to change. + +The easiest path to creating a new bot for Facebook Messenger is through Botkit Studio. [Sign up for an account here](https://studio.botkit.ai/signup/). This method will provide a guided path to hosting, along with other useful tools for creating and managing your bot. + +For advanced users looking to run their own code, you will need to [install Botkit](https://github.com/howdyai/botkit-starter-facebook) and run it before your bot can be configured with Messenger. + +### 1. [Install Botkit](https://github.com/howdyai/botkit/blob/master/readme.md#start-with-botkit-studio) + +Once installed, you will need to do steps 2-4, and steps 5 in parallel. It helps to have your development enviroment and the Facebook for Developers page open at the same time. + +### 2. Create a Facebook App for Web + +Visit [Facebook for Developers page](https://developers.facebook.com/tools-and-support/) and create a new app. + +![Create your APP ID](IMG/fb_new.png) + +* Select a Messenger application + +![Create your app](IMG/fb_mess.png) + +### 3. Get a Page Access Token for your app +Scroll down to `Token Generation` + +![page access token](IMG/fb_tokengen.png) + +If you have not yet created your page yet, you can go ahead and do this now, or associate this new bot with an existing page. + +Copy this `Page Access Token`, you'll need it when running your bot. + +### 4. Setup webhooks +Click `Setup Webhooks` to link this application to your Botkit instance. + +![page access token](IMG/fb_webhooks.png) + +The callback url will be `https://YOURURL/facebook/receive`. This URL must be publically available, and SSL-secured. More information on this can be found in the next step. + +You will also need to define a `Verify Token` for your page subscription. This is a user-defined string that you will keep secret and pass in with your environment variables. + +### 5. Run your application + +Run your application with your environment variables set: + +* `page_token` - Your Page Access Token (**required**) +* `verify_token` - Your Verify Token (**required**) +* `studio_token` - Your [Botkit Studio](https://studio.botkit.ai/signup) Token (optional) + +If your application has been configured correctly, you will be able to talk to your bot through the page you specified in Step 3. Congratulations! + +### Additional resources +* [Botkit Facebook readme](https://github.com/howdyai/botkit/blob/master/docs/readme-facebook.md) +* [Botkit Facebook Starter Kit](https://github.com/howdyai/botkit-starter-facebook) +* [Messenger Platform Documentation](https://developers.facebook.com/products/messenger/) +* [Sign up for Botkit Studio](https://studio.botkit.ai/) diff --git a/docs/provisioning/readme.md b/docs/provisioning/readme.md new file mode 100644 index 000000000..cacc889f1 --- /dev/null +++ b/docs/provisioning/readme.md @@ -0,0 +1,36 @@ +## Provisioning Botkit +As Botkit adds more platforms, it becomes increasingly difficult to keep track of all the methods for provisioning your bot on the various platforms we support. + +To help Botkit developers, we are pulling out detailed provisioning documents for each platform and keeping them in this directory. Our hope is that this will make our docs more flexible to platform changes, and clearly show the fastest way to stand up your bot! + +## Platforms + +#### [Slack (Events API)](slack-events-api.md) + +#### [Facebook Messenger](facebook_messenger.md) + +#### [Cisco Spark](cisco-spark.md) + +#### [SMS from Twilio](twilio-sms.md) + + +## Documentation + +* [Get Started](../../readme.md) +* [Botkit Studio API](../readme-studio.md) +* [Function index](../readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](../middleware.md) + * [List of current plugins](../readme-middlewares.md) +* [Storing Information](../storage.md) +* [Logging](../logging.md) +* Platforms + * [Slack](../readme-slack.md) + * [Cisco Spark](../readme-ciscospark.md) + * [Facebook Messenger](../readme-facebook.md) + * [Twilio SMS](https://../readme-twiliosms.md) + * [Twilio IPM](https://../readme-twilioipm.md) + * [Microsoft Bot Framework](../readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../../CONTRIBUTING.md) + * [Building Middleware/plugins](../howto/build_middleware.md) + * [Building platform connectors](../howto/build_connector.md) diff --git a/docs/provisioning/slack-events-api.md b/docs/provisioning/slack-events-api.md new file mode 100644 index 000000000..2a5c39088 --- /dev/null +++ b/docs/provisioning/slack-events-api.md @@ -0,0 +1,83 @@ +# Configure Botkit and the Slack Events API + +Building a bot with Botkit and the Slack Events API gives you access to all of the best tools and options available to create a feature-rich bot for Slack. + +In order to get everything set up, you will need to configure a new Slack App inside the [Slack Developer Portal](http://api.slack.com/apps), and at the same time, configure a [Botkit-powered bot](http://botkit.ai). It only takes a few moments, but there are a bunch of steps, so follow these instructions carefully. + +This feature is still in very active development at Slack, and these steps are subject to change. + +## 1. Create a new Slack App + +Go to [http://api.slack.com/apps](http://api.slack.com/apps) and create a new application record. + +![Create your app](IMG/slack-new.png) + +You will receive a `clientId` and a `clientSecret`. You need these values - copy them into a temporary text file for use in the following steps. + +![Create your app](IMG/slack_client_secret.png) + +## 2. Turn on your Botkit app + +Botkit Studio provides the easiest path to creating a new bot for Slack. This method will provide a guided path to hosting, along with other useful tools for creating and managing your bot. If you're already using [Botkit Studio](https://studio.botkit.ai/), this is happening in the background! + +For advanced users looking to run their own code, you will need to [install Botkit](https://github.com/howdyai/botkit-starter-slack) and run it before your bot can be configured with Slack. + +Once you've collected your `clientId` and `clientSecret` tokens from Slack, you can start your Botkit app. You will need to have a bot service online and reachable over the web to complete the following steps and finish setting up your bot. + +## 3. Configure OAuth + +Botkit and Slack use the OAuth authentication system to grant bots access to connect to, read from, and send messages to Slack teams. + +![Setup Oauth](IMG/slack_oauth.png) + +Click on the "OAuth & Permissions" tab in your Slack's app setting, and under Redirect URLs, add: `https://my-bot-url/oauth`, and save your progress. + +## 4. Add a Bot User + +Click on the "Bot Users" tab and specify a name for your bot. This is the name that will be used by default when your application creates a new bot on a user's team. + +![Setup Oauth](IMG/slack-botuser.png) + +In addition to a name, enable the option for "Always Show My Bot Online." This will ensure your Bot appears online to your team. + +## 5. Set up Interactive Messages + +"Interactive messages" is Slack's fancy way of saying "buttons." In order to enable buttons, under Request URL, add `https://YOURURL/slack/receive`, then click save. + +![Setup Interactive images](IMG/slack-im.png) + +## 6. Set up Event Subscriptions + +To start receiving messages, you will need to enable Event Subscriptions. Finally, scroll to the top of the page and switch "Enable Events" to "on". + +Next you will need to add your Request URL, like this: `https://YOURURL/slack/receive`. When you finish typing, Slack will verify that this endpoint is properly configured. You must be running your Botkit application at the URL specified for this to work. + +![Verify your endpoints](IMG/Slack-eventsenable.png) + +Once verified, click "Add Bot User Event", and using the dropdown that appears, select all of the `message.*` events: + +* `message.channels` +* `message.groups` +* `message.ims` +* `message.mpim` + +This configuration tells Slack to send your bot all messages that are sent in any channel or group in which your bot is present. Add other events as needed. + +![Add some subscriptions](IMG/slack_botevents.png) + +Your bot is now ready to receive messages! + +*Problems?* : If you do not see `Bot User` here, it is likely that you forgot to add a Bot User back in Step 4. Go and fix that now, and come back to Step 6 to continue. + +*Note*: If you intend on submitting to the App Directory, be sure to have a good reason to request more widely ranging events as your app can be rejected for excessively wide permissions. + +## 7. Add your bot to your Slack team + +Now that your bot is configured, and your appliacation is up and running, you can login and add your bot. Visit `https://MYURL/`, and you will be automatically directed to Slack's login page. Login and choose a team. You'll get one more confirmation before being redirected back to your app. + +Meanwhile, your bot should appear inside your Slack team. You should receive a friendly welcome message to indicates your bot is now online and working! + +## Additional resources +* [Botkit Slack readme](../readme-slack.md) +* [Slack's official documention for Events API](https://api.slack.com/events-api) +* [Sign up for Botkit Studio](https://studio.botkit.ai/signup) diff --git a/docs/provisioning/teams.md b/docs/provisioning/teams.md new file mode 100644 index 000000000..07975321b --- /dev/null +++ b/docs/provisioning/teams.md @@ -0,0 +1,91 @@ +# Configure Botkit and Microsoft Teams +Building a bot with Botkit and the Microsoft Teams API gives you access to all of the best tools and options available to create a feature-rich app for Teams. + +We've created the following guide to help you configure your Microsoft Teams bot. In order to get the best deploy experience possible, we recommend starting with [Botkit Studio](https://studio.botkit.ai/), our feature-rich tool for building bots! + +## Step 0 - Enable Developer Preview + +Before starting to work on your bot, you [should enable Developer Preview](https://msdn.microsoft.com/en-us/microsoft-teams/publicpreview#how-do-i-get-access) and the [ability to sideload apps](https://msdn.microsoft.com/en-us/microsoft-teams/setup#3-enable-sideloading-of-apps-for-microsoft-teams) for your development team so that you ensure all features are supported. + +## Step 1 - Starting with Botkit Studio +Botkit Studio is a hosted development environment for building bots with the Botkit core library. Developers using Botkit Studio get the full capabilities of Botkit, plus a full guided setup on creating a bot for Microsoft Teams. + +**[![Sign up for Botkit Studio](https://github.com/howdyai/botkit/blob/master/docs/studio.png)](https://studio.botkit.ai/signup?code=readme)** + +Botkit Studio provides dedicated tools to create your bot's [App Package](https://botkit.groovehq.com/knowledge_base/topics/create-an-app-package-for-microsoft-teams) for [sideloading](https://msdn.microsoft.com/en-us/microsoft-teams/sideload) and submission to the Office Store. While Botkit Studio is not required, it is strongly recommended as the best way to stand up a bot using Botkit. + +For more information about Botkit Studio, including our starter kits for other platforms, please read the [Botkit readme on GitHub](https://github.com/howdyai/botkit#start-with-botkit-studio). + +## Step 2 Register your bot with Bot Framework +Microsoft Teams first requires you to register with their "Bot Framework" before you can add a bot to your Teams team. This is a multi-step process: + +### Create an account / Log in + +Log into the [Bot Framework Developer Portal](https://dev.botframework.com/bots/) using your Microsoft credentials, or create an account. + +### Register a new bot +Once you are logged in, [click this link to create a new bot](https://dev.botframework.com/bots/new) and then you can skip the next two steps! + +* Click on `My Bots` and then Register. Choose `Create a bot with the Bot Builder SDK`, and click `Create`. + +* Select `Register an existing bot built using Bot Builder SDK` from the next menu and then click `OK`. + +You will be asked some questions about your bot. Some of these can be changed later, but some _cannot be changed_ so consider your responses carefully! + +These are the important fields when creating your bot: + +* `Display name` - Your bot's name in channels and directories. This can be changed later. +* `Bot handle` - This will be used in the URL for your bot. *Note: This cannot be changed.* +* `Messaging endpoint` - You may not know this yet, as you will be creating this in the next step when setting up the Botkit app. If you are using the [Botkit starter kit](https://github.com/howdyai/botkit-starter-teams) or [Botkit Studio](https://botkit.groovehq.com/knowledge_base/categories/microsoft-teams-2), by default it is: `https://YOURURL/teams/receive`. Feel free to make anything up, you can come back later and change it. + +### Generate your keys +Register your bot with Microsoft by clicking: `Create Microsoft App ID and password` + +This action will take you to a new website (and require you to log in again) and then ask you for an `App name`. + +Once added, it will provide an `App ID` which you need to copy somewhere safe to use in the next step. + +Next, click `Generate password`. *This will only be shown to you once, if you lose it, you will need to invalidate the old one and set this up again!* + +Click Register. + +### Add the Microsoft Teams channel + +Add the Microsoft Teams channel from the list of channels, making sure the `Enabled` is set to on. + +You will want to leave this window open as you finish setting up Botkit, as you will need to come back here before you are done. + +## Step 4 - Deploy your bot and install to a team + +### Turn on your Botkit app +Now that everything is setup on Microsoft's side, you can [run Botkit](https://github.com/howdyai/botkit/blob/master/docs/readme-teams.md#getting-started) using the method you've chosen and with the information created in the Bot framework in the previous step. + +### Update your Messaging Endpoint in Bot Framework +Once a Botkit instance is running, you may have a new URL that you will have to update in the bot's settings [in Microsoft Bot Framework](https://dev.botframework.com/bots/). + +Once logged in to that page: + +* Click on your bot +* Select settings +* Scroll down to the messaging endpoint field and replace the placeholder URL with your active Botkit URL (it should look something like `https://YOURURL/teams/receive`). +* Click Save. + +### Create your Application Manifest +To add a development bot on a team, you will need to prepare an [App Package](https://msdn.microsoft.com/en-us/microsoft-teams/createpackage). Botkit Studio provides [easy tools to create your App Package](https://botkit.groovehq.com/knowledge_base/topics/create-an-app-package-for-microsoft-teams), but you can also build this manifest manually if you've chosen to not use Studio. + +### Sideload your Bot to Microsoft Teams +After creating your app package, you can [load it easily into your development team](https://msdn.microsoft.com/en-us/microsoft-teams/sideload#load-your-package-into-a-team). + +Say hello to your real live bot! + +## Step 5 - Add dialogue and features +Once you have a bot up and running, you can start the fun part of [making your bot functional](https://github.com/howdyai/botkit/blob/master/docs/readme.md#basic-usage). + +You can extend your bot's functionality using various [Botkit Middleware](https://github.com/howdyai/botkit/blob/master/docs/middleware.md), or check our [example library](https://github.com/howdyai/botkit/tree/master/examples) for a good starting point. + +If you have questions or suggestions, please take a look at our [community support resources](https://github.com/howdyai/botkit/blob/master/readme.md#developer--support-community). You can chat with contributors and enthusiasts in [our Slack community](https://community.botkit.ai/). + +## Additional resources +* [Botkit Microsoft Teams readme](https://github.com/howdyai/botkit/blob/master/docs/readme-teams.md) +* [Microsoft's Bot Framework](https://dev.botframework.com/) +* [Sign up for Botkit Studio](https://studio.botkit.ai/signup) diff --git a/docs/provisioning/twilio-sms.md b/docs/provisioning/twilio-sms.md new file mode 100644 index 000000000..20c5a67fe --- /dev/null +++ b/docs/provisioning/twilio-sms.md @@ -0,0 +1,40 @@ +# Configure Botkit and Twilio SMS + +Setting up a bot for Twilio SMS is one of the easiest experiences for bot developers! Follow these steps carefully to configure your bot. + +### 1. Install Botkit + +The easiest path to creating a new bot for Twilio SMS is through Botkit Studio. [Sign up for an account here](https://studio.botkit.ai/signup/). This method will provide a guided path to hosting, along with other useful tools for creating and managing your bot. + +For advanced users looking to run their own code, you will need to [install Botkit](../readme-twilio-sms.md#getting-started) and run it before your bot can be configured with Twilio SMS. + +### 2. Create a new bot in the Twilio Developer Console + +Login and click `Get Started` in [Twilio SMS Developer Console](https://www.twilio.com/console/sms/dashboard). You will be taken through the process of obtaining a number to use with your bot. + +At this point you can use the Twilio wizard to help you create an application, or build one directly by clicking `Messanging Services`. You can give it a friendly, and chose `Mixed` for use case. + +Check the box `Process Inbound Messages` and under request URL, type the name of your request url. + +By default in Botkit, this is: +https://*mybot.yoururl.com*/sms/receive + +### 3. Collect your tokens + +Next, visit [your console Dashboard](https://www.twilio.com/console) and copy your `Account SID` and `Auth Token`. You will use these in the next step along with your assignedmobile number to setup Botkit. + +### 4. Run your bot with variables set + + [Follow these instructions](../readme-TwilioSMS.md#getting-started) to run your bot locally, or by using a third-party service such as [Glitch](https://glitch.com) or [Heroku](https://heroku.com). + + You will need the following environment variables when running your bot: + +* TWILIO_ACCOUNT_SID= Your account's SID collected in step 3 above. +* TWILIO_AUTH_TOKEN= Your account's Auth Token collected in step 3 above. +* TWILIO_NUMBER= The number you were assigned in step 2 above. + +You should now be able to text message your number the words `Hello` and receive a friendly reply back! + +### Additional resources + +Read more about making bots for this platform in the [Twilio Developer Portal](https://www.twilio.com/console). diff --git a/docs/readme-botframework.md b/docs/readme-botframework.md new file mode 100644 index 000000000..acee4e1cf --- /dev/null +++ b/docs/readme-botframework.md @@ -0,0 +1,231 @@ +# Botkit and Microsoft Bot Framework + +Botkit is designed to ease the process of designing and running useful, creative bots that live inside [Slack](http://slack.com), [Facebook Messenger](http://facebook.com), [Twilio IP Messaging](https://www.twilio.com/docs/api/ip-messaging), [Microsoft Bot Framework](https://botframework.com), +and other messaging platforms. + +The [Microsoft Bot Framework](https://botframework.com) makes it easy to create a single bot that can run across a variety of messaging channels including [Skype](https://skype.com), [Group.me](https://groupme.com), [Facebook Messenger](https://messenger.com), [Slack](https://slack.com), +[Telegram](https://telegram.org/), [Kik](https://www.kik.com/), [SMS](https://www.twilio.com/), and [email](https://microsoft.office.com). + +Built in to [Botkit](https://howdy.ai/botkit/) are a comprehensive set of features and tools to deal with any of the platforms supported by the [Microsoft Bot Framework](https://botframework.com), allowing developers to build interactive bots and applications that send and receive messages +just like real humans. + +This document covers the Bot Framework implementation details only. [Start here](readme.md) if you want to learn about to develop with Botkit. + +Table of Contents + +* [Getting Started](#getting-started) +* [Bot Framework Specific Events](#bot-framework-specific-events) +* [Working with the Bot Framework](#working-with-the-bot-framework) +* [Sending Cards and Attachments](#sending-cards-and-attachments) +* [Typing Indicator](#typing-indicator) + +## Getting Started + +1) Install Botkit [more info here](readme.md#installation) + +2) Register a developer account with the Bot Framework [Developer Portal](https://dev.botframework.com/) and follow [this guide](https://docs.botframework.com/en-us/csharp/builder/sdkreference/gettingstarted.html#registering) to register your first bot with the Bot Framework. + +* You'll be asked to provide an endpoint for your bot during the registration process which you should set to "https:///botframework/receive". If your using a service like [ngrok](https://ngrok.com/) to run your bot locally you should set the "" portion of your endpoint to + be the hostname assigned by ngrok. +* Write down the *App ID* and *App Password* assigned to your new bot as you'll need them when you run your bot. + +3) By default your bot will be configured to support the Skype channel but you'll need to add it as a contact on Skype in order to test it. You can do that from the developer portal by clicking the "Add to Skype" button in your bots profile page. + +4) Run the example bot using the App ID & Password you were assigned. If you are _not_ running your bot at a public, SSL-enabled internet address, use the --lt option and update your bots endpoint in the developer portal to use the URL assigned to your bot. + +``` +app_id= app_password= node examples/botframework_bot.js [--lt [--ltsubdomain CUSTOM_SUBDOMAIN]] +``` + +5) Your bot should be online! Within Skype, find the bot in your contacts list, and send it a message. + +Try: + * who are you? + * call me Bob + * shutdown + +### Things to note + +Since the Bot Framework delivers messages via web hook, your application must be available at a public internet address. Additionally, the Bot Framework requires this address to use SSL. Luckily, you can use [LocalTunnel](https://localtunnel.me/) to make a process running locally or in your dev environment available in a Bot Framework friendly way. + +When you are ready to go live, consider [LetsEncrypt.org](http://letsencrypt.org), a _free_ SSL Certificate Signing Authority which can be used to secure your website very quickly. + +## Bot Framework Specific Events + +Once connected to the Bot Framework, bots receive a constant stream of events. + +Normal messages will be sent to your bot using the `message_received` event. In addition, several other events may fire, depending on the channel your bot is configured to support. + +| Event | Description +|--- |--- +| message_received | A message was received by the bot. Passed an [IMessage](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.imessage.html) object. +| conversationUpdate | Your bot was added to a conversation or other conversation metadata changed. Passed an [IConversationUpdate](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.iconversationupdate.html) object. +| contactRelationUpdate | The bot was added to or removed from a user's contact list. Passed an [IContactRelationUpdate](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.icontactrelationupdate.html) object. +| typing | The user or bot on the other end of the conversation is typing. Passed an [IEvent](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.ievent.html) object. + +In addition to the event specific fields, all incoming events will contain both `user` and `channel` fields which can be used for things like storage keys. Every event also has an [address](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.ievent.html#address) field. The `user` field is just a copy of the events `address.user.id` field and the `channel` field is a copy of the events `address.conversationId.id` field. + +Other notable fields for incoming messages are the [text](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.imessage.html#text) field which contains the text of the incoming message, the [attachments](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.imessage.html#attachments) field which would contain an array of any images sent to the bot by the user, the [source](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.imessage.html#source) field which identifies the type of chat platform (facebook, skype, sms, etc.) that the bot is communicating over, and the [sourceEvent](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.imessage.html#sourceevent) field containing the original event/message received from the chat platform. + +## Working with the Bot Framework + +Botkit receives messages from the Bot Framework using webhooks, and sends messages to the Bot Framework using APIs. This means that your bot application must present a web server that is publicly addressable. Everything you need to get started is already included in Botkit. + +To connect your bot to the Bot Framework follow the step by step guide outlined in [Getting Started](#getting-started). + +Here is the complete code for a basic Bot Framework bot: + +~~~ javascript +var Botkit = require('botkit'); +var controller = Botkit.botframeworkbot({ +}); + +var bot = controller.spawn({ + appId: process.env.app_id, + appPassword: process.env.app_password +}); + +// if you are already using Express, you can use your own server instance... +// see "Use BotKit with an Express web server" +controller.setupWebserver(process.env.port,function(err,webserver) { + controller.createWebhookEndpoints(controller.webserver, bot, function() { + console.log('This bot is online!!!'); + }); +}); + +// user said hello +controller.hears(['hello'], 'message_received', function(bot, message) { + + bot.reply(message, 'Hey there.'); + +}); + +controller.hears(['cookies'], 'message_received', function(bot, message) { + + bot.startConversation(message, function(err, convo) { + + convo.say('Did someone say cookies!?!!'); + convo.ask('What is your favorite type of cookie?', function(response, convo) { + convo.say('Golly, I love ' + response.text + ' too!!!'); + convo.next(); + }); + }); +}); +~~~ + +#### Botkit.botframeworkbot() +| Argument | Description +|--- |--- +| settings | Options used to configure the bot. Supports fields from [IChatConnectorSettings](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.ichatconnectorsettings.html). + +Creates a new instance of the bots controller. The controller will create a new [ChatConnector](https://docs.botframework.com/en-us/node/builder/chat-reference/classes/_botbuilder_d_.chatconnector.html) so any options needed to configure the chat connector should be passed in via the `settings` argument. + +Generally speaking your bot needs to be configured with both an [appId](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.ichatconnectorsettings.html#appid) and [appPassword](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.ichatconnectorsettings.html#apppassword). You can leave these blank but then your bot can only be called by the [Bot Framework Emulator](https://docs.botframework.com/en-us/tools/bot-framework-emulator/#navtitle). + +#### controller.setupWebserver() +| Argument | Description +|--- |--- +| port | port for webserver +| callback | callback function + +Setup an [Express webserver](http://expressjs.com/en/index.html) for +use with `createWebhookEndpoints()` + +If you need more than a simple webserver to receive webhooks, +you should by all means create your own Express webserver! Here is a [boilerplate demo](https://github.com/mvaragnat/botkit-messenger-express-demo). + +The callback function receives the Express object as a parameter, +which may be used to add further web server routes. + +#### controller.createWebhookEndpoints() + +This function configures the route `https://_your_server_/botframework/receive` +to receive webhooks from the Bot Framework. + +This url should be used when configuring Facebook. + +## Sending Cards and Attachments + +One of the more complicated aspects of building a bot that supports multiple chat platforms is dealing with all the various schemes these platforms support. To help ease this development burden, the Bot Framework supports a cross platform card & attachment schema which lets you use a single JSON schema to express cards and attachments for any platform. The Bot Frameworks channel adapters will do their best to render a card on a given platform which sometimes result in a card being broken up into multiple messages. + +#### Sending Images & Files + +The frameworks [attachment schema](https://docs.botframework.com/en-us/node/builder/chat-reference/interfaces/_botbuilder_d_.iattachment.html) lets you send a single image/file using `contentType` and `contentUrl` fields: + +~~~ javascript + bot.reply(message, { + attachments: [ + { + contentType: 'image/png', + contentUrl: 'https://upload.wikimedia.org/wikipedia/en/a/a6/Bender_Rodriguez.png', + name: 'Bender_Rodriguez.png' + } + ] + }); +~~~ + +#### Sending Cards + +Rich cards can be expressed as attachments by changing the `contentType` and using the `content` field to pass a JSON object defining the card: + +~~~ javascript + bot.reply(message, { + attachments: [ + { + contentType: 'application/vnd.microsoft.card.hero', + content: { + title: "I'm a hero card", + subtitle: "Pig Latin Wikipedia Page", + images: [ + { url: "https://" }, + { url: "https://" } + ], + buttons: [ + { + type: "openUrl", + title: "WikiPedia Page", + value: "https://en.wikipedia.org/wiki/Pig_Latin" + } + ] + } + } + ] + }); +~~~ + +The full list of supported card types and relevant schema can be found [here](https://docs.botframework.com/en-us/csharp/builder/sdkreference/attachments.html) + +#### Using the Platforms Native Schema + +There may be times where the Bot Frameworks cross platform attachment schema doesn’t cover your needs. For instance, you may be trying to send a card type not directly supported by the framework. In those cases you can pass a message using the platforms native schema to the `sourceEvent` field on the message. Examples of this can be found [here](https://docs.botframework.com/en-us/csharp/builder/sdkreference/channels.html) (note: you should use `sourceEvent` instead of `channelData` and you don’t need to worry about the from & to fields, these will be populated for you when you call `bot.reply()`.) + +## Typing Indicator + +You can easily turn on the typing indicator on platforms that support that behaviour by sending an empty message of type "typing": + +~~~ javascript + bot.reply(message, { type: "typing" }); +~~~ + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/readme-ciscospark.md b/docs/readme-ciscospark.md new file mode 100644 index 000000000..4da582926 --- /dev/null +++ b/docs/readme-ciscospark.md @@ -0,0 +1,278 @@ +# Botkit and Cisco Spark + +Botkit is designed to ease the process of designing and running useful, creative bots that live inside Cisco Spark. + +Botkit features a comprehensive set of tools +to deal with [Cisco's Spark platform](https://developer.ciscospark.com/), and allows +developers to build interactive bots and applications that send and receive messages just like real humans. + +This document covers the Cisco Spark-specific implementation details only. [Start here](readme.md) if you want to learn about to develop with Botkit. + +Table of Contents + +* [Getting Started](#getting-started) +* [Spark-specific Events](#spark-specific-events) +* [Message Formatting](#message-formatting) +* [Attaching Files](#attaching-files) +* [Receiving Files](#receiving-files) +* [Starting Direct Messages](#starting-direct-messages) + + +## Getting Started + +1) Install Botkit [more info here](readme.md#installation) + +2) [Create a bot in the Spark for Developers site](https://developer.ciscospark.com/add-bot.html). You'll receive an `access token`. + +Copy this token, you'll need it! + +3) If you are _not_ running your bot at a public, SSL-enabled internet address, use a tool like [ngrok](http://ngrok.io) or [localtunnel](http://localtunnel.me) to create a secure route to your development application. + +``` +ngrok http 3000 +``` + +4) Run your bot application using the access token you received, the base url of your bot application, and a secret which is used to validate the origin of incoming webhooks: + +``` +access_token= public_address= secret= node examples/spark_bot.js +``` + +5) Your bot should now come online and respond to requests! Find it in Cisco Spark by searching for it's name. + +## Working with Cisco Spark + +Botkit receives messages from Cisco Spark using webhooks, and sends messages using their APIs. This means that your bot application must present a web server that is publicly addressable. Everything you need to get started is already included in Botkit. + +To connect your bot to Cisco Spark, [get an access token here](https://developer.ciscospark.com/add-bot.html). In addition to the access token, +Cisco Spark bots require a user-defined `secret` which is used to validate incoming webhooks, as well as a `public_address` which is the URL at which the bot application can be accessed via the internet. + +Each time the bot application starts, Botkit will register a webhook subscription. +Botkit will automatically manage your bot's webhook subscriptions, but if you plan on having multiple instances of your bot application with different URLs (such as a development instance and a production instance), use the `webhook_name` field with a different value for each instance. + +Bots in Cisco Spark are identified by their email address, and can be added to any space in any team or organization. If your bot should only be available to users within a specific organization, use the `limit_to_org` or `limit_to_domain` options. +This will configure your bot to respond only to messages from members of the specific organization, or whose email addresses match one of the specified domains. + +The full code for a simple Cisco Spark bot is below: + +~~~ javascript +var Botkit = require('./lib/Botkit.js'); + +var controller = Botkit.sparkbot({ + debug: true, + log: true, + public_address: process.env.public_address, + ciscospark_access_token: process.env.access_token, + secret: process.env.secret +}); + + +var bot = controller.spawn({ +}); + +controller.setupWebserver(process.env.PORT || 3000, function(err, webserver) { + controller.createWebhookEndpoints(webserver, bot, function() { + console.log("SPARK: Webhooks set up!"); + }); +}); + +controller.hears('hello', 'direct_message,direct_mention', function(bot, message) { + bot.reply(message, 'Hi'); +}); + +controller.on('direct_mention', function(bot, message) { + bot.reply(message, 'You mentioned me and said, "' + message.text + '"'); +}); + +controller.on('direct_message', function(bot, message) { + bot.reply(message, 'I got your private message. You said, "' + message.text + '"'); +}); +~~~ + +## Controller Options + +When creating the Botkit controller, there are several platform-specific options available. + +### Botkit.sparkbot +| Argument | Description +|--- |--- +| public_address | _required_ the root url of your application (https://mybot.com) +| `ciscospark_access_token` | _required_ token provided by Cisco Spark for your bot +| secret | _required_ secret for validating webhooks originate from Cisco Spark +| webhook_name | _optional_ name for webhook configuration on Cisco Spark side. Providing a name here allows for multiple bot instances to receive the same messages. Defaults to 'Botkit Firehose' +| `limit_to_org` | _optional_ organization id in which the bot should exist. If user from outside org sends message, it is ignored +| `limit_to_domain` | _optional_ email domain (@howdy.ai) or array of domains [@howdy.ai, @botkit.ai] from which messages can be received + +~~~ javascript +var controller = Botkit.sparkbot({ + debug: true, + log: true, + public_address: 'https://mybot.ngrok.io', + ciscospark_access_token: process.env.access_token, + secret: 'randomstringofnumbersandcharacters12345', + webhook_name: 'dev', + limit_to_org: 'my_spark_org_id', + limit_to_domain: ['@howdy.ai','@cisco.com'], +}); +~~~ + +## Spark Specific Events + + All events [listed here](https://developer.ciscospark.com/webhooks-explained.html#resources-events) should be expected, in the format `resource`.`event` - for example, `rooms.created`. + + In addition, the following custom Botkit-specific events are fired: + +| Event | Description +|--- |--- +| direct_message | Bot has received a message as a DM +| direct_mention | Bot has been mentioned in a public space +| self_message | Bot has received a message it sent +| user_space_join | a user has joined a space in which the bot is present +| bot_space_join | the bot has joined a new space +| user_space_leave | a user has left a space in which the bot is present +| bot_space_leave | the bot has left a space + + +## Message Formatting + +Cisco Spark supports both a `text` field and a `markdown` field for outbound messages. [Read here for details on Cisco Spark's markdown support.](https://developer.ciscospark.com/formatting-messages.html) + +To specify a markdown version, add it to your message object: + +~~~ javascript +bot.reply(message,{text: 'Hello', markdown: '*Hello!*'}); +~~~ + +## Attaching Files + +Files can be attached to outgoing messages in one of two ways. + +*Specify URL* + +If the file you wish to attach is already available online, simply specify the URL in the `files` field of the outgoing message: + +~~~ javascript +bot.reply(message,{text:'Here is your file!', files:['http://myserver.com/file.pdf']}); +~~~ + +*Send Local File* + +If the file you wish to attach is present only on the local server, attach it to the message as a readable stream: + +~~~ javascript +var fs = require('fs'); +bot.reply(message,{text: 'I made this file for you.', files:[fs.createReadStream('./newfile.txt')]}); +~~~ + +## Receiving files + +Your bot may receive messages with files attached. Attached files will appear in an array called `message.original_message.files`. + +Botkit provides 2 methods for retrieving information about the file. + +### bot.retrieveFileInfo(url, cb) +| Parameter | Description +|--- |--- +| url | url of file from message.original_message.files +| cb | callback function in the form function(err, file_info) + +The callback function will receive an object with fields like `filename`, `content-type`, and `content-length`. + +~~~ javascript +controller.on('direct_message', function(bot, message) { + bot.reply(message, 'I got your private message. You said, "' + message.text + '"'); + if (message.original_message.files) { + bot.retrieveFileInfo(message.original_message.files[0], function(err, file_info) { + bot.reply(message,'I also got an attached file called ' + file_info.filename); + }); + } +}); +~~~ + +### bot.retrieveFile(url, cb) +| Parameter | Description +|--- |--- +| url | url of file from message.original_message.files +| cb | callback function in the form function(err, file_content) + +The callback function will receive the full content of the file. + +~~~ javascript +controller.on('direct_message', function(bot, message) { + bot.reply(message, 'I got your private message. You said, "' + message.text + '"'); + if (message.original_message.files) { + bot.retrieveFileInfo(message.original_message.files[0], function(err, file_info) { + if (file_info['content-type'] == 'text/plain') { + bot.retrieveFile(message.original_message.files[0], function(err, file) { + bot.reply(message,'I got a text file with the following content: ' + file); + }); + } + }); + } +}); +~~~ + +## Starting Direct Messages + +Cisco Spark's API provides several ways to send private messages to users - +by the user's email address, or by their user id. These may be used in the case where the +user's email address is unknown or unavailable, or when the bot should respond to the `actor` +instead of the `sender` of a message. + +For example, a bot may use these methods when handling a `bot_space_join` event +in order to send a message to the _user who invited the bot_ (the actor) instead of +the bot itself (the sender). + +NOTE: Core functions like [bot.startPrivateConversation()](readme.md#botstartprivateconversation) work as expected, +and will create a direct message thread with the sender of the incoming_message. + +### bot.startPrivateConversationWithPersonId() +| Parameter | Description +|--- |--- +| personId | the personId of the user to whom the bot should send a message +| cb | callback function in the form function(err, file_content) + +Use this function to send a direct message to a user by their personId, which +can be found in message and event payloads at the following location: + +~~~ javascript +var personId = message.original_message.actorId; +~~~ + +### bot.startPrivateConversationWithActor()) +| Parameter | Description +|--- |--- +| incoming_message | a message or event that has an actorId defined in message.original_message.actorId +| cb | callback function in the form function(err, file_content) + +~~~ javascript +controller.on('bot_space_join', function(bot, message) { + bot.startPrivateConversationWithActor(message, function(err, convo) { + convo.say('The bot you invited has joined the channel.'); + }); +}); +~~~ + + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/readme-facebook.md b/docs/readme-facebook.md new file mode 100644 index 000000000..c34af9b52 --- /dev/null +++ b/docs/readme-facebook.md @@ -0,0 +1,600 @@ +# Botkit and Facebook + +Botkit is designed to ease the process of designing and running useful, creative bots that live inside [Slack](http://slack.com), [Facebook Messenger](http://facebook.com), [Twilio IP Messaging](https://www.twilio.com/docs/api/ip-messaging), and other messaging platforms. + + +Botkit features a comprehensive set of tools +to deal with [Facebooks's Messenger platform](https://developers.facebook.com/docs/messenger-platform/implementation) as well as [Facebook @Workplace](https://facebook.com/workplace), and allows +developers to build interactive bots and applications that send and receive messages just like real humans. Facebook bots can be connected to Facebook Pages, and can be triggered using a variety of [useful web plugins](https://developers.facebook.com/docs/messenger-platform/plugin-reference). + +This document covers the Facebook-specific implementation details only. [Start here](readme.md) if you want to learn about to develop with Botkit. + +Table of Contents + +* [Getting Started](#getting-started) +* [Facebook-specific Events](#facebook-specific-events) +* [Working with Facebook Webhooks](#working-with-facebook-messenger) +* [Using Structured Messages and Postbacks](#using-structured-messages-and-postbacks) +* [Thread Settings](#thread-settings-api) +* [Messenger Profile API](#messenger-profile-api) +* [Simulate typing](#simulate-typing) +* [Silent and No Notifications](#silent-and-no-notifications) +* [Messenger code API](#messenger-code-api) +* [Attachment upload API](#attachment-upload-api) +* [Running Botkit with an Express server](#use-botkit-for-facebook-messenger-with-an-express-web-server) + +## Getting Started + +1) Install Botkit [more info here](readme.md#installation) + +2) Create a [Facebook App for Web](https://developers.facebook.com/quickstarts/?platform=web) and note down or [create a new Facebook Page](https://www.facebook.com/pages/create/). Your Facebook page will be used for the app's identity. + + +3) [Get a page access token for your app](https://developers.facebook.com/docs/messenger-platform/guides/setup#page_access_token) + +Copy this token, you'll need it! + +4) Define your own "verify token" - this is a string that you control that Facebook will use to verify your web hook endpoint. + +5) Run the example bot app, using the two tokens you just created. If you are _not_ running your bot at a public, SSL-enabled internet address, use the --lt option and note the URL it gives you. + +``` +page_token= verify_token= node examples/facebook_bot.js [--lt [--ltsubdomain CUSTOM_SUBDOMAIN]] +``` + +6) [Set up a webhook endpoint for your app](https://developers.facebook.com/docs/messenger-platform/guides/setup#webhook_setup) that uses your public URL. Use the verify token you defined in step 4! + +* *Note* - You will need to provide Facebook a callback endpoint to receive requests from Facebook. By default Botkit will serve content from "https://YOURSERVER/facebook/receive". You can use a tool like [ngrok.io](http://ngrok.io) or [localtunnel.me](http://localtunnel.me) to expose your local development enviroment to the outside world for the purposes of testing your Messenger bot. + +7) Your bot should be online! Within Facebook, find your page, and click the "Message" button in the header. + +Try: + * who are you? + * call me Bob + * shutdown + + +### Things to note + +Since Facebook delivers messages via web hook, your application must be available at a public internet address. Additionally, Facebook requires this address to use SSL. Luckily, you can use [LocalTunnel](https://localtunnel.me/) to make a process running locally or in your dev environment available in a Facebook-friendly way. + +When you are ready to go live, consider [LetsEncrypt.org](http://letsencrypt.org), a _free_ SSL Certificate Signing Authority which can be used to secure your website very quickly. It is fabulous and we love it. + +## Validate Requests - Secure your webhook! +Facebook sends an X-HUB signature header with requests to your webhook. You can verify the requests are coming from Facebook by enabling `validate_requests: true` when creating your bot controller. This checks the sha1 signature of the incoming payload against your Facebook App Secret (which is seperate from your webhook's verify_token), preventing unauthorized access to your webhook. You must also pass your `app_secret` into your environment variables when running your bot. + +The Facebook App secret is available on the Overview page of your Facebook App's admin page. Click show to reveal it. + +``` +app_secret=abcdefg12345 page_token=123455abcd verify_token=VerIfY-tOkEn node examples/facebook_bot.js +``` + +## Facebook-specific Events + +Once connected to Facebook, bots receive a constant stream of events. + +Normal messages will be sent to your bot using the `message_received` event. In addition, several other events may fire, depending on your implementation and the webhooks you subscribed to within your app's Facebook configuration. + +| Event | Description +|--- |--- +| message_received | a message was received by the bot +| facebook_postback | user clicked a button in an attachment and triggered a webhook postback +| message_delivered | a confirmation from Facebook that a message has been received +| message_read | a confirmation from Facebook that a message has been read +| facebook_account_linking | a user has started the account linking +| facebook_optin | a user has clicked the [Send-to-Messenger plugin](https://developers.facebook.com/docs/messenger-platform/implementation#send_to_messenger_plugin) +| facebook_referral | a user has clicked on a [m.me URL with a referral param](https://developers.facebook.com/docs/messenger-platform/referral-params) + +All incoming events will contain the fields `user` and `channel`, both of which represent the Facebook user's ID, and a `timestamp` field. + +`message_received` events will also contain either a `text` field or an `attachment` field. + +`facebook_postback` events will contain a `payload` field. + +More information about the data found in these fields can be found [here](https://developers.facebook.com/docs/messenger-platform/webhook-reference). + +## Working with Facebook Messenger + +Botkit receives messages from Facebook using webhooks, and sends messages using Facebook's APIs. This means that your bot application must present a web server that is publicly addressable. Everything you need to get started is already included in Botkit. + +To connect your bot to Facebook, [follow the instructions here](https://developers.facebook.com/docs/messenger-platform/implementation). You will need to collect your `page token` as well as a `verify token` that you define yourself and configure inside Facebook's app settings. A step by step guide [can be found here](#getting-started). Since you must *already be running* your Botkit app to configure your Facebook app, there is a bit of back-and-forth. It's ok! You can do it. + +Here is the complete code for a basic Facebook bot: + +```javascript +var Botkit = require('botkit'); +var controller = Botkit.facebookbot({ + access_token: process.env.access_token, + verify_token: process.env.verify_token, +}) + +var bot = controller.spawn({ +}); + +// if you are already using Express, you can use your own server instance... +// see "Use BotKit with an Express web server" +controller.setupWebserver(process.env.port,function(err,webserver) { + controller.createWebhookEndpoints(controller.webserver, bot, function() { + console.log('This bot is online!!!'); + }); +}); + +// this is triggered when a user clicks the send-to-messenger plugin +controller.on('facebook_optin', function(bot, message) { + + bot.reply(message, 'Welcome to my app!'); + +}); + +// user said hello +controller.hears(['hello'], 'message_received', function(bot, message) { + + bot.reply(message, 'Hey there.'); + +}); + +controller.hears(['cookies'], 'message_received', function(bot, message) { + + bot.startConversation(message, function(err, convo) { + + convo.say('Did someone say cookies!?!!'); + convo.ask('What is your favorite type of cookie?', function(response, convo) { + convo.say('Golly, I love ' + response.text + ' too!!!'); + convo.next(); + }); + }); +}); +``` + +### Receive Postback Button Clicks as "Typed" Messages + +Facebook Messenger supports including "postback" buttons, which, when clicked, +send a specialized `facebook_postback` event. + +Developers may find it useful if button clicks are treated as "typed" messages. +In order to "hear" these events, add the `facebook_postback` event to the list of events specified in the second parameter to the `hears()` function. +This enables buttons to be used as part of a conversation flow, with the button's +`payload` field being used for the message text. + +When used in conjunction with `convo.ask`, Botkit will treat the button's `payload` field as if were a message typed by the user. + +``` +// receive a message whether it is typed or part of a button click +controller.hears('hello','message_received,facebook_postback', function(bot,message) { + + bot.reply(message, 'Got it!'); + +}); +``` + +### Require Delivery Confirmation + +In order to guarantee the order in which your messages arrive, Botkit supports an optional +delivery confirmation requirement. This will force Botkit to wait for a `message_delivered` events +for each outgoing message before continuing to the next message in a conversation. + +Developers who send many messages in a row, particularly with payloads containing images or attachments, +should consider enabling this option. Facebook's API sometimes experiences a delay delivering messages with large files attached, and this delay can cause messages to appear out of order. + +To enable this option, pass in `{require_delivery: true}` to your Botkit Facebook controller, as below: + +```javascript +var controller = Botkit.facebookbot({ + access_token: process.env.access_token, + verify_token: process.env.verify_token, + require_delivery: true, +}) +``` + +#### controller.setupWebserver() +| Argument | Description +|--- |--- +| port | port for webserver +| callback | callback function + +Setup an [Express webserver](http://expressjs.com/en/index.html) for +use with `createWebhookEndpoints()` + +If you need more than a simple webserver to receive webhooks, +you should by all means create your own Express webserver! Here is a [boilerplate demo](https://github.com/mvaragnat/botkit-messenger-express-demo). + +The callback function receives the Express object as a parameter, +which may be used to add further web server routes. + +#### controller.createWebhookEndpoints() + +This function configures the route `https://_your_server_/facebook/receive` +to receive webhooks from Facebook. + +This url should be used when configuring Facebook. + +## Using Structured Messages and Postbacks + +You can attach little bubbles + +And in those bubbles can be buttons +and when a user clicks the button, it sends a postback with the value. + +```javascript +controller.hears('test', 'message_received', function(bot, message) { + + var attachment = { + 'type':'template', + 'payload':{ + 'template_type':'generic', + 'elements':[ + { + 'title':'Chocolate Cookie', + 'image_url':'http://cookies.com/cookie.png', + 'subtitle':'A delicious chocolate cookie', + 'buttons':[ + { + 'type':'postback', + 'title':'Eat Cookie', + 'payload':'chocolate' + } + ] + }, + ] + } + }; + + bot.reply(message, { + attachment: attachment, + }); + +}); + +controller.on('facebook_postback', function(bot, message) { + + if (message.payload == 'chocolate') { + bot.reply(message, 'You ate the chocolate cookie!') + } + +}); +``` + +## Typing indicator + +Use a message with a sender_action field with "typing_on" to create a typing indicator. The typing indicator lasts 20 seconds, unless you send another message with "typing_off" + +```javascript +var reply_message = { + sender_action: "typing_on" +} + +bot.reply(message, reply_message) +``` + +## Simulate typing +To make it a bit more realistic, you can trigger a "user is typing" signal (shown in Messenger as a bubble with 3 animated dots) by using the following convenience methods. + +```javascript +bot.startTyping(message, function () { + // do something here, the "is typing" animation is visible +}); + +bot.stopTyping(message, function () { + // do something here, the "is typing" animation is not visible +}); + +bot.replyWithTyping(message, 'Hello there, my friend!'); +``` + +## Silent and No Notifications +When sending a user a message you can make the message have either no notification or have a notification that doesn't play a sound. Both of these features are unique to the mobile application messenger. To do this add the `notification_type` field to message. Notification type must be one of the following: +- REGULAR will emit a sound/vibration and a phone notification +- SILENT_PUSH will just emit a phone notification +- NO_PUSH will not emit either + +`notification_type` is optional. By default, messages will be REGULAR push notification type + +```javascript +reply_message = { + text: "Message text here", + notification_type: NOTIFICATION_TYPE +} +bot.reply(message, reply_message) +``` + +## Messenger code API + +Messenger Codes can be scanned in Messenger to instantly link the user to your bot, no typing needed. They're great for sticking on fliers, ads, or anywhere in the real world where you want people to try your bot. + +- Get Static Codes : +```javascript +controller.api.messenger_profile.get_messenger_code(2000, function (err, url) { + if(err) { + // Error + } else { + // url + } +}); +``` + +- Get Parametric Codes : +```javascript +controller.api.messenger_profile.get_messenger_code(2000, function (err, url) { + if(err) { + // Error + } else { + // url + } +}, 'billboard-ad'); +``` + +## Thread Settings API + +Thread settings API is now messenger profile API, it's highly recommended to use profile API instead of thread settings one, however, Botkit thread settings interface still available : + + +```js +controller.api.messenger_profile.YOUR_METHOD_NAME(); +controller.api.thread_settings.YOUR_METHOD_NAME(); + +``` + + +## Messenger Profile API + +Facebook offers a Messenger Profile API to customize special bot features +such as a persistent menu and a welcome screen. We highly recommend you use all of these features, which will make your bot easier for users to work with. [Read Facebook's docs here](https://developers.facebook.com/docs/messenger-platform/messenger-profile). + +#### controller.api.messenger_profile.greeting() +| Argument | Description +|--- |--- +| message | greeting message to display on welcome screen + +#### controller.api.messenger_profile.delete_greeting() + +Remove the greeting message. + +#### controller.api.messenger_profile.get_greeting() + +Get the greeting setting. + +#### controller.api.messenger_profile.get_started() +| Argument | Description +|--- |--- +| payload | value for the postback payload sent when the button is clicked + +Set the payload value of the 'Get Started' button + +#### controller.api.messenger_profile.delete_get_started() + +Clear the payload value of the 'Get Started' button and remove it. + +#### controller.api.messenger_profile.get_get_started() + +Get the get started setting. + +#### controller.api.messenger_profile.menu() +| Argument | Description +|--- |--- +| menu_items | an array of menu_item objects + +Create a [persistent menu](https://developers.facebook.com/docs/messenger-platform/messenger-profile/persistent-menu) for your Bot + +#### controller.api.messenger_profile.delete_menu() + +Clear the persistent menu setting + +#### controller.api.messenger_profile.get_menu() + +Get the menu setting. + +#### controller.api.messenger_profile.account_linking() +| Argument | Description +|--- |--- +| payload | the account link. + +#### controller.api.messenger_profile.delete_account_linking() + +Remove the account link + +#### controller.api.messenger_profile.get_account_linking() + +Get the account link + +#### controller.api.messenger_profile.domain_whitelist() +| Argument | Description +|--- |--- +| payload | A single or a list of domains to add to the whitelist, All domains must be valid and use https. Up to 10 domains allowed. + +#### controller.api.messenger_profile.delete_domain_whitelist() + +Remove all domains + +#### controller.api.messenger_profile.get_domain_whitelist() + +Get a list of the whitelisted domains. + +### controller.api.messenger_profile.home_url() +| Argument | Description +|--- |--- +| payload | A home_url object with the properties `url`, `webview_height_ratio`, `in_test` + +View the facebook documentation for details of the [home_url](https://developers.facebook.com/docs/messenger-platform/messenger-profile/home-url) payload object. + +*NB.* The value of the `url` property must be present in the domain_whitelist array + +### controller.api.messenger_profile.delete_home_url() + +Remove the home_url setting + +### controller.api.messenger_profile.get_home_url() + +Get the home_url + +#### Using the The Messenger Profile API + +```js +controller.api.messenger_profile.greeting('Hello! I\'m a Botkit bot!'); +controller.api.messenger_profile.get_started('sample_get_started_payload'); +controller.api.messenger_profile.menu([{ + "locale":"default", + "composer_input_disabled":true, + "call_to_actions":[ + { + "title":"My Skills", + "type":"nested", + "call_to_actions":[ + { + "title":"Hello", + "type":"postback", + "payload":"Hello" + }, + { + "title":"Hi", + "type":"postback", + "payload":"Hi" + } + ] + }, + { + "type":"web_url", + "title":"Botkit Docs", + "url":"https://github.com/howdyai/botkit/blob/master/readme-facebook.md", + "webview_height_ratio":"full" + } + ] + }, + { + "locale":"zh_CN", + "composer_input_disabled":false + } +]); +controller.api.messenger_profile.account_linking('https://www.yourAwesomSite.com/oauth?response_type=code&client_id=1234567890&scope=basic'); +controller.api.messenger_profile.get_account_linking(function (err, accountLinkingUrl) { + console.log('****** Account linkink URL :', accountLinkingUrl); +}); +controller.api.messenger_profile.delete_account_linking(); +controller.api.messenger_profile.domain_whitelist('https://localhost'); +controller.api.messenger_profile.domain_whitelist(['https://127.0.0.1', 'https://0.0.0.0']); +controller.api.messenger_profile.delete_domain_whitelist(); +controller.api.messenger_profile.get_domain_whitelist(function (err, data) { + console.log('****** Whitelisted domains :', data); +}); + +controller.api.messenger_profile.home_url({ + "url": 'https://mydomain.com', + "webview_height_ratio": 'tall', + "in_test": false +}) + +controller.api.messenger_profile.get_home_url(function (err, data) { + console.log('****** Home url :', data); +}); + +controller.api.messenger_profile.delete_home_url(); + +// Target Audience +controller.api.messenger_profile.target_audience({ + "audience_type":"custom", + "countries":{ + "whitelist":["US", "CA"] + } +}); +controller.api.messenger_profile.delete_target_audience(); +controller.api.messenger_profile.get_target_audience(function (err, data) { + console.log('****** Target Audience :', data); +}); + + +``` + +## Attachment upload API + +Attachment upload API allows you to upload an attachment that you may later send out to many users, without having to repeatedly upload the same data each time it is sent : + + +```js +var attachment = { + "type":"image", + "payload":{ + "url":"https://pbs.twimg.com/profile_images/803642201653858305/IAW1DBPw_400x400.png", + "is_reusable": true + } + }; + + controller.api.attachment_upload.upload(attachment, function (err, attachmentId) { + if(err) { + // Error + } else { + var image = { + "attachment":{ + "type":"image", + "payload": { + "attachment_id": attachmentId + } + } + }; + bot.reply(message, image); + } + }); + +``` + +## Built-in NLP + +Facebook offers some built-in natural language processing tools. Once enabled, messages may contain a `message.nlp.` object with the results of the Facebook NLP. +More information can be found [in Facebook's official documentation of this feature](https://developers.facebook.com/docs/messenger-platform/built-in-nlp). + +If specified, `message.nlp.entities` will include a list of entities and intents extracted by Facebook. + +Facebook's NLP option can be enabled by calling `controller.api.nlp.enable()` in your Botkit app. + +Facebook's NLP option can be disabled by calling `controller.api.nlp.enable()` in your Botkit app. + + +## Message Tags + +Adding a tag to a message allows you to send it outside the 24+1 window. + +View the facebook [documentation](https://developers.facebook.com/docs/messenger-platform/messenger-profile/home-url) for more details. + +- Get all tags : +```javascript +controller.api.tags.get_all(function (tags) { + // use tags.data +}); +``` + +- Send a tagged message : +```javascript +var taggedMessage = { + "text": "Hello Botkit !", + "tag": "RESERVATION_UPDATE" +}; +bot.reply(message, taggedMessage); +``` + + +## Use BotKit for Facebook Messenger with an Express web server +Instead of the web server generated with setupWebserver(), it is possible to use a different web server to receive webhooks, as well as serving web pages. + +Here is an example of [using an Express web server alongside BotKit for Facebook Messenger](https://github.com/mvaragnat/botkit-messenger-express-demo). + + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/readme-middlewares.md b/docs/readme-middlewares.md new file mode 100644 index 000000000..47308bc2f --- /dev/null +++ b/docs/readme-middlewares.md @@ -0,0 +1,138 @@ +# Botkit middlewares + +The functionality of Botkit can be extended using middleware functions. These functions can plugin to the core bot running processes at several useful places and make changes to both a bot's configuration and the incoming or outgoing message. Anyone can add their own middleware to the Botkit documentation, [for more information please read this.](#have-you-created-middleware) + +Currently the following types of middleware are available for Botkit: + +### [Natural language processing](#natural-language-processing) +* [Microsoft Luis](#microsoft-luis) +* [Api.ai](#apiai) +* [IBM Watson](#ibm-watson) +* [Recast.ai](#recastai) +* [Wit.ai](#witai) + + +### [Storage Modules](#storage-modules) +Storage middleware can be used for storing attributes about a user or channel or team. It is currently available for the following services: + +* [Mongo](#mongo) +* [Redis](#redis) +* [Datastore](#datastore) +* [Firebase](#firebase) +* [Postgres](#postgres) +* [CouchDB](#couchdb) + +### [Statistics](#statistics) +* [bCRM](#bcrm) +* [Botmetrics](#botmetrics) +* [Keen](#keen) + +### [CRM](#crm-modules) +* [bCRM](#bcrm) +* [Dashbot](#dashbot) +* [Wordhop](#wordhop) + + +# Natural Language Processing + +## Microsoft Luis +### [Project Page](https://github.com/Stevenic/botkit-middleware-luis) +The [Luis](http://luis.ai) middleware with Botkit causes every message sent to your bot to be first sent through Luis.ai's NLP services for processing. + +## Api.ai +### [Project Page](https://github.com/abeai/botkit-middleware-apiai) +This middleware plugin for Botkit allows you to utilize Api.ai, a natural language classifier service directly into the Botkit corebot. + +The Api.ai platform lets developers seamlessly integrate intelligent voice and text based command systems into their products to create consumer-friendly voice/text-enabled user interfaces. + +## IBM Watson +### [Project Page](https://github.com/watson-developer-cloud/botkit-middleware) +This middleware plugin for Botkit allows developers to easily integrate a Watson Conversation workspace with multiple social channels like Slack, Facebook, and Twilio. Customers can have simultaneous, independent conversations with a single workspace through different channels. + +## Recast.ai +### [Project Page](https://github.com/ouadie-lahdioui/botkit-middleware-recastai) +You can use the Recast.AI API to analyse your text or your audio file, and extract useful informations from it, to personalize your IoT, classify your data or create bots. + +## Wit.ai +### [Project Page](https://github.com/howdyai/botkit-middleware-witai) +Wit.ai provides a service that uses machine learning to help developers handle natural language input. The Wit API receives input from the user, and translates it into one or more "intents" which map to known actions or choices. The power of Wit is that it can continually be trained to understand more and more responses without changing the underlying bot code! + + +# Storage Modules + +## Mongo +### [Project Page](https://github.com/howdyai/botkit-storage-mongo) +A Mongo storage module for Botkit + +## Redis +### [Project Page](https://github.com/howdyai/botkit-storage-redis) +A redis storage module for Botkit + +## Datastore +### [Project Page](https://github.com/fabito/botkit-storage-datastore) +A Google Cloud Datastore storage module for Botkit + +## Firebase +### [Project Page](https://github.com/howdyai/botkit-storage-firebase) +A Firebase storage module for Botkit. + +## Postgres +### [Project Page](https://github.com/lixhq/botkit-storage-postgres) +Postgres storage module for Botkit + +## CouchDB +### [Project Page](https://github.com/mbarlock/botkit-storage-couchdb/) +A Couchdb storage module for botkit + + +# Statistics + +## Botmetrics +### [Project Page](https://github.com/botmetrics/botkit-middleware-botmetrics) +[Botmetrics](https://www.getbotmetrics.com) is an analytics and engagement platform for chatbots. + +## Keen +### [Project Page](https://github.com/keen/keen-botkit) +This middleware allows you to to understand how many messages are going through your system, run cohorts to measure retention, set up funnels to measure task completion, and any key metric unique to your bot. More information about the Keen platform [can be found on their website](https://keen.github.io/keen-botkit/) + +# CRM Modules +## bCRM +### [Project Page](https://github.com/howdyai/botkit-middleware-bcrm) +This Botkit plugin enables support for bCRM, a customer CRM tool that enables bot developers to send broadcast messages to users of their bot. This plugin currently works with Slack and Facebook bots. + +## Dashbot +### [Project Page Facebook](https://www.dashbot.io/sdk/facebook/botkit) +### [Project Page Slack](https://www.dashbot.io/sdk/slack/botkit) +Increase user engagement, acquisition, and monetization through actionable bots analytics. + +## Wordhop +### [Project Page](https://github.com/wordhop-io/wordhop-npm) +Wordhop monitors your Chatbot and alerts you on Slack in real-time when it detects conversational problems. You can watch your bot conversations with users in real-time without leaving Slack and take-over your bot to engage your customers directly. Simply add Wordhop to Slack and then drop in code into your Chatbot (You can use our examples as a starting point for a bot too). Wordhop integrates in minutes, and begins working immediately. + +This module has been tested with Messenger, Slack, Skype, and Microsoft Webchat. + +# Have you created middleware? +We would love to hear about it! [Contact the Howdy team](https://howdy.ai/) to be included in Botkit documentation, or [submit a PR on this documentation](https://github.com/howdyai/botkit-storage-firebase/blob/master/CONTRIBUTING.md)! + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/readme-pipeline.md b/docs/readme-pipeline.md new file mode 100644 index 000000000..de9efac27 --- /dev/null +++ b/docs/readme-pipeline.md @@ -0,0 +1,425 @@ +# Introducing the Botkit Message Pipeline + +As Botkit has added support for more and more new platforms, +it has become necessary to formalize the process by which +incoming messages are received and prepared for use within +the bot's brain, and also how outgoing messages are formatted +and delivered to the appropriate messaging platform APIs. + +The new message pipeline introduced in Botkit 0.6 defines this +formal path by exposing a series of middleware endpoints that occur +throughout the lifetime of a message as it is is transformed +from a "raw" message received from the platform into its normalized +form. This normalization process allows Botkit and its array of plugins to handle a message without necessarily caring about its origin. + +Message normalization like this has always been part of Botkit - +what is being introduced in 0.6 is a formalized process through which +all messages must now flow. + +This document should be useful to any developer interested in using Botkit's middleware system. However, the focus of this document is on the application of the available middleware endpoints to the process of message normalization in platform connectors. Developers interested in using middleware for building plugins or for feature development should [check out the main middleware documentation](middleware.md). + +## Why do we need a message pipeline? + +Each messaging service that works with Botkit sends messages in a different format - some even come in multiple formats! Some of the services send messages via incoming webhook, while others send them via web sockets. Some require decryption or other processing before being used by the bot. This means that +each different type of bot has to treat the messages differently, depending on all these factors. + +The message pipeline is a series of steps that Botkit takes for every single message that tries to manage all this chaos. It is a universal process that is applied to all messages from all platforms that results in a simple normalized message format. + +We hope that this will simplify the process of learning to use Botkit, +increase the ability of developers to port their bots between platforms or +build multi-platform bot apps, and make it easier for plugin developers to +create extensions to Botkit that work seamlessly with all messaging services. + +## How does it work? + +Messages that arrive to a Botkit-powered app are now passed into the message +processing pipeline via a function called `ingest()` that receives the raw, +unprocessed payload of the incoming message - a big blob of information in some platform specific format. + +Somewhere in that blob of information are a few key pieces of information that Botkit really cares about and needs to know about to work: the text, the user, the channel, and the type of the message. + +Once ingested, Botkit passes the message through a series of transformations, +each one with its own middleware plugin endpoint. The result of these transformations is a message object that is guaranteed to have all of those bits of information in the right places. This transformed message is then passed into Botkit to be handled by your application. + +As a result, developers writing normal Botkit code can always expect a message to be in the form: + +``` +{ + user: , + channel: , + text: , + type: , + raw_message: +} +``` + +The same process is reversed when sending a message. Developers can author messages in a generic form, in many cases only specifying the outgoing text. +These messages are then transformed by the outgoing message pipeline into +the platform-specific API payloads necessary. + +### Incoming Messages + +The incoming message pipeline consists of these steps: + +* [Ingest](#ingest) - receive a raw incoming message event +* [Normalize](#normalize) - copy, rename or transform fields in the message so that it matches Botkit's expectations +* [Categorize](#categorize) - adjust the type of the message depending on its content. for example, identifying the difference between a direct message and a direct mention. +* [Receive](#receive) - accepts the normalized and transformed message for processing + +After passing through the `receive` step, one of three things will happen: + +* the message is recognized as part of an ongoing conversation, and captured by that conversation. This will fire the [capture middleware](#capture) . +* the message matches a 'hears' pattern and passed in to a handler function. This will fire the [heard middleware](#heard) . +* the message will trigger a Botkit event based on value of the `message.type` field. No further middleware will fire in this case. + +### Outgoing Messages + +The outgoing message pipeline consists of these steps: + +* [Send](#send) - accept an outgoing message to be sent to a message platform. +* [Format](#format) - do the necessary transformations from a simplified Botkit message object to the platform specific API payload + +# Middleware Definitions + +The pipeline middlewares are useful for two primary reasons: + +* It allows the development of new platform connectors via a prescribed mechanism +* It gives developers hooks to modify the internal workings of Botkit + +Middleware functions can be developed to do all sorts of useful things, and can now be tied the pipeline at any phase, depending on the purpose of the middleware. + +As of version 0.6, all Botkit platform connectors include middleware functions +responsible for their platform-specific transformations. These platform middlewares will _always fire first_, before any additional middlewares defined by the developer or included in the application code. + +Developers can specify as many middlewares as desired, including multiple middleware functions registered to the same endpoints. These functions will fire in the order they are defined. + +Plugin middlewares can do things like: +* Call third party NLP/NLU APIs and enrich the message object +* Load information from databases and enrich the message object +* Verify or authenticate message objects and discard unverified or unwanted messages +* Further categorize messages in order to fire new or different events +* Handle classes of events "in the background" without changing specific handlers +* Log or record messages for debugging or statistics + + +## Ingest + +Ingestion into Botkit is the first step in the message pipeline. + +Message objects that pass through the ingest phase will have 2 additional fields: + +`message.raw_message` contains the unmodified content of the incoming message payload as received from the messaging service + +`message._pipeline` is an object that tracks a message's progress through the pipeline. The subfield `message._pipeline.stage` will contain the name of the current pipeline step. + +Functions added to the ingest middleware endpoint need to receive 4 parameters, as below. + +| Field | Description +|--- |--- +| bot | an instance of the bot +| message | the incoming message object +| response channel | the http response object +| next | function to call to proceed with pipeline + +``` +controller.middleware.ingest.use(function(bot, message, res, next) { + + // define action + // perhaps set an http status header + // res.status(200); + // you can even send an http response + // res.send('OK'); + + // you can access message.raw_message here + + // call next to proceed + next(); + +}); +``` + +The ingest phase is useful for actions like: + +* Validating the origin of the incoming payload using a shared secret or encrypted header +* Sending necessary HTTP response to the incoming webhooks. For example, some platforms require a 200 response code! + +Note that in the ingest phase, the message object has _not yet been normalized_ and may not contain the fields you expect. Developers should treat these messages as raw, platform specific messages, +and as a result should check the `bot.type` field, which contains the name of the specific messaging platform, before taking any actions on the message object! + +## Normalize + +Normalization is the second phase of the message pipeline. + +After passing through the normalize phase, the message object is expected to have the following fields: + +* `type` will contain either the raw value of the incoming `type` field specified by the platform, OR `message_received` which is the default message type defined by Botkit. +* `user` will contain the unique id of the sending user +* `channel` will include the unique id of the channel in which the message was sent +* `text` will contain the text, if any, of the message. + +Note that the entire original unmodified message object will still be available as `message.raw_message`. Though Botkit's normalization process does not remove any fields from the message as it is normalized, those fields are not guaranteed to be present. It is our recommendation that developers who want to access to platform-specific fields _always_ use the `message.raw_message` location. + +For example, if the originally ingested payload included a field called `message.my_platform_value`, you should refer to it in your code as `message.raw_message.my_platform_value`. + +Functions added to the normalize middleware endpoint need to receive these parameters: + +| Field | Description +|--- |--- +| bot | an instance of the bot +| message | the incoming message object +| next | function to call to proceed with pipeline + +``` +controller.middleware.normalize.use(function(bot, message, next) { + + // here's an example of what a message normalizer might do! + // this is a make believe example not specific to any real platform + // the idea is to copy/rename or tranform fields from raw_message + // into an object with {user, text, channel, type} while leaving everything else alone + + // translate a "from" field into message.user + message.user = message.raw_message.from.id; + + // find the text value and set it in message.text + message.text = message.raw_message.user_text; + + // make sure a channel value is set + message.channel = message.raw_message.source_channel + + // call next to proceed + next(); + +}); +``` + + +## Categorize + +Categorization is the third phase of the message pipeline. + +After passing through the catgorize phase, the message object's `type` field +should represent a the final event type that will be handled by Botkit. + +The most obvious example of a categorization action is identifying and transforming a message from a generic `message_received` event into more narrowly defined `direct_mention`, `direct_mention`, `mention` or `ambient` message event. + +In addition to changing the message `type` field, the `categorize` middleware may also +change the value of the `message.text` field. For example, it should _remove direct mentions from the text_ so that developers do not have to compensate for its possible presence in the input text. + +Categorize middlewares can also catch and delegate complex message types to simpler, +easier to handle events, taking some of the burden off of developers for handling these subtleties. For example, a categorize middleware might identify different types of button click events that all share the same `type` value and create new event names for each. + +Functions added to the categorize middleware endpoint need to receive these parameters: + +| Field | Description +|--- |--- +| bot | an instance of the bot +| message | the incoming message object +| next | function to call to proceed with pipeline + +``` +controller.middleware.categorize.use(function(bot, message, next) { + + // messages in Slack that are sent in a 1:1 channel + // can be identified by the first letter of the channel ID + // if it is "D", this is a direct_message! + if (message.type == 'message_received') { + if (message.channel[0] == 'D') { + message.type = 'direct_message'; + } + } + + // call next to proceed + next(); + +}); +``` + + +## Receive + +Receive is the final step in the incoming message pipeline before the message +actually reaches the bot's internal logic. + +By the time a message hits the `receive` stage, it is in its final form, +and is ready to be processed by a Botkit event handler. This middleware endpoint +occurs _just before_ a message is evaluated for trigger matches, and before any +user-defined handler runs. It will fire for every incoming message, regardless of whether or not it matches a trigger or if any event handlers are registered to receive it. + +As noted above, after passing through the `receive` step, one of three things will happen: + +* the message is recognized as part of an ongoing conversation, and captured by that conversation. This will fire the [capture middleware](#capture) . +* the message matches a 'hears' pattern and passed in to a handler function. This will fire the [heard middleware](#heard) . +* the message will trigger a Botkit event based on value of the `message.type` field. No further middleware will fire in this case, and Botkit will fire any handlers registered with the `controller.on()` function. + +Developers seeking to enrich their messages with data from external sources, such as external NLP services, databases or other third party APIs, may wish to tie this functionality to the receive middleware endpoint. This will cause the enrichment to occur for _every single message_ that is received. This may or may not be desirable, depending on the number and type of messages the platforms send, and the types of messages your bot is supposed to handle. + +Before calling any external service in a receive middleware, developers should evaluate the message's `type` and `text` field to make sure enrichment is appropriate. For example, you don't want to call an expensive NLP process on messages without text, or messages that represent button clicks. + +Alternately, developers may wish to attach their enrichment functionality to the more narrowly defined `heard` and `capture` middlewares, which occur _after_ pattern matching has occured, and as a result will only fire for messages that _are definitely going to be handled_. + +Functions added to the categorize middleware endpoint need to receive these parameters: + +| Field | Description +|--- |--- +| bot | an instance of the bot +| message | the incoming message object +| next | function to call to proceed with pipeline + +``` +controller.middleware.receive.use(function(bot, message, next) { + + // lets call a pretend external NLP system + // and enrich this message with intent and entity data + + // make sure we have some text, and that this is + // not a message from the bot itself... + if (message.text && message.type != 'self_message') { + callExternalNLP(message.text).then(function(api_results) { + message.intent = api_results.intent; + message.entities = api_results.entities; + next(); + }); + } else { + next(); + } +}); +``` + +## Heard + +The `heard` middleware endpoint occurs after a message has matched a trigger pattern, and is about to be handled. It works just like the `receive` endpoint, but instead of firing for every incoming message, it will only fire for messages that the is explicitly listening for. + +This makes the `heard` endpoint useful for firing expensive operations, such as database lookups or calls to external APIs tat take a long time, require a lot of processing, or actually cost money to use. However, it makes it less useful for use with NLP tools, since the pattern matching has already occured. + +Note that the heard middleware fires only for messages that match handlers set up with `controller.hears()`, and does not fire with handlers configured with the `controller.on()` method. + +Functions added to the categorize heard endpoint need to receive these parameters: + +| Field | Description +|--- |--- +| bot | an instance of the bot +| message | the incoming message object +| next | function to call to proceed with pipeline + +``` +controller.middleware.heard.use(function(bot, message, next) { + + // load up any user info associated with this sender + // using Botkit's internal storage system + // and enrich the message with a new `user_profile` field + // now, every message will have the user_profile field automatically + // and you don't need to load the info in each individual handler function + controller.storage.users.get(message.user, function(err, user_profile) { + if (!err && user_profile) { + message.user_profile = user_profile; + } + + // call next to proceed, now with additional info! + next(); + }); +}); +``` + +## Capture + +The `capture` middleware once again works like the `receive` or `heard` endpoints, +but fires only on the condition that the incoming message is part of an existing +conversation. Generally, this means that the message will actually be handled a callback function passed into `convo.ask()` + +This endpoint is useful for transforming the value used by the conversation to something +other than the user's raw input text. For example, in a bot that presents a numbered list of options to a user as part of a multiple choice selection, a capture middleware could be created that transforms the user's numeric input into the full text of the selected item. + + +## Send + +When you send a message with `bot.send()` or `bot.reply()`, the outgoing message is first sent +through the send middleware. + +The send middleware receives the raw message, as created in your bot's code. It has not yet been formatted for delivery to the messaging service API. This can be used to modify or track the outgoing messages BEFORE they are formatted for delivery to the platform API. + +In particular, this middleware is useful for recording stats about outgoing messages. At this point in the pipeline, the message object will contain the outgoing message text and any attachments, as well as a special `message.to` field, which represents the unique user id of the message's recipient. Depending on the platform, this value is not always present in the final outgoing message payload. + +Any modifications to the _content_ of the outgoing message should happen in a send middleware function. For example, developers can use a send middleware to translate the message text into different languages. Or, developers might pass the message text through a template engine to replace tokens or expand custom shortcodes. + +Send middlewares should _not_ make changes to the actual structure or layout of the outgoing message object. Final formatting for delivery to the platform is done by the `format` endpoint. + +Functions added to the categorize send endpoint need to receive these parameters: + +| Field | Description +|--- |--- +| bot | an instance of the bot +| message | the outgoing message object +| next | function to call to proceed with pipeline + +``` +controller.middleware.send.use(function(bot, message, next) { + + // log the outgoing message for debugging purposes + console.log('SENDING ', message.text,'TO USER', message.text); + + next(); + +}); +``` + +## Format + +This middleware happens immediately before a message is delivered to the platform API. + +Each platform as its own special format for incoming message objects. This middleware should exclusively be used for constructing the final API parameters required for delivering the message. The message object that emerges from this function is intended only for use with the messaging service API. + +After being formatted, the resulting `platform_message` is passed into the platform-specific `bot.send()` function, which is responsible for the final delivery of the message the appropriate external API endpoint. This allows the `bot.send()` function to be designed to accept only pre-formatted messages. + +Unlike all the other pipeline endpoints, this function does NOT modify the original message object. In fact, the final object is constructed by the middleware in the `platform_message` parameter, +allowing the original message to pass through unmolested. + +Functions added to the categorize format endpoint need to receive these parameters: + +| Field | Description +|--- |--- +| bot | an instance of the bot +| message | the outgoing message object +| platform_message | the formatted message, ready for delivery +| next | function to call to proceed with pipeline + +``` +controller.middleware.format.use(function(bot, message, platform_message, next) { + + // let's construct an outgoign message payload + // to an imaginary platform that uses some different fieldnames. + + platform_message.message = { + text: message.text, + recipient: message.to, + room: message.channel, + } + + platform_message.type = 'message'; + + next(); + +}); +``` + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/readme-slack.md b/docs/readme-slack.md new file mode 100644 index 000000000..d963f1f2e --- /dev/null +++ b/docs/readme-slack.md @@ -0,0 +1,1057 @@ +# Botkit and Slack + +Botkit is designed to ease the process of designing and running useful, creative bots that live inside [Slack](http://slack.com), [Facebook Messenger](http://facebook.com), [Twilio IP Messaging](https://www.twilio.com/docs/api/ip-messaging), and other messaging platforms. + +Botkit features a comprehensive set of tools +to deal with [Slack's integration platform](http://api.slack.com), and allows +developers to build both custom integrations for their +team, as well as public "Slack Button" applications that can be +run from a central location, and be used by many teams at the same time. + +This document covers the Slack-specific implementation details only. [Start here](readme.md) if you want to learn about how to develop with Botkit. + +Table of Contents + +* [Getting Started](#getting-started) +* [Connecting Your Bot To Slack](#connecting-your-bot-to-slack) +* [Slack-specific Events](#slack-specific-events) +* [Working with Slack Custom Integrations](#working-with-slack-integrations) +* [Using the Slack Button](#use-the-slack-button) +* [Message Buttons](#message-buttons) + +--- +## Getting Started + +1) Install Botkit on your hosting platform of choice [more info here](readme.md#installation). + +2) First make a bot integration inside of your Slack channel. Go here: + +https://my.slack.com/services/new/bot + +Enter a name for your bot. +Make it something fun and friendly, but avoid a single task specific name. +Bots can do lots! Let's not pigeonhole them. + +3) When you click "Add Bot Integration", you are taken to a page where you can add additional details about your bot, like an avatar, as well as customize its name & description. + +Copy the API token that Slack gives you. You'll need it. + +4) Run the example bot app, using the token you just copied: +​ +``` +token=REPLACE_THIS_WITH_YOUR_TOKEN node examples/slack_bot.js +``` +​ +5) Your bot should be online! Within Slack, send it a quick direct message to say hello. It should say hello back! + +Try: + * who are you? + * call me Bob + * shutdown +​ + +### Things to note +​ +Much like a vampire, a bot has to be invited into a channel. DO NOT WORRY bots are not vampires. + +Type: `/invite @` to invite your bot into another channel. + + +## Connecting Your Bot to Slack + +Bot users connect to Slack using a real time API based on web sockets. The bot connects to Slack using the same protocol that the native Slack clients use! + +To connect a bot to Slack, [get a Bot API token from the Slack integrations page](https://my.slack.com/services/new/bot). + +Note: Since API tokens can be used to connect to your team's Slack, it is best practices to handle API tokens with caution. For example, pass tokens into your application via an environment variable or command line parameter, rather than including it in the code itself. +This is particularly true if you store and use API tokens on behalf of users other than yourself! + +[Read Slack's Bot User documentation](https://api.slack.com/bot-users) + +### Slack Controller + +The Botkit Slack controller object can be configured in a few different ways, depending on the type of integration you are building. + +A simple single-team bot that uses Slack's [Real Time Messaging (RTM) API](https://api.slack.com/rtm) can be instantiated without any special options: + +```javascript +var controller = Botkit.slackbot({}); +``` + +In order to use Botkit's built in support for multi-team Slack "apps", pass in [additional configuration options](#use-the-slack-button): + +```javascript +var controller = Botkit.slackbot({ + clientId: process.env.clientId, + clientSecret: process.env.clientSecret, + scopes: ['bot'], +}); +``` + +#### Botkit.slackbot() +| Argument | Description +|--- |--- +| config | Configuration object + +Creates a new Botkit SlackBot controller. + +```javascript +var controller = Botkit.slackbot({debug: true}) +``` + +`config` object accepts these properties: + +| Name | Value | Description +|--- |--- |--- +| debug | Boolean | Enable debug logging +| stale_connection_timeout | Positive integer | Number of milliseconds to wait for a connection keep-alive "pong" response before declaring the connection stale. Default is `12000` +| send_via_rtm | Boolean | Send outgoing messages via the RTM instead of using Slack's RESTful API which supports more features +| retry | Positive integer or `Infinity` | Maximum number of reconnect attempts after failed connection to Slack's real time messaging API. Retry is disabled by default +| api_root | Alternative root URL which allows routing requests to the Slack API through a proxy, or use of a mocked endpoints for testing. defaults to `https://slack.com` +| disable_startup_messages | Boolean | Disable start up messages, like: `"Initializing Botkit vXXX"` + +#### controller.spawn() +| Argument | Description +|--- |--- +| config | Incoming message object + +Spawn an instance of your bot and connect it to Slack. +This function takes a configuration object which should contain +at least one method of talking to the Slack API. + +To use the real time / bot user API, pass in a token. + +Controllers can also spawn bots that use [incoming webhooks](#incoming-webhooks). + +Spawn `config` object accepts these properties: + +| Name | Value | Description +|--- |--- |--- +| token | String | Slack bot token + + +### Require Delivery Confirmation for RTM Messages + +In order to guarantee the order in which your messages arrive, Botkit supports an optional +delivery confirmation requirement. This will force Botkit to wait for a confirmation events +for each outgoing message before continuing to the next message in a conversation. + +Developers who send many messages in a row, particularly with payloads containing images or attachments, +should consider enabling this option. Slack's API sometimes experiences a delay delivering messages with large files attached, and this delay can cause messages to appear out of order. Note that for Slack, this is only applies to bots with the `send_via_rtm` option enabled. + +To enable this option, pass in `{require_delivery: true}` to your Botkit Slack controller, as below: + +```javascript +var controller = Botkit.slackbot({ + require_delivery: true, +}) +``` + +#### bot.startRTM() +| Argument | Description +|--- |--- +| callback | _Optional_ Callback in the form function(err,bot,payload) { ... } + +Opens a connection to Slack's real time API. This connection will remain +open until it fails or is closed using `closeRTM()`. + +The optional callback function receives: + +* Any error that occurred while connecting to Slack +* An updated bot object +* The resulting JSON payload of the Slack API command [rtm.start](https://api.slack.com/methods/rtm.start) + +The payload that this callback function receives contains a wealth of information +about the bot and its environment, including a complete list of the users +and channels visible to the bot. This information should be cached and used +when possible instead of calling Slack's API. + +A successful connection the API will also cause a `rtm_open` event to be +fired on the `controller` object. + + +#### bot.closeRTM() + +Close the connection to the RTM. Once closed, an `rtm_close` event is fired +on the `controller` object. + + +```javascript +var Botkit = require('botkit'); + +var controller = Botkit.slackbot(); + +var bot = controller.spawn({ + token: my_slack_bot_token +}) + +bot.startRTM(function(err,bot,payload) { + if (err) { + throw new Error('Could not connect to Slack'); + } + + // close the RTM for the sake of it in 5 seconds + setTimeout(function() { + bot.closeRTM(); + }, 5000); +}); +``` + +#### bot.destroy() + +Completely shutdown and cleanup the spawned worker. Use `bot.closeRTM()` only to disconnect +but not completely tear down the worker. + + +```javascript +var Botkit = require('botkit'); +var controller = Botkit.slackbot(); +var bot = controller.spawn({ + token: my_slack_bot_token +}) + +bot.startRTM(function(err, bot, payload) { + if (err) { + throw new Error('Could not connect to Slack'); + } +}); + +// some time later (e.g. 10s) when finished with the RTM connection and worker +setTimeout(bot.destroy.bind(bot), 10000) +``` + +### Ephemeral Messages + +Using the Web API, messages can be sent to a user "ephemerally" which will only show to them, and no one else. Learn more about ephemeral messages at the [Slack API Documentation](https://api.slack.com/methods/chat.postEphemeral). When sending an ephemeral message, you must specify a valid `user` and `channel` id. Valid meaning the specified user is in the specified channel. Currently, updating interactive messages are not supported by ephemeral messages, but you can still create them and listen to the events. They will not have a reference to the original message, however. + +#### Ephemeral Message Authorship + +Slack allows you to post an ephemeral message as either the user you have an auth token for (would be your bot user in most cases), or as your app. The display name and icon will be different accordingly. The default is set to `as_user: true` for all functions except `bot.sendEphemeral()`. Override the default of any message by explicitly setting `as_user` on the outgoing message. + + +#### bot.whisper() +| Argument | Description +|--- |--- +| src | Message object to reply to, **src.user is required** +| message | _String_ or _Object_ Outgoing response +| callback | _Optional_ Callback in the form function(err,response) { ... } + +Functions the same as `bot.reply()` but sends the message ephemerally. Note, src message must have a user field set in addition to a channel + + +`bot.whisper()` defaults to `as_user: true` unless otherwise specified on the message object. This means messages will be attributed to your bot user, or whichever user who's token you are making the API call with. + + +#### bot.sendEphemeral() +| Argument | Description +|--- |--- +| message | _String_ or _Object_ Outgoing response, **message.user is required** +| callback | _Optional_ Callback in the form function(err,response) { ... } + +To send a spontaneous ephemeral message (which slack discourages you from doing) use `bot.sendEphemeral` which functions similarly as `bot.say()` and `bot.send()` + + +```javascript +controller.hears(['^spooky$'], function(bot, message) { + // default behavior, post as the bot user + bot.whisper(message, 'Booo! This message is ephemeral and private to you') +}) + +controller.hears(['^spaghetti$'], function(bot, message) { + // attribute slack message to app, not bot user + bot.whisper(message, {as_user: false, text: 'I may be a humble App, but I too love a good noodle'}) +}) + +controller.on('custom_triggered_event', function(bot, trigger) { + // pretend to get a list of user ids from out analytics api... + fetch('users/champions', function(err, userArr) { + userArr.map(function(user) { + // iterate over every user and send them a message + bot.sendEphemeral({ + channel: 'general', + user: user.id, + text: "Pssst! You my friend, are a true Bot Champion!"}) + }) + }) +}) +``` + +#### Ephemeral Conversations + +To reply to a user ephemerally in a conversation, pass a message object to `convo.say()` `convo.sayFirst()` `convo.ask()` `convo.addMessage()` `convo.addQuestion()` that sets ephemeral to true. + +When using interactive message attachments with ephemeral messaging, Slack does not send the original message as part of the payload. With non-ephemeral interactive messages Slack sends a copy of the original message for you to edit and send back. To respond with an edited message when updating ephemeral interactive messages, you must construct a new message to send as the response, containing at least a text field. + +```javascript +controller.hears(['^tell me a secret$'], 'direct_mention, ambient, mention', function(bot, message) { + bot.startConversation(message, function(err, convo) { + convo.say('Better take this private...') + convo.say({ ephemeral: true, text: 'These violent delights have violent ends' }) + }) +}) + +``` + +### Slack Threads + +Messages in Slack may now exist as part of a thread, separate from the messages included in the main channel. +Threads can be used to create new and interesting interactions for bots. [This blog post discusses some of the possibilities.](https://blog.howdy.ai/threads-serious-software-in-slack-ba6b5ceec94c#.jzk3e7i2d) + +Botkit's default behavior is for replies to be sent in-context. That is, if a bot replies to a message in a main channel, the reply will be added to the main channel. If a bot replies to a message in a thread, the reply will be added to the thread. This behavior can be changed by using one of the following specialized functions: + +#### bot.replyInThread() +| Argument | Description +|--- |--- +| message | Incoming message object +| reply | _String_ or _Object_ Outgoing response +| callback | _Optional_ Callback in the form function(err,response) { ... } + +This specialized version of [bot.reply()](readme.md#botreply) ensures that the reply being sent will be in a thread. +When used to reply to a message that is already in a thread, the reply will be properly added to the thread. +Developers who wish to ensure their bot's replies appear in threads should use this function instead of bot.reply(). + +#### bot.startConversationInThread() +| Argument | Description +|--- |--- +| message | incoming message to which the conversation is in response +| callback | a callback function in the form of function(err,conversation) { ... } + +Like [bot.startConversation()](readme.md#botstartconversation), this creates conversation in response to an incoming message. +However, the resulting conversation and all followup messages will occur in a thread attached to the original incoming message. + +#### bot.createConversationInThread() +| Argument | Description +|--- |--- +| message | incoming message to which the conversation is in response +| callback | a callback function in the form of function(err,conversation) { ... } + +Creates a conversation that lives in a thread, but returns it in an inactive state. See [bot.createConversation()](readme.md#botcreateconversation) for details. + + +### Slack-Specific Events + +Once connected to Slack, bots receive a constant stream of events - everything from the normal messages you would expect to typing notifications and presence change events. + +Botkit's message parsing and event system does a great deal of filtering on this +real time stream so developers do not need to parse every message. See [Receiving Messages](readme.md#receiving-messages) +for more information about listening for and responding to messages. + +It is also possible to bind event handlers directly to any of the enormous number of native Slack events, as well as a handful of custom events emitted by Botkit. + +You can receive and handle any of the [native events thrown by slack](https://api.slack.com/events). + +```javascript +controller.on('channel_joined',function(bot,message) { + + // message contains data sent by slack + // in this case: + // https://api.slack.com/events/channel_joined + +}); +``` + +You can also receive and handle a long list of additional events caused +by messages that contain a subtype field, [as listed here](https://api.slack.com/events/message) + +```javascript +controller.on('channel_leave',function(bot,message) { + + // message format matches this: + // https://api.slack.com/events/message/channel_leave + +}) +``` + +Finally, Botkit throws a handful of its own events! +Events related to the general operation of bots are below. +When used in conjunction with the Slack Button, Botkit also fires +a [few additional events](#use-the-slack-button). + + +#### User Activity Events: + +| Event | Description +|--- |--- +| message_received | a message was received by the bot +| bot_channel_join | the bot has joined a channel +| user_channel_join | a user has joined a channel +| bot_group_join | the bot has joined a group +| user_group_join | a user has joined a group + +#### Message Received Events +| Event | Description +|--- |--- +| direct_message | the bot received a direct message from a user +| direct_mention | the bot was addressed directly in a channel +| mention | the bot was mentioned by someone in a message +| ambient | the message received had no mention of the bot + +#### Websocket Events: + +| Event | Description +|--- |--- +| rtm_open | a connection has been made to the RTM api +| rtm_close | a connection to the RTM api has closed +| rtm_reconnect_failed | if retry enabled, retry attempts have been exhausted + + +## Working with Slack Integrations + +There are a dizzying number of ways to integrate your application into Slack. +Up to this point, this document has mainly dealt with the real time / bot user +integration. In addition to this type of integration, Botkit also supports: + +* Incoming Webhooks - a way to send (but not receive) messages to Slack +* Outgoing Webhooks - a way to receive messages from Slack based on a keyword or phrase +* Slash Command - a way to add /slash commands to Slack +* Slack Web API - a full set of RESTful API tools to deal with Slack +* The Slack Button - a way to build Slack applications that can be used by multiple teams +* Events API - receive messages and other events via a RESTful web API + + +```javascript +var Botkit = require('botkit'); +var controller = Botkit.slackbot({}) + +var bot = controller.spawn({ + token: my_slack_bot_token +}); + +// use RTM +bot.startRTM(function(err,bot,payload) { + // handle errors... +}); + +// send webhooks +bot.configureIncomingWebhook({url: webhook_url}); +bot.sendWebhook({ + text: 'Hey!', + channel: '#testing', +},function(err,res) { + // handle error +}); + +// receive outgoing or slash commands +// if you are already using Express, you can use your own server instance... +// see "Use BotKit with an Express web server" +controller.setupWebserver(process.env.port,function(err,webserver) { + + controller.createWebhookEndpoints(controller.webserver); + +}); + +controller.on('slash_command',function(bot,message) { + + // reply to slash command + bot.replyPublic(message,'Everyone can see the results of this slash command'); + +}); +``` + +### Incoming webhooks + +Incoming webhooks allow you to send data from your application into Slack. +To configure Botkit to send an incoming webhook, first set one up +via [Slack's integration page](https://my.slack.com/services/new/incoming-webhook/). + +Once configured, use the `sendWebhook` function to send messages to Slack. + +[Read official docs](https://api.slack.com/incoming-webhooks) + +#### bot.configureIncomingWebhook() + +| Argument | Description +|--- |--- +| config | Configure a bot to send webhooks + +Add a webhook configuration to an already spawned bot. +It is preferable to spawn the bot pre-configured, but hey, sometimes +you need to do it later. + +#### bot.sendWebhook() + +| Argument | Description +|--- |--- +| message | A message object +| callback | _Optional_ Callback in the form function(err,response) { ... } + +Pass `sendWebhook` an object that contains at least a `text` field. + This object may also contain other fields defined [by Slack](https://api.slack.com/incoming-webhooks) which can alter the + appearance of your message. + +```javascript +var bot = controller.spawn({ + incoming_webhook: { + url: + } +}) + +bot.sendWebhook({ + text: 'This is an incoming webhook', + channel: '#general', +},function(err,res) { + if (err) { + // ... + } +}); +``` + +### Outgoing Webhooks and Slash commands + +Outgoing webhooks and Slash commands allow you to send data out of Slack. + +Outgoing webhooks are used to match keywords or phrases in Slack. [Read Slack's official documentation here.](https://api.slack.com/outgoing-webhooks) + +Slash commands are special commands triggered by typing a "/" then a command. +[Read Slack's official documentation here.](https://api.slack.com/slash-commands) + +Though these integrations are subtly different, Botkit normalizes the details +so developers may focus on providing useful functionality rather than peculiarities +of the Slack API parameter names. + +Note that since these integrations use send webhooks from Slack to your application, +your application will have to be hosted at a public IP address or domain name, +and properly configured within Slack. + +[Set up an outgoing webhook](https://my.slack.com/services/new/outgoing-webhook) + +[Set up a Slash command](https://my.slack.com/services/new/slash-commands) + +```javascript +controller.setupWebserver(port,function(err,express_webserver) { + controller.createWebhookEndpoints(express_webserver) +}); +``` + +#### Securing Outgoing Webhooks and Slash commands + +You can optionally protect your application with authentication of the requests +from Slack. Slack will generate a unique request token for each Slash command and +outgoing webhook (see [Slack documentation](https://api.slack.com/slash-commands#validating_the_command)). +You can configure the web server to validate that incoming requests contain a valid api token +by adding an express middleware authentication module. + +```javascript +controller.setupWebserver(port,function(err,express_webserver) { + controller.createWebhookEndpoints(express_webserver, ['AUTH_TOKEN', 'ANOTHER_AUTH_TOKEN']); + // you can pass the tokens as an array, or variable argument list + //controller.createWebhookEndpoints(express_webserver, 'AUTH_TOKEN_1', 'AUTH_TOKEN_2'); + // or + //controller.createWebhookEndpoints(express_webserver, 'AUTH_TOKEN'); +}); +``` + +#### Handling `slash_command` and `outgoing_webhook` events + +```javascript +controller.on('slash_command',function(bot,message) { + + // reply to slash command + bot.replyPublic(message,'Everyone can see this part of the slash command'); + bot.replyPrivate(message,'Only the person who used the slash command can see this.'); + +}) + +controller.on('outgoing_webhook',function(bot,message) { + + // reply to outgoing webhook command + bot.replyPublic(message,'Everyone can see the results of this webhook command'); + +}) +``` + +#### controller.setupWebserver() +| Argument | Description +|--- |--- +| port | port for webserver +| callback | callback function + +Setup an [Express webserver](http://expressjs.com/en/index.html) for +use with `createWebhookEndpoints()` + +If you need more than a simple webserver to receive webhooks, +you should by all means create your own Express webserver! + +The callback function receives the Express object as a parameter, +which may be used to add further web server routes. + +#### controller.createWebhookEndpoints() + +This function configures the route `http://_your_server_/slack/receive` +to receive webhooks from Slack. + +This url should be used when configuring Slack. + +When a slash command is received from Slack, Botkit fires the `slash_command` event. + +When an outgoing webhook is received from Slack, Botkit fires the `outgoing_webhook` event. + + +#### bot.replyAcknowledge + +| Argument | Description +|--- |--- +| callback | optional callback + +When used with slash commands, this function responds with a 200 OK response +with an empty response body. +[View Slack's docs here](https://api.slack.com/slash-commands) + + + +#### bot.replyPublic() + +| Argument | Description +|--- |--- +| src | source message as received from slash or webhook +| reply | reply message (string or object) +| callback | optional callback + +When used with outgoing webhooks, this function sends an immediate response that is visible to everyone in the channel. + +When used with slash commands, this function has the same functionality. However, +slash commands also support private, and delayed messages. See below. +[View Slack's docs here](https://api.slack.com/slash-commands) + +#### bot.replyPrivate() + +| Argument | Description +|--- |--- +| src | source message as received from slash +| reply | reply message (string or object) +| callback | optional callback + + +#### bot.replyPublicDelayed() + +| Argument | Description +|--- |--- +| src | source message as received from slash +| reply | reply message (string or object) +| callback | optional callback + +#### bot.replyPrivateDelayed() + +| Argument | Description +|--- |--- +| src | source message as received from slash +| reply | reply message (string or object) +| callback | optional callback + +#### bot.replyAndUpdate() + +| Argument | Description +|--- |--- +| src | source message as received from slash or webhook +| reply | reply message that might get updated (string or object) +| callback | optional asynchronous callback that performs a task and updates the reply message + +Sending a message, performing a task and then updating the sent message based on the result of that task is made simple with this method: + +> **Note**: For the best user experience, try not to use this method to indicate bot activity. Instead, use `bot.startTyping`. + +```javascript +// fixing a typo +controller.hears('hello', ['ambient'], function(bot, msg) { + // send a message back: "hellp" + bot.replyAndUpdate(msg, 'hellp', function(err, src, updateResponse) { + if (err) console.error(err); + // oh no, "hellp" is a typo - let's update the message to "hello" + updateResponse('hello', function(err) { + console.error(err) + }); + }); +}); +``` + + + +### Using the Slack Web API + +All (or nearly all - they change constantly!) of Slack's current web api methods are supported +using a syntax designed to match the endpoints themselves. + +If your bot has the appropriate scope, it may call [any of these methods](https://api.slack.com/methods) using this syntax: + +```javascript +bot.api.channels.list({},function(err,response) { + //Do something... +}) +``` + + + +## Use the Slack Button + +The [Slack Button](https://api.slack.com/docs/slack-button) is a way to offer a Slack +integration as a service available to multiple teams. Botkit includes a framework +on top of which Slack Button applications can be built. + +Slack button applications can use one or more of the [real time API](http://api.slack.com/rtm), +[incoming webhook](http://api.slack.com/incoming-webhooks) and [slash command](http://api.slack.com/slash-commands) integrations, which can be +added *automatically* to a team using a special oauth scope. + +If special oauth scopes sounds scary, this is probably not for you! +The Slack Button is useful for developers who want to offer a service +to multiple teams. + +How many teams can a Slack button app built using Botkit handle? +This will largely be dependent on the environment it is hosted in and the +type of integrations used. A reasonably well equipped host server should +be able to easily handle _at least one hundred_ real time connections at once. + +To handle more than one hundred bots at once, [consider speaking to the +creators of Botkit at Howdy.ai](http://howdy.ai) + +For Slack button applications, Botkit provides: + +* A simple webserver +* OAuth Endpoints for login via Slack +* Storage of API tokens and team data via built-in Storage +* Events for when a team joins, a new integration is added, and others... + +See the [included examples](readme.md#included-examples) for several ready to use example apps. + +#### controller.configureSlackApp() + +| Argument | Description +|--- |--- +| config | configuration object containing clientId, clientSecret, redirectUri and scopes + +Configure Botkit to work with a Slack application. + +Get a clientId and clientSecret from [Slack's API site](https://api.slack.com/applications). +Configure Slash command, incoming webhook, or bot user integrations on this site as well. + +Configuration must include: + +* clientId - Application clientId from Slack +* clientSecret - Application clientSecret from Slack +* redirectUri - the base url of your application +* scopes - an array of oauth permission scopes + +Slack has [_many, many_ oauth scopes](https://api.slack.com/docs/oauth-scopes) +that can be combined in different ways. There are also [_special oauth scopes_ +used when requesting Slack Button integrations](https://api.slack.com/docs/slack-button). +It is important to understand which scopes your application will need to function, +as without the proper permission, your API calls will fail. + +#### controller.createOauthEndpoints() +| Argument | Description +|--- |--- +| webserver | an Express webserver Object +| error_callback | function to handle errors that may occur during oauth + +Call this function to create two web urls that handle login via Slack. +Once called, the resulting webserver will have two new routes: `http://_your_server_/login` and `http://_your_server_/oauth`. The second url will be used when configuring +the "Redirect URI" field of your application on Slack's API site. + + +```javascript +var Botkit = require('botkit'); +var controller = Botkit.slackbot(); + +controller.configureSlackApp({ + clientId: process.env.clientId, + clientSecret: process.env.clientSecret, + redirectUri: 'http://localhost:3002', + scopes: ['incoming-webhook','team:read','users:read','channels:read','im:read','im:write','groups:read','emoji:read','chat:write:bot'] +}); + +controller.setupWebserver(process.env.port,function(err,webserver) { + + // set up web endpoints for oauth, receiving webhooks, etc. + controller + .createHomepageEndpoint(controller.webserver) + .createOauthEndpoints(controller.webserver,function(err,req,res) { ... }) + .createWebhookEndpoints(controller.webserver); + +}); + +``` + +#### Custom auth flows +In addition to the Slack Button, you can send users through an auth flow via a Slack interaction. +The `getAuthorizeURL` provides the url. It requires the `team_id` and accepts an optional `redirect_params` argument. +```javascript +controller.getAuthorizeURL(team_id, redirect_params); +``` + +The `redirect_params` argument is passed back into the `create_user` and `update_user` events so you can handle +auth flows in different ways. For example: + +```javascript +controller.on('create_user', function(bot, user, redirect_params) { + if (redirect_params.slash_command_id) { + // continue processing the slash command for the user + } +}); +``` + +### How to identify what team your message came from +```javascript +var team = bot.identifyTeam() // returns team id +``` + + +### How to identify the bot itself (for RTM only) +```javascript +var identity = bot.identifyBot() // returns object with {name, id, team_id} +``` + + +### Slack Button specific events: + +| Event | Description +|--- |--- +| create_incoming_webhook | +| create_bot | +| update_team | +| create_team | +| create_user | +| update_user | +| oauth_error | + + +## Message Buttons + +Slack applications can use "message buttons" or "interactive messages" to include buttons inside attachments. [Read the official Slack documentation here](https://api.slack.com/docs/message-buttons) + +Interactive messages can be sent via any of Botkit's built in functions by passing in +the appropriate attachment as part of the message. When users click the buttons in Slack, +Botkit triggers an `interactive_message_callback` event. + +When an `interactive_message_callback` is received, your bot can either reply with a new message, or use the special `bot.replyInteractive` function which will result in the original message in Slack being _replaced_ by the reply. Using `replyInteractive`, bots can present dynamic interfaces inside a single message. + +In order to use interactive messages, your bot will have to be [registered as a Slack application](https://api.slack.com/apps), and will have to use the Slack button authentication system. +To receive callbacks, register a callback url as part of applications configuration. Botkit's built in support for the Slack Button system supports interactive message callbacks at the url `https://_your_server_/slack/receive` Note that Slack requires this url to be secured with https. + +During development, a tool such as [localtunnel.me](http://localtunnel.me) is useful for temporarily exposing a compatible webhook url to Slack while running Botkit privately. + +```javascript +// set up a botkit app to expose oauth and webhook endpoints +controller.setupWebserver(process.env.port,function(err,webserver) { + + // set up web endpoints for oauth, receiving webhooks, etc. + controller + .createHomepageEndpoint(controller.webserver) + .createOauthEndpoints(controller.webserver,function(err,req,res) { ... }) + .createWebhookEndpoints(controller.webserver); + +}); +``` + +### Send an interactive message +```javascript +controller.hears('interactive', 'direct_message', function(bot, message) { + + bot.reply(message, { + attachments:[ + { + title: 'Do you want to interact with my buttons?', + callback_id: '123', + attachment_type: 'default', + actions: [ + { + "name":"yes", + "text": "Yes", + "value": "yes", + "type": "button", + }, + { + "name":"no", + "text": "No", + "value": "no", + "type": "button", + } + ] + } + ] + }); +}); +``` + +### Receive an interactive message callback + +```javascript +// receive an interactive message, and reply with a message that will replace the original +controller.on('interactive_message_callback', function(bot, message) { + + // check message.actions and message.callback_id to see what action to take... + + bot.replyInteractive(message, { + text: '...', + attachments: [ + { + title: 'My buttons', + callback_id: '123', + attachment_type: 'default', + actions: [ + { + "name":"yes", + "text": "Yes!", + "value": "yes", + "type": "button", + }, + { + "text": "No!", + "name": "no", + "value": "delete", + "style": "danger", + "type": "button", + "confirm": { + "title": "Are you sure?", + "text": "This will do something!", + "ok_text": "Yes", + "dismiss_text": "No" + } + } + ] + } + ] + }); + +}); +``` + +### Using Interactive Messages in Conversations + +It is possible to use interactive messages in conversations, with the `convo.ask` function. + +When used in conjunction with `convo.ask`, Botkit will treat the button's `value` field as if were a message typed by the user. + +```javascript +bot.startConversation(message, function(err, convo) { + + convo.ask({ + attachments:[ + { + title: 'Do you want to proceed?', + callback_id: '123', + attachment_type: 'default', + actions: [ + { + "name":"yes", + "text": "Yes", + "value": "yes", + "type": "button", + }, + { + "name":"no", + "text": "No", + "value": "no", + "type": "button", + } + ] + } + ] + },[ + { + pattern: "yes", + callback: function(reply, convo) { + convo.say('FABULOUS!'); + convo.next(); + // do something awesome here. + } + }, + { + pattern: "no", + callback: function(reply, convo) { + convo.say('Too bad'); + convo.next(); + } + }, + { + default: true, + callback: function(reply, convo) { + // do nothing + } + } + ]); +}); +``` + + +## Events API + +The [Events API](https://api.slack.com/events-api) is a streamlined way to build apps and bots that respond to activities in Slack. You must setup a [Slack App](https://api.slack.com/slack-apps) to use Events API. Slack events are delivered to a secure webhook, and allows you to connect to slack without the RTM websocket connection. + +During development, a tool such as [localtunnel.me](http://localtunnel.me) is useful for temporarily exposing a compatible webhook url to Slack while running Botkit privately. + +### To get started with the Events API: + +1. Create a [Slack App](https://api.slack.com/apps/new) +2. Setup oauth url with Slack so teams can add your app with the slack button. Botkit creates an oAuth endpoint at `http://MY_HOST/oauth` if using localtunnel your url may look like this `https://example-134l123.localtunnel.me/oauth` +3. Setup request URL under Events API to receive events at. Botkit will create webhooks for slack to send messages to at `http://MY_HOST/slack/receive`. if using localtunnel your url may look like this `https://example-134l123.localtunnel.me/slack/receive` +4. Select the specific events you would like to subscribe to with your bot. Slack only sends your webhook the events you subscribe to. Read more about Event Types [here](https://api.slack.com/events) +5. When running your bot, you must configure the slack app, setup webhook endpoints, and oauth endpoints. + +Note: If you are not also establishing an RTM connection, you will need to manually run the `controller.startTicking()` method for conversations to work properly. + +```javascript +var controller = Botkit.slackbot({ + debug: false, +}).configureSlackApp({ + clientId: process.env.clientId, + clientSecret: process.env.clientSecret, + // Disable receiving messages via the RTM even if connected + rtm_receive_messages: false, + // Request bot scope to get all the bot events you have signed up for + scopes: ['bot'], +}); + +// Setup the webhook which will receive Slack Event API requests +controller.setupWebserver(process.env.port, function(err, webserver) { + controller.createWebhookEndpoints(controller.webserver); + + controller.createOauthEndpoints(controller.webserver, function(err, req, res) { + if (err) { + res.status(500).send('ERROR: ' + err); + } else { + res.send('Success!'); + } + }); + + // If not also opening an RTM connection + controller.startTicking(); +}); +``` + +### Bot Presence + +Currently [presence](https://api.slack.com/docs/presence) is not supported by Slack Events API, so bot users will appear offline, but will still function normally. +Developers may want to establish an RTM connection in order to make the bot appear online. + +Since the Events API will send duplicates copies of many of the messages normally received via RTM, Botkit provides a configuration option that allows an RTM connection to be open, but for messages received via that connection to be discarded in favor +of the Events API. + +To enable this option, pass in `rtm_receive_messages: false` to your Botkit controller: + +```javascript +var controller = Botkit.slackbot({ + rtm_receive_messages: false +}); +``` + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/readme-studio.md b/docs/readme-studio.md new file mode 100644 index 000000000..b08390087 --- /dev/null +++ b/docs/readme-studio.md @@ -0,0 +1,368 @@ +# Building with Botkit Studio + +[Botkit Studio](https://studio.botkit.ai) is a hosted development tool that enhances and expands the capabilities of Botkit. While developers may use Botkit without Studio, a Studio account will substantially ease the development and deployment of a Bot, help to avoid common coding pitfalls, +a valuable management interface for the bot's dialog content and configuration. Botkit Studio is a product of [Howdy.ai](http://howdy.ai), the creators of Botkit. + +This document covers the Botkit Studio SDK details only. [Start here](readme.md) if you want to learn about to develop with Botkit. + +For bot developers who have existing apps but would like to benefit from features like bot-specific content management without using Botkit, you can access the [Botkit Studio SDK here](https://github.com/howdyai/botkit-studio-sdk) + +Table of Contents + +* [Key Features](#key-features) +* [Getting Started](#getting-started) +* [Function Index](#function-index) + +## Key Features +### Why use Botkit Studio? +The goal of Botkit Studio is to make building bots with Botkit easier than ever before. +The tools and this SDK are based on feedback from dozens of Botkit developers and more than a year of building and operating our flagship bot, [Howdy](https://howdy.ai). +However, our intent is not to cover every base needed for building a successful bot. Rather, we focus on several key problem areas: + +* **Script Authoring**: Botkit Studio provides a cloud-based authoring tool for creating and maintaining multi-thread conversations and scripts. Scripts managed in Studio can be changed without changing the underlying application code, allowing for content updates, feature additions and product development without code deploys. Many types of conversation will _no longer require code_ to function. + +* **Trigger Management**: Developers may define trigger words and patterns externally from their code, allowing those triggers to expand and adapt as humans use the bot. + +* **Easy to use SDK**: Our open source SDK provides simple mechanisms for accessing these features from within your application. With only a few new functions: [studio.get](#controllerstudioget), [studio.run](#controllerstudiorun), and [studio.runTrigger](#controllerruntrigger), your bot has full access to all of the content managed by the cloud service. These functions can be used to build light weight integrations with Studio, or turn over complete control to the cloud brain. + +![Botkit Studio authoring tool](studio_script_author.png) + +The screenshot above shows the script authoring tool, where bot developers can write dialog, +manage [conversation threads](readme.md#conversation-threads), define variables to capture user input, and set triggers that will automatically activate the bot. + +## Getting Started + +Before you get started, it is important to note that Botkit Studio is an extra set of features +built on top of [all of the other existing Botkit features and plugins](readme.md), and all of the documentation and tutorials for those features applies to bots built with Studio as well. + +If you already have a Botkit bot, you may want to start [here](#adding-studio-features-to-an-existing-bot). + +The instructions below cover setting up a bot on a Slack team. However, you may also use any of the other Botkit connectors to operate a bot on [Facebook](readme-facebook.md), [Twilio](readme-twilioipm.md), or any other supported platform. Follow the instructions for configuring Botkit on those dedicated pages, then pick up below with the additional Studio configuration steps. + +### Start with the Starter Kit + +1) [Register for a developer account at Botkit Studio](https://studio.botkit.ai/signup) + +2) Download the Botkit Studio Starter Kit [for Slack](https://github.com/howdyai/botkit-starter-slack) or [for Facebook Messenger](https://github.com/howdyai/botkit-starter-facebook). This code repository contains a fully functioning bot configured to work with Botkit Studio, and code examples demonstrating all of the key features. It also contains supporting material for the [tutorial included here](#tutorial). + +3) Make a bot integration inside of your Slack channel. Go here: + +https://my.slack.com/services/new/bot + +Enter a name for your bot. +Make it something fun and friendly, but avoid a single task specific name. +Bots can do lots! Let's not pigeonhole them. + +When you click "Add Bot Integration", you are taken to a page where you can add additional details about your bot, like an avatar, as well as customize its name & description. + +Copy the API token that Slack gives you. You'll need it. + +4) Inside Botkit Studio, locate the "API Keys" tab for your bot account, and copy the token. Now you've got everything you need! + +4) With both tokens in hand, run the starter kit bot application from your command line: + +``` +token=REPLACE_THIS_WITH_YOUR_TOKEN studio_token=REPLACE_WITH_YOUR_BOTKIT_TOKEN node . +``` + +5) Your bot should be online! Within Slack, send it a quick direct message to say hello. It should say hello back! + +6) Create new scripts, triggers and behaviors within your Botkit Studio developer account, while connecting it to the application logic present in your bot code by using the features described [in the function index](#function-index). + +### Adding Studio Features to an Existing Bot + +If you've already got a bot built with Botkit, you can get started with new Studio features with only a few extra lines of code. + +After you've registered for a Botkit Studio developer account, you will receive an API token that grants your bot access to the content and features managed by the Studio cloud service. You can add this to your existing Botkit app by passing in the Studio token to the Botkit constructor using the `studio_token` field: + +```javascript +// Create the Botkit controller that has access to Botkit Studio +var controller = Botkit.slackbot({ + debug: false, + studio_token: 'MY_BOTKIT_STUDIO_TOKEN' +}); +``` + +In order to add the Botkit Studio "catch all" handler that will activate the cloud script triggering service into your bot, add the following code to your application below all other Botkit `hears()` events. This will pass any un-handled direct message or direct mention through Botkit Studio's trigger service, and, should a matching trigger be found, execute the script. + +```javascript +controller.on('direct_message,direct_mention,mention', function(bot, message) { + controller.studio.runTrigger(bot, message.text, message.user, message.channel).catch(function(err) { + bot.reply(message, 'I experienced an error with a request to Botkit Studio: ' + err); + }); +}); +``` + +## The Botkit Studio Data Flow +### Keep your bot secure and your user messages private! + +How do the Botkit tools handle your messages? Where do messages come from and go to? + +1. The Botkit-powered application you build (and host yourself) receives messages directly from a messaging platform such as Slack or Facebook. + +2. Botkit will evaluate this message locally (within your application) to match it to internally defined triggers (using `hears()` or `on()` handlers). + +3. If and only if a message matches the conditions for being analyzed remotely -- by default, only messages sent directly to the bot that are not already part of an ongoing interaction -- the message is sent over an encrypted SSL channel to the Botkit Studio trigger system. + + The trigger system (using [studio.runTrigger](#controllerstudioruntrigger)) will evaluate the input message against all configured triggers, and, should one be matched, return the full content of the script to your application. + +4. Botkit will _compile_ the script received from the API into a [conversation object](readme.md#multi-message-replies-to-incoming-messages) and conduct the resulting conversation. During the course of the conversation, *no messages* are sent to the Botkit Studio APIs. Said another way, while a bot is conducting a scripted conversation, all messages are sent and received directly between the application and the messaging platform. This projects your user's messages, reduces network traffic, and ensures that your bot will not share information unnecessarily. + +Our recommended best practices for the security and performance of your bot and the privacy of your users is to send as few messages to be interpreted by the trigger APIs as possible. As described above, a normal Botkit Studio bot should _only_ send messages to the API that can reasonably be expected to contain trigger words. + +The bottom line is, Botkit Studio does _not_ put itself between your users and your application. All messages are delivered _first_ and directly to your application, and only sent to our APIs once specific criteria are met. This is notably different than some other bot building services that actually receive and process messages on your behalf. + +## Function Index + +### controller.studio.run() +| Argument | Description +|--- |--- +| bot | A bot instance +| input_text | The name of a script defined in Botkit Studio +| user | the user id of the user having the conversation +| channel | the channel id where the conversation is occurring + +`controller.studio.run()` will load a script defined in the Botkit Studio authoring tool, convert it into a Botkit conversation, and perform the conversation to it's completion. + +Developers may tap into the conversation as it is conducted using the [before](#controllerstudiobefore), [after](#controllerstudioafter), and [validate](#controllerstudiovalidate) hooks. It is also possible to bind to the normal `convo.on('end')` event because this function also returns the resulting conversation object via a promise: + +```javascript +controller.studio.run(bot, 'hello', message.user, message.channel).then(function(convo) { + convo.on('end', function(convo) { + if (convo.status=='completed') { + // handle successful conversation + } else { + // handle failed conversation + } + }); +}); +``` + +### controller.studio.get() +| Argument | Description +|--- |--- +| bot | A bot instance +| input_text | The name of a script defined in Botkit Studio +| user | the user id of the user having the conversation +| channel | the channel id where the conversation is occurring + +`controller.studio.get()` is nearly identical to `controller.studio.run()`, except that instead of automatically and immediately starting the conversation, the function returns it in a dormant state. + +While developers may still tap into the conversation as it is conducted using the [before](#controllerstudiobefore), [after](#controllerstudioafter), and [validate](#controllerstudiovalidate) hooks, it must first be activated using `convo.activate()` in the results of the promise returned by the function. + +This enables developers to add template variables to the conversation object before it sends its first message. Read about [using variables in messages](readme.md#using-variable-tokens-and-templates-in-conversation-threads) + +```javascript +controller.studio.get(bot, 'hello', message.user, message.channel).then(function(convo) { + convo.setVar('date', new Date()); // available in message text as {{vars.date}} + convo.setVar('news', 'This is a news item!'); // ailable as {{vars.news}} + + // crucial! call convo.activate to set it in motion + convo.activate(); +}); +``` + + +### controller.studio.runTrigger() +| Argument | Description +|--- |--- +| bot | A bot instance +| input_text | The name of a script defined in Botkit Studio +| user | the user id of the user having the conversation +| channel | the channel id where the conversation is occurring + +In addition to storing the content and structure of conversation threads, developers can also use Botkit Studio to define and maintain the trigger phrases and patterns that cause the bot to take actions. `controller.studio.runTrigger()` takes _arbitrary input text_ and evaluates it against all existing triggers configured in a bot's account. If a trigger is matched, the script data is returned, compiled, and executed by the bot. + +This is different than `studio.run()` and `studio.get()` in that the input text may include _additional text_ other than an the exact name of a script. In most cases, `runTrigger()` will be configured to receive all messages addressed to the bot that were not otherwise handled, allowing Botkit Studio to be catch-all. See below: + +```javascript +// set up a catch-all handler that will send all messages directed to the bot +// through Botkit Studio's trigger evaluation system +controller.on('direct_message,direct_mention,mention', function(bot, message) { + controller.studio.runTrigger(bot, message.text, message.user, message.channel).catch(function(err) { + bot.reply(message, 'I experienced an error with a request to Botkit Studio: ' + err); + }); +}); +``` + +In order to customize the behavior of scripts triggered using `runTrigger()`, developers define `before`, `after` and `validate` hooks for each script. See docs for these functions below. + +Another potential scenario for using `runTrigger()` would be to trigger a script that includes additional parameters that would normally be provided by a user, but are being provided instead by the bot. For example, it is sometimes useful to trigger another script to start when another script has ended. + +```javascript +controller.hears(['checkin'], 'direct_message', function(bot, message) { + + // when a user says checkin, we're going to pass in a more complex command to be evaluated + + // send off a string to be evaluated for triggers + // assuming a trigger is setup to respond to `run`, this will work! + controller.studio.runTrigger(bot, 'run checkin with ' + message.user, message.user, message.channel); +}); +``` + +#### A note about handling parameters to a trigger + +While Botkit does not currently provide any built-in mechanisms for extracting entities or parameters from the input text, that input text remains available in the `before` and `after` hooks, and can be used by developers to process this information. + +The original user input text is available in the field `convo.source_message.text`. An example of its use can be see in the [Botkit Studio Starter bot](https://github.com/howdyai/botkit-studio-starter), which extracts a parameter from the help command. + +```javascript +controller.studio.before('help', function(convo, next) { + + // is there a parameter on the help command? + // if so, change topic. + if (matches = convo.source_message.text.match(/^help (.*)/i)) { + if (convo.hasThread(matches[1])) { + convo.gotoThread(matches[1]); + } + } + + next(); + +}); +``` + +### controller.studio.before() +| Argument | Description +|--- |--- +| script_name | The name of a script defined in Botkit Studio +| hook_function | A function that accepts (convo, next) as parameters + +Define `before` hooks to add data or modify the behavior of a Botkit Studio script _before_ it is activated. Multiple before hooks can be defined for any script - they will be executed in the order they are defined. + +Note: hook functions _must_ call next() before ending, or the script will stop executing and the bot will be confused! + +```javascript +// Before the "tacos" script runs, set some extra template tokens like "special" and "extras" +controller.studio.before('tacos', function(convo, next) { + + convo.setVar('special', 'Taco Boats'); + convo.setVar('extras', [{'name': 'Cheese'}, {'name': 'Lettuce'}]); + + next(); + +}); +``` + + +### controller.studio.after() +| Argument | Description +|--- |--- +| script_name | The name of a script defined in Botkit Studio +| hook_function | A function that accepts (convo, next) as parameters + +Define `after` hooks capture the results, or take action _after_ a Botkit Studio script has finished executing. Multiple after hooks can be defined for any script - they will be executed in the order they are defined. + +Note: hook functions _must_ call next() before ending, or the script will stop executing and the bot will be confused! + +```javascript +// After the "tacos" command is finished, collect the order data +controller.studio.after('tacos', function(convo, next) { + if (convo.status == 'completed') { + var responses = convo.extractResponses(); + // store in a database + } + next(); +}); +``` + + +### controller.studio.validate() +| Argument | Description +|--- |--- +| script_name | The name of a script defined in Botkit Studio +| variable_name | The name of a variable defined in Botkit Studio +| hook_function | A function that accepts (convo, next) as parameters + +`validate` hooks are called whenever a Botkit Studio script sets or changes the value of a variable that has been defined as part of the script. + +Note: hook functions _must_ call next() before ending, or the script will stop executing and the bot will be confused! + +```javascript +// Validate a "sauce" variable in the "taco" script +// this will run whenever the sauce variable is set and can be used to +// alter the course of the conversation +controller.studio.validate('tacos', 'sauce', function(convo, next) { + + var sauce = convo.extractResponse('sauce'); + sauce = sauce.toLowerCase(); + + if (sauce == 'red' || sauce == 'green' || sauce == 'cheese') { + convo.setVar('valid_sauce', true); + next(); + } else { + convo.gotoThread('wrong_sauce'); + next(); + } + +}); +``` + +### controller.studio.beforeThread() +| Argument | Description +|--- |--- +| script_name | The name of a script defined in Botkit Studio +| thread_name | The name of a thread defined in Botkit Studio +| hook_function | A function that accepts (convo, next) as parameters + +`beforeThread` hooks are called whenever a Botkit Studio script changes from one thread to another. + +This works just like [convo.beforeThread()](readme.md#convobeforethread), but operates on the automagically compiled scripts managed by Botkit Studio's IDE. + +Note: hook functions _must_ call next() before ending, or the script will stop executing and the bot will be confused! +Allows developers to specify one or more functions that will be called before the thread +referenced in `thread_name` is activated. + +`handler_function` will receive the conversation object and a `next()` function. Developers +must call the `next()` function when their asynchronous operations are completed, or the conversation +may not continue as expected. + +Note that if `gotoThread()` is called inside the handler function, +it is recommended that `next()` be passed with an error parameter to stop processing of any additional thread handler functions that may be defined - that is, call `next('stop');` + +```javascript +// This example demonstrates how to use beforeThread to capture user input, do an asynchronous operation, then display the results in a new thread +// Imagine a conversation called `search` in which the first action is to collect search terms +// the conversation then transitions to the `results` thread, before which we do the actual search! +controller.studio.beforeThread('search', 'results', function(convo, next) { + + var query = convo.extractResponse('query'); + mySearchQuery(query).then(function(results) { + + convo.setVar('results', results); + next(); + + }).catch(function(err) { + + convo.setVar('error', err); + convo.gotoThread('error'); + next(err); + + }); + +}); +``` + + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/readme-teams.md b/docs/readme-teams.md new file mode 100644 index 000000000..c9d3c0e34 --- /dev/null +++ b/docs/readme-teams.md @@ -0,0 +1,1014 @@ +# Botkit for Microsoft Teams + +Botkit is designed to ease the process of designing and running useful, creative bots that live inside [Microsoft Teams](https://products.office.com/en-us/microsoft-teams/group-chat-software). For a full list of supported platforms, [check out the main Botkit readme](https://github.com/howdyai/botkit#botkit---building-blocks-for-building-bots) + +Botkit features a comprehensive set of tools to deal with [Microsoft Teams's integration platform](https://msdn.microsoft.com/en-us/microsoft-teams/), and allows developers to build both custom integrations for their team, as well as public "Microsoft Teams" applications that can be run from a central location, and be used by many teams at the same time. + +This document covers the Microsoft Teams-specific implementation details only. [Start here](https://github.com/howdyai/botkit/blob/master/docs/readme.md#developing-with-botkit) if you want to learn about how to develop with Botkit. + +Table of Contents + +* [Getting Started](#getting-started) +* [Developing with Botkit for Microsoft Teams](#developing-with-botkit-for-microsoft-teams) +* [Working with Microsoft Teams](#working-with-microsoft-teams) +* [Developer and Support Community](#developer--support-community) +* [About Botkit](#about-botkit) +* [Botkit Documentation Index](#botkit-documentation-index) + +## Getting Started + +Building bots is a fun and rewarding experience, but requires a few technical details be sorted out before you can start poking around inside your robot's brain. + +To get started building your bot, you'll need get these *three components* set up so that they can communicate with each other: + +* A Botkit-powered Node.js web app - this is the container inside which your bot brain lives, and where all its capabilities are defined +* The messaging platform - the place users interact with your bot, which provides a set of features, APIs and client applications +* A hosting environment - this gives your bot application a publicly reachable address on the public internet, which allows the messaging platform to communicate with your bot + +Getting these elements in place is a multi-step process, but only takes a few minutes, and in most cases, only has to be done once! + +### Fastest Option: Use Botkit Studio + +The fastest way to get up and running with Botkit for Microsoft Teams is to use [Botkit Studio](https://studio.botkit.ai/signup?code=teams). +Botkit Studio will guide you through the process of setting up the [Botkit Starter Kit for Microsoft Teams](https://github.com/howdyai/botkit-starter-teams), walk you through the process of configuring the Microsoft Teams and Bot Framework APIs, and deploy your bot to a stable hosting environment so that you can start building right away. + +**[![Sign up for Botkit Studio](docs/studio.png)](https://studio.botkit.ai/signup?code=readme)** + +### Manual Setup: Get the Starter Kit + +If you are comfortable with developer tools like Git, NPM, and setting up your own web host, +or if you want to build your bot on your laptop before making it available on the internet, +you can start by cloning the [Botkit Starter Kit for Microsoft Teams](https://github.com/howdyai/botkit-starter-teams). +The starter kit contains everything you need to build your bot, including a pre-configured Express webserver, +customizable webhook endpoints, and a set of example features that provide a great base for your new bot. + +[Get Botkit Starter Kit for Microsoft Teams](https://github.com/howdyai/botkit-starter-teams) + +[Read our step-by-step guide for configuring your starter kit](provisioning/teams.md) + +### Expert Option: Get Botkit from NPM + +If you are excited about building your entire bot from scratch, +or if you want to integrate bot functionality into an existing Node application, +you can install the Botkit core library directly from NPM. + +`npm install --save botkit` + +If you choose to use Botkit's core library directly like this, you'll need +to either use Botkit's simple [built-in webserver](#using-the-built-in-webserver), +or configure your own webserver and connect it to Botkit. +An example of this can be seen [in the starter kit](https://github.com/howdyai/botkit-starter-teams). + +([Our step-by-step guide to setting things up is probably still be useful, even for experts.](provisioning/teams.md)) + + +## Developing with Botkit for Microsoft Teams + +The full code for a simple Microsoft Teams bot is below: + +~~~ javascript +var Botkit = require('botkit'); + +var controller = Botkit.teamsbot({ + clientId: process.env.clientId, + clientSecret: process.env.clientSecret, +}); + +controller.setupWebserver(process.env.PORT || 3000, function(err, webserver) { + controller.createWebhookEndpoints(webserver, function() { + console.log("BOTKIT: Webhooks set up!"); + }); +}); + +controller.hears('hello', 'direct_message,direct_mention', function(bot, message) { + bot.reply(message, 'Hi'); +}); + +controller.on('direct_mention', function(bot, message) { + bot.reply(message, 'You mentioned me and said, "' + message.text + '"'); +}); + +controller.on('direct_message', function(bot, message) { + bot.reply(message, 'I got your private message. You said, "' + message.text + '"'); +}); +~~~ + + +#### App Package / Manifest File + +Before your bot application can be used, you must prepare an "App Package" - +a zip file containing a JSON file of configuration options, and (optionally) +icons for your bot to use inside the Teams interface. This file must then be +"sideloaded" into your Microsoft Teams account - this is just a fancy way +of saying that you will have to upload this file into a settings page. + +The manifest.json file is a hefty document, with lots of options! [Here is the full documentation from Microsoft](https://msdn.microsoft.com/en-us/microsoft-teams/schema). +We highly recommend using [Botkit Studio](https://studio.botkit.ai) to build your app package, as we have provided +an easy to use tool to configure and generate the necessary file! + +Here is a [COMPLETE SAMPLE](../examples/teams/manifest.json) + +[Manifest.json schema docs](https://msdn.microsoft.com/en-us/microsoft-teams/schema) + +[How to sideload your app](https://msdn.microsoft.com/en-us/microsoft-teams/sideload) + + + +#### Botkit.teamsbot() +| Argument | Description +|--- |--- +| clientId | The application' client id, provided by Bot Framework +| clientSecret | The application's client secret, provided by Bot Framework + +This function creates a Teams-ready Botkit controller. The values for clientId and clientSecret must be acquired from [Bot Framework](http://dev.botframework.com). + +~~~ javascript +var controller = Botkit.teamsbot({ + debug: true, + log: true, + clientId: process.env.clientId, + clientSecret: process.env.clientSecret +}); +~~~ + +#### controller.spawn() +| Argument | Description +|--- |--- +| options | An object defining options for this specific bot instance - MUST include a serviceUrl. + +This function returns a new instance of the bot. This is used internally by Botkit +to respond to incoming events. + +When spawning a bot for Microsoft Teams, you must pass in a `serviceUrl` field as part of +the options parameter. The serviceUrl can be extracted from the incoming message payload at `message.serviceUrl`. + +For those curious about this parameter: the serviceUrl is used to construct API calls the bot makes to Microsoft's API endpoints. +The endpoint URLs are actually defined dynamically in response to different kinds of incoming messages. This is because Microsoft Teams is just one of a +network of Microsoft products that uses the Bot Framework API specification, each one with its own endpoint URLs. + +In the event that your bot needs to send outbound messages without first receiving an inbound event from teams, +you should capture and store the serviceUrl value you receive from the `bot_channel_join` event, which indicates +that a bot has been added to a new team. + +``` +var bot = controller.spawn({serviceUrl: my_team_info.serviceUrl}); +``` + +#### Using the built-in webserver + +In order to receive messages and other events from Microsoft Teams, Botkit must +expose multiple web endpoints. + +Botkit includes a simple built-in webserver based on Express that is great for +getting started. With just a few lines of code, Botkit automatically configure +the necessary web endpoints. There are very few options available for the built-in +webserver, as it is intended to be used only for stand-alone bots. + +If you want your bot application to have additional web features (like [tabs](#using-tabs)), +or if you need to add bot functionality to an existing Express website, +or if you want to configure your own custom endpoints, +we suggest using the [Express Webserver component](https://github.com/howdyai/botkit-starter-teams/blob/master/components/express_webserver.js) +and [Incoming Webhook Route](https://github.com/howdyai/botkit-starter-teams/blob/master/components/routes/teams.js) +from the Botkit Starter Kit as a guide for your custom implementation. + +#### controller.setupWebserver() +| Argument | Description +|--- |--- +| port | port for webserver +| callback | callback function + +Setup an [Express webserver](http://expressjs.com/en/index.html) for +use with `createWebhookEndpoints()` + +#### controller.createWebhookEndpoints() +| Argument | Description +|--- |--- +| webserver | An instance of the Express webserver + +This function configures the route `http://_your_server_/teams/receive` +to receive incoming event data from Microsoft Teams. + +This url should be used when configuring your Bot Framework record. + +## Working with Microsoft Teams + +In addition to sending and receiving chat messages, Botkit bots can use all +of the other features in the Microsoft Teams API. With these other features, +Botkit bots can send rich attachments with interactive buttons, integrate into +the message composer, and expose integrated tab applications that live inside +the Teams window and share data with the bot. + +* [Events](#microsoft-teams-specific-events) +* [API Methods](#api-methods) +* [Attachments](#working-with-attachments-and-media) +* [Buttons](#buttons) +* [User Mentions](#user-mentions) +* [Compose Extensions](#using-compose-extensions) +* [Tabs](#using-tabs) + +### Microsoft Teams-specific Events + +Botkit receives and makes available all of the events supported by Microsoft Teams. + +The full list and payload schema of these events is [available from Microsoft](https://msdn.microsoft.com/en-us/microsoft-teams/botevents). + +These events undergo a normalization process for use inside Botkit, +so that any type of event can be passed to `bot.reply()` in order for a normal +message response to be sent. All incoming events will have _at least_ the following fields: + +``` +{ + type: , + user: , + channel: , + text: , + raw_message: +} +``` + +Botkit leaves all the native fields intact, so any fields that come in from Teams are still present in the original message. +However, our recommendation for accessing any Teams-native fields is to use the `message.raw_message` sub-object +which contains an unmodified version of the event data. + +#### Message Received Events +| Event | Description +|--- |--- +| direct_message | the bot received a 1:1 direct message from a user +| direct_mention | the bot was addressed directly in a mult-user channel ("@bot hello!") +| mention | the bot was mentioned by someone in a message ("hello everyone including @bot") + +#### User Activity Events: + +| Event | Description +|--- |--- +| bot_channel_join | the bot has joined a channel +| user_channel_join | a user has joined a channel +| bot_channel_leave | the bot has left a channel +| user_channel_leave | a user has left a channel + +#### Channel Events +| Event | Description +|--- |--- +| channelDeleted | a channel was deleted +| channelRenamed | a channel was renamed +| channelCreated | a new channel was created + +#### Teams Features +| Event | Description +|--- |--- +| invoke | a user clicked an `invoke` button [See Buttons](#buttons) +| composeExtension | user submitted a query with the compose extension [See Compose Extensions](#using-compose-extensions) + + +#### API Methods + +The [Microsoft Teams API](https://msdn.microsoft.com/en-us/microsoft-teams/botapis) provides a number of features the bot developer can use to power a useful bot application that operates seamlessly in Teams. + +#### bot.api.getUserById(conversationId, userId, cb) +| Parameter | Description +|--- |--- +| conversationId | Contains the unique identifier of a conversation +| userId | The unique identifier for a given user +| cb | Callback function in the form function(err, user_profile) + +`getUserById` takes elements from an incoming message object, and returns the user profile data +associated with the message's sender. + +```javascript +controller.hears('who am i', 'direct_message, direct_mention', function(bot, message) { + bot.api.getUserById(message.channel, message.user, function(err, user) { + if (err) { + bot.reply(message,'Error loading user:' + err); + } else { + bot.reply(message,'You are ' + user.name + ' and your email is ' + user.email + ' and your user id is ' + user.id); + } + }); +}); +``` + +#### bot.api.getUserByUpn(conversationId, upn, cb) +| Parameter | Description +|--- |--- +| conversationId | Contains the unique identifier of a conversation +| upn | The [User Principal Name](https://msdn.microsoft.com/en-us/library/windows/desktop/ms721629(v=vs.85).aspx#_security_user_principal_name_gly) of a given team member +| cb | Callback function in the form function(err, user_profile) + +This function is identical to `getUserById()`, but instead of fetching the user by the Teams-only user ID, it uses the user's "universal principal name" or "UPN", which defines the account in terms of the broader Microsoft Office ecosystem. This function is useful when connecting users in Microsoft Teams chat with the same users in a [Tab Application](#using-tabs), as tab applications only expose the `upn` value. + +The [Botkit Starter Kit for Microsoft Teams](https://github.com/howdyai/botkit-starter-teams) includes [a sample middleware](https://github.com/howdyai/botkit-starter-teams/blob/master/skills/load_user_data.js) that uses this function to automatically +translate the Teams-only ID into a UPN for use with the [built-in storage system](storage.md). + +#### bot.api.getConversationMembers(conversationId, cb) +| Parameter | Description +|--- |--- +| conversationId | Contains the unique identifier of a conversation +| cb | Callback function in the form function(err, members) + +This function returns a list of members in the specified channel - either a 1:1 channel, or a multi-user team channel. +This API returns an array of user profile objects identical to those returned by `getUserById()` and `getUserByUpn()`. + +```javascript +controller.hears('get members','direct_mention,direct_message', function(bot, message) { + bot.api.getConversationMembers(message.channel, function(err, roster) { + if (err) { + bot.reply(message,'Error loading roster: ' + err); + } else { + + var list = []; + for (var u = 0; u < roster.length; u++) { + list.push(roster[u].name); + } + bot.reply(message,'Conversation members: ' + list.join(', ')); + } + }); +}); +``` + +#### bot.api.getTeamRoster(teamId, cb) +| Parameter | Description +|--- |--- +| teamId | The unique identifier for a given team +| cb | Callback function in the form function(err, members) + +This function works just like `getConversationMembers()`, but returns all members of a team instead of just the members of a +specific channel. + +The teamId, when present, can be extracted from a message object at the Teams-specific field `message.channelData.team.id`. This field is present in messages that occur in multi-user channels, but not in 1:1 messages and other events. + +Note that since the team id is not always part of the incoming message payload, and because all multi-user channel contain all members +of the team, `getConversationMembers()` is likely more reliable and easy to use. + +```javascript +controller.hears('roster','direct_mention', function(bot, message) { + bot.api.getTeamRoster(message.channelData.team.id, function(err, roster) { + if (err) { + bot.reply(message,'Error loading roster: ' + err); + } else { + var list = []; + for (var u = 0; u < roster.length; u++) { + list.push(roster[u].name); + } + bot.reply(message,'Team roster: ' + list.join(', ')); + } + }); + +}); +``` + +### bot.api.updateMessage(conversationId, messageId, replacement, cb) +| Parameter | Description +|--- |--- +| conversationId | Contains the identifier for the conversation in which the original message occured +| messageId | Contains the unique identifier of message to be replaced +| replacement | A message object which will be used to replace the previous message +| cb | Callback function in the form function(err, results) + +This method allows you to update an existing message with a replacement. +This is super handy when responding to button click events, or updating a message with new information. + +In order to update a message, you must first capture it's ID. The message id is part of the response +passed back from bot.reply or bot.say. + +`updateMessage()` expects an API-ready message object - the replacement message does _not_ undergo the +normal pre-send transformations that occur during a normal bot.reply or bot.say. + +```javascript + controller.hears('update', 'direct_message,direct_mention', function(bot, message) { + bot.reply(message,'This is the original message', function(err, outgoing_message) { + bot.api.updateMessage(message.channel, outgoing_message.id, {type: 'message', text: 'This message has UPDATED CONTENT'}, function(err) { + if (err) { + console.error(err); + } + }); + }); + }) +``` + +#### bot.api.getChannels(teamId, cb) +| Parameter | Description +|--- |--- +| teamId | The unique identifier for a given team +| cb | Callback function in the form function(err, channels) + +This function returns an array of all the channels in a given team. + +The teamId, when present, can be extracted from a message object at the Teams-specific field `message.channelData.team.id`. This field is present in messages that occur in multi-user channels, but not in 1:1 messages and other events. + +```javascript + controller.hears('channels','direct_mention', function(bot, message) { + bot.api.getChannels(message.channelData.team.id, function(err, roster) { + if (err) { + bot.reply(message,'Error loading channel list: ' + err); + } else { + var list = []; + for (var u = 0; u < roster.length; u++) { + list.push(bot.channelLink(roster[u])); + } + bot.reply(message,'Channels: ' + list.join(', ')); + } + }); + }); +``` + + +#### bot.api.addMessageToConversation(conversationId, message, cb) +| Parameter | Description +|--- |--- +| conversationId | Contains the unique identifier of a conversation +| message | The contents of your message +| cb | Callback function in the form function(err, results) + +This function is used to send messages to Teams. It is used internally by Botkit's +`bot.say()` function, and is not intended to be used directly by developers. + +#### bot.api.createConversation(options, cb) +| Parameter | Description +|--- |--- +| options | an object containing {bot: id, members: [], channelData: {}} +| cb | Callback function in the form function(err, new_conversation_object) + +This function creates a new conversation context inside Teams. +This is used internally by Botkit inside functions like `startPrivateConversation()` +(to create the 1:1 channel between user and bot). It is not intended to be used directly by developers. + + +#### Working with attachments and media + +In addition to, or as an alternative to text, messages in Microsoft Teams can include one or more attachments. +Attachments appear as interactive cards inside the Teams client, and can include elements such as images, +text, structured data, and interactive buttons. + +[Read the official Teams documentation about message attachments](https://msdn.microsoft.com/en-us/microsoft-teams/botsmessages) + +To use attachments with Botkit, construct an attachment object and add it to the message object. +Botkit provides a few helper functions to make creating attachment objects easier. + +##### Attachment Helpers + +##### bot.createHero() +| Parameter | Description +|--- |--- +| title OR object| string value for the title of the card, OR an object representing all the fields in the card +| subtitle | string value for the subtitle of the card +| text | string value for the text of the card +| images | an array of image objects - {url: string, alt: string} - currently limited to 1 item +| buttons | an array of action objects - {type: string, title: string, value: string} +| tap action | a single of action object that defines the action to take when a user taps anywhere on the card - {type: string, title: string, value: string} + +(See usage notes below) + +##### bot.createThumbnail() +| Parameter | Description +|--- |--- +| title OR object| string value for the title of the card, OR an object representing all the fields in the card +| subtitle | string value for the subtitle of the card +| text | string value for the text of the card +| images | an array of image objects - {url: string, alt: string} - currently limited to 1 item +| buttons | an array of action objects - {type: string, title: string, value: string} +| tap action | a single of action object that defines the action to take when a user taps anywhere on the card - {type: string, title: string, value: string} + +The attachment building helper functions `bot.createHero()` and `bot.createThumbnail()` can be used to +quickly create attachment objects for inclusion in a message. + +The return value of these functions is an attachment object that can be directly added to the outgoing message object's `attachments` array. +In addition, the returned attachment object has a few helper methods of its that allow developers to adjust the values: + +###### attachment.title() +| Parameter | Description +|--- |--- +| value | new value for the title field + +###### attachment.subtitle() +| Parameter | Description +|--- |--- +| value | new value for the subtitle field + +###### attachment.text() +| Parameter | Description +|--- |--- +| value | new value for the text field + +###### attachment.image() +| Parameter | Description +|--- |--- +| url | url to image +| alt | alt description for image + +###### attachment.button() +| Parameter | Description +|--- |--- +| type OR button object | type of button OR an button object {type: string, title: string, value: string} +| title | string value for the button title +| value | string for the object payload. + +###### attachment.tap() +| Parameter | Description +|--- |--- +| type OR button object | new value for the title field +| title | string value for the action title +| value | string for the object payload. + +###### attachment.asString() + +Returns the stringified version of the attachment object + +##### Attachment Examples + +These functions can be used in a few different ways: + +*Create attachment with individual parameters:* +```javascript + +var reply = { + text: 'Here is an attachment!', + attachments: [], +} + +var attachment = bot.createHero('Title','subtitle','text',[{url:'http://placeimg.com/1900/600'}],[{type:'imBack','title':'Got it','value':'acknowledged'}],{type:'openUrl',value:'http://mywebsite.com'}); + +reply.attachments.push(attachment); +``` + +*Create attachment with pre-defined object:* +```javascript +var reply = { + text: 'Here is an attachment!', + attachments: [], +} + +var attachment = bot.createHero({ + title: 'My title', + subtitle: 'My subtitle', + text: 'My text', +}); + +reply.attachments.push(attachment); +``` + +*Create attachment with helper methods:* +```javascript +var reply = { + text: 'Here is an attachment!', + attachments: [], +} + +var attachment = bot.createHero(); + +attachment.title('This is the title'); +attachment.text('I am putting some text into a hero card'); +attachment.button('imBack','Click Me','I clicked a button!'); +attachment.button('openUrl','Link Me','http://website.com'); +attachment.button('invoke','Trigger Event',JSON.stringify({'key':'value'})); + +reply.attachments.push(attachment); +``` + +##### Multiple Attachments + +When sending multiple attachments, you may want to specify the `attachmentLayout` attribute +of the message object. Setting `attachmentLayout` to `carousel` will cause attachments +to be displayed as a [carousel](https://msdn.microsoft.com/en-us/microsoft-teams/botsmessages#carousel-layout), while the default behavior is to use a [list layout](https://msdn.microsoft.com/en-us/microsoft-teams/botsmessages#list-layout). + +##### Sample Hero Card + +``` +controller.hears('hero', 'direct_mention, direct_message', function(bot, message) { + + // this is a sample message object with an attached hero card + var reply = { + "text": "Here is a hero card:", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.hero", + "content": { + "title": "Hero card title", + "subtitle": "Hero card subtitle", + "text": "The text of my hero card" + "images": [ + { + "url": "http://placeimg.com/1600/900", + "alt": "An image from placeimg.com" + } + ], + } + } + ] + }; + + bot.reply(message,reply); +}); +``` + +##### Sample Thumbnail Card + +``` +controller.hears('thumbnail', 'direct_mention, direct_message', function(bot, message) { + + // this is a sample message object with an attached hero card + var reply = { + "text": "Here is a thumbnail card:", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.thumbnail", + "content": { + "title": "Thumbnail title", + "subtitle": "Thumbnail subtitle", + "text": "The text of my Thumbnail card" + "images": [ + { + "url": "http://placeimg.com/900/900", + "alt": "A nice square image from placeimg.com" + } + ], + } + } + ] + }; + + bot.reply(message,reply); +}); +``` + +##### Sample Image Attachment + +``` +controller.hears('image', 'direct_mention, direct_message', function(bot, message) { + + // this is a sample message object with an attached hero card + var reply = { + "text": "Check out this attached image!", + "attachments": [ + { + "contentType": "image/png", + "contentUrl": "http://mywebsite.com/image.png", + } + ] + }; + + bot.reply(message,reply); +}); +``` + + +##### Sample O365 Connector Card + +TODO + + +### Buttons + +Buttons can be included in attachments. +There are [several types of button](https://msdn.microsoft.com/en-us/microsoft-teams/botsmessages#buttons) that result in different actions. + +* `openUrl` buttons cause a browser to open to a specific web url +* `invoke` buttons cause a message to be sent back to your bot application +* `imBack` buttons cause the user to "say" something back to the bot +* `messageBack` buttons cause the user to "say" something back to your bot, while displaying a different message for other users to see. + +Note that is possible to send an attachment that is empty except for buttons - this can be useful! + +To use buttons, build them with the [attachment helpers](#attachment-helpers), +or construct them in code and include them in your attachment objects, as seen in the examples below: + +##### Sample invoke button + +``` +controller.hears('invoke button', function(bot, message) { +var reply = + { + text: 'This message has an invoke button', + attachments: [ + { + "contentType": "application/vnd.microsoft.card.hero", + "content": { + // other card fields here, see above + // title: '...', + // subtitle: '...', + // ... + "buttons": [ + { + "type": "invoke", + "title": "Click Me", + "value": "{\"action\":\"click\"}" + } + ] + } + ] + }; + + bot.reply(messge, reply); +}); +``` + + +##### Sample imBack button + +``` +controller.hears('imback button', function(bot, message) { +var reply = + { + text: 'This message has an imBack button', + attachments: [ + { + "contentType": "application/vnd.microsoft.card.hero", + "content": { + // other card fields here, see above + // title: '...', + // subtitle: '...', + // ... + "buttons": [ + { + "type": "imBack", + "title": "Hello!", + "value": "hello" + } + ] + } + ] + }; + + bot.reply(messge, reply); +}); +``` + +##### Sample openUrl button +``` +controller.hears('openurl button', function(bot, message) { +var reply = + { + text: 'This message has an openUrl button', + attachments: [ + { + "contentType": "application/vnd.microsoft.card.hero", + "content": { + // other card fields here, see above + // title: '...', + // subtitle: '...', + // ... + "buttons": [ + { + "type": "openUrl", + "title": "Open Link", + "value": "http://mywebsite.com" + } + ] + } + ] + }; + + bot.reply(messge, reply); +}); +``` + + +##### Tap Actions + +In addition to buttons, developers can add a "tap action" to a card which will be +triggered when a user clicks on any part of the card - sort of like a default action. + +Tap actions are defined using the same options as buttons - a [card action object](https://msdn.microsoft.com/en-us/microsoft-teams/botsmessages#card-actions) +set in the `message.tap` field: + +```javascript +var attachment = { + "contentType": "application/vnd.microsoft.card.hero", + "content": { + "title": "Hero card title", + "subtitle": "Hero card subtitle", + "text": "The text of my hero card" + "images": [ + { + "url": "http://placeimg.com/1600/900", + "alt": "An image from placeimg.com" + } + ], + "tap": { + "type": "imBack", + "value": "That tickles!" + } + } + } +``` + +#### Handling Invoke Events + +Botkit translates button clicks into `invoke` events. To respond to button click events, create one or more handlers for the invoke event. + +The message object passed in with the invoke event has a `value` field which will match the `value` specified when the button was created. + +Invoke events can be replied to, or used to start conversations, just like normal message events. + +```javascript +controller.on('invoke', function(bot, message) { + + // value is a user-defined object + var value = message.value; + + // send a reply to the user + bot.reply(messge,'You clicked a button!'); + +}); +``` + +### User Mentions + +Your bot can @mention another user in a message, which causes their username to be highlighted and a special notification to be sent. Teams mentions are slightly more complex than some other platforms, and require not only a special syntax in the message itself, but also additional fields in the message object. [See Microsoft's full docs for user mentions here](https://msdn.microsoft.com/en-us/microsoft-teams/botsinchannels#mentions). + +A native Teams user mention requires BOTH: + +* The `message.text` field includes the mention in the format "@User Name" +* The `message.entities` field includes an array element further defining the mention with the user's name and user ID. + +This makes life a bit tricky, because the user name is not part of the incoming message, and requires additional API or DB calls to retrieve. +Maintaining the entities field is also annoying! + +To make life easier for developers, Botkit supports an easier to use syntax by providing a translation middleware. +This allows developers to create mentions by including a modified mention syntax in the message text only, without having to also +specify the entity field. Botkit uses a "Slack-style" mention syntax: `<@USERID>`. + +Using this syntax, developers can create inline mentions in response to incoming messages with much less effort: + +``` +controller.hears('mention me', function(bot, message) { + + bot.reply(message,'I heard you, <@' + message.user +'>!'); + +}); +``` + + +#### Using Compose Extensions + +One of the unique features of Microsoft Teams is support for "[compose extensions](https://msdn.microsoft.com/en-us/microsoft-teams/composeextensions)" - +custom UI elements that appear adjacent to the "compose" bar in the Teams client that allow users to +create cards of their own using your bot application. + +With a compose extension, you can offer users a way to search or create content in your application +which is then attached to their message. Compose extensions can live in both multi-user team chats, as well as 1:1 discussions with the bot. +They work sort of like web forms - as a user types a query, the compose extension API retrieves results from the application and displays them in +the teams UI. When a result is selected, a custom app-defined card is attached to the user's outgoing message. Compose extensions use the [same attachment format as normal messages](#working-with-attachments-and-media). + +To enable a compose extension in your bot app, you must first add a configuration section to [your app's manifest file](#app-package--manifest-file). +Luckily, [Botkit Studio](http://studio.botkit.ai) has a tool for building these manifests. Using it will make your life much easier! + +Once configured, whenever a user uses your compose extension, your Botkit application will receive a `composeExtension` event. Botkit automatically +makes the user's query available in the `message.text` field, and provides a `bot.replyToComposeExtension()` function for formatting and delivering the results to Teams. +`replyToComposeExtension()` expects the response to be an array of [attachments](#working-with-attachments-and-media). + +```javascript +controller.on('composeExtension', function(bot, message) { + + var query = message.text; + + my_custom_search(query).then(function(results) { + + // let's format the results an array of hero card attachments + var attachments = []; + for (var r = 0; r < results.length; r++) { + var attachment = bot.createHero(results.title, results.subtitle, results.text); + attachments.push(attachment); + } + + // you can use the normal bot.reply function to send back the compose results! + bot.replyToComposeExtension(message, results); + + }); + +}); +``` + + +#### Using Tabs + +Tab applications provide the ability to display web content directly in the Teams UI. There are a few different types of tab, and applications can contain multiple tabs. [Microsoft has extensive documentation about building tab applications](https://msdn.microsoft.com/en-us/microsoft-teams/tabs), but the short story is: your bot can include an integrated web app component that interacts with Teams in some neat ways. Microsoft provides an easy to use [Javascript library](https://msdn.microsoft.com/en-us/microsoft-teams/jslibrary) that +is used to set tab configuration options, and provide information about the user, team, and channels in which the tab is installed. + +Tabs are configured in the [manifest.json](#app-package--manifest-file) as part of your app package. Read up on that, or use [Botkit Studio](https://studio.botkit.ai) to build this file. + +[The Botkit Starter Kit for Microsoft Teams](https://github.com/howdyai/botkit-starter-teams) contains a complete tab application, and demonstrates the interplay between the tab and bot components. This is a great starting point, and gives you all pieces you'd otherwise have to build yourself. + +The relevant information about building tabs in a Botkit application are: + +* Adding new web endpoints for serving the tab application +* Linking the _bot_ user to the _tab_ user in order to share data + +##### Adding Tab Endpoints to Botkit + +When using the [built-in webserver](#using-the-built-in-webserver), developers +can add web endpoints to the built in Express webserver inside the `setupWebserver()` callback function. +Tabs serve normal webpages, and can live at whatever URI you specify - as long as it matches +the settings in the manifest file. + +```javascript +controller.setupWebserver(3000, function(err, webserver) { + // set up the normal webhooks to receive messages from teams + controller.createWebhookEndpoints(webserver, function() { + console.log("BOTKIT: Webhooks set up!"); + }); + + // create a custom static tab url + webserver.get('/static-tab', function(req, res) { + res.send('This is my static tab url!') + }); + +}); +``` + +When using [the starter kit](https://github.com/howdyai/botkit-starter-teams), +tab urls can be added in the [components/routes/teams_tabs.js](https://github.com/howdyai/botkit-starter-teams/tree/master/components/routes) folder. + +##### Linking bot users to tab users + +Using the [Microsoft Teams Javascript library](https://msdn.microsoft.com/en-us/microsoft-teams/jslibrary), +developers can get access to the active user's "upn" - a user ID that identifies the user in the broader context of +Microsoft Office 365. + +This is a _different user ID_ than the one used inside Teams chat . A little bit of extra work is necessary to connect the dots between these +different account identifiers. + +Botkit provides a method, [bot.api.getUserByUpn()](#botapigetuserbyupn), that can be used to translate the upn value from the tab +into user id expected by Teams chat. It is also possible to translate a `message.user` field into a upn by using [bot.api.getUserById()](#botapigetuserbyid), +the results of which include the upn value. + +For these reasons, we recommend using a user's `upn` value as the primary key when storing information about a user. +The following example demonstrates using `getUserById()` to load a user's upn, then use the upn to load data from +Botkit's built-in storage system. Using the storage system in this way will allow data stored in it to be used by both +the bot and the tab application. + +[The starter kit](https://github.com/howdyai/botkit-starter-teams) implements this automatically using a +middleware that automatically translates the user ID into a UPN and pre-loads the user data before firing any handlers. +[This is a good reference for any customized solution.](https://github.com/howdyai/botkit-starter-teams/blob/master/skills/load_user_data.js) + +``` +controller.hears('save', 'direct_message', function(bot, message) { + + var value = 'special value'; + // use the microsoft teams API to load user data + bot.api.getUserById(message.channel, message.user, function(err, user_profile) { + // check errors + var upn = user_profile.userPrincipalName; + controller.storage.users.get(upn, function(err, user_data) { + // check errors + + // if no user found, create a new record with upn as primary id + if (!user_data) { + user_data = { + id: upn + } + } + + // update with new value + user_data.value = value; + + // store the user + controller.storage.users.save(user_data); + + // send a reply + bot.reply(message,'Saved'); + }); + }); +}); +``` + +## Developer & Support Community +Complete documentation for Botkit can be found on our [GitHub page](https://github.com/howdyai/botkit/blob/master/readme.md). Botkit Studio users can access the [Botkit Studio Knowledge Base](https://botkit.groovehq.com/help_center) for help in managing their Studio integration. + +### Get Involved! +Botkit is made possible with feedback and contributions from the community. A full guide to submitting code, reporting bugs, or asking questions on [Github can be found here](https://github.com/howdyai/botkit/blob/master/CONTRIBUTING.md) + +### Need more help? +* Join our thriving community of Botkit developers and bot enthusiasts at large. Over 4500 members strong, [our open Slack group](http://community.botkit.ai) is _the place_ for people interested in the art and science of making bots. + +Come to ask questions, share your progress, and commune with your peers! + +* We also host a [regular meet-up called TALKABOT.](http://talkabot.ai) Come meet, present, and learn from other bot developers! + + [Full video of our 2016 conference is available on our Youtube channel.](https://www.youtube.com/playlist?list=PLD3JNfKLDs7WsEHSal2cfwG0Fex7A6aok) + + +## About Botkit +Botkit is a product of [Howdy](https://howdy.ai) and made in Austin, TX with the help of a worldwide community of botheads. + + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/readme-twilioipm.md b/docs/readme-twilioipm.md new file mode 100644 index 000000000..2eaa1f2d0 --- /dev/null +++ b/docs/readme-twilioipm.md @@ -0,0 +1,312 @@ +# Botkit and Twilio IP Messaging + +Botkit is designed to ease the process of designing and running useful, creative bots that live inside [Slack](http://slack.com), [Facebook Messenger](http://facebook.com), [Twilio IP Messaging](https://www.twilio.com/docs/api/ip-messaging), and other messaging platforms. + +Built in to [Botkit](https://howdy.ai/botkit/) are a comprehensive set of features and tools to deal with [Twilio IP Messaging platform](https://www.twilio.com/docs/api/ip-messaging), allowing +developers to build interactive bots and applications that send and receive messages just like real humans. + +This document covers the Twilio-IPM implementation details only. [Start here](readme.md) if you want to learn about to develop with Botkit. + +Table of Contents + +* [Getting Started](#getting-started) +* [Twilio IPM Events](#twilio-ipm-specific-events) +* [Working with Twilio IPM](#working-with-twilio-ip-messaging) +* [System Bots vs User Bots](#system-bots-vs-user-bots) +* [Using Twilio's API](#using-the-twilio-api) + +## Getting Started + +1) Install Botkit [more info here](readme.md#installation) + +2) Register a developer account with Twilio. Once you've got it, navigate your way to the [Get Started with IP Messaging](https://www.twilio.com/user/account/ip-messaging/getting-started) documentation on Twilio's site. Read up!! + +3) To get your bot running, you need to collect *5 different API credentials*. You will need to acquire your Twilio Account SID, Auth Token, Service SID, API Key, and your API Secret to integrate with your Botkit. This is a multi-step process! + +##### Twilio Account SID and Auth Token + +These values are available on your [Twilio Account page](https://www.twilio.com/user/account/settings). Copy both the SID and token values. + +##### API Key and API Secret + +To get an API key and secret [go here](https://www.twilio.com/user/account/ip-messaging/dev-tools/api-keys) and click 'Create an API Key'. Provide a friendly name for the API service and click 'Create API Key'. Be sure to copy your Twilio API key and API Secret keys to a safe location - this is the last time Twilio will show you your secret! Click the checkbox for 'Got it! I have saved my API Key Sid and Secret in a safe place to use in my application.' + +##### Service SID + +To generate a Twilio service SID, [go here](https://www.twilio.com/user/account/ip-messaging/services) and click 'Create an IP Messaging Service'. + +Provide a friendly name and click 'create'. At the top under 'Properties' you should see Service SID. Copy this to a safe place. You now have all 5 values! + +*Keep this tab open!* You'll come back here in step 7 to specify your bot's webhook endpoint URL. + +4) Now that you've got all the credentials, you need to set up an actual IP Messaging client. If you don't already have a native app built, the quickest way to get started is to clone the Twilio IPM client demo, which is available at [https://github.com/twilio/ip-messaging-demo-js](https://github.com/twilio/ip-messaging-demo-js) + +Follow the instructions to get your IP Messaging Demo client up and running using the credentials you collected above. + +5) Start up the sample Twilio IPM Bot. From inside your cloned Botkit repo, run: +``` +TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_IPM_SERVICE_SID= TWILIO_API_KEY= TWILIO_API_SECRET= node examples/twilio_ipm_bot.js +``` + +6) If you are _not_ running your bot at a public, SSL-enabled internet address, use [localtunnel.me](http://localtunnel.me) to make it available to Twilio. Note the URL it gives you. For example, it may say your url is `https://xyx.localtunnel.me/` In this case, the webhook URL for use in step 7 would be `https://xyx.localtunnel.me/twilio/receive` + +7) Set up a webhook endpoint for your app that uses your public URL, or the URL that localtunnel gave you. This is done on [settings page for your IP Messaging service](https://www.twilio.com/user/account/ip-messaging/services). Enable *all of the POST-event* webhooks events! + +6) Load your IP Messaging client, and talk to your bot! + +Try: + +* hello +* who am i? +* call me Bob +* shutdown + +### Things to note + +Since Twilio delivers messages via web hook, your application must be available at a public internet address. Additionally, Twilio requires this address to use SSL. Luckily, you can use [LocalTunnel](https://localtunnel.me/) to make a process running locally or in your dev environment available in a Twilio-friendly way. + +Additionally, you need to enable your Twilio IPM instance's webhook callback events. This can be done via the Twilio dashboard, but can also be done automatically using a Bash script. You can use the sample script below to enable all of the post-event webhook callbacks: + +``` +#!/bin/bash +echo 'please enter the service uri' +read servuri + +echo 'please enter the service sid' +read servsid + +echo 'please enter the account sid' +read accsid + +echo 'please enter the auth token' +read authtok + +onChannelDestroyedCurl="curl -X POST https://ip-messaging.twilio.com/v1/Services/$servsid -d 'Webhooks.OnChannelDestroyed.Url=$servuri/twilio/receive' -d 'Webhooks.OnChannelDestroyed.Method=POST' -d 'Webhooks.OnChannelDestroyed.Format=XML' -u '$accsid:$authtok'" +eval $onChannelDestroyedCurl + +onChannelAddedCurl="curl -X POST https://ip-messaging.twilio.com/v1/Services/$servsid -d 'Webhooks.OnChannelAdded.Url=$servuri/twilio/receive' -d 'Webhooks.OnChannelAdded.Method=POST' -d 'Webhooks.OnChannelAdded.Format=XML' -u '$accsid:$authtok'" +eval $onChannelAddedCurl + +onMemberRemovedCurl="curl -X POST https://ip-messaging.twilio.com/v1/Services/$servsid -d 'Webhooks.OnMemberRemoved.Url=$servuri/twilio/receive' -d 'Webhooks.OnMemberRemoved.Method=POST' -d 'Webhooks.OnMemberRemoved.Format=XML' -u '$accsid:$authtok'" +eval $onMemberRemovedCurl +onMessageRemovedCurl="curl -X POST https://ip-messaging.twilio.com/v1/Services/$servsid -d 'Webhooks.OnMessageRemoved.Url=$servuri/twilio/receive' -d 'Webhooks.OnMessageRemoved.Method=POST' -d 'Webhooks.OnMessageRemoved.Format=XML' -u '$accsid:$authtok'" +eval $onMessageRemovedCurl + +onMessageUpdatedCurl="curl -X POST https://ip-messaging.twilio.com/v1/Services/$servsid -d 'Webhooks.OnMessageUpdated.Url=$servuri/twilio/receive' -d 'Webhooks.OnMessageUpdated.Method=POST' -d 'Webhooks.OnMessageUpdated.Format=XML' -u '$accsid:$authtok'" +eval $onMessageUpdatedCurl + +onChannelUpdatedCurl="curl -X POST https://ip-messaging.twilio.com/v1/Services/$servsid -d 'Webhooks.OnChannelUpdated.Url=$servuri/twilio/receive' -d 'Webhooks.OnChannelUpdated.Method=POST' -d 'Webhooks.OnChannelUpdated.Format=XML' -u '$accsid:$authtok'" +eval $onChannelUpdatedCurl + +onMemberAddedCurl="curl -X POST https://ip-messaging.twilio.com/v1/Services/$servsid -d 'Webhooks.OnMemberAdded.Url=$servuri/twilio/receive' -d 'Webhooks.OnMemberAdded.Method=POST' -d 'Webhooks.OnMemberAdded.Format=XML' -u '$accsid:$authtok'" +eval $onMemberAddedCurl +``` + +When you are ready to go live, consider [LetsEncrypt.org](http://letsencrypt.org), a _free_ SSL Certificate Signing Authority which can be used to secure your website very quickly. It is fabulous and we love it. + +## Twilio IPM Specific Events + +Once connected to your Twilio IPM service, bots receive a constant stream of events. + +Normal messages will be sent to your bot using the `message_received` event. In addition, Botkit will trigger these Botkit-specific events: + +| Event | Description +|--- |--- +| bot_channel_join| The bot has joined a channel +| bot_channel_leave | The bot has left a channel +| user_channel_join | A user (not the bot) has joined a channel +| user_channel_leave | A user (not the bot) has left a channel + +Botkit will handle and distribute [all of the Twilio IPM API webhooks events](https://www.twilio.com/docs/api/ip-messaging/webhooks). Your Bot can act on any of these events, and will receive the complete payload from Twilio. Below, is a list of the IPM API callback events that can be subscribed to in your Bot: + +| Event | Description +|--- |--- +| onMessageSent | Message sent +| onMessageRemoved | Message removed/deleted +| onMessageUpdated | Message edited +| onChannelAdded | Channel created +| onChannelUpdated | Channel FriendlyName or Attributes updated +| onChannelDestroyed | Channel Deleted/Destroyed +| onMemberAdded | Channel Member Joined or Added +| onMemberRemoved | Channel Member Removed or Left + + +## Working with Twilio IP Messaging + +Botkit receives messages from Twilio IPM using Webhooks, and sends messages using Twilio's REST APIs. This means that your Bot application must present a web server that is publicly addressable. Everything you need to get started is already included in Botkit. + +To connect your bot to Twilio, [follow the instructions here](https://www.twilio.com/user/account/ip-messaging/getting-started). You will need to collect 5 separate pieces of your API credentials. A step by step guide [can be found here](#getting-started). Since you must *already be running* your Botkit app to fully configure your Twilio app, there is a bit of back-and-forth. It's ok! You can do it. + +Here is the complete code for a basic Twilio bot: + +```javascript +var Botkit = require('botkit'); +var controller = Botkit.twilioipmbot({ + debug: false +}) + +var bot = controller.spawn({ + TWILIO_IPM_SERVICE_SID: process.env.TWILIO_IPM_SERVICE_SID, + TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID, + TWILIO_API_KEY: process.env.TWILIO_API_KEY, + TWILIO_API_SECRET: process.env.TWILIO_API_SECRET, + identity: 'Botkit', + autojoin: true +}); + +// if you are already using Express, you can use your own server instance... +// see "Use BotKit with an Express web server" +controller.setupWebserver(process.env.port,function(err,webserver) { + controller.createWebhookEndpoints(controller.webserver, bot, function() { + console.log('This bot is online!!!'); + }); +}); + +// user said hello +controller.hears(['hello'], 'message_received', function(bot, message) { + + bot.reply(message, 'Hey there.'); + +}); + +controller.hears(['cookies'], 'message_received', function(bot, message) { + + bot.startConversation(message, function(err, convo) { + + convo.say('Did someone say cookies!?!!'); + convo.ask('What is your favorite type of cookie?', function(response, convo) { + convo.say('Golly, I love ' + response.text + ' too!!!'); + convo.next(); + }); + }); +}); +``` + + +#### controller.setupWebserver() +| Argument | Description +|--- |--- +| port | port for webserver +| callback | callback function + +Setup an [Express webserver](http://expressjs.com/en/index.html) for +use with `createWebhookEndpoints()` + +If you need more than a simple webserver to receive webhooks, +you should by all means create your own Express webserver! + +The callback function receives the Express object as a parameter, +which may be used to add further web server routes. + +#### controller.createWebhookEndpoints() + +This function configures the route `https://_your_server_/twilio/receive` +to receive webhooks from twilio. + +This url should be used when configuring Twilio. + +## System Bots vs User Bots + +Bots inside a Twilio IPM environment can run in one of two ways: as the "system" user, +ever present and automatically available in all channels, OR, as a specific "bot" user +who must be added to channels in order to interact. + +By default, bots are "system" users, and can be configured as below: + +```javascript +var bot = controller.spawn({ + TWILIO_IPM_SERVICE_SID: process.env.TWILIO_IPM_SERVICE_SID, + TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID, + TWILIO_API_KEY: process.env.TWILIO_API_KEY, + TWILIO_API_SECRET: process.env.TWILIO_API_SECRET, +}); +``` + +To connect as a "bot" user, pass in an `identity` field: + +```javascript +var bot = controller.spawn({ + TWILIO_IPM_SERVICE_SID: process.env.TWILIO_IPM_SERVICE_SID, + TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID, + TWILIO_API_KEY: process.env.TWILIO_API_KEY, + TWILIO_API_SECRET: process.env.TWILIO_API_SECRET, + identity: 'My Bot Name', +}); +``` + +To have your bot automatically join every channel as they are created and removed, +pass in `autojoin`: + +```javascript +var bot = controller.spawn({ + TWILIO_IPM_SERVICE_SID: process.env.TWILIO_IPM_SERVICE_SID, + TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID, + TWILIO_API_KEY: process.env.TWILIO_API_KEY, + TWILIO_API_SECRET: process.env.TWILIO_API_SECRET, + identity: 'Botkit', + autojoin: true +}); +``` + +## Using the Twilio API + +You can use the Twilio API directly in your Bot via Botkit's bot.api object. Botkit's bot.api provides a thin wrapper on the [Twilio official module](http://twilio.github.io/twilio-node/). + +For example, to [retrieve a member from a channel](https://www.twilio.com/docs/api/ip-messaging/rest/members#action-get) using the un-wrapped Twilio API client, you would use the following code: + +```javascript +service.channels('CHANNEL_SID').members('MEMBER_SID').get().then(function(response) { + console.log(response); +}).fail(function(error) { + console.log(error); +}); +``` + +In Botkit, this can be accomplished by simply replacing the reference to a `service` object, with the `bot.api` object, as shown here: + +```javascript +bot.api.channels('CHANNEL_SID').members('MEMBER_SID').get().then(function(response) { + console.log(response); +}).fail(function(error) { + console.log(error); +}); +``` +This gives you full access to all of the Twilio API methods so that you can use them in your Bot. + +Here is an example showing how to join a channel using Botkit's bot.api object, which creates a member to the channel, by wrapping the IPM API. + +```javascript +controller.on('onChannelAdded', function(bot, message){ + // whenever a channel gets added, join it! + bot.api.channels(message.channel).members.create({ + identity: bot.identity + }).then(function(response) { + + }).fail(function(error) { + console.log(error); + }); +}); +``` + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/readme-twiliosms.md b/docs/readme-twiliosms.md new file mode 100644 index 000000000..984dbfa3b --- /dev/null +++ b/docs/readme-twiliosms.md @@ -0,0 +1,145 @@ +# Botkit and Twilio Programmable SMS + +Botkit is designed to ease the process of designing and running useful, creative bots that live inside [Slack](http://slack.com), [Facebook Messenger](http://facebook.com), [Twilio IP Messaging](https://www.twilio.com/docs/api/ip-messaging), and other messaging platforms like [Twilio's Programmable SMS](https://www.twilio.com/sms/). + +Botkit features a comprehensive set of tools to deal with [Twilio's Programmable SMS API](http://www.twilio.com/sms/), and allows developers to build interactive bots and applications that send and receive messages just like real humans. Twilio SMS bots receive and send messages through a regular phone number. + +This document covers the Twilio Programmable SMS API implementation details only. [Start here](readme.md) if you want to learn about how to develop with Botkit. + +# Getting Started + +1) Install Botkit [more info here](readme.md#installation) + +2) [Register a developer account with Twilio](https://github.com/howdyai/botkit/blob/master/docs/provisioning/twilio-sms.md). Once you've got it, head to the [Get Started with SMS](https://www.twilio.com/console/sms/getting-started/basics) page in your Twilio Console. + + After completing the tutorial above you should have all three values to get your bot running: A **Twilio Account SID**, a **Twilio Auth Token**, and a **Twilio Number**. + + **Twilio Account SID and Auth Token** + + These values are available on your [Twilio Account Settings](https://www.twilio.com/user/account/settings) page on the Twilio Console. Copy both the SID and token values (located under API Credentials) + + **Twilio Number** + + You should have purchased a Twilio Number. You will send/receive messages using this phone number. Example: `+19098765432` + +3) Configure your Twilio Number. Head to the [Phone Numbers](https://www.twilio.com/console/phone-numbers) in your Twilio Console and select the phone number you will use for your SMS bot. + + Under the *Messaging* section, select "Webhooks/TwiML" as your *Configure with* preference. Two more fields will pop up: ***A message comes in***, and ***Primary handler fails***. + + The first one is the type of handler you will use to respond to Twilio webhooks. Select "Webhook" and input the URI of your endpoint (e.g. `https://mysmsbot.localtunnel.me/sms/receive`) and select `HTTP POST` as your handling method. + + Twilio will send `POST` request to this address every time a user sends an SMS to your Twilio Number. + + > By default Botkit will serve content from `https://YOURSERVER/sms/receive`. If you are not running your bot on a public, SSL-enabled internet address, you can use a tool like [ngrok.io](http://ngrok.io/) or [localtunnel.me](localtunnel.me) to expose your local development enviroment to the outside world for the purposes of testing your SMS bot. + + The second preference ("Primary handler fails") is your backup plan. The URI Twilio should `POST` to in case your primary handler is unavailable. You can leave this field in blank for now but keep in mind this is useful for error handling (e.g. to notify users that your bot is unavailable). + +4) Run the example Twilio SMS bot included in Botkit's repository ([`twilio_sms_bot.js`](../examples/twilio_sms_bot.js)). Copy and paste the example bot's code into a new JavaScript file (e.g. `twilio_sms_bot.js`) in your current working directory and run the following command on your terminal: + + ```bash + $ TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_NUMBER= node twilio_sms_bot.js + ``` + + > Note: Remember to run localtunnel or ngrok to expose your local development environment to the outside world. For example, in localtunnel run `lt --port 5000 --subdomain mysmsbot` (See note on step 4) + +6) Your bot should be online! Grab your phone and text `hi` to your Twilio Number and you will get a `Hello.` message back! + + Try the following messages: `Hi`, `Call me bob`, `what's my name?` + +## Usage + +*Note: This document assumes that you are familiarized with Botkit and Twilio's Programmable SMS API* + +To connect your bot to Twilio you must point a Messaging webhook to http://your_host/sms/receive, after doing so, every Twilio message will be sent to that address. + +Then you need to write your bot. First, create a TwilioSMSBot instance and pass an object with your configuration properties: + +* `account_sid`: found in your [Twilio Console Dashboard](https://www.twilio.com/console) +* `auth_token`: found in your [Twilio Console Dashboard](https://www.twilio.com/console) +* `twilio_number`: your app's phone number, found in your [Phone Numbers Dashboard](https://www.twilio.com/console/phone-numbers/dashboard) **The phone number format must be: `+15551235555`** + +```js +const Botkit = require('botkit') +const controller = Botkit.twiliosmsbot({ + account_sid: process.env.TWILIO_ACCOUNT_SID, + auth_token: process.env.TWILIO_AUTH_TOKEN, + twilio_number: process.env.TWILIO_NUMBER +}) +``` + +`spawn()` your bot instance: + +```js +let bot = controller.spawn({}) +``` + +Then you need to set up your Web server and create the webhook endpoints so your app can receive Twilio's messages: + +```js +controller.setupWebserver(process.env.PORT, function (err, webserver) { + controller.createWebhookEndpoints(controller.webserver, bot, function () { + console.log('TwilioSMSBot is online!') + }) +}) +``` + +And finally, you can setup listeners for specific messages, like you would in any other `botkit` bot: + +```js +controller.hears(['hi', 'hello'], 'message_received', (bot, message) => { + bot.startConversation(message, (err, convo) => { + convo.say('Hi, I am Oliver, an SMS bot! :D') + convo.ask('What is your name?', (res, convo) => { + convo.say(`Nice to meet you, ${res.text}!`) + convo.next() + }) + }) +}) + +controller.hears('.*', 'message_received', (bot, message) => { + bot.reply(message, 'huh?') +}) +``` + +See full example in the `examples` directory of this repo. + +### Sending media attachments (MMS) + +To send media attachments, pass a `mediaUrl` property to any of Botkit's outgoing messages functions (`reply`, `say`, `ask`, etc.) with an optional `text` property for text that goes along with your attachment. + +```js +/* + Sending an attachment +*/ +bot.reply(message, { + text: 'Optional text to go with attachment', + mediaUrl: 'https://i.imgur.com/9n3qoKx.png' +}) +``` + +> Note: your Twilio number as well as the recipient's phone must support MMS for media attachments to work + +For more details on outgoing media attachments and a full list of accepted MIME types, go to [Twilio's docs on media attachment](https://www.twilio.com/docs/api/rest/accepted-mime-types). + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/readme.md b/docs/readme.md new file mode 100755 index 000000000..67b92c866 --- /dev/null +++ b/docs/readme.md @@ -0,0 +1,960 @@ +# [Botkit](http://howdy.ai/botkit) - Building Blocks for Building Bots + +## Core Concepts + +Bots built with Botkit have a few key capabilities, which can be used to create clever, conversational applications. These capabilities map to the way real human people talk to each other. + +Bots can [hear things](#receiving-messages), [say things and reply](#sending-messages) to what they hear. + +With these two building blocks, almost any type of conversation can be created. + +To organize the things a bot says and does into useful units, Botkit bots have a subsystem available for managing [multi-message conversations](#multi-message-conversations). Conversations add features like the ability to ask a question, queue several messages at once, and track when an interaction has ended. Handy! + +After a bot has been told what to listen for and how to respond, +it is ready to be connected to a stream of incoming messages. Currently, Botkit supports receiving messages from a variety of sources: + +* [Slack Real Time Messaging (RTM)](http://api.slack.com/rtm) +* [Slack Incoming Webhooks](http://api.slack.com/incoming-webhooks) +* [Slack Slash Commands](http://api.slack.com/slash-commands) +* [Cisco Spark Webhooks](https://developer.ciscospark.com/webhooks-explained.html) +* [Microsoft Teams](https://msdn.microsoft.com/en-us/microsoft-teams/bots) +* [Facebook Messenger Webhooks](https://developers.facebook.com/docs/messenger-platform/implementation) +* [Twilio SMS](https://www.twilio.com/console/sms/dashboard) +* [Twilio IP Messaging](https://www.twilio.com/console/chat/dashboard) +* [Microsoft Bot Framework](http://botframework.com/) + +Read more about +[connecting your bot to Slack](readme-slack.md#connecting-your-bot-to-slack), +[connecting your bot to Cisco Spark](readme-ciscospark.md#getting-started), +[connecting your bot to Microsoft Teams](readme-teams.md#getting-started), +[connecting your bot to Facebook](readme-facebook.md#getting-started), +[connecting your bot to Twilio](readme-twilioipm.md#getting-started), +or [connecting your bot to Microsoft Bot Framework](readme-botframework.md#getting-started) + +## Basic Usage + +This sample bot listens for the word "hello" to be said to it -- either as a direct mention ("@bot hello") or an indirect mention ("hello @bot") or a direct message (a private message inside Slack between the user and the bot). + +The Botkit constructor returns a `controller` object. By attaching event handlers +to the controller object, developers can specify what their bot should look for and respond to, +including keywords, patterns and various [messaging and status events](#responding-to-events). +These event handlers can be thought of metaphorically as skills or features the robot brain has -- each event handler defines a new "When a human says THIS the bot does THAT." + +The `controller` object is then used to `spawn()` bot instances that represent +a specific bot identity and connection to Slack. Once spawned and connected to +the API, the bot user will appear online in Slack, and can then be used to +send messages and conduct conversations with users. They are called into action by the `controller` when firing event handlers. + +```javascript +var Botkit = require('botkit'); + +var controller = Botkit.slackbot(configuration); + + +// give the bot something to listen for. +controller.hears('hello',['direct_message','direct_mention','mention'],function(bot,message) { + + bot.reply(message,'Hello yourself.'); + +}); + +``` + + +# Developing with Botkit + +Table of Contents + +* [Receiving Messages](#receiving-messages) +* [Sending Messages](#sending-messages) +* [Multi-message Conversations](#multi-message-conversations) +* [Middleware](middleware.md) +* [Advanced Topics](#advanced-topics) + +### Responding to events + +Once connected to a messaging platform, bots receive a constant stream of events - everything from the normal messages you would expect to typing notifications and presence change events. The set of events your bot will receive will depend on what messaging platform it is connected to. + +All platforms will receive the `message_received` event. This event is the first event fired for every message of any type received - before any platform specific events are fired. + +```javascript +controller.on('message_received', function(bot, message) { + + // carefully examine and + // handle the message here! + // Note: Platforms such as Slack send many kinds of messages, not all of which contain a text field! +}); +``` + +Due to the multi-channel, multi-user nature of Slack, Botkit does additional filtering on the messages (after firing message_received), and will fire more specific events based on the type of message - for example, `direct_message` events indicate a message has been sent directly to the bot, while `direct_mention` indicates that the bot has been mentioned in a multi-user channel. +[List of Slack-specific Events](readme-slack.md#slack-specific-events) + +Similarly, bots in Cisco Spark will receive `direct_message` events to indicate a message has been sent directly to the bot, while `direct_mention` indicates that the bot has been mentioned in a multi-user channel. Several other Spark-specific events will also fire. [List of Cisco Spark-specific Events](readme-ciscospark.md#spark-specific-events) + +Twilio IPM bots can also exist in a multi-channel, multi-user environment. As a result, there are many additional events that will fire. In addition, Botkit will filter some messages, so that the bot will not receive it's own messages or messages outside of the channels in which it is present. +[List of Twilio IPM-specific Events](readme-twilioipm.md#twilio-ipm-specific-events) + +Facebook messages are fairly straightforward. However, because Facebook supports inline buttons, there is an additional event fired when a user clicks a button. +[List of Facebook-specific Events](readme-facebook.md#facebook-specific-events) + + +## Receiving Messages + +Botkit bots receive messages through a system of specialized event handlers. Handlers can be set up to respond to specific types of messages, or to messages that match a given keyword or pattern. + +These message events can be handled by attaching an event handler to the main controller object. +These event handlers take two parameters: the name of the event, and a callback function which is invoked whenever the event occurs. +The callback function receives a bot object, which can be used to respond to the message, and a message object. + +```javascript +// reply to any incoming message +controller.on('message_received', function(bot, message) { + bot.reply(message, 'I heard... something!'); +}); + +// reply to a direct mention - @bot hello +controller.on('direct_mention',function(bot,message) { + // reply to _message_ by using the _bot_ object + bot.reply(message,'I heard you mention me!'); +}); + +// reply to a direct message +controller.on('direct_message',function(bot,message) { + // reply to _message_ by using the _bot_ object + bot.reply(message,'You are talking directly to me'); +}); +``` + +### Matching Patterns and Keywords with `hears()` + +In addition to these traditional event handlers, Botkit also provides the `hears()` function, +which configures event handlers based on matching specific keywords or phrases in the message text. +The hears function works just like the other event handlers, but takes a third parameter which +specifies the keywords to match. + +| Argument | Description +|--- |--- +| patterns | An _array_ or a _comma separated string_ containing a list of regular expressions to match +| types | An _array_ or a _comma separated string_ of the message events in which to look for the patterns +| middleware function | _optional_ function to redefine how patterns are matched. see [Botkit Middleware](middleware.md) +| callback | callback function that receives a message object + +```javascript +controller.hears(['keyword','^pattern$'],['message_received'],function(bot,message) { + + // do something to respond to message + bot.reply(message,'You used a keyword!'); + +}); +``` + +When using the built in regular expression matching, the results of the expression will be stored in the `message.match` field and will match the expected output of normal Javascript `string.match(/pattern/i)`. For example: + +```javascript +controller.hears('open the (.*) doors',['message_received'],function(bot,message) { + var doorType = message.match[1]; //match[1] is the (.*) group. match[0] is the entire group (open the (.*) doors). + if (doorType === 'pod bay') { + return bot.reply(message, 'I\'m sorry, Dave. I\'m afraid I can\'t do that.'); + } + return bot.reply(message, 'Okay'); +}); +``` + +## Sending Messages + +Bots have to send messages to deliver information and present an interface for their +functionality. Botkit bots can send messages in several different ways, depending +on the type and number of messages that will be sent. + +Single message replies to incoming commands can be sent using the `bot.reply()` function. + +Multi-message replies, particularly those that present questions for the end user to respond to, +can be sent using the `bot.startConversation()` function and the related conversation sub-functions. + +Bots can originate messages - that is, send a message based on some internal logic or external stimulus - +using `bot.say()` method. + +All `message` objects must contain a `text` property, even if it's only an empty string. + +### Single Message Replies to Incoming Messages + +Once a bot has received a message using a `on()` or `hears()` event handler, a response +can be sent using `bot.reply()`. + +Messages sent using `bot.reply()` are sent immediately. If multiple messages are sent via +`bot.reply()` in a single event handler, they will arrive in the client very quickly +and may be difficult for the user to process. We recommend using `bot.startConversation()` +if more than one message needs to be sent. + +You may pass either a string, or a message object to the function. + +Message objects may also contain any additional fields supported by the messaging platform in use: + +[Slack's chat.postMessage](https://api.slack.com/methods/chat.postMessage) API accepts several additional fields. These fields can be used to adjust the message appearance, add attachments, or even change the displayed user name. + +This is also true of Facebook. Calls to [Facebook's Send API](https://developers.facebook.com/docs/messenger-platform/send-api-reference) can include attachments which result in interactive "structured messages" which can include images, links and action buttons. + +#### bot.reply() + +| Argument | Description +|--- |--- +| message | Incoming message object +| reply | _String_ or _Object_ Outgoing response +| callback | _Optional_ Callback in the form function(err,response) { ... } + +Simple reply example: +```javascript +controller.hears(['keyword','^pattern$'],['message_received'],function(bot,message) { + + // do something to respond to message + // ... + + bot.reply(message,"Tell me more!"); + +}); +``` + +Slack-specific fields and attachments: +```javascript +controller.on('ambient',function(bot,message) { + + // do something... + + // then respond with a message object + // + bot.reply(message,{ + text: "A more complex response", + username: "ReplyBot", + icon_emoji: ":dash:", + }); + +}) + +//Using attachments +controller.hears('another_keyword','direct_message,direct_mention',function(bot,message) { + var reply_with_attachments = { + 'username': 'My bot' , + 'text': 'This is a pre-text', + 'attachments': [ + { + 'fallback': 'To be useful, I need you to invite me in a channel.', + 'title': 'How can I help you?', + 'text': 'To be useful, I need you to invite me in a channel ', + 'color': '#7CD197' + } + ], + 'icon_url': 'http://lorempixel.com/48/48' + } + + bot.reply(message, reply_with_attachments); +}); + +``` + + +Facebook-specific fields and attachments: +```javascript +// listen for the phrase `shirt` and reply back with structured messages +// containing images, links and action buttons +controller.hears(['shirt'],'message_received',function(bot, message) { + bot.reply(message, { + attachment: { + 'type':'template', + 'payload':{ + 'template_type':'generic', + 'elements':[ + { + 'title':'Classic White T-Shirt', + 'image_url':'http://petersapparel.parseapp.com/img/item100-thumb.png', + 'subtitle':'Soft white cotton t-shirt is back in style', + 'buttons':[ + { + 'type':'web_url', + 'url':'https://petersapparel.parseapp.com/view_item?item_id=100', + 'title':'View Item' + }, + { + 'type':'web_url', + 'url':'https://petersapparel.parseapp.com/buy_item?item_id=100', + 'title':'Buy Item' + }, + { + 'type':'postback', + 'title':'Bookmark Item', + 'payload':'USER_DEFINED_PAYLOAD_FOR_ITEM100' + } + ] + }, + { + 'title':'Classic Grey T-Shirt', + 'image_url':'http://petersapparel.parseapp.com/img/item101-thumb.png', + 'subtitle':'Soft gray cotton t-shirt is back in style', + 'buttons':[ + { + 'type':'web_url', + 'url':'https://petersapparel.parseapp.com/view_item?item_id=101', + 'title':'View Item' + }, + { + 'type':'web_url', + 'url':'https://petersapparel.parseapp.com/buy_item?item_id=101', + 'title':'Buy Item' + }, + { + 'type':'postback', + 'title':'Bookmark Item', + 'payload':'USER_DEFINED_PAYLOAD_FOR_ITEM101' + } + ] + } + ] + } + } + }); +}); +``` + +## Multi-message Conversations + +For more complex commands, multiple messages may be necessary to send a response, +particularly if the bot needs to collect additional information from the user. + +Botkit provides a `Conversation` object type that is used to string together several +messages, including questions for the user, into a cohesive unit. Botkit conversations +provide useful methods that enable developers to craft complex conversational +user interfaces that may span a several minutes of dialog with a user, without having to manage +the complexity of connecting multiple incoming and outgoing messages across +multiple API calls into a single function. + +Messages sent as part of a conversation are sent no faster than one message per second, +which roughly simulates the time it would take for the bot to "type" the message. + + +### Conversation Threads + +While conversations with only a few questions can be managed by writing callback functions, +more complex conversations that require branching, repeating or looping sections of dialog, +or data validation can be handled using feature of the conversations we call `threads`. + +Threads are pre-built chains of dialog between the bot and end user that are built before the conversation begins. Once threads are built, Botkit can be instructed to navigate through the threads automatically, allowing many common programming scenarios such as yes/no/quit prompts to be handled without additional code. + +You can build conversation threads in code, or you can use [Botkit Studio](readme-studio.md)'s script management tool to build them in a friendly web environment. Conversations you build yourself and conversations managed in Botkit Studio work the same way -- they run inside your bot and use your code to manage the outcome. + +If you've used the conversation system at all, you've used threads - you just didn't know it. When calling `convo.say()` and `convo.ask()`, you were actually adding messages to the `default` conversation thread that is activated when the conversation object is created. + + +### Start a Conversation + +#### bot.startConversation() +| Argument | Description +|--- |--- +| message | incoming message to which the conversation is in response +| callback | a callback function in the form of function(err,conversation) { ... } + +`startConversation()` is a function that creates conversation in response to an incoming message. +The conversation will occur _in the same channel_ in which the incoming message was received. +Only the user who sent the original incoming message will be able to respond to messages in the conversation. + +#### bot.startPrivateConversation() +| Argument | Description +|--- |--- +| message | message object containing {user: userId} of the user you would like to start a conversation with +| callback | a callback function in the form of function(err,conversation) { ... } + +`startPrivateConversation()` is a function that initiates a conversation with a specific user. Note function is currently *Slack-only!* + +#### bot.createConversation() +| Argument | Description +|--- |--- +| message | incoming message to which the conversation is in response +| callback | a callback function in the form of function(err,conversation) { ... } + +This works just like `startConversation()`, with one main difference - the conversation +object passed into the callback will be in a dormant state. No messages will be sent, +and the conversation will not collect responses until it is activated using [convo.activate()](#conversationactivate). + +Use `createConversation()` instead of `startConversation()` when you plan on creating more complex conversation structures using [threads](#conversation-threads) or [variables and templates](#using-variable-tokens-and-templates-in-conversation-threads) in your messages. + +#### bot.createPrivateConversation() +| Argument | Description +|--- |--- +| message | incoming message to which the conversation is in response +| callback | a callback function in the form of function(err,conversation) { ... } + +This works just like `startPrivateConversation()`, with one main difference - the conversation +object passed into the callback will be in a dormant state. No messages will be sent, +and the conversation will not collect responses until it is activated using [convo.activate()](#conversationactivate). + +### Control Conversation Flow + +#### convo.activate() + +This function will cause a dormant conversation created with [bot.createConversation()](#botcreateconversation) to be activated, which will cause it to start sending messages and receiving replies from end users. + +A conversation can be kept dormant in order to preload it with [variables](#using-variable-tokens-and-templates-in-conversation-threads), particularly data that requires asynchronous actions to take place such as loading data from a database or remote source. You may also keep a conversation inactive while you build threads, setting it in motion only when all of the user paths have been defined. + +#### convo.addMessage +| Argument | Description +|--- |--- +| message | String or message object +| thread_name | String defining the name of a thread + +This function works identically to `convo.say()` except that it takes a second parameter which defines the thread to which the message will be added rather than being queued to send immediately, as is the case when using convo.say(). + +#### convo.addQuestion +| Argument | Description +|--- |--- +| message | String or message object containing the question +| callback _or_ array of callbacks | callback function in the form function(response_message,conversation), or array of objects in the form ``{ pattern: regular_expression, callback: function(response_message,conversation) { ... } }`` +| capture_options | Object defining options for capturing the response. Pass an empty object if capture options are not needed +| thread_name | String defining the name of a thread + + +When passed a callback function, conversation.ask will execute the callback function for any response. +This allows the bot to respond to open ended questions, collect the responses, and handle them in whatever +manner it needs to. + +When passed an array, the bot will look first for a matching pattern, and execute only the callback whose +pattern is matched. This allows the bot to present multiple choice options, or to proceed +only when a valid response has been received. At least one of the patterns in the array must be marked as the default option, +which will be called should no other option match. Botkit comes pre-built with several useful patterns which can be used with this function. See [included utterances](#included-utterances) + +Callback functions passed to `addQuestion()` receive two parameters - the first is a standard message object containing +the user's response to the question. The second is a reference to the conversation itself. + +Note that in order to continue the conversation, `convo.next()` must be called by the callback function. This +function tells Botkit to continue processing the conversation. If it is not called, the conversation will hang +and never complete causing memory leaks and instability of your bot application! + +The optional third parameter `capture_options` can be used to define different behaviors for collecting the user's response. +This object can contain the following fields: + +| Field | Description +|--- |--- +| key | _String_ If set, the response will be stored and can be referenced using this key +| multiple | _Boolean_ if true, support multi-line responses from the user (allow the user to respond several times and aggregate the response into a single multi-line value) + +##### Using conversation.addQuestion with a callback: + +```javascript +controller.hears(['question me'], 'message_received', function(bot,message) { + + // start a conversation to handle this response. + bot.startConversation(message,function(err,convo) { + + convo.addQuestion('How are you?',function(response,convo) { + + convo.say('Cool, you said: ' + response.text); + convo.next(); + + },{},'default'); + + }) + +}); +``` + +##### Using conversation.addQuestion with an array of callbacks: + +```javascript +controller.hears(['question me'], 'message_received', function(bot,message) { + + // start a conversation to handle this response. + bot.startConversation(message,function(err,convo) { + + convo.addQuestion('Shall we proceed Say YES, NO or DONE to quit.',[ + { + pattern: 'done', + callback: function(response,convo) { + convo.say('OK you are done!'); + convo.next(); + } + }, + { + pattern: bot.utterances.yes, + callback: function(response,convo) { + convo.say('Great! I will continue...'); + // do something else... + convo.next(); + + } + }, + { + pattern: bot.utterances.no, + callback: function(response,convo) { + convo.say('Perhaps later.'); + // do something else... + convo.next(); + } + }, + { + default: true, + callback: function(response,convo) { + // just repeat the question + convo.repeat(); + convo.next(); + } + } + ],{},'default'); + + }) + +}); +``` + +#### convo.say() +| Argument | Description +|--- |--- +| message | String or message object + +convo.say() is a specialized version of `convo.addMessage()` that adds messages to the _current_ thread, essentially adding a message dynamically to the conversation. This should only be used in simple cases, or when building a conversation with lots of dynamic content. Otherwise, creating `threads` is the recommended approach. + +Call convo.say() several times in a row to queue messages inside the conversation. Only one message will be sent at a time, in the order they are queued. + +```javascript +controller.hears(['hello world'], 'message_received', function(bot,message) { + + // start a conversation to handle this response. + bot.startConversation(message,function(err,convo) { + + convo.say('Hello!'); + convo.say('Have a nice day!'); + + }); +}); +``` + +#### convo.ask() +| Argument | Description +|--- |--- +| message | String or message object containing the question +| callback _or_ array of callbacks | callback function in the form function(response_message,conversation), or array of objects in the form ``{ pattern: regular_expression, callback: function(response_message,conversation) { ... } }`` +| capture_options | _Optional_ Object defining options for capturing the response + +convo.ask() is a specialized version of `convo.addQuestion()` that adds messages to the _current_ thread, essentially adding a message dynamically to the conversation. This should only be used in simple cases, or when building a conversation with lots of dynamic content. Otherwise, creating `threads` is the recommended approach. + +In particular, we recommend that developers avoid calling `convo.ask()` or `convo.say()` inside a callbacks for `convo.ask()`. Multi-level callbacks encourage fragile code - for conversations requiring more than one branch, use threads! + + +#### convo.gotoThread +| Argument | Description +|--- |--- +| thread_name | String defining the name of a thread + +Cause the bot to immediately jump to the named thread. +All conversations start in a thread called `default`, but you may switch to another existing thread before the conversation has been activated, or in a question callback. + +Threads are created by adding messages to them using `addMessage()` and `addQuestion()` + +```javascript +// create the validation_error thread +convo.addMessage('This is a validation error.', 'validation_error'); +convo.addMessage('I am sorry, your data is wrong!', 'validation_error'); + +// switch to the validation thread immediately +convo.gotoThread('validation_error'); +``` + + +#### convo.transitionTo +| Argument | Description +|--- |--- +| thread_name | String defining the name of a thread +| message | String or message object + +Like `gotoThread()`, jumps to the named thread. However, before doing so, +Botkit will first send `message` to the user as a transition. This allows +developers to specify dynamic transition messages to improve the flow of the +conversation. + +```javascript +// create an end state thread +convo.addMessage('This is the end!', 'the_end'); + +// now transition there with a nice message +convo.transitionTo('the_end','Well I think I am all done.'); +``` + +### convo.beforeThread +| Argument | Description +|--- |--- +| thread_name | String defining the name of a thread +| handler_function | handler in the form function(convo, next) {...} + +Allows developers to specify one or more functions that will be called before the thread +referenced in `thread_name` is activated. + +`handler_function` will receive the conversation object and a `next()` function. Developers +must call the `next()` function when their asynchronous operations are completed, or the conversation +may not continue as expected. + +Note that if `gotoThread()` is called inside the handler function, +it is recommended that `next()` be passed with an error parameter to stop processing of any additional thread handler functions that may be defined - that is, call `next('stop');` + +```javascript +// create a thread that asks the user for their name. +// after collecting name, call gotoThread('completed') to display completion message +convo.addMessage({text: 'Hello let me ask you a question, then i will do something useful'},'default'); +convo.addQuestion({text: 'What is your name?'},function(res, convo) { + // name has been collected... + convo.gotoThread('completed'); +},{key: 'name'},'default'); + +// create completed thread +convo.addMessage({text: 'I saved your name in the database, {{vars.name}}'},'completed'); + +// create an error thread +convo.addMessage({text: 'Oh no I had an error! {{vars.error}}'},'error'); + + +// now, define a function that will be called AFTER the `default` thread ends and BEFORE the `completed` thread begins +convo.beforeThread('completed', function(convo, next) { + + var name = convo.extractResponse('name'); + + // do something complex here + myFakeFunction(name).then(function(results) { + + convo.setVar('results',results); + + // call next to continue to the secondary thread... + next(); + + }).catch(function(err) { + convo.setVar('error', err); + convo.gotoThread('error'); + next(err); // pass an error because we changed threads again during this transition + }); + +}); +``` + + +#### Automatically Switch Threads using Actions + +You can direct a conversation to switch from one thread to another automatically +by including the `action` field on a message object. Botkit will switch threads immediately after sending the message. + +```javascript +// first, define a thread called `next_step` that we'll route to... +convo.addMessage({ + text: 'This is the next step...', +},'next_step'); + + +// send a message, and tell botkit to immediately go to the next_step thread +convo.addMessage({ + text: 'Anyways, moving on...', + action: 'next_step' +}); +``` + +Developers can create fairly complex conversational systems by combining these message actions with conditionals in `ask()` and `addQuestion()`. Actions can be used to specify +default or next step actions, while conditionals can be used to route between threads. + +From inside a callback function, use `convo.gotoThread()` to instantly switch to a different pre-defined part of the conversation. Botkit can be set to automatically navigate between threads based on user input, such as in the example below. + +```javascript +bot.createConversation(message, function(err, convo) { + + // create a path for when a user says YES + convo.addMessage({ + text: 'You said yes! How wonderful.', + },'yes_thread'); + + // create a path for when a user says NO + convo.addMessage({ + text: 'You said no, that is too bad.', + },'no_thread'); + + // create a path where neither option was matched + // this message has an action field, which directs botkit to go back to the `default` thread after sending this message. + convo.addMessage({ + text: 'Sorry I did not understand.', + action: 'default', + },'bad_response'); + + // Create a yes/no question in the default thread... + convo.addQuestion('Do you like cheese?', [ + { + pattern: 'yes', + callback: function(response, convo) { + convo.gotoThread('yes_thread'); + }, + }, + { + pattern: 'no', + callback: function(response, convo) { + convo.gotoThread('no_thread'); + }, + }, + { + default: true, + callback: function(response, convo) { + convo.gotoThread('bad_response'); + }, + } + ],{},'default'); + + convo.activate(); +}); +``` + +#### Special Actions + +In addition to routing from one thread to another using actions, you can also use +one of a few reserved words to control the conversation flow. + +Set the action field of a message to `completed` to end the conversation immediately and mark as success. + +Set the action field of a message to `stop` end immediately, but mark as failed. + +Set the action field of a message to `timeout` to end immediately and indicate that the conversation has timed out. + +After the conversation ends, these values will be available in the `convo.status` field. This field can then be used to check the final outcome of a conversation. See [handling the end of conversations](#handling-end-of-conversation). + +### Using Variable Tokens and Templates in Conversation Threads + +Pre-defined conversation threads are great, but many times developers will need to inject dynamic content into a conversation. +Botkit achieves this by processing the text of every message using the [Mustache template language](https://mustache.github.io/). +Mustache offers token replacement, as well as access to basic iterators and conditionals. + +Variables can be added to a conversation at any point after the conversation object has been created using the function `convo.setVar()`. See the example below. + +```javascript +convo.createConversation(message, function(err, convo) { + + // .. define threads which will use variables... + // .. and then, set variable values: + convo.setVar('foo','bar'); + convo.setVar('list',[{value:'option 1'},{value:'option 2'}]); + convo.setVar('object',{'name': 'Chester', 'type': 'imaginary'}); + + // now set the conversation in motion... + convo.activate(); +}); +``` + +Given the variables defined in this code sample, `foo`, a simple string, `list`, an array, and `object`, a JSON-style object, +the following Mustache tokens and patterns would be available: + +``` +The value of foo is {{vars.foo}} + +The items in this list include {{#vars.list}}{{value}}{{/vars.list}} + +The object's name is {{vars.object.name}}. + +{{#foo}}If foo is set, I will say this{{/foo}}{{^foo}}If foo is not set, I will say this other thing.{{/foo}} +``` +Botkit ensures that your template is a valid Mustache template, and passes the variables you specify directly to the Mustache template rendering system. +Our philosophy is that it is OK to stuff whatever type of information your conversation needs into these variables and use them as you please! + +#### convo.setVar +| Argument | Description +|--- |--- +| variable_name | The name of a variable to be made available to message text templates. +| value | The value of the variable, which can be any type of normal Javascript variable + +Create or update a variable that is available as a Mustache template token to all the messages in all the threads contained in the conversation. + +The variable will be available in the template as `{{vars.variable_name}}` + +#### Built-in Variables + +Botkit provides several built in variables that are automatically available to all messages: + +{{origin}} - a message object that represents the initial triggering message that caused the conversation. + +{{responses}} - an object that contains all of the responses a user has given during the course of the conversation. This can be used to make references to previous responses. This requires that `convo.ask()` questions include a keyname, making responses available at `{{responses.keyname}}` + +##### Included Utterances + +| Pattern Name | Description +|--- |--- +| bot.utterances.yes | Matches phrases like yes, yeah, yup, ok and sure. +| bot.utterances.no | Matches phrases like no, nah, nope +| bot.utterances.quit | Matches phrases like, cancel, exit, stop + +##### Conversation Control Functions + +In order to direct the flow of the conversation, several helper functions +are provided. These functions should only be called from within a convo.ask +handler function! + +`convo.sayFirst(message)` Works just like convo.say, but injects a message into the first spot in the queue +so that it is sent immediately, before any other queued messages. + +`convo.stop()` end the conversation immediately, and set convo.status to `stopped` + +`convo.repeat()` repeat the last question sent and continue to wait for a response. + +`convo.silentRepeat()` simply wait for another response without saying anything. + +`convo.next()` proceed to the next message in the conversation. *This must be called* at the end of each handler. + +`convo.setTimeout(timeout)` times out conversation if no response from user after specified time period (in milliseconds). + +### Handling End of Conversation + +Conversations trigger events during the course of their life. Currently, +only two events are fired, and only one is very useful: end. + +Conversations end naturally when the last message has been sent and no messages remain in the queue. +In this case, the value of `convo.status` will be `completed`. Other values for this field include `active`, `stopped`, and `timeout`. + +```javascript +convo.on('end',function(convo) { + + if (convo.status=='completed') { + // do something useful with the users responses + var res = convo.extractResponses(); + + // reference a specific response by key + var value = convo.extractResponse('key'); + + // ... do more stuff... + + } else { + // something happened that caused the conversation to stop prematurely + } + +}); +``` + +### Handling Conversation Timeouts + +If a conversation reaches its timeout threshold (set using `convo.setTimeout()`) while waiting for a user to respond to a `convo.ask()` question, the conversation will automatically end. By default, the conversation will end immediately without sending any further messages. Developers may change this behavior in one of two ways: + +*Provide a handler function with convo.onTimeout():* +Use `convo.onTimeout(handler)` to define a function that will be called when the conversation reaches the timeout threshold. This function +can be used to prevent the conversation from ending, or to take some other action before ending such as using `gotoThread()` to change the direction of the conversation. + +Note that functions used with onTimeout must call `gotoThread()`, `next()`, or `stop()` in order for the conversation to continue. + +``` +convo.onTimeout(function(convo) { + + convo.say('Oh no! The time limit has expired.'); + convo.next(); + +}); +``` + +*Provide an `on_timeout` conversation thread:* +Instead of providing a function, developers may choose to specify a pre-defined thread to be used in the case of a timeout event. +This thread should be called `on_timeout`. + +``` +convo.addMessage('Oh no! The time limit has expired.','on_timeout'); +convo.addMessage('TTYL.','on_timeout'); +``` + +#### convo.onTimeout() +| Argument | Description +|--- |--- +| callback | _Optional_ Callback in the form function(convo) { ... } + +Provide a handler function that will be called in the event that a conversation reaches its timeout threshold without any user response. + + +#### convo.extractResponses() + +Returns an object containing all of the responses a user sent during the course of a conversation. + +```javascript +var values = convo.extractResponses(); +var value = values.key; +``` + +#### convo.extractResponse() + +Return one specific user response, identified by its key. + +```javascript +var value = convo.extractResponse('key'); +``` + +### Originating Messages + +#### bot.say() +| Argument | Description +|--- |--- +| message | A message object +| callback | _Optional_ Callback in the form function(err,response) { ... } + +Slack-specific Example: +```javascript +bot.say( + { + text: 'my message text', + channel: 'C0H338YH4' // a valid slack channel, group, mpim, or im ID + } +); +``` +Note: If your primary need is to spontaneously send messages rather than respond to incoming messages, you may want to use [Slack's incoming webhooks feature](readme-slack.md#incoming-webhooks) rather than the real time API. + + +Facebook-specific Example: +```javascript +bot.say( + { + text: 'my message_text', + channel: '+1(###)###-####' // a valid facebook user id or phone number + } +); +``` + + +### Botkit Statistics Gathering + +As of version 0.4, Botkit records anonymous usage statistics about Botkit bots in the wild. +These statistics are used by the Botkit team at [Howdy](http://howdy.ai) to measure and +analyze the Botkit community, and help to direct resources to the appropriate parts of the project. + +We take the privacy of Botkit developers and their users very seriously. Botkit does not collect, +or transmit any message content, user data, or personally identifiable information to our statistics system. +The information that is collected is anonymized inside Botkit and converted using one-way encryption +into a hash before being transmitted. + +#### Opt Out of Stats + +To opt out of the stats collection, pass in the `stats_optout` parameter when initializing Botkit, +as seen in the example below: + +```javascript +var controller = Botkit.slackbot({ + stats_optout: true +}); +``` + +# Advanced Topics + +## Use Botkit with an Express web server +Instead of controller.setupWebserver(), it is possible to use a different web server to manage authentication flows, as well as serving web pages. + +Here is an example of [using an Express web server alongside Botkit](https://github.com/mvaragnat/botkit-express-demo). + + +## Documentation + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/slack-events-api.md b/docs/slack-events-api.md new file mode 100644 index 000000000..b6b792564 --- /dev/null +++ b/docs/slack-events-api.md @@ -0,0 +1,89 @@ +# Configure Botkit and the Slack Events API + +Building a bot with Botkit and the Slack Events API gives you access to all +of the best tools and options available to create the best and most feature +rich bot. + +In order to get everything set up, you will need to configure a "Slack App" +inside the [Slack Developer Portal](http://api.slack.com/apps), and at the same time, +configure a [Botkit-powered bot](http://botkit.ai). It only takes a few moments, +but there are a bunch of steps! + +## 1) Create a Slack App + +Go to [http://api.slack.com/apps](http://api.slack.com/apps) and create a new application record. +You will receive a `clientId` and a `clientSecret`. You need these values - copy them into a temporary text file +for use in the following steps. + +## 2) Turn on your Botkit app + +Now that you've got your clientId and clientSecret, you can start your Botkit app. +Assuming you are using the [Botkit Slack Starter Kit](https://github.com/howdyai/botkit-starter-slack), +this can be done by passing the values in via the command line: + +``` +clientId= clientSecret= PORT=3000 node . +``` + +This will cause the Botkit application to boot up and start listening for messages via incoming web hooks. + +With the Events API, Slack will send all information to your bot via simple +web hooks. To receive web hooks, you will need a public internet address that is +SSL-enabled. + +During development, you can use a tool like [ngrok.io](http://ngrok.io) or [localtunnel.me](http://localtunnel.me) +to temporarily expose your development environment to the public internet. +However, once you go live, you will need a real, stable address. + +In the end, you will have a known url - for example `https://my-dev-bot.ngrok.io` - +that you will use to finish the configuration inside Slack. + +## 3) Configure oauth + +Botkit and Slack use the oauth authentication system to grant bots access to +connect to, read from, and send messages to Slack teams. + +Click on the "Oauth & Permissions" tab in your Slack's app setting, and under +Redirect URLs, add: `https://my-bot-url/oauth`, then click save. + +## 4) Add a Bot User + +Click on the "Bot Users" tab and specify a name for your bot. This is the name +that will be used by default when your application creates a new bot on a user's +team. + +In addition to a name, enable the option for "Always Show My Bot Online." + +## 5) Set up Interactive messages + +"Interactive messages" is Slack's fancy way of saying "buttons." In order to enable buttons, +under Request URL, add `https://my-bot-url/slack/receive`, then click save. + +## 6) Set up Event Subscriptions + +To start receiving messages, enable event subscriptions. First, under Request URL, +add `https://my-bot-url/slack/receive`. When you finish typing, Slack will verify +that this endpoint is properly configured. You must be running your Botkit application, +and the application must be accessible at the URL specified for this to work. + +Once verified, click "Add Bot User Event", and using the dropdown that appears, +select all of the message.* events: `message.channels`, `message.groups`, `message.im`, `message.mpim`. +This tells Slack to send your bot all messages that are sent in any channel or group +in which your bot is present. Add other events as needed. + +Finally, scroll to the top of the page and switch "Enable Events" to "on". +Your bot is now ready to receive messages! + +## 7) Add your bot to your Slack team + +Now that your bot is configured, and your application is up and running, +you can login and add your bot. Visit `https://my-bot-url/login`, and you +will be automatically directed to Slack's login page. Login and choose a team. +You'll get one more confirmation before being redirected back to your app. + +Meanwhile, your bot should appear inside your Slack team! You should receive +a friendly welcome message! + +## 8) Customize your Bot + +Using [Botkit Studio's conversation design tools](https://studio.botkit.ai) and the powerful [Botkit SDK](https://github.com/howdyai/botkit), you can build your dream bot! diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 000000000..48a5176a3 --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,70 @@ + +## Storing Information + +Botkit has a built in storage system used to keep data on behalf of users and teams between sessions. Botkit uses this system automatically when storing information for Slack Button applications (see below). + +By default, Botkit will use [json-file-store](https://github.com/flosse/json-file-store) to keep data in JSON files in the filesystem of the computer where the bot is executed. (Note this will not work on Heroku or other hosting systems that do not let node applications write to the file system.) Initialize this system when you create the bot: +```javascript +var controller = Botkit.slackbot({ + json_file_store: 'path_to_json_database' +}); +``` + +This system supports freeform storage on a team-by-team, user-by-user, and channel-by-channel basis. Basically ```controller.storage``` is a key value store. All access to this system is through the following twelve functions. Example usage: +```javascript +controller.storage.users.save({id: message.user, foo:'bar'}, function(err) { ... }); +controller.storage.users.get(id, function(err, user_data) {...}); +controller.storage.users.delete(id, function(err) {...}); +controller.storage.users.all(function(err, all_user_data) {...}); + +controller.storage.channels.save({id: message.channel, foo:'bar'}, function(err) { ... }); +controller.storage.channels.get(id, function(err, channel_data) {...}); +controller.storage.channels.delete(id, function(err) {...}); +controller.storage.channels.all(function(err, all_channel_data) {...}); + +controller.storage.teams.save({id: message.team, foo:'bar'}, function(err) { ... }); +controller.storage.teams.get(id, function(err, team_data) {...}); +controller.storage.teams.delete(id, function(err) {...}); +controller.storage.teams.all(function(err, all_team_data) {...}); +``` + +Note that save must be passed an object with an id. It is recommended to use the team/user/channel id for this purpose. +```[user/channel/team]_data``` will always be an object while ```all_[user/channel/team]_data``` will always be a list of objects. + +### Writing your own storage module + +If you want to use a database or do something else with your data, +you can write your own storage module and pass it in. + +Make sure your module returns an object with all the methods. See [simple_storage.js](https://github.com/howdyai/botkit/blob/master/lib/storage/simple_storage.js) for an example of how it is done! +Make sure your module passes the test in [storage_test.js](https://github.com/howdyai/botkit/blob/master/lib/storage/storage_test.js). + +Then, use it when you create your bot: +```javascript +var controller = Botkit.slackbot({ + storage: my_storage_provider +}) +``` + +## Botkit Documentation Index + +* [Get Started](readme.md) +* [Botkit Studio API](readme-studio.md) +* [Function index](readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](middleware.md) + * [Message Pipeline](readme-pipeline.md) + * [List of current plugins](readme-middlewares.md) +* [Storing Information](storage.md) +* [Logging](logging.md) +* Platforms + * [Slack](readme-slack.md) + * [Cisco Spark](readme-ciscospark.md) + * [Microsoft Teams](readme-teams.md) + * [Facebook Messenger](readme-facebook.md) + * [Twilio SMS](readme-twiliosms.md) + * [Twilio IPM](readme-twilioipm.md) + * [Microsoft Bot Framework](readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](../CONTRIBUTING.md) + * [Building Middleware/plugins](howto/build_middleware.md) + * [Building platform connectors](howto/build_connector.md) diff --git a/docs/studio.png b/docs/studio.png new file mode 100644 index 000000000..174855b41 Binary files /dev/null and b/docs/studio.png differ diff --git a/docs/studio_script_author.png b/docs/studio_script_author.png new file mode 100644 index 000000000..e0612b8b4 Binary files /dev/null and b/docs/studio_script_author.png differ diff --git a/examples/botframework_bot.js b/examples/botframework_bot.js new file mode 100644 index 000000000..4f21e2e98 --- /dev/null +++ b/examples/botframework_bot.js @@ -0,0 +1,212 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ______ ______ ______ __ __ __ ______ + /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ + \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + + +This is a sample Microsoft Bot Framework bot built with Botkit. + +This bot demonstrates many of the core features of Botkit: + +* Connect to the Microsoft Bot Framework Service +* Receive messages based on "spoken" patterns +* Reply to messages +* Use the conversation system to ask questions +* Use the built in storage system to store and retrieve information + for a user. + +# RUN THE BOT: + + Follow the instructions in the "Getting Started" section of the readme-botframework.md file to register your bot. + + Run your bot from the command line: + + app_id= app_password= node botframework_bot.js [--lt [--ltsubdomain LOCALTUNNEL_SUBDOMAIN]] + + Use the --lt option to make your bot available on the web through localtunnel.me. + +# USE THE BOT: + + Find your bot inside Skype to send it a direct message. + + Say: "Hello" + + The bot will reply "Hello!" + + Say: "who are you?" + + The bot will tell you its name, where it running, and for how long. + + Say: "Call me " + + Tell the bot your nickname. Now you are friends. + + Say: "who am I?" + + The bot will tell you your nickname, if it knows one for you. + + Say: "shutdown" + + The bot will ask if you are sure, and then shut itself down. + + Make sure to invite your bot into other channels using /invite @! + +# EXTEND THE BOT: + + Botkit has many features for building cool and useful bots! + + Read all about it here: + + -> http://howdy.ai/botkit + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + + +var Botkit = require('../lib/Botkit.js'); +var os = require('os'); +var commandLineArgs = require('command-line-args'); +var localtunnel = require('localtunnel'); + +const ops = commandLineArgs([ + {name: 'lt', alias: 'l', args: 1, description: 'Use localtunnel.me to make your bot available on the web.', + type: Boolean, defaultValue: false}, + {name: 'ltsubdomain', alias: 's', args: 1, + description: 'Custom subdomain for the localtunnel.me URL. This option can only be used together with --lt.', + type: String, defaultValue: null}, + ]); + +if(ops.lt === false && ops.ltsubdomain !== null) { + console.log("error: --ltsubdomain can only be used together with --lt."); + process.exit(); +} + +var controller = Botkit.botframeworkbot({ + debug: true +}); + +var bot = controller.spawn({ + appId: process.env.app_id, + appPassword: process.env.app_password +}); + + + +controller.setupWebserver(process.env.port || 3000, function(err, webserver) { + controller.createWebhookEndpoints(webserver, bot, function() { + console.log('ONLINE!'); + if(ops.lt) { + var tunnel = localtunnel(process.env.port || 3000, {subdomain: ops.ltsubdomain}, function(err, tunnel) { + if (err) { + console.log(err); + process.exit(); + } + console.log("Your bot is available on the web at the following URL: " + tunnel.url + '/botframework/receive'); + }); + + tunnel.on('close', function() { + console.log("Your bot is no longer available on the web at the localtunnnel.me URL."); + process.exit(); + }); + } + }); +}); + + + +controller.hears(['hello', 'hi'], 'message_received', function(bot, message) { + + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Hello ' + user.name + '!!'); + } else { + bot.reply(message, 'Hello.', function(err) { + + console.error(err); + }); + } + }); +}); + +controller.hears(['call me (.*)'], 'message_received', function(bot, message) { + var matches = message.text.match(/call me (.*)/i); + var name = matches[1]; + controller.storage.users.get(message.user, function(err, user) { + if (!user) { + user = { + id: message.user, + }; + } + user.name = name; + controller.storage.users.save(user, function(err, id) { + bot.reply(message, 'Got it. I will call you ' + user.name + ' from now on.'); + }); + }); +}); + +controller.hears(['what is my name', 'who am i'], 'message_received', function(bot, message) { + + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message,'Your name is ' + user.name); + } else { + bot.reply(message,'I don\'t know yet!'); + } + }); +}); + + +controller.hears(['shutdown'],'message_received',function(bot, message) { + + bot.startConversation(message,function(err, convo) { + convo.ask('Are you sure you want me to shutdown?',[ + { + pattern: bot.utterances.yes, + callback: function(response, convo) { + convo.say('Bye!'); + convo.next(); + setTimeout(function() { + process.exit(); + },3000); + } + }, + { + pattern: bot.utterances.no, + default: true, + callback: function(response, convo) { + convo.say('*Phew!*'); + convo.next(); + } + } + ]); + }); +}); + + +controller.hears(['uptime','identify yourself','who are you','what is your name'],'message_received',function(bot, message) { + + var hostname = os.hostname(); + var uptime = formatUptime(process.uptime()); + + bot.reply(message,'I am a bot! I have been running for ' + uptime + ' on ' + hostname + '.'); + +}); + +function formatUptime(uptime) { + var unit = 'second'; + if (uptime > 60) { + uptime = uptime / 60; + unit = 'minute'; + } + if (uptime > 60) { + uptime = uptime / 60; + unit = 'hour'; + } + if (uptime != 1) { + unit = unit + 's'; + } + + uptime = uptime + ' ' + unit; + return uptime; +} diff --git a/examples/console_bot.js b/examples/console_bot.js new file mode 100644 index 000000000..6f6b38e7a --- /dev/null +++ b/examples/console_bot.js @@ -0,0 +1,218 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ______ ______ ______ __ __ __ ______ + /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ + \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + + +This is a sample Console bot built with Botkit. + +This bot demonstrates many of the core features of Botkit: + +* Receive messages based on "spoken" patterns +* Reply to messages +* Use the conversation system to ask questions +* Use the built in storage system to store and retrieve information + for a user. + +# RUN THE BOT: + + Run your bot from the command line: + + node console_bot.js + +# USE THE BOT: + + Say: "Hello" + + The bot will reply "Hello!" + + Say: "who are you?" + + The bot will tell you its name, where it is running, and for how long. + + Say: "Call me " + + Tell the bot your nickname. Now you are friends. + + Say: "who am I?" + + The bot will tell you your nickname, if it knows one for you. + + Say: "shutdown" + + The bot will ask if you are sure, and then shut itself down. + + Make sure to invite your bot into other channels using /invite @! + +# EXTEND THE BOT: + + Botkit has many features for building cool and useful bots! + + Read all about it here: + + -> http://howdy.ai/botkit + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + +var Botkit = require('../lib/Botkit.js'); +var os = require('os'); + +var controller = Botkit.consolebot({ + debug: false, +}); + +var bot = controller.spawn(); + +controller.hears(['hello', 'hi'], 'message_received', function(bot, message) { + + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Hello ' + user.name + '!!'); + } else { + bot.reply(message, 'Hello.'); + } + }); +}); + +controller.hears(['call me (.*)', 'my name is (.*)'], 'message_received', function(bot, message) { + var name = message.match[1]; + controller.storage.users.get(message.user, function(err, user) { + if (!user) { + user = { + id: message.user, + }; + } + user.name = name; + controller.storage.users.save(user, function(err, id) { + bot.reply(message, 'Got it. I will call you ' + user.name + ' from now on.'); + }); + }); +}); + +controller.hears(['what is my name', 'who am i'], 'message_received', function(bot, message) { + + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Your name is ' + user.name); + } else { + bot.startConversation(message, function(err, convo) { + if (!err) { + convo.say('I do not know your name yet!'); + convo.ask('What should I call you?', function(response, convo) { + convo.ask('You want me to call you `' + response.text + '`?', [ + { + pattern: 'yes', + callback: function(response, convo) { + // since no further messages are queued after this, + // the conversation will end naturally with status == 'completed' + convo.next(); + } + }, + { + pattern: 'no', + callback: function(response, convo) { + // stop the conversation. this will cause it to end with status == 'stopped' + convo.stop(); + } + }, + { + default: true, + callback: function(response, convo) { + convo.repeat(); + convo.next(); + } + } + ]); + + convo.next(); + + }, {'key': 'nickname'}); // store the results in a field called nickname + + convo.on('end', function(convo) { + if (convo.status == 'completed') { + bot.reply(message, 'OK! I will update my dossier...'); + + controller.storage.users.get(message.user, function(err, user) { + if (!user) { + user = { + id: message.user, + }; + } + user.name = convo.extractResponse('nickname'); + controller.storage.users.save(user, function(err, id) { + bot.reply(message, 'Got it. I will call you ' + user.name + ' from now on.'); + }); + }); + + + + } else { + // this happens if the conversation ended prematurely for some reason + bot.reply(message, 'OK, nevermind!'); + } + }); + } + }); + } + }); +}); + + +controller.hears(['shutdown'], 'message_received', function(bot, message) { + + bot.startConversation(message, function(err, convo) { + + convo.ask('Are you sure you want me to shutdown?', [ + { + pattern: bot.utterances.yes, + callback: function(response, convo) { + convo.say('Bye!'); + convo.next(); + setTimeout(function() { + process.exit(); + }, 3000); + } + }, + { + pattern: bot.utterances.no, + default: true, + callback: function(response, convo) { + convo.say('*Phew!*'); + convo.next(); + } + } + ]); + }); +}); + + +controller.hears(['uptime', 'identify yourself', 'who are you', 'what is your name'], + 'message_received', function(bot, message) { + + var hostname = os.hostname(); + var uptime = formatUptime(process.uptime()); + + bot.reply(message, + ':robot_face: I am ConsoleBot. I have been running for ' + uptime + ' on ' + hostname + '.'); + + }); + +function formatUptime(uptime) { + var unit = 'second'; + if (uptime > 60) { + uptime = uptime / 60; + unit = 'minute'; + } + if (uptime > 60) { + uptime = uptime / 60; + unit = 'hour'; + } + if (uptime != 1) { + unit = unit + 's'; + } + + uptime = uptime + ' ' + unit; + return uptime; +} diff --git a/examples/facebook_bot.js b/examples/facebook_bot.js new file mode 100755 index 000000000..8dc81db1b --- /dev/null +++ b/examples/facebook_bot.js @@ -0,0 +1,508 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ______ ______ ______ __ __ __ ______ + /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ + \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + + +This is a sample Facebook bot built with Botkit. + +This bot demonstrates many of the core features of Botkit: + +* Connect to Facebook's Messenger APIs +* Receive messages based on "spoken" patterns +* Reply to messages +* Use the conversation system to ask questions +* Use the built in storage system to store and retrieve information + for a user. + +# RUN THE BOT: + + Follow the instructions here to set up your Facebook app and page: + + -> https://developers.facebook.com/docs/messenger-platform/implementation + + Run your bot from the command line: + + app_secret= page_token= verify_token= node facebook_bot.js [--lt [--ltsubdomain LOCALTUNNEL_SUBDOMAIN]] + + Use the --lt option to make your bot available on the web through localtunnel.me. + +# USE THE BOT: + + Find your bot inside Facebook to send it a direct message. + + Say: "Hello" + + The bot will reply "Hello!" + + Say: "who are you?" + + The bot will tell you its name, where it running, and for how long. + + Say: "Call me " + + Tell the bot your nickname. Now you are friends. + + Say: "who am I?" + + The bot will tell you your nickname, if it knows one for you. + + Say: "shutdown" + + The bot will ask if you are sure, and then shut itself down. + + Make sure to invite your bot into other channels using /invite @! + +# EXTEND THE BOT: + + Botkit has many features for building cool and useful bots! + + Read all about it here: + + -> http://howdy.ai/botkit + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + + +if (!process.env.page_token) { + console.log('Error: Specify page_token in environment'); + process.exit(1); +} + +if (!process.env.verify_token) { + console.log('Error: Specify verify_token in environment'); + process.exit(1); +} + +if (!process.env.app_secret) { + console.log('Error: Specify app_secret in environment'); + process.exit(1); +} + +var Botkit = require('../lib/Botkit.js'); +var os = require('os'); +var commandLineArgs = require('command-line-args'); +var localtunnel = require('localtunnel'); + +const ops = commandLineArgs([ + {name: 'lt', alias: 'l', args: 1, description: 'Use localtunnel.me to make your bot available on the web.', + type: Boolean, defaultValue: false}, + {name: 'ltsubdomain', alias: 's', args: 1, + description: 'Custom subdomain for the localtunnel.me URL. This option can only be used together with --lt.', + type: String, defaultValue: null}, + ]); + +if(ops.lt === false && ops.ltsubdomain !== null) { + console.log("error: --ltsubdomain can only be used together with --lt."); + process.exit(); +} + +var controller = Botkit.facebookbot({ + debug: true, + log: true, + access_token: process.env.page_token, + verify_token: process.env.verify_token, + app_secret: process.env.app_secret, + validate_requests: true, // Refuse any requests that don't come from FB on your receive webhook, must provide FB_APP_SECRET in environment variables +}); + +var bot = controller.spawn({ +}); + +controller.setupWebserver(process.env.port || 3000, function(err, webserver) { + controller.createWebhookEndpoints(webserver, bot, function() { + console.log('ONLINE!'); + if(ops.lt) { + var tunnel = localtunnel(process.env.port || 3000, {subdomain: ops.ltsubdomain}, function(err, tunnel) { + if (err) { + console.log(err); + process.exit(); + } + console.log("Your bot is available on the web at the following URL: " + tunnel.url + '/facebook/receive'); + }); + + tunnel.on('close', function() { + console.log("Your bot is no longer available on the web at the localtunnnel.me URL."); + process.exit(); + }); + } + }); +}); + + +controller.hears(['attachment_upload'], 'message_received', function(bot, message) { + var attachment = { + "type":"image", + "payload":{ + "url":"https://pbs.twimg.com/profile_images/803642201653858305/IAW1DBPw_400x400.png", + "is_reusable": true + } + }; + + controller.api.attachment_upload.upload(attachment, function (err, attachmentId) { + if(err) { + // Error + } else { + var image = { + "attachment":{ + "type":"image", + "payload": { + "attachment_id": attachmentId + } + } + }; + bot.reply(message, image); + } + }); +}); + + +controller.api.nlp.enable(); +controller.api.messenger_profile.greeting('Hello! I\'m a Botkit bot!'); +controller.api.messenger_profile.get_started('sample_get_started_payload'); +controller.api.messenger_profile.menu([{ + "locale":"default", + "composer_input_disabled":true, + "call_to_actions":[ + { + "title":"My Skills", + "type":"nested", + "call_to_actions":[ + { + "title":"Hello", + "type":"postback", + "payload":"Hello" + }, + { + "title":"Hi", + "type":"postback", + "payload":"Hi" + } + ] + }, + { + "type":"web_url", + "title":"Botkit Docs", + "url":"https://github.com/howdyai/botkit/blob/master/readme-facebook.md", + "webview_height_ratio":"full" + } + ] +}, + { + "locale":"zh_CN", + "composer_input_disabled":false + } +]); + +// controller.api.messenger_profile.account_linking('https://www.yourAwesomSite.com/oauth?response_type=code&client_id=1234567890&scope=basic'); +// controller.api.messenger_profile.get_account_linking(function (err, accountLinkingUrl) { +// console.log('****** Account linkink URL :', accountLinkingUrl); +// }); +// controller.api.messenger_profile.delete_account_linking(); +// controller.api.messenger_profile.domain_whitelist('https://localhost'); +// controller.api.messenger_profile.domain_whitelist(['https://127.0.0.1', 'https://0.0.0.0']); +// controller.api.messenger_profile.delete_domain_whitelist('https://localhost'); +// controller.api.messenger_profile.delete_domain_whitelist(['https://127.0.0.1', 'https://0.0.0.0']); +// controller.api.messenger_profile.get_domain_whitelist(function (err, data) { +// console.log('****** Whitelisted domains :', data); +// }); + + +// returns the bot's messenger code image +controller.hears(['code'], 'message_received,facebook_postback', function(bot, message) { + controller.api.messenger_profile.get_messenger_code(2000, function (err, url) { + if(err) { + // Error + } else { + var image = { + "attachment":{ + "type":"image", + "payload":{ + "url": url + } + } + }; + bot.reply(message, image); + } + }); +}); + +controller.hears(['quick'], 'message_received', function(bot, message) { + + bot.reply(message, { + text: 'Hey! This message has some quick replies attached.', + quick_replies: [ + { + "content_type": "text", + "title": "Yes", + "payload": "yes", + }, + { + "content_type": "text", + "title": "No", + "payload": "no", + } + ] + }); + +}); + +controller.hears(['^hello', '^hi'], 'message_received,facebook_postback', function(bot, message) { + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Hello ' + user.name + '!!'); + } else { + bot.reply(message, 'Hello.'); + } + }); +}); + +controller.hears(['silent push reply'], 'message_received', function(bot, message) { + reply_message = { + text: "This message will have a push notification on a mobile phone, but no sound notification", + notification_type: "SILENT_PUSH" + } + bot.reply(message, reply_message) +}) + +controller.hears(['no push'], 'message_received', function(bot, message) { + reply_message = { + text: "This message will not have any push notification on a mobile phone", + notification_type: "NO_PUSH" + } + bot.reply(message, reply_message) +}) + +controller.hears(['structured'], 'message_received', function(bot, message) { + + bot.startConversation(message, function(err, convo) { + convo.ask({ + attachment: { + 'type': 'template', + 'payload': { + 'template_type': 'generic', + 'elements': [ + { + 'title': 'Classic White T-Shirt', + 'image_url': 'http://petersapparel.parseapp.com/img/item100-thumb.png', + 'subtitle': 'Soft white cotton t-shirt is back in style', + 'buttons': [ + { + 'type': 'web_url', + 'url': 'https://petersapparel.parseapp.com/view_item?item_id=100', + 'title': 'View Item' + }, + { + 'type': 'web_url', + 'url': 'https://petersapparel.parseapp.com/buy_item?item_id=100', + 'title': 'Buy Item' + }, + { + 'type': 'postback', + 'title': 'Bookmark Item', + 'payload': 'White T-Shirt' + } + ] + }, + { + 'title': 'Classic Grey T-Shirt', + 'image_url': 'http://petersapparel.parseapp.com/img/item101-thumb.png', + 'subtitle': 'Soft gray cotton t-shirt is back in style', + 'buttons': [ + { + 'type': 'web_url', + 'url': 'https://petersapparel.parseapp.com/view_item?item_id=101', + 'title': 'View Item' + }, + { + 'type': 'web_url', + 'url': 'https://petersapparel.parseapp.com/buy_item?item_id=101', + 'title': 'Buy Item' + }, + { + 'type': 'postback', + 'title': 'Bookmark Item', + 'payload': 'Grey T-Shirt' + } + ] + } + ] + } + } + }, function(response, convo) { + // whoa, I got the postback payload as a response to my convo.ask! + convo.next(); + }); + }); +}); + +controller.on('facebook_postback', function(bot, message) { + // console.log(bot, message); + bot.reply(message, 'Great Choice!!!! (' + message.payload + ')'); + +}); + + +controller.hears(['call me (.*)', 'my name is (.*)'], 'message_received', function(bot, message) { + var name = message.match[1]; + controller.storage.users.get(message.user, function(err, user) { + if (!user) { + user = { + id: message.user, + }; + } + user.name = name; + controller.storage.users.save(user, function(err, id) { + bot.reply(message, 'Got it. I will call you ' + user.name + ' from now on.'); + }); + }); +}); + +controller.hears(['what is my name', 'who am i'], 'message_received', function(bot, message) { + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Your name is ' + user.name); + } else { + bot.startConversation(message, function(err, convo) { + if (!err) { + convo.say('I do not know your name yet!'); + convo.ask('What should I call you?', function(response, convo) { + convo.ask('You want me to call you `' + response.text + '`?', [ + { + pattern: 'yes', + callback: function(response, convo) { + // since no further messages are queued after this, + // the conversation will end naturally with status == 'completed' + convo.next(); + } + }, + { + pattern: 'no', + callback: function(response, convo) { + // stop the conversation. this will cause it to end with status == 'stopped' + convo.stop(); + } + }, + { + default: true, + callback: function(response, convo) { + convo.repeat(); + convo.next(); + } + } + ]); + + convo.next(); + + }, {'key': 'nickname'}); // store the results in a field called nickname + + convo.on('end', function(convo) { + if (convo.status == 'completed') { + bot.reply(message, 'OK! I will update my dossier...'); + + controller.storage.users.get(message.user, function(err, user) { + if (!user) { + user = { + id: message.user, + }; + } + user.name = convo.extractResponse('nickname'); + controller.storage.users.save(user, function(err, id) { + bot.reply(message, 'Got it. I will call you ' + user.name + ' from now on.'); + }); + }); + + + + } else { + // this happens if the conversation ended prematurely for some reason + bot.reply(message, 'OK, nevermind!'); + } + }); + } + }); + } + }); +}); + +controller.hears(['shutdown'], 'message_received', function(bot, message) { + + bot.startConversation(message, function(err, convo) { + + convo.ask('Are you sure you want me to shutdown?', [ + { + pattern: bot.utterances.yes, + callback: function(response, convo) { + convo.say('Bye!'); + convo.next(); + setTimeout(function() { + process.exit(); + }, 3000); + } + }, + { + pattern: bot.utterances.no, + default: true, + callback: function(response, convo) { + convo.say('*Phew!*'); + convo.next(); + } + } + ]); + }); +}); + + +controller.hears(['uptime', 'identify yourself', 'who are you', 'what is your name'], 'message_received', + function(bot, message) { + + var hostname = os.hostname(); + var uptime = formatUptime(process.uptime()); + + bot.reply(message, + ':|] I am a bot. I have been running for ' + uptime + ' on ' + hostname + '.'); + }); + + + +controller.on('message_received', function(bot, message) { + bot.reply(message, 'Try: `what is my name` or `structured` or `call me captain`'); + return false; +}); + +controller.hears(['tags'], 'message_received', function (bot, message) { + controller.api.tags.get_all(function (tags) { + for (var i = 0; i < tags.data.length; i++) { + bot.reply(message, tags.data[i].tag + ': ' + tags.data[i].description); + } + }); +}); + +controller.hears(['send tagged message'], 'message_received', function (bot, message) { + var taggedMessage = { + "text": "Hello Botkit !", + "tag": "RESERVATION_UPDATE" + }; + bot.reply(message, taggedMessage); +}); + + +function formatUptime(uptime) { + var unit = 'second'; + if (uptime > 60) { + uptime = uptime / 60; + unit = 'minute'; + } + if (uptime > 60) { + uptime = uptime / 60; + unit = 'hour'; + } + if (uptime != 1) { + unit = unit + 's'; + } + + uptime = uptime + ' ' + unit; + return uptime; +} diff --git a/examples/convo_bot.js b/examples/slack/convo_bot.js similarity index 80% rename from examples/convo_bot.js rename to examples/slack/convo_bot.js index 7692c014f..cb10d1244 100644 --- a/examples/convo_bot.js +++ b/examples/slack/convo_bot.js @@ -1,10 +1,10 @@ /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - ______ ______ ______ __ __ __ ______ + ______ ______ ______ __ __ __ ______ /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ - \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ - \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ - + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + This is a sample Slack bot built with Botkit. @@ -38,13 +38,13 @@ This bot demonstrates a multi-stage conversation Say where you want it delivered. - The bot will reply "Ok! Good by." + The bot will reply "Ok! Goodbye." ...and will refrain from billing your card because this is just a demo :P # EXTEND THE BOT: - Botkit is has many features for building cool and useful bots! + Botkit has many features for building cool and useful bots! Read all about it here: @@ -52,7 +52,7 @@ This bot demonstrates a multi-stage conversation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ -var Botkit = require('../lib/Botkit.js'); +var Botkit = require('../../lib/Botkit.js'); if (!process.env.token) { console.log('Error: Specify token in environment'); @@ -71,7 +71,7 @@ controller.spawn({ } }); -controller.hears(['pizzatime'],['ambient'],function(bot,message) { +controller.hears(['pizzatime'],['ambient','direct_message'],function(bot,message) { bot.startConversation(message, askFlavor); }); @@ -91,7 +91,7 @@ askSize = function(response, convo) { } askWhereDeliver = function(response, convo) { convo.ask("So where do you want it delivered?", function(response, convo) { - convo.say("Ok! Good by."); + convo.say("Ok! Goodbye."); convo.next(); }); -} \ No newline at end of file +} diff --git a/examples/demo_bot.js b/examples/slack/demo_bot.js similarity index 88% rename from examples/demo_bot.js rename to examples/slack/demo_bot.js index 1b698c350..85ced8211 100755 --- a/examples/demo_bot.js +++ b/examples/slack/demo_bot.js @@ -1,9 +1,9 @@ /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - ______ ______ ______ __ __ __ ______ + ______ ______ ______ __ __ __ ______ /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ - \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ - \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ This is a sample Slack bot built with Botkit. @@ -37,7 +37,7 @@ This bot demonstrates many of the core features of Botkit: The bot will send a message with a multi-field attachment. - Send: "dm" + Send: "dm me" The bot will reply with a direct message. @@ -45,7 +45,7 @@ This bot demonstrates many of the core features of Botkit: # EXTEND THE BOT: - Botkit is has many features for building cool and useful bots! + Botkit has many features for building cool and useful bots! Read all about it here: @@ -53,7 +53,7 @@ This bot demonstrates many of the core features of Botkit: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ -var Botkit = require('../lib/Botkit.js'); +var Botkit = require('../../lib/Botkit.js'); if (!process.env.token) { @@ -65,6 +65,7 @@ var controller = Botkit.slackbot({ debug: false }); + controller.spawn({ token: process.env.token }).startRTM(function(err) { diff --git a/examples/incoming_webhooks.js b/examples/slack/incoming_webhooks.js similarity index 100% rename from examples/incoming_webhooks.js rename to examples/slack/incoming_webhooks.js diff --git a/bot.js b/examples/slack/middleware_example.js old mode 100755 new mode 100644 similarity index 68% rename from bot.js rename to examples/slack/middleware_example.js index 2d3f33e85..2fdfdc62d --- a/bot.js +++ b/examples/slack/middleware_example.js @@ -1,9 +1,9 @@ /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - ______ ______ ______ __ __ __ ______ + ______ ______ ______ __ __ __ ______ /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ - \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ - \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ This is a sample Slack bot built with Botkit. @@ -55,7 +55,7 @@ This bot demonstrates many of the core features of Botkit: # EXTEND THE BOT: - Botkit is has many features for building cool and useful bots! + Botkit has many features for building cool and useful bots! Read all about it here: @@ -69,7 +69,7 @@ if (!process.env.token) { process.exit(1); } -var Botkit = require('./lib/Botkit.js'); +var Botkit = require('../../lib/Botkit.js'); var os = require('os'); var controller = Botkit.slackbot({ @@ -81,7 +81,49 @@ var bot = controller.spawn({ }).startRTM(); -controller.hears(['hello','hi'],'direct_message,direct_mention,mention',function(bot, message) { +// Example receive middleware. +// for example, recognize several common variations on "hello" and add an intent field to the message +// see below for example hear_intent function +controller.middleware.receive.use(function(bot, message, next) { + + console.log('Receive middleware!'); + // make changes to bot or message here before calling next + if (message.text == 'hello' || message.text == 'hi' || message.text == 'howdy' || message.text == 'hey') { + message.intent = 'hello'; + } + + next(); + +}); + +// Example send middleware +// make changes to bot or message here before calling next +// for example, do formatting or add additional information to the message +controller.middleware.send.use(function(bot, message, next) { + + console.log('Send middleware!'); + next(); + +}); + + +// Example hear middleware +// Return true if one of [patterns] matches message +// In this example, listen for an intent field, and match using that instead of the text field +function hear_intent(patterns, message) { + + for (var p = 0; p < patterns.length; p++) { + if (message.intent == patterns[p]) { + return true; + } + } + + return false; +} + + +/* note this uses example middlewares defined above */ +controller.hears(['hello'],'direct_message,direct_mention,mention',hear_intent, function(bot, message) { bot.api.reactions.add({ timestamp: message.ts, @@ -96,16 +138,17 @@ controller.hears(['hello','hi'],'direct_message,direct_mention,mention',function controller.storage.users.get(message.user,function(err, user) { if (user && user.name) { - bot.reply(message,'Hello ' + user.name + '!!'); + bot.reply(message, 'Hello ' + user.name + '!!'); } else { - bot.reply(message,'Hello.'); + bot.reply(message, 'Hello.'); } }); }); -controller.hears(['call me (.*)'],'direct_message,direct_mention,mention',function(bot, message) { - var matches = message.text.match(/call me (.*)/i); - var name = matches[1]; +controller.hears(['call me (.*)','my name is (.*)'],'direct_message,direct_mention,mention',function(bot, message) { + + // the name will be stored in the message.match field + var name = message.match[1]; controller.storage.users.get(message.user,function(err, user) { if (!user) { user = { @@ -134,6 +177,7 @@ controller.hears(['what is my name','who am i'],'direct_message,direct_mention,m controller.hears(['shutdown'],'direct_message,direct_mention,mention',function(bot, message) { bot.startConversation(message,function(err, convo) { + convo.ask('Are you sure you want me to shutdown?',[ { pattern: bot.utterances.yes, diff --git a/examples/slack/sentiment_analysis.js b/examples/slack/sentiment_analysis.js new file mode 100644 index 000000000..34d79f4ed --- /dev/null +++ b/examples/slack/sentiment_analysis.js @@ -0,0 +1,155 @@ +'use strict'; +// Author: James D. Wilson, james@jameswilson.name + +/* "dependencies": { + "botkit": "0.0.7", + "escape-string-regexp": "^1.0.5", + "lodash": "^4.5.1", + "mongodb": "^2.1.7", + "sentiment": "^1.0.6", + } + */ +var _ = require('lodash'); +var escapeStringRegexp = require('escape-string-regexp'); +var botkit = require('botkit'); +var mongodb = require('mongodb'); +var sentiment = require('sentiment'); + +function connectToDb() { + mongodb.MongoClient.connect('mongodb://localhost:27017/sentiment', function (err, db) { + if (err) { + throw err; + } + console.log('Connection established to mongodb'); + startBot(db); + }); +} + +function startBot(db) { + var collection = db.collection('sentiment'); + + var INSTANCE_PUBLIC_SHAMING = true; + var INSTANCE_PUBLIC_SHAMING_THRESHOLD = -4; + + var INSTANCE_PRIVATE_SHAMING = true; + var INSTANCE_PRIVATE_SHAMING_THRESHOLD = -4; + + var COUNT_POSITIVE_SCORES = true; + var COUNT_NEGATIVE_SCORES = true; + + var INSTANCE_PUBLIC_SHAMING_MESSAGES = [ + 'Remember to keep up the DoublePlusGood GoodThink vibes for our SafeSpace.', + 'Remember, we\'re all in this together for the benefit of our Company.', + 'Let\'s stay positive! Remember: There\'s no I in team but there\'s an "eye" in ' + + 'this team. ;)', + 'We wouldn\'t want this to stay on our permanent record. Let\'s speak more positively' ]; + var INSTANCE_PRIVATE_SHAMING_MESSAGES = [ + 'Please remember to be civil. This will be on your HR file.', + 'Only Happy fun times are allowed here. Remember GoodThink and PositiveVibes.', + 'Let\'s stay positive! Remember: There\'s no I in team but there\'s an "eye" in this ' + + 'team. ;). Watching you.', + 'Upbeat messages only. This has been logged to keep everyone safe.' ]; + + var afinn = require('sentiment/build/AFINN.json'); + + var botkitController = botkit.slackbot({ + debug: false + }); + + botkitController.spawn({ + token: process.env.token + }).startRTM(function (err) { + if (err) { + throw err; + } + }); + + botkitController.hears([ 'hello', 'hi' ], [ 'direct_mention' ], function (bot, message) { + bot.reply(message, 'Hello. I\'m watching you.'); + }); + + var formatReportList = function formatReportList(result) { + return result.map(function (i) { + return '<@' + i._id + '>: ' + i.score; + }); + }; + + botkitController.hears([ 'report' ], [ 'direct_message', 'direct_mention' ], function (bot, message) { + collection.aggregate([ { $sort: { score: 1 } }, { $limit: 10 } ]).toArray( + function (err, result) { + if (err) { + throw err; + } + + var topList = formatReportList(result); + bot.reply(message, 'Top 10 Scores:\n' + topList.join('"\n"')); + }); + collection.aggregate([ { $sort: { score: -1 } }, { $limit: 10 } ]).toArray( + function (err, result) { + if (err) { + throw err; + } + + var bottomList = formatReportList(result); + bot.reply(message, 'Bottom 10 Scores:\n' + bottomList.join('\n')); + }); + }); + + var listeningFor = '^' + Object.keys(afinn).map(escapeStringRegexp).join('|') + '$'; + botkitController.hears([ listeningFor ], [ 'ambient' ], function (bot, message) { + var sentimentAnalysis = sentiment(message.text); + console.log({ sentimentAnalysis: sentimentAnalysis }); + if (COUNT_POSITIVE_SCORES == false && sentimentAnalysis.score > 0) { + return; + } + + if (COUNT_NEGATIVE_SCORES == false && sentimentAnalysis.score < 0) { + return; + } + + collection.findAndModify({ _id: message.user }, [ [ '_id', 1 ] ], { + $inc: { score: sentimentAnalysis.score } + }, { 'new': true, upsert: true }, function (err, result) { + if (err) { + throw err; + } + + // full doc is available in result object: + // console.log(result) + var shamed = false; + if (INSTANCE_PUBLIC_SHAMING && + sentimentAnalysis.score <= INSTANCE_PUBLIC_SHAMING_THRESHOLD) { + shamed = true; + bot.startConversation(message, function (err, convo) { + if (err) { + throw err; + } + + var publicShamingMessage = _.sample(INSTANCE_PUBLIC_SHAMING_MESSAGES); + console.log({ publicShamingMessage: publicShamingMessage }); + convo.say(publicShamingMessage); + }); + } + + if (!shamed && INSTANCE_PRIVATE_SHAMING && + sentimentAnalysis.score <= INSTANCE_PRIVATE_SHAMING_THRESHOLD) { + bot.startPrivateConversation(message, function (err, dm) { + if (err) { + throw err; + } + + var privateShamingMessage = _.sample(INSTANCE_PRIVATE_SHAMING_MESSAGES); + console.log({ privateShamingMessage: privateShamingMessage }); + dm.say(privateShamingMessage); + }); + } + }); + }); +} + +if (!process.env.token) { + console.log('Error: Specify token in environment'); + process.exit(1); +} + +connectToDb(); diff --git a/examples/slack_app.js b/examples/slack/slack_app.js similarity index 100% rename from examples/slack_app.js rename to examples/slack/slack_app.js diff --git a/examples/slack/slackbutton_bot.js b/examples/slack/slackbutton_bot.js new file mode 100755 index 000000000..b86dff0fe --- /dev/null +++ b/examples/slack/slackbutton_bot.js @@ -0,0 +1,229 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ______ ______ ______ __ __ __ ______ + /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ + \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + + +This is a sample Slack Button application that adds a bot to one or many slack teams. + +# RUN THE APP: + Create a Slack app. Make sure to configure the bot user! + -> https://api.slack.com/applications/new + -> Add the Redirect URI: http://localhost:3000/oauth + Run your bot from the command line: + clientId= clientSecret= port=3000 node slackbutton_bot.js +# USE THE APP + Add the app to your Slack by visiting the login page: + -> http://localhost:3000/login + After you've added the app, try talking to your bot! +# EXTEND THE APP: + Botkit has many features for building cool and useful bots! + Read all about it here: + -> http://howdy.ai/botkit +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + +/* Uses the slack button feature to offer a real time bot to multiple teams */ +var Botkit = require('../../lib/Botkit.js'); + +if (!process.env.clientId || !process.env.clientSecret || !process.env.port) { + console.log('Error: Specify clientId clientSecret and port in environment'); + process.exit(1); +} + + +var controller = Botkit.slackbot({ + studio_token: process.env.studio_token, + json_file_store: './db_slackbutton_bot/', + rtm_receive_messages: false, // disable rtm_receive_messages if you enable events api +}).configureSlackApp( + { + clientId: process.env.clientId, + clientSecret: process.env.clientSecret, + redirectUri: process.env.redirectUri, // optional parameter passed to slackbutton oauth flow + scopes: ['bot'], + } +); + +controller.setupWebserver(process.env.port,function(err,webserver) { + controller.createWebhookEndpoints(controller.webserver); + + controller.createOauthEndpoints(controller.webserver,function(err,req,res) { + if (err) { + res.status(500).send('ERROR: ' + err); + } else { + res.send('Success!'); + } + }); +}); + + +// just a simple way to make sure we don't +// connect to the RTM twice for the same team +var _bots = {}; +function trackBot(bot) { + _bots[bot.config.token] = bot; +} + + +controller.startTicking(); + + + +controller.on('direct_message,direct_mention,mention', function(bot, message) { + console.log('GOT MSG'); + controller.studio.runTrigger(bot, message.text, message.user, message.channel).then(function(convo) { + console.log('got a convo'); + }).catch(function(err) { + throw new Error(err); + }); + return false; +}); + +controller.studio.before('zample', function(convo, next) { + + console.log('BEFORE ZAMPLE'); + next(); + +}) + +controller.studio.beforeThread('zample','foo', function(convo, next) { + + console.log('FIRING PLUGIN'); + console.log('changing from ', convo.thread,' to ', convo.next_thread); + convo.gotoThread('bar'); + next(); + +}); + + +controller.studio.beforeThread('zample','foo', function(convo, next) { + + console.log('FIRING PLUGIN 2'); + console.log('changing from ', convo.thread,' to ', convo.next_thread); + + next(); + +}); + +controller.studio.beforeThread('zample','bar', function(convo, next) { + + console.log('BEFORE BAR'); + console.log('changing from ', convo.thread,' to ', convo.next_thread); + + next(); +}); + + + + +controller.hears(['hello', 'hi'], 'direct_message,direct_mention,mention', function(bot, message) { + + bot.startConversation(message, function(err, convo) { + + convo.addMessage({text: 'hello', action:'foo'},'default'); + + convo.addMessage({text: 'foo'},'foo'); + + convo.beforeThread('foo', function(convo, next) { + + console.log('BEFORE FOO!'); + next(); + + }); + + convo.beforeThread('foo', function(convo, next) { + + console.log('ALSO BEFORE FOO'); + next(); + + }); + + + console.log('GO BAB GO'); + + }); + + +}); + + + + +controller.on('create_bot',function(bot,config) { + + if (_bots[bot.config.token]) { + // already online! do nothing. + } else { + bot.startRTM(function(err) { + + if (!err) { + trackBot(bot); + } + + bot.startPrivateConversation({user: config.createdBy},function(err,convo) { + if (err) { + console.log(err); + } else { + convo.say('I am a bot that has just joined your team'); + convo.say('You must now /invite me to a channel so that I can be of use!'); + } + }); + + }); + } + +}); + + +// Handle events related to the websocket connection to Slack +controller.on('rtm_open',function(bot) { + console.log('** The RTM api just connected!'); +}); + +controller.on('rtm_close',function(bot) { + console.log('** The RTM api just closed'); + // you may want to attempt to re-open +}); + +controller.hears('hello','direct_message',function(bot,message) { + bot.reply(message,'Hello!'); +}); + +controller.hears('^stop','direct_message',function(bot,message) { + bot.reply(message,'Goodbye'); + bot.rtm.close(); +}); + +controller.on(['direct_message','mention','direct_mention'],function(bot,message) { + bot.api.reactions.add({ + timestamp: message.ts, + channel: message.channel, + name: 'robot_face', + },function(err) { + if (err) { console.log(err) } + bot.reply(message,'I heard you loud and clear boss.'); + }); +}); +// +// controller.storage.teams.all(function(err,teams) { +// +// if (err) { +// throw new Error(err); +// } +// +// // connect all teams with bots up to slack! +// for (var t in teams) { +// if (teams[t].bot) { +// controller.spawn(teams[t]).startRTM(function(err, bot) { +// if (err) { +// console.log('Error connecting bot to Slack:',err); +// } else { +// trackBot(bot); +// } +// }); +// } +// } +// +// }); diff --git a/examples/slack/slackbutton_bot_interactivemsg.js b/examples/slack/slackbutton_bot_interactivemsg.js new file mode 100644 index 000000000..6dbc04404 --- /dev/null +++ b/examples/slack/slackbutton_bot_interactivemsg.js @@ -0,0 +1,333 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ______ ______ ______ __ __ __ ______ + /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ + \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + + +This is a sample Slack Button application that adds a bot to one or many slack teams. + +# RUN THE APP: + Create a Slack app. Make sure to configure the bot user! + -> https://api.slack.com/applications/new + -> Add the Redirect URI: http://localhost:3000/oauth + Run your bot from the command line: + clientId= clientSecret= port=3000 node slackbutton_bot_interactivemsg.js +# USE THE APP + Add the app to your Slack by visiting the login page: + -> http://localhost:3000/login + After you've added the app, try talking to your bot! +# EXTEND THE APP: + Botkit has many features for building cool and useful bots! + Read all about it here: + -> http://howdy.ai/botkit +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + +/* Uses the slack button feature to offer a real time bot to multiple teams */ +var Botkit = require('../../lib/Botkit.js'); + +if (!process.env.clientId || !process.env.clientSecret || !process.env.port) { + console.log('Error: Specify clientId clientSecret and port in environment'); + process.exit(1); +} + + +var controller = Botkit.slackbot({ + // interactive_replies: true, // tells botkit to send button clicks into conversations + json_file_store: './db_slackbutton_bot/', + // rtm_receive_messages: false, // disable rtm_receive_messages if you enable events api +}).configureSlackApp( + { + clientId: process.env.clientId, + clientSecret: process.env.clientSecret, + scopes: ['bot'], + } +); + +controller.setupWebserver(process.env.port,function(err,webserver) { + controller.createWebhookEndpoints(controller.webserver); + + controller.createOauthEndpoints(controller.webserver,function(err,req,res) { + if (err) { + res.status(500).send('ERROR: ' + err); + } else { + res.send('Success!'); + } + }); +}); + + +// just a simple way to make sure we don't +// connect to the RTM twice for the same team +var _bots = {}; +function trackBot(bot) { + _bots[bot.config.token] = bot; +} + + +controller.on('interactive_message_callback', function(bot, message) { + + var ids = message.callback_id.split(/\-/); + var user_id = ids[0]; + var item_id = ids[1]; + + controller.storage.users.get(user_id, function(err, user) { + + if (!user) { + user = { + id: user_id, + list: [] + } + } + + for (var x = 0; x < user.list.length; x++) { + if (user.list[x].id == item_id) { + if (message.actions[0].value=='flag') { + user.list[x].flagged = !user.list[x].flagged; + } + if (message.actions[0].value=='delete') { + user.list.splice(x,1); + } + } + } + + + var reply = { + text: 'Here is <@' + user_id + '>s list:', + attachments: [], + } + + for (var x = 0; x < user.list.length; x++) { + reply.attachments.push({ + title: user.list[x].text + (user.list[x].flagged? ' *FLAGGED*' : ''), + callback_id: user_id + '-' + user.list[x].id, + attachment_type: 'default', + actions: [ + { + "name":"flag", + "text": ":waving_black_flag: Flag", + "value": "flag", + "type": "button", + }, + { + "text": "Delete", + "name": "delete", + "value": "delete", + "style": "danger", + "type": "button", + "confirm": { + "title": "Are you sure?", + "text": "This will do something!", + "ok_text": "Yes", + "dismiss_text": "No" + } + } + ] + }) + } + + bot.replyInteractive(message, reply); + controller.storage.users.save(user); + + + }); + +}); + + +controller.on('create_bot',function(bot,config) { + + if (_bots[bot.config.token]) { + // already online! do nothing. + } else { + bot.startRTM(function(err) { + + if (!err) { + trackBot(bot); + } + + bot.startPrivateConversation({user: config.createdBy},function(err,convo) { + if (err) { + console.log(err); + } else { + convo.say('I am a bot that has just joined your team'); + convo.say('You must now /invite me to a channel so that I can be of use!'); + } + }); + + }); + } + +}); + + +// Handle events related to the websocket connection to Slack +controller.on('rtm_open',function(bot) { + console.log('** The RTM api just connected!'); +}); + +controller.on('rtm_close',function(bot) { + console.log('** The RTM api just closed'); + // you may want to attempt to re-open +}); + + +controller.hears(['add (.*)'],'direct_mention,direct_message',function(bot,message) { + + controller.storage.users.get(message.user, function(err, user) { + + if (!user) { + user = { + id: message.user, + list: [] + } + } + + user.list.push({ + id: message.ts, + text: message.match[1], + }); + + bot.reply(message,'Added to list. Say `list` to view or manage list.'); + + controller.storage.users.save(user); + + }); +}); + + +controller.hears(['list','tasks'],'direct_mention,direct_message',function(bot,message) { + + controller.storage.users.get(message.user, function(err, user) { + + if (!user) { + user = { + id: message.user, + list: [] + } + } + + if (!user.list || !user.list.length) { + user.list = [ + { + 'id': 1, + 'text': 'Test Item 1' + }, + { + 'id': 2, + 'text': 'Test Item 2' + }, + { + 'id': 3, + 'text': 'Test Item 3' + } + ] + } + + var reply = { + text: 'Here is your list. Say `add ` to add items.', + attachments: [], + } + + for (var x = 0; x < user.list.length; x++) { + reply.attachments.push({ + title: user.list[x].text + (user.list[x].flagged? ' *FLAGGED*' : ''), + callback_id: message.user + '-' + user.list[x].id, + attachment_type: 'default', + actions: [ + { + "name":"flag", + "text": ":waving_black_flag: Flag", + "value": "flag", + "type": "button", + }, + { + "text": "Delete", + "name": "delete", + "value": "delete", + "style": "danger", + "type": "button", + "confirm": { + "title": "Are you sure?", + "text": "This will do something!", + "ok_text": "Yes", + "dismiss_text": "No" + } + } + ] + }) + } + + bot.reply(message, reply); + + controller.storage.users.save(user); + + }); + +}); + +controller.hears('interactive', 'direct_message', function(bot, message) { + + bot.reply(message, { + attachments:[ + { + title: 'Do you want to interact with my buttons?', + callback_id: '123', + attachment_type: 'default', + actions: [ + { + "name":"yes", + "text": "Yes", + "value": "yes", + "type": "button", + }, + { + "name":"no", + "text": "No", + "value": "no", + "type": "button", + } + ] + } + ] + }); +}); + + +controller.hears('^stop','direct_message',function(bot,message) { + bot.reply(message,'Goodbye'); + bot.rtm.close(); +}); + +controller.on(['direct_message','mention','direct_mention'],function(bot,message) { + bot.api.reactions.add({ + timestamp: message.ts, + channel: message.channel, + name: 'robot_face', + },function(err) { + if (err) { console.log(err) } + bot.reply(message,'I heard you loud and clear boss.'); + }); +}); + +controller.storage.teams.all(function(err,teams) { + + if (err) { + throw new Error(err); + } + + // connect all teams with bots up to slack! + for (var t in teams) { + if (teams[t].bot) { + controller.spawn(teams[t]).startRTM(function(err, bot) { + if (err) { + console.log('Error connecting bot to Slack:',err); + } else { + trackBot(bot); + } + }); + } + } + +}); diff --git a/examples/slackbutton_incomingwebhooks.js b/examples/slack/slackbutton_incomingwebhooks.js old mode 100755 new mode 100644 similarity index 91% rename from examples/slackbutton_incomingwebhooks.js rename to examples/slack/slackbutton_incomingwebhooks.js index 7b74480f5..047f8f3ea --- a/examples/slackbutton_incomingwebhooks.js +++ b/examples/slack/slackbutton_incomingwebhooks.js @@ -1,10 +1,10 @@ /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - ______ ______ ______ __ __ __ ______ + ______ ______ ______ __ __ __ ______ /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ - \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ - \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ - + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + This is a sample Slack Button application that allows the application to post messages into Slack. @@ -42,14 +42,14 @@ This bot demonstrates many of the core features of Botkit: # EXTEND THE APP: - Botkit is has many features for building cool and useful bots! + Botkit has many features for building cool and useful bots! Read all about it here: -> http://howdy.ai/botkit ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ -var Botkit = require('../lib/Botkit.js'); +var Botkit = require('../../lib/Botkit.js'); if (!process.env.clientId || !process.env.clientSecret || !process.env.port) { console.log('Error: Specify clientId clientSecret and port in environment'); diff --git a/examples/slackbutton_slashcommand.js b/examples/slack/slackbutton_slashcommand.js similarity index 86% rename from examples/slackbutton_slashcommand.js rename to examples/slack/slackbutton_slashcommand.js index ea23ab02b..ef0368a54 100755 --- a/examples/slackbutton_slashcommand.js +++ b/examples/slack/slackbutton_slashcommand.js @@ -1,10 +1,10 @@ /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - ______ ______ ______ __ __ __ ______ + ______ ______ ______ __ __ __ ______ /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ - \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ - \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ - + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + This is a sample Slack Button application that provides a custom Slash command. @@ -32,14 +32,14 @@ This bot demonstrates many of the core features of Botkit: # EXTEND THE BOT: - Botkit is has many features for building cool and useful bots! + Botkit has many features for building cool and useful bots! Read all about it here: -> http://howdy.ai/botkit ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ -var Botkit = require('../lib/Botkit.js'); +var Botkit = require('../../lib/Botkit.js'); if (!process.env.clientId || !process.env.clientSecret || !process.env.port) { console.log('Error: Specify clientId clientSecret and port in environment'); diff --git a/examples/slack/team_outgoingwebhook.js b/examples/slack/team_outgoingwebhook.js new file mode 100755 index 000000000..7e4813625 --- /dev/null +++ b/examples/slack/team_outgoingwebhook.js @@ -0,0 +1,16 @@ +var Botkit = require('../../lib/Botkit.js'); + +var controller = Botkit.slackbot({ + debug: true +}); + + +controller.setupWebserver(3000, function(err, webserver) { + controller.createWebhookEndpoints(webserver); +}); + +controller.on('outgoing_webhook', function(bot, message) { + + bot.replyPublic(message, 'This is a public reply to the outgoing webhook!'); + +}); diff --git a/examples/slack/team_slashcommand.js b/examples/slack/team_slashcommand.js new file mode 100755 index 000000000..286ce7040 --- /dev/null +++ b/examples/slack/team_slashcommand.js @@ -0,0 +1,23 @@ +var Botkit = require('../../lib/Botkit.js'); + +var controller = Botkit.slackbot({ + debug: true +}); + + +controller.setupWebserver(3000, function(err, webserver) { + controller.createWebhookEndpoints(webserver); +}); + +controller.on('slash_command', function(bot, message) { + // check message.command + // and maybe message.text... + // use EITHER replyPrivate or replyPublic... + bot.replyPrivate(message, 'This is a private reply to the ' + message.command + ' slash command!'); + + // and then continue to use replyPublicDelayed or replyPrivateDelayed + bot.replyPublicDelayed(message, 'This is a public reply to the ' + message.command + ' slash command!'); + + bot.replyPrivateDelayed(message, ':dash:'); + +}); diff --git a/examples/slack_bot.js b/examples/slack_bot.js new file mode 100644 index 000000000..3bcaab063 --- /dev/null +++ b/examples/slack_bot.js @@ -0,0 +1,245 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ______ ______ ______ __ __ __ ______ + /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ + \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + + +This is a sample Slack bot built with Botkit. + +This bot demonstrates many of the core features of Botkit: + +* Connect to Slack using the real time API +* Receive messages based on "spoken" patterns +* Reply to messages +* Use the conversation system to ask questions +* Use the built in storage system to store and retrieve information + for a user. + +# RUN THE BOT: + + Get a Bot token from Slack: + + -> http://my.slack.com/services/new/bot + + Run your bot from the command line: + + token= node slack_bot.js + +# USE THE BOT: + + Find your bot inside Slack to send it a direct message. + + Say: "Hello" + + The bot will reply "Hello!" + + Say: "who are you?" + + The bot will tell you its name, where it is running, and for how long. + + Say: "Call me " + + Tell the bot your nickname. Now you are friends. + + Say: "who am I?" + + The bot will tell you your nickname, if it knows one for you. + + Say: "shutdown" + + The bot will ask if you are sure, and then shut itself down. + + Make sure to invite your bot into other channels using /invite @! + +# EXTEND THE BOT: + + Botkit has many features for building cool and useful bots! + + Read all about it here: + + -> http://howdy.ai/botkit + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + + +if (!process.env.token) { + console.log('Error: Specify token in environment'); + process.exit(1); +} + +var Botkit = require('../lib/Botkit.js'); +var os = require('os'); + +var controller = Botkit.slackbot({ + debug: true, +}); + +var bot = controller.spawn({ + token: process.env.token +}).startRTM(); + +controller.hears(['hello', 'hi'], 'direct_message,direct_mention,mention', function(bot, message) { + + bot.api.reactions.add({ + timestamp: message.ts, + channel: message.channel, + name: 'robot_face', + }, function(err, res) { + if (err) { + bot.botkit.log('Failed to add emoji reaction :(', err); + } + }); + + + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Hello ' + user.name + '!!'); + } else { + bot.reply(message, 'Hello.'); + } + }); +}); + +controller.hears(['call me (.*)', 'my name is (.*)'], 'direct_message,direct_mention,mention', function(bot, message) { + var name = message.match[1]; + controller.storage.users.get(message.user, function(err, user) { + if (!user) { + user = { + id: message.user, + }; + } + user.name = name; + controller.storage.users.save(user, function(err, id) { + bot.reply(message, 'Got it. I will call you ' + user.name + ' from now on.'); + }); + }); +}); + +controller.hears(['what is my name', 'who am i'], 'direct_message,direct_mention,mention', function(bot, message) { + + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Your name is ' + user.name); + } else { + bot.startConversation(message, function(err, convo) { + if (!err) { + convo.say('I do not know your name yet!'); + convo.ask('What should I call you?', function(response, convo) { + convo.ask('You want me to call you `' + response.text + '`?', [ + { + pattern: 'yes', + callback: function(response, convo) { + // since no further messages are queued after this, + // the conversation will end naturally with status == 'completed' + convo.next(); + } + }, + { + pattern: 'no', + callback: function(response, convo) { + // stop the conversation. this will cause it to end with status == 'stopped' + convo.stop(); + } + }, + { + default: true, + callback: function(response, convo) { + convo.repeat(); + convo.next(); + } + } + ]); + + convo.next(); + + }, {'key': 'nickname'}); // store the results in a field called nickname + + convo.on('end', function(convo) { + if (convo.status == 'completed') { + bot.reply(message, 'OK! I will update my dossier...'); + + controller.storage.users.get(message.user, function(err, user) { + if (!user) { + user = { + id: message.user, + }; + } + user.name = convo.extractResponse('nickname'); + controller.storage.users.save(user, function(err, id) { + bot.reply(message, 'Got it. I will call you ' + user.name + ' from now on.'); + }); + }); + + + + } else { + // this happens if the conversation ended prematurely for some reason + bot.reply(message, 'OK, nevermind!'); + } + }); + } + }); + } + }); +}); + + +controller.hears(['shutdown'], 'direct_message,direct_mention,mention', function(bot, message) { + + bot.startConversation(message, function(err, convo) { + + convo.ask('Are you sure you want me to shutdown?', [ + { + pattern: bot.utterances.yes, + callback: function(response, convo) { + convo.say('Bye!'); + convo.next(); + setTimeout(function() { + process.exit(); + }, 3000); + } + }, + { + pattern: bot.utterances.no, + default: true, + callback: function(response, convo) { + convo.say('*Phew!*'); + convo.next(); + } + } + ]); + }); +}); + + +controller.hears(['uptime', 'identify yourself', 'who are you', 'what is your name'], + 'direct_message,direct_mention,mention', function(bot, message) { + + var hostname = os.hostname(); + var uptime = formatUptime(process.uptime()); + + bot.reply(message, + ':robot_face: I am a bot named <@' + bot.identity.name + + '>. I have been running for ' + uptime + ' on ' + hostname + '.'); + + }); + +function formatUptime(uptime) { + var unit = 'second'; + if (uptime > 60) { + uptime = uptime / 60; + unit = 'minute'; + } + if (uptime > 60) { + uptime = uptime / 60; + unit = 'hour'; + } + if (uptime != 1) { + unit = unit + 's'; + } + + uptime = uptime + ' ' + unit; + return uptime; +} diff --git a/examples/slackbutton_bot.js b/examples/slackbutton_bot.js deleted file mode 100755 index 31b9aeb13..000000000 --- a/examples/slackbutton_bot.js +++ /dev/null @@ -1,115 +0,0 @@ -/* Uses the slack button feature to offer a real time bot to multiple teams */ -var Botkit = require('../lib/Botkit.js'); - -if (!process.env.clientId || !process.env.clientSecret || !process.env.port) { - console.log('Error: Specify clientId clientSecret and port in environment'); - process.exit(1); -} - - -var controller = Botkit.slackbot({ - json_file_store: './db_slackbutton_bot/', -}).configureSlackApp( - { - clientId: process.env.clientId, - clientSecret: process.env.clientSecret, - scopes: ['bot'], - } -); - -controller.setupWebserver(process.env.port,function(err,webserver) { - controller.createWebhookEndpoints(controller.webserver); - - controller.createOauthEndpoints(controller.webserver,function(err,req,res) { - if (err) { - res.status(500).send('ERROR: ' + err); - } else { - res.send('Success!'); - } - }); -}); - - -// just a simple way to make sure we don't -// connect to the RTM twice for the same team -var _bots = {}; -function trackBot(bot) { - _bots[bot.config.token] = bot; -} - -controller.on('create_bot',function(bot,config) { - - if (_bots[bot.config.token]) { - // already online! do nothing. - } else { - bot.startRTM(function(err) { - - if (!err) { - trackBot(bot); - } - - bot.startPrivateConversation({user: config.createdBy},function(err,convo) { - if (err) { - console.log(err); - } else { - convo.say('I am a bot that has just joined your team'); - convo.say('You must now /invite me to a channel so that I can be of use!'); - } - }); - - }); - } - -}); - - -// Handle events related to the websocket connection to Slack -controller.on('rtm_open',function(bot) { - console.log('** The RTM api just connected!'); -}); - -controller.on('rtm_close',function(bot) { - console.log('** The RTM api just closed'); - // you may want to attempt to re-open -}); - -controller.hears('hello','direct_message',function(bot,message) { - bot.reply(message,'Hello!'); -}); - -controller.hears('^stop','direct_message',function(bot,message) { - bot.reply(message,'Goodbye'); - bot.rtm.close(); -}); - -controller.on(['direct_message','mention','direct_mention'],function(bot,message) { - bot.api.reactions.add({ - timestamp: message.ts, - channel: message.channel, - name: 'robot_face', - },function(err) { - if (err) { console.log(err) } - bot.reply(message,'I heard you loud and clear boss.'); - }); -}); - -controller.storage.teams.all(function(err,teams) { - - if (err) { - throw new Error(err); - } - - // connect all teams with bots up to slack! - for (var t in teams) { - if (teams[t].bot) { - var bot = controller.spawn(teams[t]).startRTM(function(err) { - if (err) { - console.log('Error connecting bot to Slack:',err); - } else { - trackBot(bot); - } - }); - } - } - -}); diff --git a/examples/spark_bot.js b/examples/spark_bot.js new file mode 100644 index 000000000..e1d41e4c6 --- /dev/null +++ b/examples/spark_bot.js @@ -0,0 +1,99 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ______ ______ ______ __ __ __ ______ + /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ + \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + + +This is a sample Cisco Spark bot built with Botkit. + +This bot demonstrates many of the core features of Botkit: + +* Connect to Cisco Spark's APIs +* Receive messages based on "spoken" patterns +* Reply to messages +* Use the conversation system to ask questions +* Use the built in storage system to store and retrieve information + for a user. + +# EXTEND THE BOT: + + Botkit has many features for building cool and useful bots! + + Read all about it here: + + -> http://botkit.ai + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + +var Botkit = require('../lib/Botkit.js'); + +var controller = Botkit.sparkbot({ + debug: false, + log: false, + public_address: process.env.public_address, + ciscospark_access_token: process.env.access_token, + studio_token: process.env.studio_token, // get one from studio.botkit.ai to enable content management, stats, message console and more + secret: process.env.secret, // this is an RECOMMENDED but optional setting that enables validation of incoming webhooks + webhook_name: 'Cisco Spark bot created with Botkit, override me before going to production', +// limit_to_domain: ['mycompany.com'], +// limit_to_org: 'my_cisco_org_id', +}); + +var bot = controller.spawn({}); + +controller.setupWebserver(process.env.PORT || 3000, function(err, webserver) { + controller.createWebhookEndpoints(webserver, bot, function() { + console.log("Cisco Spark: Webhooks set up!"); + }); +}); + +controller.hears(['^markdown'], 'direct_message,direct_mention', function(bot, message) { + + bot.reply(message, {text: '*this is cool*', markdown: '*this is super cool*'}); + +}); + +controller.on('user_space_join', function(bot, message) { + bot.reply(message, 'Welcome, ' + message.original_message.data.personDisplayName); +}); + +controller.on('user_space_leave', function(bot, message) { + bot.reply(message, 'Bye, ' + message.original_message.data.personDisplayName); +}); + + +controller.on('bot_space_join', function(bot, message) { + + bot.reply(message, 'This trusty bot is here to help.'); + +}); + + +controller.on('direct_mention', function(bot, message) { + bot.reply(message, 'You mentioned me and said, "' + message.text + '"'); +}); + +controller.on('direct_message', function(bot, message) { + bot.reply(message, 'I got your private message. You said, "' + message.text + '"'); + if (message.original_message.files) { + bot.retrieveFileInfo(message.original_message.files[0], function(err, file) { + bot.reply(message,'I also got an attached file called ' + file.filename); + }); + } +}); + +if (process.env.studio_token) { + controller.on('direct_message,direct_mention', function(bot, message) { + controller.studio.runTrigger(bot, message.text, message.user, message.channel).then(function(convo) { + if (!convo) { + // console.log('NO STUDIO MATCH'); + } else { + // found a conversation + } + }).catch(function(err) { + console.error('Error with Botkit Studio: ', err); + }); + }); +} diff --git a/examples/team_outgoingwebhook.js b/examples/team_outgoingwebhook.js deleted file mode 100755 index ee920d5a8..000000000 --- a/examples/team_outgoingwebhook.js +++ /dev/null @@ -1 +0,0 @@ -/* TODO a bot that responds to outgoing webhooks for a team */ diff --git a/examples/team_slashcommand.js b/examples/team_slashcommand.js deleted file mode 100755 index 4222eee5e..000000000 --- a/examples/team_slashcommand.js +++ /dev/null @@ -1 +0,0 @@ -/* TODO a bot that responds to slash commands for a team */ diff --git a/examples/teams/contoso20x20.png b/examples/teams/contoso20x20.png new file mode 100755 index 000000000..c2f348068 Binary files /dev/null and b/examples/teams/contoso20x20.png differ diff --git a/examples/teams/contoso96x96.png b/examples/teams/contoso96x96.png new file mode 100755 index 000000000..bd7353f01 Binary files /dev/null and b/examples/teams/contoso96x96.png differ diff --git a/examples/teams/manifest.json b/examples/teams/manifest.json new file mode 100644 index 000000000..ea6f88d4c --- /dev/null +++ b/examples/teams/manifest.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://statics.teams.microsoft.com/sdk/v1.0/manifest/MicrosoftTeams.schema.json", + "manifestVersion": "1.0", + "version": "1.0.0", + "id": "%MICROSOFT-APP-ID%", + "packageName": "com.example.myapp", + "developer": { + "name": "Publisher Name", + "websiteUrl": "https://website.com/", + "privacyUrl": "https://website.com/privacy", + "termsOfUseUrl": "https://website.com/app-tos" + }, + "name": { + "short": "Name of your app - 30 characters", + "full": "Full name of app, if greater than 30" + }, + "description": { + "short": "Short description of your app", + "full": "Full description of your app" + }, + "icons": { + "outline": "contoso20x20.png", + "color": "contoso96x96.png" + }, + "accentColor": "%HEX-COLOR%", + "configurableTabs": [ + { + "configurationUrl": "https://taburl.com/config.html", + "canUpdateConfiguration": true, + "scopes": [ "team" ] + } + ], + "staticTabs": [ + { + "entityId": "idForPage", + "name": "Display name of tab", + "contentUrl": "https://teams-specific-webview.website.com", + "websiteUrl": "http://fullwebsite.website.com", + "scopes": [ "personal" ] + } + ], + "bots": [ + { + "botId": "%MICROSOFT-APP-ID-REGISTERED-WITH-BOT-FRAMEWORK%", + "needsChannelSelector": "true", + "isNotificationOnly": "false", + "scopes": [ "team", "personal" ], + "commandLists": [ + { + "scopes": ["team"], + "commands": [ + { + "title": "Command 1", + "description": "Description of Command 1" + }, + { + "title": "Command N", + "description": "Description of Command N" + } + ] + }, + { + "scopes": ["personal"], + "commands": [ + { + "title": "Personal command 1", + "description": "Description of Personal command 1" + }, + { + "title": "Personal command N", + "description": "Description of Personal command N" + } + ] + } + ] + } + ], + "connectors": [ + { + "connectorId": "GUID-FROM-CONNECTOR-DEV-PORTAL%", + "scopes": [ "team"] + } + ], + "composeExtensions": [ + { + "botId": "%MICROSOFT-APP-ID-REGISTERED-WITH-BOT-FRAMEWORK%", + "scopes": ["team", "personal"], + "commands": [ + { + "id": "exampleCmd", + "title": "Example Command", + "description": "Comamand Description e.g. Search on the web", + "initialRun": "true", + "parameters": [ + { + "name": "keyword", + "title": "Search keywords", + "description": "Enter the keywords to search for" + } + ] + } + ] + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [ + "*.taburl.com", + "*.otherdomains.com" + ] +} diff --git a/examples/twilio_ipm_bot.js b/examples/twilio_ipm_bot.js new file mode 100644 index 000000000..2dcd4d935 --- /dev/null +++ b/examples/twilio_ipm_bot.js @@ -0,0 +1,130 @@ +var Botkit = require('../lib/Botkit.js'); +var os = require('os'); +var controller = Botkit.twilioipmbot({ + debug: false, +}); + +var bot = controller.spawn({ + TWILIO_IPM_SERVICE_SID: process.env.TWILIO_IPM_SERVICE_SID, + TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID, + TWILIO_API_KEY: process.env.TWILIO_API_KEY, + TWILIO_API_SECRET: process.env.TWILIO_API_SECRET, + identity: 'Botkit', + autojoin: true +}); + +controller.setupWebserver(process.env.port || 3000, function(err, server) { + + server.get('/', function(req, res) { + res.send(':)'); + }); + + controller.createWebhookEndpoints(server, bot); + +}); + +controller.on('bot_channel_join', function(bot, message) { + bot.reply(message, 'Here I am!'); +}); + +controller.on('user_channel_join', function(bot,message) { + bot.reply(message, 'Welcome, ' + message.user + '!'); +}); + +controller.on('user_channel_leave', function(bot,message) { + bot.reply(message, 'Bye, ' + message.user + '!'); +}); + + +controller.hears(['hello', 'hi'], 'message_received', function(bot, message) { + + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Hello ' + user.name + '!!'); + } else { + bot.reply(message, 'Hello.'); + } + }); +}); + +controller.hears(['call me (.*)'], 'message_received', function(bot, message) { + var matches = message.text.match(/call me (.*)/i); + var name = matches[1]; + controller.storage.users.get(message.user, function(err, user) { + if (!user) { + user = { + id: message.user, + }; + } + user.name = name; + controller.storage.users.save(user, function(err, id) { + bot.reply(message, 'Got it. I will call you ' + user.name + ' from now on.'); + }); + }); +}); + +controller.hears(['what is my name', 'who am i'], 'message_received', function(bot, message) { + + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message,'Your name is ' + user.name); + } else { + bot.reply(message,'I don\'t know yet!'); + } + }); +}); + + +controller.hears(['shutdown'],'message_received',function(bot, message) { + + bot.startConversation(message,function(err, convo) { + convo.ask('Are you sure you want me to shutdown?',[ + { + pattern: bot.utterances.yes, + callback: function(response, convo) { + convo.say('Bye!'); + convo.next(); + setTimeout(function() { + process.exit(); + },3000); + } + }, + { + pattern: bot.utterances.no, + default: true, + callback: function(response, convo) { + convo.say('*Phew!*'); + convo.next(); + } + } + ]); + }); +}); + + +controller.hears(['uptime','identify yourself','who are you','what is your name'],'message_received',function(bot, message) { + + var hostname = os.hostname(); + var uptime = formatUptime(process.uptime()); + + bot.reply(message,'I am a bot! I have been running for ' + uptime + ' on ' + hostname + '.'); + +}); + +function formatUptime(uptime) { + var unit = 'second'; + if (uptime > 60) { + uptime = uptime / 60; + unit = 'minute'; + } + if (uptime > 60) { + uptime = uptime / 60; + unit = 'hour'; + } + if (uptime != 1) { + unit = unit + 's'; + } + + uptime = uptime + ' ' + unit; + return uptime; +} diff --git a/examples/twilio_sms_bot.js b/examples/twilio_sms_bot.js new file mode 100644 index 000000000..ade546dd4 --- /dev/null +++ b/examples/twilio_sms_bot.js @@ -0,0 +1,108 @@ +var Botkit = require('./lib/Botkit.js'); +var os = require('os'); + +var controller = Botkit.twiliosmsbot({ + account_sid: process.env.TWILIO_ACCOUNT_SID, + auth_token: process.env.TWILIO_AUTH_TOKEN, + twilio_number: process.env.TWILIO_NUMBER, + debug: true +}); + +var bot = controller.spawn({}); + +controller.setupWebserver(5000, function(err, server) { + server.get('/', function(req, res) { + res.send(':)'); + }); + + controller.createWebhookEndpoints(server, bot); +}) + +controller.hears(['hello', 'hi'], 'message_received', function(bot, message) { + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Hello ' + user.name + '!!'); + } else { + bot.reply(message, 'Hello.'); + } + }); +}); + +controller.hears(['call me (.*)'], 'message_received', function(bot, message) { + var matches = message.text.match(/call me (.*)/i); + var name = matches[1]; + controller.storage.users.get(message.user, function(err, user) { + if (!user) { + user = { + id: message.user, + }; + } + user.name = name; + controller.storage.users.save(user, function(err, id) { + bot.reply(message, 'Got it. I will call you ' + user.name + ' from now on.'); + }); + }); +}); + +controller.hears(['what is my name', 'who am i'], 'message_received', function(bot, message) { + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Your name is ' + user.name); + } else { + bot.reply(message, 'I don\'t know yet!'); + } + }); +}); + + +controller.hears(['shutdown'], 'message_received', function(bot, message) { + bot.startConversation(message, function(err, convo) { + convo.ask('Are you sure you want me to shutdown?', [{ + pattern: bot.utterances.yes, + callback: function(response, convo) { + convo.say('Bye!'); + convo.next(); + setTimeout(function() { + process.exit(); + }, 3000); + } + }, + { + pattern: bot.utterances.no, + default: true, + callback: function(response, convo) { + convo.say('*Phew!*'); + convo.next(); + } + } + ]); + }); +}); + + +controller.hears(['uptime', 'identify yourself', 'who are you', 'what is your name'], 'message_received', function(bot, message) { + + var hostname = os.hostname(); + var uptime = formatUptime(process.uptime()); + + bot.reply(message, 'I am a bot! I have been running for ' + uptime + ' on ' + hostname + '.'); + +}); + +function formatUptime(uptime) { + var unit = 'second'; + if (uptime > 60) { + uptime = uptime / 60; + unit = 'minute'; + } + if (uptime > 60) { + uptime = uptime / 60; + unit = 'hour'; + } + if (uptime != 1) { + unit = unit + 's'; + } + + uptime = uptime + ' ' + unit; + return uptime; +} diff --git a/examples/typescript_bot.ts b/examples/typescript_bot.ts new file mode 100644 index 000000000..222584935 --- /dev/null +++ b/examples/typescript_bot.ts @@ -0,0 +1,214 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ______ ______ ______ __ __ __ ______ + /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ + \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + + +This is a sample Slack bot built with Botkit and Typescript. + +# RUN THE BOT: + + Get a Bot token from Slack: + + -> http://my.slack.com/services/new/bot + + Compile typescript to javascript: + + tsc typescript_bot.ts + + Run your bot from the command line: + + token= node typescript_bot.js + +# USE THE BOT: + + Find your bot inside Slack to send it a direct message. + + Say: "Hello" + + The bot will reply "Hello!" + + Say: "who are you?" + + The bot will tell you its name, where it is running, and for how long. + + Say: "Call me " + + Tell the bot your nickname. Now you are friends. + + Say: "who am I?" + + The bot will tell you your nickname, if it knows one for you. + + Say: "shutdown" + + The bot will ask if you are sure, and then shut itself down. + + Make sure to invite your bot into other channels using /invite @! + +# EXTEND THE BOT: + + Botkit has many features for building cool and useful bots! + + Read all about it here: + + -> http://howdy.ai/botkit + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + +declare let process: { + env: { + token: string; + }; + exit(status?: number); +}; + +import Botkit = require('../lib/botkit'); + +if (!process.env.token) { + console.log('Error: Specify token in environment'); + process.exit(1); +} + +const controller = Botkit.slackbot({ + debug: true, +}); + +const bot = controller.spawn({ + token: process.env.token +}).startRTM(); + +controller.hears(['hello', 'hi'], 'direct_message,direct_mention,mention', function(bot, message) { + + bot.api.reactions.add({ + timestamp: message.ts, + channel: message.channel, + name: 'robot_face', + }, function(err, res) { + if (err) { + bot.botkit.log('Failed to add emoji reaction :(', err); + } + }); + + + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Hello ' + user.name + '!!'); + } else { + bot.reply(message, 'Hello.'); + } + }); +}); + +controller.hears(['call me (.*)', 'my name is (.*)'], 'direct_message,direct_mention,mention', function(bot, message) { + var name = message.match[1]; + controller.storage.users.get(message.user, function(err, user) { + if (!user) { + user = { + id: message.user, + }; + } + user.name = name; + controller.storage.users.save(user, function(err, id) { + bot.reply(message, 'Got it. I will call you ' + user.name + ' from now on.'); + }); + }); +}); + +controller.hears(['what is my name', 'who am i'], 'direct_message,direct_mention,mention', function(bot, message) { + + controller.storage.users.get(message.user, function(err, user) { + if (user && user.name) { + bot.reply(message, 'Your name is ' + user.name); + } else { + bot.startConversation(message, function(err, convo) { + if (!err) { + convo.say('I do not know your name yet!'); + convo.ask('What should I call you?', function(response, convo) { + convo.ask('You want me to call you `' + response.text + '`?', [ + { + pattern: 'yes', + callback: function(response, convo) { + // since no further messages are queued after this, + // the conversation will end naturally with status == 'completed' + convo.next(); + } + }, + { + pattern: 'no', + callback: function(response, convo) { + // stop the conversation. this will cause it to end with status == 'stopped' + convo.stop(); + } + }, + { + default: true, + callback: function(response, convo) { + convo.repeat(); + convo.next(); + } + } + ]); + + convo.next(); + + }, {'key': 'nickname'}); // store the results in a field called nickname + + convo.on('end', function(convo) { + if (convo.status == 'completed') { + bot.reply(message, 'OK! I will update my dossier...'); + + controller.storage.users.get(message.user, function(err, user) { + if (!user) { + user = { + id: message.user, + }; + } + user.name = convo.extractResponse('nickname'); + controller.storage.users.save(user, function(err, id) { + bot.reply(message, 'Got it. I will call you ' + user.name + ' from now on.'); + }); + }); + + + + } else { + // this happens if the conversation ended prematurely for some reason + bot.reply(message, 'OK, nevermind!'); + } + }); + } + }); + } + }); +}); + + +controller.hears(['shutdown'], 'direct_message,direct_mention,mention', function(bot, message) { + + bot.startConversation(message, function(err, convo) { + + convo.ask('Are you sure you want me to shutdown?', [ + { + pattern: bot.utterances.yes, + callback: function(response, convo) { + convo.say('Bye!'); + convo.next(); + setTimeout(function() { + process.exit(); + }, 3000); + } + }, + { + pattern: bot.utterances.no, + default: true, + callback: function(response, convo) { + convo.say('*Phew!*'); + convo.next(); + } + } + ]); + }); +}); diff --git a/lib/BotFramework.js b/lib/BotFramework.js new file mode 100644 index 000000000..f91c37596 --- /dev/null +++ b/lib/BotFramework.js @@ -0,0 +1,176 @@ +var Botkit = require(__dirname + '/CoreBot.js'); +var builder = require('botbuilder'); + +function BotFrameworkBot(configuration) { + + // Create a core botkit bot + var bf_botkit = Botkit(configuration || {}); + + // customize the bot definition, which will be used when new connections + // spawn! + bf_botkit.defineBot(function(botkit, config) { + + var bot = { + botkit: botkit, + config: config || {}, + utterances: botkit.utterances, + }; + + bot.send = function(message, cb) { + function done(err, res) { + if (cb) { + cb(err); + } + } + + if (!message || !message.address) { + if (cb) { + cb(new Error('Outgoing message requires a valid address...')); + } + return; + } + + // Copy message minus user & channel fields + var bf_message = {}; + for (var key in message) { + switch (key) { + case 'user': + case 'channel': + // ignore + break; + default: + bf_message[key] = message[key]; + break; + } + } + if (!bf_message.type) { + bf_message.type = 'message'; + } + + + // Ensure the message address has a valid conversation id. + if (!bf_message.address.conversation) { + bot.connector.startConversation(bf_message.address, function(err, adr) { + if (!err) { + // Send message through connector + bf_message.address = adr; + bot.connector.send([bf_message], done); + } else { + done(err); + } + }); + } else { + // Send message through connector + bot.connector.send([bf_message], done); + } + }; + + bot.reply = function(src, resp, cb) { + var msg = {}; + + if (typeof(resp) == 'string') { + msg.text = resp; + } else { + msg = resp; + } + + msg.user = src.user; + msg.channel = src.channel; + msg.address = src.address; + msg.to = src.user; + + bot.say(msg, cb); + }; + + bot.findConversation = function(message, cb) { + botkit.debug('CUSTOM FIND CONVO', message.user, message.channel); + for (var t = 0; t < botkit.tasks.length; t++) { + for (var c = 0; c < botkit.tasks[t].convos.length; c++) { + if ( + botkit.tasks[t].convos[c].isActive() && + botkit.tasks[t].convos[c].source_message.user == message.user && + botkit.tasks[t].convos[c].source_message.channel == message.channel + ) { + botkit.debug('FOUND EXISTING CONVO!'); + cb(botkit.tasks[t].convos[c]); + return; + } + } + } + + cb(); + }; + + // Create connector + bot.connector = new builder.ChatConnector(config); + + return bot; + + }); + + + bf_botkit.middleware.normalize.use(function(bot, message, next) { + + // Break out user & channel fields from event + // - These fields are used as keys for tracking conversations and storage. + // - Prefixing with channelId to ensure that users & channels for different + // platforms are unique. + + var prefix = message.address.channelId + ':'; + message.user = prefix + message.address.user.id; + message.channel = prefix + message.address.conversation.id; + + // MS supplies a type field that is 'message' for most messages, but we want it to be our more generic message_received event + if (message.type == 'message') { + message.type = 'message_received'; + } + + next(); + + }); + + bf_botkit.middleware.format.use(function(bot, message, platform_message, next) { + // clone the incoming message + for (var k in message) { + platform_message[k] = message[k]; + } + }); + + // set up a web route for receiving outgoing webhooks and/or slash commands + + bf_botkit.createWebhookEndpoints = function(webserver, bot, cb) { + + // Listen for incoming events + bf_botkit.log( + '** Serving webhook endpoints for the Microsoft Bot Framework at: ' + + 'http://' + bf_botkit.config.hostname + ':' + + bf_botkit.config.port + '/botframework/receive'); + webserver.post('/botframework/receive', bot.connector.listen()); + + // Receive events from chat connector + bot.connector.onEvent(function(events, done) { + for (var i = 0; i < events.length; i++) { + var bf_event = events[i]; + + bf_botkit.ingest(bot, bf_event, null); + + } + + if (done) { + done(null); + } + }); + + if (cb) { + cb(); + } + + bf_botkit.startTicking(); + + return bf_botkit; + }; + + return bf_botkit; +}; + +module.exports = BotFrameworkBot; diff --git a/lib/Botkit.d.ts b/lib/Botkit.d.ts new file mode 100644 index 000000000..1271475c9 --- /dev/null +++ b/lib/Botkit.d.ts @@ -0,0 +1,500 @@ +declare namespace botkit { + function botframeworkbot(configuration: BotFrameworkConfiguration): BotFrameworkController; + function consolebot(configuration: ConsoleConfiguration): ConsoleController; + function facebookbot(configuration: FacebookConfiguration): FacebookController; + function slackbot(configuration: SlackConfiguration): SlackController; + function sparkbot(configuration: CiscoSparkConfiguration): CiscoSparkController; + function twilioipmbot(configuration: TwilioIPMConfiguration): TwilioIPMController; + function twiliosmsbot(configuration: TwilioSMSConfiguration): TwilioSMSController; + interface Bot { + readonly botkit: Controller; + readonly identity: Identity; + readonly utterances: { + yes: RegExp; + no: RegExp; + quit: RegExp; + }; + createConversation(message: M, cb: (err: Error, convo: Conversation) => void): void; + reply(src: M, resp: string | M, cb?: (err: Error, res: any) => void): void; + startConversation(message: M, cb: (err: Error, convo: Conversation) => void): void; + } + interface BotFrameworkBot extends Bot { + } + interface BotFrameworkConfiguration extends Configuration { + } + interface BotFrameworkController extends Controller { + createWebhookEndpoints(webserver: any, bot: TwilioSMSBot, cb?: () => void): this; + } + interface BotFrameworkMessage extends Message { + } + interface BotFrameworkSpawnConfiguration { + appId: string; + appPassword: string; + } + interface Channel { + id: string; + } + interface CiscoSparkBot extends Bot { + retrieveFile(url: string, cb: (err: Error, body: any) => void): void; + retrieveFileInfo(url: string, cb: (err: Error, obj: any) => void): void; + startPrivateConversation(message: CiscoSparkMessage, cb: (err: Error, convo: Conversation) => void): void; + startPrivateConversationWithActor(message: CiscoSparkMessage, cb: (err: Error, convo: Conversation) => void): void; + startPrivateConversationWithPersonId(personId: string, cb: (err: Error, convo: Conversation) => void): void; + } + interface CiscoSparkConfiguration extends Configuration { + ciscospark_access_token: string; + limit_to_domain?: string | string[]; + limit_to_org?: string; + public_address: string; + secret?: string; + webhook_name?: string; + } + interface CiscoSparkController extends Controller { + createWebhookEndpoints(webserver: any, bot: CiscoSparkBot, cb?: () => void): this; + } + interface CiscoSparkMessage extends Message { + actorId?: string; + data?: { + personDisplayName: string; + }; + files?: any[]; + markdown?: string; + original_message?: CiscoSparkMessage; + } + interface CiscoSparkSpawnConfiguration { + } + interface Configuration { + debug?: boolean; + hostname?: string; + json_file_store?: string; + log?: boolean; + logger?: { log: Function; }; + storage?: { + users: Storage; + channels: Storage; + teams: Storage; + }; + studio_token?: string; + } + interface ConsoleBot extends Bot { + } + interface ConsoleConfiguration extends Configuration { + } + interface ConsoleController extends Controller { + } + interface ConsoleMessage extends Message { + } + interface ConsoleSpawnConfiguration { + } + interface Controller> { + readonly changeEars: HearsFunction; + readonly log: { + (...params: any[]): void; + } + readonly middleware: { + capture: { + use(cb: (bot: B, message: M, convo: Conversation, next: () => void) => void): void; + }; + heard: { + use(cb: (bot: B, message: M, next: () => void) => void): void; + }; + receive: { + use(cb: (bot: B, message: M, next: () => void) => void): void; + }; + send: { + use(cb: (bot: B, message: M, next: () => void) => void): void; + }; + } + readonly storage: { + users: Storage; + channels: Storage; + teams: Storage; + }; + readonly studio: Studio; + hears(keywords: string | string[] | RegExp | RegExp[], events: string | string[], cb: HearsCallback): this; + hears(keywords: string | string[] | RegExp | RegExp[], events: string | string[], middleware_or_cb: HearsFunction, cb: HearsCallback): this; + on(event: string, cb: HearsCallback): this; + setupWebserver(port: number | string, cb: (err: Error, webserver: any) => void): this; + spawn(config?: S, cb?: (worker: B) => void): B; + startTicking(): void; + } + interface Conversation { + readonly status: ConversationStatusType; + activate(): void; + addMessage(message: string | M, thread: string): void; + addQuestion(message: string | M, cb: ConversationCallback, capture_options: ConversationCaptureOptions, thread: string): void; + ask(message: string | M, cb: ConversationCallback, capture_options?: ConversationCaptureOptions): void; + beforeThread(thread: string, callback: (convo: this, next: (err: string | Error) => void) => void): void; + extractResponse(key: string): string; + extractResponses(): { [key: string]: string }; + gotoThread(thread: string): void; + next(): void; + on(event: string, cb: (convo: this) => void): void; + onTimeout(handler: (convo: this) => void): void; + repeat(): void; + say(message: string | M): void; + sayFirst(message: string | M): void; + setTimeout(timeout: number): void; + setVar(field: string, value: any): void; + silentRepeat(): void; + stop(status?: ConversationStatusType): void; + transitionTo(thread: string, message: string | M): void; + } + interface ConversationCaptureOptions { + key?: string; + multiple?: boolean; + } + interface FacebookAttachment { + type: 'audio' | 'file' | 'image' | 'video'; + payload: any; + } + interface FacebookBot extends Bot { + replyWithTyping(src: FacebookMessage, resp: string | FacebookMessage, cb?: (err: Error) => void): void; + startTyping(src: FacebookMessage, cb?: (err: Error) => void): void; + stopTyping(src: FacebookMessage, cb?: (err: Error) => void): void; + } + interface FacebookConfiguration extends Configuration { + access_token: string; + app_secret?: string; + receive_via_postback?: boolean; + require_delivery?: boolean; + validate_requests?: boolean; + verify_token: string; + } + interface FacebookController extends Controller { + readonly api: { + attachment_upload: { + upload(attachment: FacebookAttachment, cb: (err: Error, attachment_id: string) => void): void; + }; + messenger_profile: any; + thread_settings: any; + tags: any; + nlp: any; + + }; + createWebhookEndpoints(webserver: any, bot: FacebookBot, cb?: () => void): this; + } + interface FacebookMessage extends Message { + attachment?: FacebookAttachment; + notification_type: 'REGULAR' | 'SILENT_PUSH' | 'NO_PUSH'; + payload?: string; + sender_action?: 'typing_on' | 'typing_off'; + } + interface FacebookMessengerProfileAPI { + account_linking(payload: string): void; + delete_account_linking(): void; + delete_domain_whitelist(): void; + delete_get_started(): void; + delete_greeting(): void; + delete_home_url(): void; + delete_menu(): void; + delete_target_audience(): void; + domain_whitelist(payload: string | string[]): void; + get_account_linking(cb: (err: Error, body: any) => void): void; + get_domain_whitelist(cb: (err: Error, body: any) => void): void; + get_get_started(cb: (err: Error, body: any) => void): void; + get_greeting(cb: (err: Error, body: any) => void): void; + get_home_url(cb: (err: Error, body: any) => void): void; + get_started(payload: string): void; + get_menu(cb: (err: Error, body: any) => void): void; + get_messenger_code(image_size: number, cb: (err: Error, uri: string) => void, ref?: string): void; + get_target_audience(cb: (err: Error, body: any) => void): void; + greeting(payload: string | { locale: string; text: string; }[]): void; + home_url(payload: { url: string; webview_height_ratio: 'tall'; webview_share_button?: 'show' | 'hide'; in_test?: boolean; }): void; + menu(payload: any): void; + target_audience(payload: { audience_type: 'all' | 'custom' | 'none'; countries?: { blacklist?: string[]; whitelist?: string[]; }; }): void; + } + interface FacebookSpawnConfiguration { + } + interface Identity { + name: string; + emails: string[]; + } + interface Message { + action?: string; + channel?: string; + match?: RegExpMatchArray; + text?: string; + user?: string; + } + interface SlackAttachment { + author_icon?: string; + author_link?: string; + author_name?: string; + color?: string; + fallback?: string; + fields?: { + title: string; + value: string; + short: boolean; + }[]; + footer?: string; + footer_icon?: string; + image_url?: string; + pretext?: string; + text?: string; + thumb_url?: string; + title?: string; + title_link?: string; + ts?: string; + } + interface SlackBot extends Bot { + readonly api: SlackWebAPI; + configureIncomingWebhook(config: { url: string; }): this; + createConversationInThread(src: SlackMessage, cb: (err: Error, res: string) => void): void; + createPrivateConversation(message: SlackMessage & { user: string; }, cb: (err: Error, convo: Conversation) => void): void; + closeRTM(): void; + destroy(): void; + identifyTeam(): string; + identifyBot(): { id: string; name: string; team_id: string; }; + replyAcknowledge(cb?: (err: Error) => void): void; + replyAndUpdate(src: SlackMessage, resp: string | SlackMessage, cb: (err: Error, res: string) => void): void; + replyInThread(src: SlackMessage, resp: string | SlackMessage, cb: (err: Error, res: string) => void): void; + replyPrivate(src: SlackMessage, resp: string | SlackMessage, cb?: (err: Error) => void): void; + replyPrivateDelayed(src: SlackMessage, resp: string | SlackMessage, cb?: (err: Error) => void): void; + replyPublic(src: SlackMessage, resp: string | SlackMessage, cb?: (err: Error) => void): void; + replyPublicDelayed(src: SlackMessage, resp: string | SlackMessage, cb?: (err: Error) => void): void; + replyInteractive(src: SlackMessage, resp: string | SlackMessage, cb?: (err: Error) => void): void; + sendWebhook(options: SlackMessage, cb: (err: string, body: any) => void): void; + startPrivateConversation(message: SlackMessage & { user: string; }, cb: (err: Error, convo: Conversation) => void): void; + startConversationInThread(src: SlackMessage, cb: (err: Error, res: string) => void): void; + startRTM(cb?: (err: string, bot: SlackBot, payload: any) => void): SlackBot; + } + interface SlackConfiguration extends Configuration { + api_root?: string; + clientId?: string; + clientSecret?: string; + disable_startup_messages?: boolean; + incoming_webhook?: { url: string; }; + interactive_replies?: boolean; + rtm_receive_messages?: boolean; + require_delivery?: boolean; + retry?: number; + scopes?: string[]; + send_via_rtm?: boolean; + stale_connection_timeout?: number; + } + interface SlackController extends Controller { + configureSlackApp(config: { clientId: string; clientSecret: string; redirectUri: string; scopes: string[]; }): this; + createHomepageEndpoint(webserver: any): this; + createOauthEndpoints(webserver: any, callback: (err: Error, req: any, res: any) => void): this; + createWebhookEndpoints(webserver: any, authenticationTokens?: string[]): this; + setupWebserver(); + getAuthorizeURL(team_id: string, redirect_params: any): string; + } + interface SlackMessage extends Message { + attachments?: SlackAttachment[]; + icon_emoji?: string; + icon_url?: string; + link_names?: boolean; + parse?: string; + reply_broadcast?: boolean; + type?: string; + thread_ts?: string; + ts?: string; + unfurl_links?: boolean; + unfurl_media?: boolean; + username?: string; + } + interface SlackSpawnConfiguration { + token: string; + } + interface SlackWebAPI { + auth: { + test: SlackWebAPIMethod; + }, + oauth: { + access: SlackWebAPIMethod; + } + channels: { + archive: SlackWebAPIMethod; + create: SlackWebAPIMethod; + history: SlackWebAPIMethod; + info: SlackWebAPIMethod; + invite: SlackWebAPIMethod; + join: SlackWebAPIMethod; + kick: SlackWebAPIMethod; + leave: SlackWebAPIMethod; + list: SlackWebAPIMethod; + mark: SlackWebAPIMethod; + rename: SlackWebAPIMethod; + replies: SlackWebAPIMethod; + setPurpose: SlackWebAPIMethod; + setTopic: SlackWebAPIMethod; + unarchive: SlackWebAPIMethod; + }; + chat: { + delete: SlackWebAPIMethod; + postMessage: SlackWebAPIMethod; + update: SlackWebAPIMethod; + unfurl: SlackWebAPIMethod; + }; + dnd: { + endDnd: SlackWebAPIMethod; + endSnooze: SlackWebAPIMethod; + info: SlackWebAPIMethod; + setSnooze: SlackWebAPIMethod; + teamInfo: SlackWebAPIMethod; + }; + emoji: { + list: SlackWebAPIMethod; + }; + files: { + delete: SlackWebAPIMethod; + info: SlackWebAPIMethod; + list: SlackWebAPIMethod; + upload: SlackWebAPIMethod; + }; + groups: { + archive: SlackWebAPIMethod; + close: SlackWebAPIMethod; + create: SlackWebAPIMethod; + createChild: SlackWebAPIMethod; + history: SlackWebAPIMethod; + info: SlackWebAPIMethod; + invite: SlackWebAPIMethod; + kick: SlackWebAPIMethod; + leave: SlackWebAPIMethod; + list: SlackWebAPIMethod; + mark: SlackWebAPIMethod; + open: SlackWebAPIMethod; + rename: SlackWebAPIMethod; + replies: SlackWebAPIMethod; + setPurpose: SlackWebAPIMethod; + setTopic: SlackWebAPIMethod; + unarchive: SlackWebAPIMethod; + }; + im: { + close: SlackWebAPIMethod; + history: SlackWebAPIMethod; + list: SlackWebAPIMethod; + mark: SlackWebAPIMethod; + open: SlackWebAPIMethod; + replies: SlackWebAPIMethod; + }; + mpim: { + close: SlackWebAPIMethod; + history: SlackWebAPIMethod; + list: SlackWebAPIMethod; + mark: SlackWebAPIMethod; + open: SlackWebAPIMethod; + replies: SlackWebAPIMethod; + }; + pins: { + add: SlackWebAPIMethod; + list: SlackWebAPIMethod; + remove: SlackWebAPIMethod; + }; + reactions: { + add: SlackWebAPIMethod; + get: SlackWebAPIMethod; + list: SlackWebAPIMethod; + remove: SlackWebAPIMethod; + }; + reminders: { + add: SlackWebAPIMethod; + complete: SlackWebAPIMethod; + delete: SlackWebAPIMethod; + info: SlackWebAPIMethod; + list: SlackWebAPIMethod; + }; + rtm: { + start: SlackWebAPIMethod; + connect: SlackWebAPIMethod; + }; + search: { + all: SlackWebAPIMethod; + files: SlackWebAPIMethod; + messages: SlackWebAPIMethod; + }; + stars: { + add: SlackWebAPIMethod; + list: SlackWebAPIMethod; + remove: SlackWebAPIMethod; + }; + team: { + accessLogs: SlackWebAPIMethod; + info: SlackWebAPIMethod; + billableInfo: SlackWebAPIMethod; + integrationLogs: SlackWebAPIMethod; + profile: { + get: SlackWebAPIMethod; + }; + }; + users: { + getPresence: SlackWebAPIMethod; + info: SlackWebAPIMethod; + list: SlackWebAPIMethod; + setActive: SlackWebAPIMethod; + setPresence: SlackWebAPIMethod; + deletePhoto: SlackWebAPIMethod; + identity: SlackWebAPIMethod; + setPhoto: SlackWebAPIMethod; + profile: { + get: SlackWebAPIMethod; + set: SlackWebAPIMethod; + }; + }; + } + interface Storage { + save: (data: O, cb?: (err: Error, id: string) => void) => void; + get: (id: string, cb: (err: Error, data: O) => void) => void; + delete?: (id: string, cb?: (err: Error) => void) => void; + all?: (cb: (err: Error, data: O[]) => void) => void; + } + interface Studio> { + after(command_name: string, func: (convo: Conversation, next: () => void) => void): this; + before(command_name: string, func: (convo: Conversation, next: () => void) => void): this; + beforeThread(command_name: string, thread_name: string, func: (convo: Conversation, next: () => void) => void): this; + get(bot: B, input_text: string, user: string, channel: string): Promise>; + run(bot: B, input_text: string, user: string, channel: string): Promise>; + runTrigger(bot: B, input_text: string, user: string, channel: string): Promise>; + validate(command_name: string, key: string, func: (convo: Conversation, next: () => void) => void): this; + } + interface Team { + id: string; + } + interface TwilioIPMBot extends Bot { + readonly api: any; + } + interface TwilioIPMConfiguration extends Configuration { + } + interface TwilioIPMController extends Controller { + createWebhookEndpoints(webserver: any, bot: TwilioIPMBot): this; + } + interface TwilioIPMMessage extends Message { + } + interface TwilioIPMSpawnConfiguration { + autojoin?: boolean; + identity?: string; + TWILIO_IPM_SERVICE_SID: string; + TWILIO_ACCOUNT_SID: string; + TWILIO_API_KEY: string; + TWILIO_API_SECRET: string; + } + interface TwilioSMSBot extends Bot { + } + interface TwilioSMSConfiguration extends Configuration { + account_sid: string; + auth_token: string; + twilio_number: string; + } + interface TwilioSMSController extends Controller { + createWebhookEndpoints(webserver: any, bot: TwilioSMSBot, cb?: () => void): this; + } + interface TwilioSMSMessage extends Message { + } + interface TwilioSMSSpawnConfiguration { + } + interface User { + id: string; + name?: string; + } + type ConversationCallback = ((message: M, convo: Conversation) => void) | ({ pattern?: string | RegExp; default?: boolean; callback: (message: M, convo: Conversation) => void; }[]); + type ConversationStatusType = 'completed' | 'active' | 'stopped' | 'timeout' | 'ending' | 'inactive'; + type HearsCallback> = (bot: B, message: M) => void; + type HearsFunction = (tests: string | string[] | RegExp | RegExp[], message: M) => boolean; + type SlackWebAPIMethod = (data: any, cb: (err: Error, response: any) => void) => void; +} + +export = botkit; diff --git a/lib/Botkit.js b/lib/Botkit.js index ae1e1d19d..fa00d88b2 100755 --- a/lib/Botkit.js +++ b/lib/Botkit.js @@ -1,7 +1,20 @@ var CoreBot = require(__dirname + '/CoreBot.js'); var Slackbot = require(__dirname + '/SlackBot.js'); +var Facebookbot = require(__dirname + '/Facebook.js'); +var TwilioIPMbot = require(__dirname + '/TwilioIPMBot.js'); +var TwilioSMSbot = require(__dirname + '/TwilioSMSBot.js'); +var BotFrameworkBot = require(__dirname + '/BotFramework.js'); +var SparkBot = require(__dirname + '/CiscoSparkbot.js'); +var ConsoleBot = require(__dirname + '/ConsoleBot.js'); module.exports = { core: CoreBot, slackbot: Slackbot, + sparkbot: SparkBot, + facebookbot: Facebookbot, + twilioipmbot: TwilioIPMbot, + twiliosmsbot: TwilioSMSbot, + botframeworkbot: BotFrameworkBot, + teamsbot: require(__dirname + '/Teams.js'), + consolebot: ConsoleBot, }; diff --git a/lib/CiscoSparkbot.js b/lib/CiscoSparkbot.js new file mode 100644 index 000000000..cbc01c4ac --- /dev/null +++ b/lib/CiscoSparkbot.js @@ -0,0 +1,472 @@ +var Botkit = require(__dirname + '/CoreBot.js'); +var request = require('request'); +var url = require('url'); +var crypto = require('crypto'); + +function Sparkbot(configuration) { + + // Create a core botkit bot + var controller = Botkit(configuration || {}); + + if (!controller.config.ciscospark_access_token) { + throw new Error('ciscospark_access_token required to create controller'); + } else { + controller.api = require('ciscospark').init({ + credentials: { + authorization: { + access_token: controller.config.ciscospark_access_token + } + } + }); + + if (!controller.api) { + throw new Error('Could not create Cisco Spark API'); + } + + controller.api.people.get('me').then(function(identity) { + console.log('Cisco Spark: My identity is', identity); + controller.identity = identity; + }).catch(function(err) { + throw new Error(err); + }); + } + + if (!controller.config.public_address) { + throw new Error('public_address parameter required to receive webhooks'); + } else { + + var endpoint = url.parse(controller.config.public_address); + if (!endpoint.hostname) { + throw new Error('Could not determine hostname of public address: ' + controller.config.public_address); + } else if (endpoint.protocol != 'https:') { + throw new Error('Please specify an SSL-enabled url for your public address: ' + controller.config.public_address); + } else { + controller.config.public_address = endpoint.hostname + (endpoint.port ? ':' + endpoint.port : ''); + } + + } + + if (!controller.config.secret) { + console.log('WARNING: No secret specified. Source of incoming webhooks will not be validated. https://developer.ciscospark.com/webhooks-explained.html#auth'); + // throw new Error('secret parameter required to secure webhooks'); + } + + + controller.resetWebhookSubscriptions = function() { + controller.api.webhooks.list().then(function(list) { + for (var i = 0; i < list.items.length; i++) { + controller.api.webhooks.remove(list.items[i]).then(function(res) { + console.log('Removed subscription: ' + list.items[i].name); + }).catch(function(err) { + console.log('Error removing subscription:', err); + }); + } + }); + }; + + // set up a web route for receiving outgoing webhooks and/or slash commands + controller.createWebhookEndpoints = function(webserver, bot, cb) { + + + var webhook_name = controller.config.webhook_name || 'Botkit Firehose'; + + controller.log( + '** Serving webhook endpoints for Cisco Spark Platform at: ' + + 'http://' + controller.config.hostname + ':' + controller.config.port + '/ciscospark/receive'); + webserver.post('/ciscospark/receive', function(req, res) { + res.sendStatus(200); + controller.handleWebhookPayload(req, res, bot); + + }); + + + var list = controller.api.webhooks.list().then(function(list) { + var hook_id = null; + + for (var i = 0; i < list.items.length; i++) { + if (list.items[i].name == webhook_name) { + hook_id = list.items[i].id; + } + } + + var hook_url = 'https://' + controller.config.public_address + '/ciscospark/receive'; + + console.log('Cisco Spark: incoming webhook url is ', hook_url); + + if (hook_id) { + controller.api.webhooks.update({ + id: hook_id, + resource: 'all', + targetUrl: hook_url, + event: 'all', + secret: controller.config.secret, + name: webhook_name, + }).then(function(res) { + console.log('Cisco Spark: SUCCESSFULLY UPDATED CISCO SPARK WEBHOOKS'); + if (cb) cb(); + }).catch(function(err) { + console.log('FAILED TO REGISTER WEBHOOK', err); + throw new Error(err); + }); + + } else { + controller.api.webhooks.create({ + resource: 'all', + targetUrl: hook_url, + event: 'all', + secret: controller.config.secret, + name: webhook_name, + }).then(function(res) { + + console.log('Cisco Spark: SUCCESSFULLY REGISTERED CISCO SPARK WEBHOOKS'); + if (cb) cb(); + }).catch(function(err) { + console.log('FAILED TO REGISTER WEBHOOK', err); + throw new Error(err); + }); + + } + }); + }; + + + controller.middleware.ingest.use(function limitUsers(bot, message, res, next) { + + if (controller.config.limit_to_org) { + console.log('limit to org', controller.config.limit_to_org, message.raw_message.orgId); + if (!message.raw_message.orgId || message.raw_message.orgId != controller.config.limit_to_org) { + // this message is from a user outside of the proscribed org + console.log('WARNING: this message is from a user outside of the proscribed org', controller.config.limit_to_org); + return false; + } + } + + if (controller.config.limit_to_domain) { + var domains = []; + if (typeof(controller.config.limit_to_domain) == 'string') { + domains = [controller.config.limit_to_domain]; + } else { + domains = controller.config.limit_to_domain; + } + + var allowed = false; + for (var d = 0; d < domains.length; d++) { + if (message.user.toLowerCase().indexOf(domains[d]) >= 0) { + allowed = true; + } + } + + if (!allowed) { + console.log('WARNING: this message came from a domain that is outside of the allowed list', controller.config.limit_to_domain); + // this message came from a domain that is outside of the allowed list. + return false; + } + } + + next(); + }); + + controller.middleware.normalize.use(function getDecryptedMessage(bot, message, next) { + + if (message.resource == 'messages' && message.event == 'created') { + + controller.api.messages.get(message.data).then(function(decrypted_message) { + + message.user = decrypted_message.personEmail; + message.channel = decrypted_message.roomId; + message.text = decrypted_message.text; + message.id = decrypted_message.id; + + // remove @mentions of the bot from the source text before we ingest it + if (message.raw_message.html) { + + // strip the mention & HTML from the message + var pattern = new RegExp('^(\)?\.*?\<\/spark\-mention\>', 'im'); + if (!message.raw_message.html.match(pattern)) { + var encoded_id = controller.identity.id; + var decoded = new Buffer(encoded_id, 'base64').toString('ascii'); + + // this should look like ciscospark://us/PEOPLE/ + var matches; + if (matches = decoded.match(/ciscospark\:\/\/.*\/(.*)/im)) { + pattern = new RegExp('^(\)?\.*?\<\/spark\-mention\>', 'im'); + } + } + var action = message.raw_message.html.replace(pattern, ''); + + + // strip the remaining HTML tags + action = action.replace(/\<.*?\>/img, ''); + + // strip remaining whitespace + action = action.trim(); + + // replace the message text with the the HTML version + message.text = action; + + } else { + var pattern = new RegExp('^' + controller.identity.displayName + '\\s+', 'i'); + if (message.text) { + message.text = message.text.replace(pattern, ''); + } + } + + next(); + + }).catch(function(err) { + console.error('Could not get message', err); + }); + } else { + next(); + } + + + }); + + controller.middleware.normalize.use(function handleEvents(bot, message, next) { + + if (message.resource != 'messages' || message.event != 'created') { + + var event = message.resource + '.' + message.event; + message.user = message.data.personEmail; + message.channel = message.data.roomId; + message.id = message.data.id; + message.type = event; + + switch (event) { + case 'memberships.deleted': + if (message.user === controller.identity.emails[0]) { + message.type = 'bot_space_leave'; + } else { + message.type = 'user_space_leave'; + } + break; + case 'memberships.created': + if (message.user === controller.identity.emails[0]) { + message.type = 'bot_space_join'; + } else { + message.type = 'user_space_join'; + } + break; + } + } + next(); + + }); + + controller.middleware.categorize.use(function(bot, message, next) { + + // further categorize messages + if (message.type == 'message_received') { + if (message.user === controller.identity.emails[0]) { + message.type = 'self_message'; + } else if (message.raw_message.roomType == 'direct') { + message.type = 'direct_message'; + } else { + message.type = 'direct_mention'; + } + } + + next(); + + }); + + + controller.middleware.format.use(function(bot, message, platform_message, next) { + + // clone the incoming message + for (var k in message) { + platform_message[k] = message[k]; + } + + // mutate the message into proper spark format + platform_message.roomId = message.channel; + delete platform_message.channel; + + // delete reference to recipient + delete platform_message.to; + + // default the markdown field to be the same as tex. + if (platform_message.text && !platform_message.markdown) { + platform_message.markdown = message.text; + } + + next(); + + }); + + + controller.handleWebhookPayload = function(req, res, bot) { + + var payload = req.body; + if (controller.config.secret) { + var signature = req.headers['x-spark-signature']; + var hash = crypto.createHmac('sha1', controller.config.secret).update(JSON.stringify(payload)).digest('hex'); + if (signature != hash) { + console.error('WARNING: Webhook received message with invalid signature. Potential malicious behavior!'); + return false; + } + } + + controller.ingest(bot, req.body, res); + + }; + + // customize the bot definition, which will be used when new connections + // spawn! + controller.defineBot(function(botkit, config) { + + var bot = { + type: 'ciscospark', + botkit: botkit, + config: config || {}, + utterances: botkit.utterances, + }; + + /** + * Convenience method for creating a DM convo. + */ + bot.startPrivateConversation = function(message, cb) { + + var message_options = {}; + + message_options.toPersonEmail = message.user; + + botkit.startTask(bot, message_options, function(task, convo) { + convo.on('sent', function(sent_message) { + // update this convo so that future messages will match + // since the source message did not have this info in it. + convo.source_message.user = message_options.toPersonEmail; + convo.source_message.channel = sent_message.roomId; + + convo.context.user = convo.source_message.user; + convo.context.channel = convo.source_message.channel; + + }); + cb(null, convo); + }); + }; + + + /** + * Convenience method for creating a DM based on a personId instead of email + */ + bot.startPrivateConversationWithPersonId = function(personId, cb) { + + controller.api.people.get(personId).then(function(identity) { + bot.startPrivateConversation({user: identity.emails[0]}, cb); + }).catch(function(err) { + cb(err); + }); + }; + + + /** + * Convenience method for creating a DM convo with the `actor`, not the sender + * this applies to events like channel joins, where the actor may be the user who sent the invite + */ + bot.startPrivateConversationWithActor = function(message, cb) { + bot.startPrivateConversationWithPersonId(message.raw_message.actorId, cb); + }; + + + bot.send = function(message, cb) { + + controller.api.messages.create(message).then(function(message) { + if (cb) cb(null, message); + }).catch(function(err) { + if (cb) cb(err); + }); + + }; + + bot.reply = function(src, resp, cb) { + var msg = {}; + + if (typeof(resp) == 'string') { + msg.text = resp; + } else { + msg = resp; + } + + if (src.channel) { + msg.channel = src.channel; + } else if (src.toPersonEmail) { + msg.toPersonEmail = src.toPersonEmail; + } else if (src.toPersonId) { + msg.toPersonId = src.toPersonId; + } + + msg.to = src.user; + + bot.say(msg, cb); + }; + + bot.findConversation = function(message, cb) { + botkit.debug('CUSTOM FIND CONVO', message.user, message.channel); + for (var t = 0; t < botkit.tasks.length; t++) { + for (var c = 0; c < botkit.tasks[t].convos.length; c++) { + if ( + botkit.tasks[t].convos[c].isActive() && + botkit.tasks[t].convos[c].source_message.user == message.user && + botkit.tasks[t].convos[c].source_message.channel == message.channel + ) { + botkit.debug('FOUND EXISTING CONVO!'); + cb(botkit.tasks[t].convos[c]); + return; + } + } + } + + cb(); + }; + + bot.retrieveFileInfo = function(url, cb) { + request.head({ + url: url, + headers: { + 'Authorization': 'Bearer ' + controller.config.ciscospark_access_token + }, + }, function(err, response, body) { + + if (!err) { + var obj = response.headers; + if (obj['content-disposition']) { + obj.filename = obj['content-disposition'].replace(/.*filename=\"(.*)\".*/gi, '$1'); + } + cb(null, obj); + } else { + cb(err); + } + + }); + }; + + bot.retrieveFile = function(url, cb) { + + request({ + url: url, + headers: { + 'Authorization': 'Bearer ' + controller.config.ciscospark_access_token + }, + encoding: 'binary', + }, function(err, response, body) { + + cb(err, body); + + }); + + }; + + return bot; + + }); + + controller.startTicking(); + + return controller; + +} + + +module.exports = Sparkbot; diff --git a/lib/ConsoleBot.js b/lib/ConsoleBot.js new file mode 100644 index 000000000..38ec29ee5 --- /dev/null +++ b/lib/ConsoleBot.js @@ -0,0 +1,104 @@ +var Botkit = require(__dirname + '/CoreBot.js'); +var readline = require('readline'); + + +function TextBot(configuration) { + + // Create a core botkit bot + var text_botkit = Botkit(configuration || {}); + + + text_botkit.middleware.spawn.use(function(bot, next) { + + text_botkit.listenStdIn(bot); + next(); + + }); + + text_botkit.middleware.format.use(function(bot, message, platform_message, next) { + // clone the incoming message + for (var k in message) { + platform_message[k] = message[k]; + } + }); + + text_botkit.defineBot(function(botkit, config) { + + var bot = { + botkit: botkit, + config: config || {}, + utterances: botkit.utterances, + }; + + bot.createConversation = function(message, cb) { + botkit.createConversation(this, message, cb); + }; + + bot.startConversation = function(message, cb) { + botkit.startConversation(this, message, cb); + }; + + bot.send = function(message, cb) { + console.log('BOT:', message.text); + if (cb) { + cb(); + } + }; + + bot.reply = function(src, resp, cb) { + var msg = {}; + + if (typeof(resp) == 'string') { + msg.text = resp; + } else { + msg = resp; + } + + msg.channel = src.channel; + + bot.say(msg, cb); + }; + + bot.findConversation = function(message, cb) { + botkit.debug('CUSTOM FIND CONVO', message.user, message.channel); + for (var t = 0; t < botkit.tasks.length; t++) { + for (var c = 0; c < botkit.tasks[t].convos.length; c++) { + if ( + botkit.tasks[t].convos[c].isActive() && + botkit.tasks[t].convos[c].source_message.user == message.user + ) { + botkit.debug('FOUND EXISTING CONVO!'); + cb(botkit.tasks[t].convos[c]); + return; + } + } + } + + cb(); + }; + + return bot; + + }); + + text_botkit.listenStdIn = function(bot) { + + text_botkit.startTicking(); + var rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); + rl.on('line', function(line) { + var message = { + text: line, + user: 'user', + channel: 'text', + timestamp: Date.now() + }; + + text_botkit.ingest(bot, message, null); + + }); + }; + + return text_botkit; +}; + +module.exports = TextBot; diff --git a/lib/CoreBot.js b/lib/CoreBot.js index 8553b2cb5..8cc07dc74 100755 --- a/lib/CoreBot.js +++ b/lib/CoreBot.js @@ -1,12 +1,20 @@ /** * This is a module that makes a bot - * It expects to receive messages via the botkit.receiveMessage function - * These messages are expected to match Slack's message format. + * It expects to receive messages via the botkit.ingest function **/ var mustache = require('mustache'); var simple_storage = require(__dirname + '/storage/simple_storage.js'); var ConsoleLogger = require(__dirname + '/console_logger.js'); var LogLevels = ConsoleLogger.LogLevels; +var ware = require('ware'); +var clone = require('clone'); +var fs = require('fs'); +var studio = require('./Studio.js'); +var os = require('os'); +var async = require('async'); +var PKG_VERSION = require('../package.json').version; +var express = require('express'); +var bodyParser = require('body-parser'); function Botkit(configuration) { var botkit = { @@ -15,6 +23,8 @@ function Botkit(configuration) { tasks: [], taskCount: 0, convoCount: 0, + my_version: null, + my_user_agent: null, memory_store: { users: {}, channels: {}, @@ -22,23 +32,117 @@ function Botkit(configuration) { } }; + /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + + // TODO: externalize this into some sort of utterances.json file botkit.utterances = { yes: new RegExp(/^(yes|yea|yup|yep|ya|sure|ok|y|yeah|yah)/i), no: new RegExp(/^(no|nah|nope|n)/i), + quit: new RegExp(/^(quit|cancel|end|stop|done|exit|nevermind|never mind)/i) + }; + + /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + + // define some middleware points where custom functions + // can plug into key points of botkits process + botkit.middleware = { + spawn: ware(), + ingest: ware(), + normalize: ware(), + categorize: ware(), + receive: ware(), + heard: ware(), // best place for heavy i/o because fewer messages + capture: ware(), + format: ware(), + send: ware(), + }; + + + botkit.ingest = function(bot, payload, source) { + + // keep an unmodified copy of the message + payload.raw_message = clone(payload); + + payload._pipeline = { + stage: 'ingest', + }; + + + botkit.middleware.ingest.run(bot, payload, source, function(err, bot, payload, source) { + if (err) { + console.error('An error occured in the ingest middleware: ', err); + return; + } + botkit.normalize(bot, payload); + }); + }; + + botkit.normalize = function(bot, payload) { + payload._pipeline.stage = 'normalize'; + botkit.middleware.normalize.run(bot, payload, function(err, bot, message) { + if (err) { + console.error('An error occured in the normalize middleware: ', err); + return; + } + + if (!message.type) { + message.type = 'message_received'; + } + botkit.categorize(bot, message); + }); }; + botkit.categorize = function(bot, message) { + message._pipeline.stage = 'categorize'; + botkit.middleware.categorize.run(bot, message, function(err, bot, message) { + if (err) { + console.error('An error occured in the categorize middleware: ', err); + return; + } + + botkit.receiveMessage(bot, message); + }); + }; + + botkit.receiveMessage = function(bot, message) { + message._pipeline.stage = 'receive'; + botkit.middleware.receive.run(bot, message, function(err, bot, message) { + if (err) { + console.error('An error occured in the receive middleware: ', err); + return; + } else { + botkit.debug('RECEIVED MESSAGE'); + bot.findConversation(message, function(convo) { + if (convo) { + convo.handle(message); + } else { + botkit.trigger(message.type, [bot, message]); + } + }); + } + }); + }; + + /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + function Conversation(task, message) { this.messages = []; this.sent = []; this.transcript = []; + this.context = { + user: message.user, + channel: message.channel, + bot: task.bot, + }; + this.events = {}; this.vars = {}; - this.topics = {}; - this.topic = null; + this.threads = {}; + this.thread = null; this.status = 'new'; this.task = task; @@ -48,66 +152,109 @@ function Botkit(configuration) { this.capture_options = {}; this.startTime = new Date(); this.lastActive = new Date(); + /** will be pointing to a callback which will be called after timeout, + * conversation will be not be ended and should be taken care by callback + */ + this.timeOutHandler = null; - this.capture = function(response) { + this.collectResponse = function(key, value) { + this.responses[key] = value; + }; + + this.capture = function(response, cb) { + + var that = this; var capture_key = this.sent[this.sent.length - 1].text; + botkit.middleware.capture.run(that.task.bot, response, that, function(err, bot, response, convo) { + if (response.text) { + response.text = response.text.trim(); + } else { + response.text = ''; + } - if (this.capture_options.key) { - capture_key = this.capture_options.key; - } + if (that.capture_options.key != undefined) { + capture_key = that.capture_options.key; + } - if (this.capture_options.multiple) { - if (!this.responses[capture_key]) { - this.responses[capture_key] = []; + // capture the question that was asked + // if text is an array, get 1st + if (typeof(that.sent[that.sent.length - 1].text) == 'string') { + response.question = that.sent[that.sent.length - 1].text; + } else if (Array.isArray(that.sent[that.sent.length - 1].text)) { + response.question = that.sent[that.sent.length - 1].text[0]; + } else { + response.question = ''; } - this.responses[capture_key].push(response); - } else { - this.responses[capture_key] = response; - } + + if (that.capture_options.multiple) { + if (!that.responses[capture_key]) { + that.responses[capture_key] = []; + } + that.responses[capture_key].push(response); + } else { + that.responses[capture_key] = response; + } + + if (cb) cb(response); + }); }; this.handle = function(message) { + var that = this; this.lastActive = new Date(); this.transcript.push(message); botkit.debug('HANDLING MESSAGE IN CONVO', message); // do other stuff like call custom callbacks if (this.handler) { - this.capture(message); - - // if the handler is a normal function, just execute it! - // NOTE: anyone who passes in their own handler has to call - // convo.next() to continue after completing whatever it is they want to do. - if (typeof(this.handler) == 'function') { - this.handler(message, this); - } else { - // handle might be a mapping of keyword to callback. - // lets see if the message matches any of the keywords - var patterns = this.handler; - for (var p = 0; p < patterns.length; p++) { - if (patterns[p].pattern && message.text.match(patterns[p].pattern)) { - patterns[p].callback(message, this); - return; + this.capture(message, function(message) { + // if the handler is a normal function, just execute it! + // NOTE: anyone who passes in their own handler has to call + // convo.next() to continue after completing whatever it is they want to do. + if (typeof(that.handler) == 'function') { + that.handler(message, that); + } else { + // handle might be a mapping of keyword to callback. + // lets see if the message matches any of the keywords + var match, patterns = that.handler; + for (var p = 0; p < patterns.length; p++) { + if (patterns[p].pattern && botkit.hears_test([patterns[p].pattern], message)) { + botkit.middleware.heard.run(that.task.bot, message, function(err, bot, message) { + patterns[p].callback(message, that); + }); + return; + } } - } - // none of the messages matched! What do we do? - // if a default exists, fire it! - for (var p = 0; p < patterns.length; p++) { - if (patterns[p].default) { - patterns[p].callback(message, this); - return; + // none of the messages matched! What do we do? + // if a default exists, fire it! + for (var p = 0; p < patterns.length; p++) { + if (patterns[p].default) { + botkit.middleware.heard.run(that.task.bot, message, function(err, bot, message) { + patterns[p].callback(message, that); + }); + return; + } } - } - } + } + }); } else { // do nothing } }; + this.setVar = function(field, value) { + if (!this.vars) { + this.vars = {}; + } + this.vars[field] = value; + }; + this.activate = function() { + this.task.trigger('conversationStarted', [this]); + this.task.botkit.trigger('conversationStarted', [this.task.bot, this]); this.status = 'active'; }; @@ -161,7 +308,6 @@ function Botkit(configuration) { } } } else { - botkit.debug('No handler for ', event); } }; @@ -182,7 +328,7 @@ function Botkit(configuration) { return; }; - this.addQuestion = function(message, cb, capture_options, topic) { + this.addQuestion = function(message, cb, capture_options, thread) { if (typeof(message) == 'string') { message = { text: message, @@ -197,17 +343,17 @@ function Botkit(configuration) { } message.handler = cb; - this.addMessage(message, topic); + this.addMessage(message, thread); }; this.ask = function(message, cb, capture_options) { - this.addQuestion(message, cb, capture_options, this.topic || 'default'); + this.addQuestion(message, cb, capture_options, this.thread || 'default'); }; - this.addMessage = function(message, topic) { - if (!topic) { - topic = this.topic; + this.addMessage = function(message, thread) { + if (!thread) { + thread = this.thread; } if (typeof(message) == 'string') { message = { @@ -218,29 +364,123 @@ function Botkit(configuration) { message.channel = this.source_message.channel; } - if (!this.topics[topic]) { - this.topics[topic] = []; + if (!this.threads[thread]) { + this.threads[thread] = []; } - this.topics[topic].push(message); + this.threads[thread].push(message); // this is the current topic, so add it here as well - if (this.topic == topic) { + if (this.thread == thread) { this.messages.push(message); } }; + // how long should the bot wait while a user answers? + this.setTimeout = function(timeout) { + this.task.timeLimit = timeout; + }; + + // For backwards compatibility, wrap gotoThread in its previous name this.changeTopic = function(topic) { - this.topic = topic; + this.gotoThread(topic); + }; + + this.hasThread = function(thread) { + return (this.threads[thread] != undefined); + }; + + + this.transitionTo = function(thread, message) { + + // add a new transition thread + // add this new message to it + // set that message action to execute the actual transition + // then change threads to transition thread + + var num = 1; + while (this.hasThread('transition_' + num)) { + num++; + } + + var threadname = 'transition_' + num; + + if (typeof(message) == 'string') { + message = { + text: message, + action: thread + }; + } else { + message.action = thread; + } + + this.addMessage(message, threadname); + + this.gotoThread(threadname); + + }; + + this.beforeThread = function(thread, callback) { + if (!this.before_hooks) { + this.before_hooks = {}; + } + + if (!this.before_hooks[thread]) { + this.before_hooks[thread] = []; + } + this.before_hooks[thread].push(callback); + }; + + this.gotoThread = function(thread) { + var that = this; + that.next_thread = thread; + that.processing = true; + + var makeChange = function() { + if (!that.hasThread(that.next_thread)) { + if (that.next_thread == 'default') { + that.threads[that.next_thread] = []; + } else { + botkit.debug('WARN: gotoThread() to an invalid thread!', thread); + that.stop('unknown_thread'); + return; + } + } + + that.thread = that.next_thread; + that.messages = that.threads[that.next_thread].slice(); + + that.handler = null; + that.processing = false; + }; + + if (that.before_hooks && that.before_hooks[that.next_thread]) { + + // call any beforeThread hooks in sequence + async.eachSeries(this.before_hooks[that.next_thread], function(before_hook, next) { + before_hook(that, next); + }, function(err) { + if (!err) { + makeChange(); + } + }); + + } else { + + makeChange(); - if (!this.topics[topic]) { - this.topics[topic] = []; } - this.messages = this.topics[topic].slice(); - this.handler = null; }; this.combineMessages = function(messages) { + if (!messages) { + return ''; + } + + if (Array.isArray(messages) && !messages.length) { + return ''; + } + if (messages.length > 1) { var txt = []; var last_user = null; @@ -272,6 +512,37 @@ function Botkit(configuration) { } }; + this.getResponses = function() { + + var res = {}; + for (var key in this.responses) { + + res[key] = { + question: this.responses[key].length ? + this.responses[key][0].question : this.responses[key].question, + key: key, + answer: this.extractResponse(key), + }; + } + return res; + }; + + this.getResponsesAsArray = function() { + + var res = []; + for (var key in this.responses) { + + res.push({ + question: this.responses[key].length ? + this.responses[key][0].question : this.responses[key].question, + key: key, + answer: this.extractResponse(key), + }); + } + return res; + }; + + this.extractResponses = function() { var res = {}; @@ -285,6 +556,32 @@ function Botkit(configuration) { return this.combineMessages(this.responses[key]); }; + this.replaceAttachmentTokens = function(attachments) { + + if (attachments && attachments.length) { + for (var a = 0; a < attachments.length; a++) { + for (var key in attachments[a]) { + if (typeof(attachments[a][key]) == 'string') { + attachments[a][key] = this.replaceTokens(attachments[a][key]); + } else { + attachments[a][key] = this.replaceAttachmentTokens(attachments[a][key]); + } + } + } + } else { + for (var a in attachments) { + if (typeof(attachments[a]) == 'string') { + attachments[a] = this.replaceTokens(attachments[a]); + } else { + attachments[a] = this.replaceAttachmentTokens(attachments[a]); + } + } + } + + return attachments; + }; + + this.replaceTokens = function(text) { var vars = { @@ -293,22 +590,102 @@ function Botkit(configuration) { origin: this.task.source_message, vars: this.vars, }; - return mustache.render(text, vars); + + var rendered = ''; + + try { + rendered = mustache.render(text, vars); + } catch (err) { + botkit.log('Error in message template. Mustache failed with error: ', err); + rendered = text; + }; + + return rendered; }; this.stop = function(status) { this.handler = null; this.messages = []; this.status = status || 'stopped'; - botkit.debug('Conversation is over!'); + botkit.debug('Conversation is over with status ' + this.status); this.task.conversationEnded(this); }; + // was this conversation successful? + // return true if it was completed + // otherwise, return false + // false could indicate a variety of failed states: + // manually stopped, timed out, etc + this.successful = function() { + + // if the conversation is still going, it can't be successful yet + if (this.isActive()) { + return false; + } + + if (this.status == 'completed') { + return true; + } else { + return false; + } + + }; + + this.cloneMessage = function(message) { + // clone this object so as not to modify source + var outbound = clone(message); + + if (typeof(message.text) == 'string') { + outbound.text = this.replaceTokens(message.text); + } else if (message.text) { + outbound.text = this.replaceTokens( + message.text[Math.floor(Math.random() * message.text.length)] + ); + } + + if (outbound.attachments) { + outbound.attachments = this.replaceAttachmentTokens(outbound.attachments); + } + + if (outbound.attachment) { + + // pick one variation of the message text at random + if (outbound.attachment.payload.text && typeof(outbound.attachment.payload.text) != 'string') { + outbound.attachment.payload.text = this.replaceTokens( + outbound.attachment.payload.text[ + Math.floor(Math.random() * outbound.attachment.payload.text.length) + ] + ); + } + outbound.attachment = this.replaceAttachmentTokens([outbound.attachment])[0]; + } + + if (this.messages.length && !message.handler) { + outbound.continue_typing = true; + } + + if (typeof(message.attachments) == 'function') { + outbound.attachments = message.attachments(this); + } + + return outbound; + }; + + this.onTimeout = function(handler) { + if (typeof(handler) == 'function') { + this.timeOutHandler = handler; + } else { + botkit.debug('Invalid timeout function passed to onTimeout'); + } + }; + this.tick = function() { var now = new Date(); if (this.isActive()) { - if (this.handler) { + if (this.processing) { + // do nothing. The bot is waiting for async process to complete. + } else if (this.handler) { // check timeout! // how long since task started? var duration = (now.getTime() - this.task.startTime.getTime()); @@ -317,12 +694,15 @@ function Botkit(configuration) { if (this.task.timeLimit && // has a timelimit (duration > this.task.timeLimit) && // timelimit is up - (lastActive > (60 * 1000)) // nobody has typed for 60 seconds at least + (lastActive > this.task.timeLimit) // nobody has typed for 60 seconds at least ) { - - if (this.topics.timeout) { + // if timeoutHandler is set then call it, otherwise follow the normal flow + // this will not break others code, after the update + if (this.timeOutHandler) { + this.timeOutHandler(this); + } else if (this.hasThread('on_timeout')) { this.status = 'ending'; - this.changeTopic('timeout'); + this.gotoThread('on_timeout'); } else { this.stop('timeout'); } @@ -330,6 +710,20 @@ function Botkit(configuration) { // otherwise do nothing } else { if (this.messages.length) { + + if (this.sent.length && + !this.sent[this.sent.length - 1].sent + ) { + return; + } + + if (this.task.bot.botkit.config.require_delivery && + this.sent.length && + !this.sent[this.sent.length - 1].delivered + ) { + return; + } + if (typeof(this.messages[0].timestamp) == 'undefined' || this.messages[0].timestamp <= now.getTime()) { var message = this.messages.shift(); @@ -351,28 +745,51 @@ function Botkit(configuration) { this.capture_options = {}; } - this.sent.push(message); - this.transcript.push(message); this.lastActive = new Date(); - if (message.text || message.attachments) { - message.text = this.replaceTokens(message.text); - if (this.messages.length && !message.handler) { - message.continue_typing = true; - } + // is there any text? + // or an attachment? (facebook) + // or multiple attachments (slack) + if (message.text || message.attachments || message.attachment) { - if (typeof(message.attachments) == 'function') { - message.attachments = message.attachments(this); - } + var outbound = this.cloneMessage(message); + var that = this; + + outbound.sent_timestamp = new Date().getTime(); - this.task.bot.say(message, function(err) { + that.sent.push(outbound); + that.transcript.push(outbound); + + this.task.bot.reply(this.source_message, outbound, function(err, sent_message) { if (err) { botkit.log('An error occurred while sending a message: ', err); + + // even though an error occured, set sent to true + // this will allow the conversation to keep going even if one message fails + // TODO: make a message that fails to send _resend_ at least once + that.sent[that.sent.length - 1].sent = true; + that.sent[that.sent.length - 1].api_response = err; + + } else { + + that.sent[that.sent.length - 1].sent = true; + that.sent[that.sent.length - 1].api_response = sent_message; + + // if sending via slack's web api, there is no further confirmation + // so we can mark the message delivered + if (that.task.bot.type == 'slack' && sent_message && sent_message.ts) { + that.sent[that.sent.length - 1].delivered = true; + } + + that.trigger('sent', [sent_message]); + } }); } if (message.action) { - if (message.action == 'repeat') { + if (typeof(message.action) == 'function') { + message.action(this); + } else if (message.action == 'repeat') { this.repeat(); } else if (message.action == 'wait') { this.silentRepeat(); @@ -380,8 +797,8 @@ function Botkit(configuration) { this.stop(); } else if (message.action == 'timeout') { this.stop('timeout'); - } else if (this.topics[message.action]) { - this.changeTopic(message.action); + } else if (this.threads[message.action]) { + this.gotoThread(message.action); } } } else { @@ -390,25 +807,23 @@ function Botkit(configuration) { // end immediately instad of waiting til next tick. // if it hasn't already been ended by a message action! - if (this.isActive() && !this.messages.length && !this.handler) { - this.status = 'completed'; - botkit.debug('Conversation is over!'); - this.task.conversationEnded(this); + if (this.isActive() && !this.messages.length && !this.handler && !this.processing) { + this.stop('completed'); } } else if (this.sent.length) { // sent at least 1 message - this.status = 'completed'; - botkit.debug('Conversation is over!'); - this.task.conversationEnded(this); + this.stop('completed'); } } } }; botkit.debug('CREATED A CONVO FOR', this.source_message.user, this.source_message.channel); - this.changeTopic('default'); + this.gotoThread('default'); }; + /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + function Task(bot, message, botkit) { this.convos = []; @@ -424,15 +839,19 @@ function Botkit(configuration) { return this.status == 'active'; }; - this.startConversation = function(message) { + this.createConversation = function(message) { var convo = new Conversation(this, message); convo.id = botkit.convoCount++; + this.convos.push(convo); + return convo; + }; + + this.startConversation = function(message) { + var convo = this.createConversation(message); botkit.log('> [Start] ', convo.id, ' Conversation with ', message.user, 'in', message.channel); convo.activate(); - this.convos.push(convo); - this.trigger('conversationStarted', [convo]); return convo; }; @@ -440,6 +859,7 @@ function Botkit(configuration) { botkit.log('> [End] ', convo.id, ' Conversation with ', convo.source_message.user, 'in', convo.source_message.channel); this.trigger('conversationEnded', [convo]); + this.botkit.trigger('conversationEnded', [bot, convo]); convo.trigger('end', [convo]); var actives = 0; for (var c = 0; c < this.convos.length; c++) { @@ -453,6 +873,16 @@ function Botkit(configuration) { }; + this.endImmediately = function(reason) { + + for (var c = 0; c < this.convos.length; c++) { + if (this.convos[c].isActive()) { + this.convos[c].stop(reason || 'stopped'); + } + } + + }; + this.taskEnded = function() { botkit.log('[End] ', this.id, ' Task for ', this.source_message.user, 'in', this.source_message.channel); @@ -482,8 +912,6 @@ function Botkit(configuration) { return; } } - } else { - botkit.debug('No handler for ', event); } }; @@ -535,6 +963,8 @@ function Botkit(configuration) { }; }; + /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + botkit.storage = { teams: { get: function(team_id, cb) { @@ -589,52 +1019,95 @@ function Botkit(configuration) { } }; - botkit.debug = function() { - if (configuration.debug) { - var args = []; - for (var k = 0; k < arguments.length; k++) { - args.push(arguments[k]); + /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + + + /** + * hears_regexp - default string matcher uses regular expressions + * + * @param {array} tests patterns to match + * @param {object} message message object with various fields + * @return {boolean} whether or not a pattern was matched + */ + botkit.hears_regexp = function(tests, message) { + for (var t = 0; t < tests.length; t++) { + if (message.text) { + + // the pattern might be a string to match (including regular expression syntax) + // or it might be a prebuilt regular expression + var test = null; + if (typeof(tests[t]) == 'string') { + try { + test = new RegExp(tests[t], 'i'); + } catch (err) { + botkit.log('Error in regular expression: ' + tests[t] + ': ' + err); + return false; + } + if (!test) { + return false; + } + } else { + test = tests[t]; + } + + if (match = message.text.match(test)) { + message.match = match; + return true; + } } - console.log.apply(null, args); } + return false; }; - botkit.log = function() { - if (configuration.log || configuration.log === undefined) { //default to true - var args = []; - for (var k = 0; k < arguments.length; k++) { - args.push(arguments[k]); - } - console.log.apply(null, args); - } + + /** + * changeEars - change the default matching function + * + * @param {function} new_test a function that accepts (tests, message) and returns a boolean + */ + botkit.changeEars = function(new_test) { + botkit.hears_test = new_test; }; - botkit.hears = function(keywords, events, cb) { + + botkit.hears = function(keywords, events, middleware_or_cb, cb) { + + // the third parameter is EITHER a callback handler + // or a middleware function that redefines how the hear works + var test_function = botkit.hears_test; + if (cb) { + test_function = middleware_or_cb; + } else { + cb = middleware_or_cb; + } + if (typeof(keywords) == 'string') { keywords = [keywords]; } + + if (keywords instanceof RegExp) { + keywords = [keywords]; + } + if (typeof(events) == 'string') { - events = events.split(/\,/g); + events = events.split(/\,/g).map(function(str) { return str.trim(); }); } - var match; - for (var k = 0; k < keywords.length; k++) { - var keyword = keywords[k]; - for (var e = 0; e < events.length; e++) { - (function(keyword) { - botkit.on(events[e], function(bot, message) { - if (message.text) { - if (match = message.text.match(new RegExp(keyword, 'i'))) { - botkit.debug('I HEARD ', keyword); - message.match = match; - cb.apply(this, [bot, message]); - return false; - } - } - }); - })(keyword); - } + for (var e = 0; e < events.length; e++) { + (function(keywords, test_function) { + botkit.on(events[e], function(bot, message) { + if (test_function && test_function(keywords, message)) { + botkit.debug('I HEARD', keywords); + botkit.middleware.heard.run(bot, message, function(err, bot, message) { + cb.apply(this, [bot, message]); + botkit.trigger('heard_trigger', [bot, keywords, message]); + }); + return false; + } + }); + })(keywords, test_function); } + return this; }; @@ -659,8 +1132,6 @@ function Botkit(configuration) { return; } } - } else { - botkit.debug('No handler for ', event); } }; @@ -670,6 +1141,20 @@ function Botkit(configuration) { }); }; + botkit.createConversation = function(bot, message, cb) { + + var task = new Task(bot, message, this); + + task.id = botkit.taskCount++; + + var convo = task.createConversation(message); + + this.tasks.push(task); + + cb(null, convo); + + }; + botkit.defineBot = function(unit) { if (typeof(unit) != 'function') { throw new Error('Bot definition must be a constructor function'); @@ -678,8 +1163,46 @@ function Botkit(configuration) { }; botkit.spawn = function(config, cb) { + + var worker = new this.worker(this, config); - if (cb) { cb(worker); } + // mutate the worker so that we can call middleware + worker.say = function(message, cb) { + var platform_message = {}; + botkit.middleware.send.run(worker, message, function(err, worker, message) { + if (err) { + botkit.log('An error occured in the send middleware:: ' + err); + } else { + botkit.middleware.format.run(worker, message, platform_message, function(err, worker, message, platform_message) { + if (err) { + botkit.log('An error occured in the format middleware: ' + err); + } else { + worker.send(platform_message, cb); + } + }); + } + }); + }; + + // add platform independent convenience methods + worker.startConversation = function(message, cb) { + botkit.startConversation(worker, message, cb); + }; + + worker.createConversation = function(message, cb) { + botkit.createConversation(worker, message, cb); + }; + + botkit.middleware.spawn.run(worker, function(err, worker) { + if (err) { + botkit.log('Error in middlware.spawn.run: ' + err); + } else { + botkit.trigger('spawned', [worker]); + + if (cb) { cb(worker); } + } + }); + return worker; }; @@ -688,7 +1211,7 @@ function Botkit(configuration) { // set up a once a second tick to process messages botkit.tickInterval = setInterval(function() { botkit.tick(); - }, 1000); + }, 1500); } }; @@ -718,17 +1241,6 @@ function Botkit(configuration) { }; - botkit.receiveMessage = function(bot, message) { - botkit.debug('RECEIVED MESSAGE'); - bot.findConversation(message, function(convo) { - if (convo) { - convo.handle(message); - } else { - botkit.trigger('message_received', [bot, message]); - } - }); - }; - botkit.tick = function() { for (var t = 0; t < botkit.tasks.length; t++) { botkit.tasks[t].tick(); @@ -744,6 +1256,42 @@ function Botkit(configuration) { }; + // Provide a fairly simple Express-based webserver + botkit.setupWebserver = function(port, cb) { + + if (!port) { + throw new Error('Cannot start webserver without a port'); + } + if (isNaN(port)) { + throw new Error('Specified port is not a valid number'); + } + + var static_dir = process.cwd() + '/public'; + + if (botkit.config && botkit.config.webserver && botkit.config.webserver.static_dir) + static_dir = botkit.config.webserver.static_dir; + + botkit.config.port = port; + + botkit.webserver = express(); + botkit.webserver.use(bodyParser.json()); + botkit.webserver.use(bodyParser.urlencoded({ extended: true })); + botkit.webserver.use(express.static(static_dir)); + + var server = botkit.webserver.listen( + botkit.config.port, + botkit.config.hostname, + function() { + botkit.log('** Starting webserver on port ' + + botkit.config.port); + if (cb) { cb(null, botkit.webserver); } + botkit.trigger('webserver_up', [botkit.webserver]); + }); + + return botkit; + }; + + /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ /** * Define a default worker bot. This function should be customized outside @@ -754,7 +1302,7 @@ function Botkit(configuration) { this.config = config; this.say = function(message, cb) { - botkit.debug('SAY: ', message); + botkit.debug('SAY:', message); }; this.replyWithQuestion = function(message, question, cb) { @@ -766,7 +1314,7 @@ function Botkit(configuration) { }; this.reply = function(src, resp) { - botkit.debug('REPLY: ', resp); + botkit.debug('REPLY:', resp); }; @@ -776,8 +1324,47 @@ function Botkit(configuration) { }; }; + /* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + + botkit.userAgent = function() { + + if (!botkit.my_user_agent) { + // set user agent to Botkit + var ua = 'Botkit/' + botkit.version(); + + // add OS info + ua = ua + ' ' + os.platform() + '/' + os.release(); + + // add Node info + ua = ua + ' ' + 'node/' + process.version.replace('v', ''); + + botkit.my_user_agent = ua; + } + + return botkit.my_user_agent; + + }; + + botkit.version = function() { + + if (!botkit.my_version) { + botkit.my_version = PKG_VERSION; + } + return botkit.my_version; + + }; + botkit.config = configuration; + /** Default the application to listen to the 0.0.0.0, the default + * for node's http module. Developers can specify a hostname or IP + * address to override this. + **/ + if (!botkit.config.hostname) { + botkit.config.hostname = '0.0.0.0'; + }; + + if (!configuration.logLevel) { if (configuration.debug) { configuration.logLevel = 'debug'; @@ -806,6 +1393,10 @@ function Botkit(configuration) { }); botkit.debug = botkit.log.debug; + if (!botkit.config.disable_startup_messages) { + console.log('Initializing Botkit v' + botkit.version()); + } + if (configuration.storage) { if ( configuration.storage.teams && @@ -832,6 +1423,12 @@ function Botkit(configuration) { botkit.log('** No persistent storage method specified! Data may be lost when process shuts down.'); } + // set the default set of ears to use the regular expression matching + botkit.changeEars(botkit.hears_regexp); + + //enable Botkit Studio + studio(botkit); + return botkit; } diff --git a/lib/Facebook.js b/lib/Facebook.js new file mode 100644 index 000000000..fdd4f5383 --- /dev/null +++ b/lib/Facebook.js @@ -0,0 +1,708 @@ +var Botkit = require(__dirname + '/CoreBot.js'); +var request = require('request'); +var crypto = require('crypto'); + +function Facebookbot(configuration) { + + var api_host = configuration.api_host || 'graph.facebook.com'; + + // Create a core botkit bot + var facebook_botkit = Botkit(configuration || {}); + + if (facebook_botkit.config.require_delivery) { + + facebook_botkit.on('message_delivered', function(bot, message) { + + // get list of mids in this message + for (var m = 0; m < message.delivery.mids.length; m++) { + var mid = message.delivery.mids[m]; + + // loop through all active conversations this bot is having + // and mark messages in conversations as delivered = true + bot.findConversation(message, function(convo) { + if (convo) { + for (var s = 0; s < convo.sent.length; s++) { + if (convo.sent[s].sent_timestamp <= message.delivery.watermark || + (convo.sent[s].api_response && convo.sent[s].api_response.mid == mid)) { + convo.sent[s].delivered = true; + } + } + } + }); + } + + }); + + } + + // For backwards-compatability, support the receive_via_postback config option + // this causes facebook_postback events to be replicated as message_received events + // allowing them to be heard without subscribing to additional events + if (facebook_botkit.config.receive_via_postback) { + facebook_botkit.on('facebook_postback', function(bot, message) { + facebook_botkit.trigger('message_received', [bot, message]); + }); + } + + + facebook_botkit.middleware.format.use(function(bot, message, platform_message, next) { + + platform_message.recipient = {}; + platform_message.message = message.sender_action ? undefined : {}; + + if (typeof(message.channel) == 'string' && message.channel.match(/\+\d+\(\d\d\d\)\d\d\d\-\d\d\d\d/)) { + platform_message.recipient.phone_number = message.channel; + } else { + platform_message.recipient.id = message.channel; + } + + if (!message.sender_action) { + if (message.text) { + platform_message.message.text = message.text; + } + + if (message.attachment) { + platform_message.message.attachment = message.attachment; + } + + if (message.tag) { + platform_message.message.tag = message.tag; + } + + if (message.sticker_id) { + platform_message.message.sticker_id = message.sticker_id; + } + + if (message.quick_replies) { + + // sanitize the length of the title to maximum of 20 chars + var titleLimit = function(title) { + if (title.length > 20) { + var newTitle = title.substring(0, 16) + '...'; + return newTitle; + } else { + return title; + } + }; + + platform_message.message.quick_replies = message.quick_replies.map(function(item) { + var quick_reply = {}; + if (item.content_type === 'text' || !item.content_type) { + quick_reply = { + content_type: 'text', + title: titleLimit(item.title), + payload: item.payload, + image_url: item.image_url, + }; + } else if (item.content_type === 'location') { + quick_reply = { + content_type: 'location' + }; + } else { + // Future quick replies types + } + return quick_reply; + }); + } + } else { + platform_message.sender_action = message.sender_action; + } + + if (message.sender_action) { + platform_message.sender_action = message.sender_action; + } + + if (message.notification_type) { + platform_message.notification_type = message.notification_type; + } + + next(); + + }); + + // customize the bot definition, which will be used when new connections + // spawn! + facebook_botkit.defineBot(function(botkit, config) { + + var bot = { + type: 'fb', + botkit: botkit, + config: config || {}, + utterances: botkit.utterances, + }; + + bot.send = function(message, cb) { + + + //Add Access Token to outgoing request + message.access_token = configuration.access_token; + + request({ + method: 'POST', + json: true, + headers: { + 'content-type': 'application/json', + }, + body: message, + uri: 'https://' + api_host + '/v2.6/me/messages' + }, + function(err, res, body) { + + + if (err) { + botkit.debug('WEBHOOK ERROR', err); + return cb && cb(err); + } + + if (body.error) { + botkit.debug('API ERROR', body.error); + return cb && cb(body.error.message); + } + + botkit.debug('WEBHOOK SUCCESS', body); + cb && cb(null, body); + }); + }; + + bot.startTyping = function(src, cb) { + var msg = {}; + msg.channel = src.channel; + msg.sender_action = 'typing_on'; + bot.say(msg, cb); + }; + + bot.stopTyping = function(src, cb) { + var msg = {}; + msg.channel = src.channel; + msg.sender_action = 'typing_off'; + bot.say(msg, cb); + }; + + bot.replyWithTyping = function(src, resp, cb) { + var textLength; + + if (typeof(resp) == 'string') { + textLength = resp.length; + } else if (resp.text) { + textLength = resp.text.length; + } else { + textLength = 80; //default attachement text length + } + + var avgWPM = 85; + var avgCPM = avgWPM * 7; + + var typingLength = Math.min(Math.floor(textLength / (avgCPM / 60)) * 1000, 5000); + + bot.startTyping(src, function(err) { + if (err) console.log(err); + setTimeout(function() { + bot.reply(src, resp, cb); + }, typingLength); + }); + + }; + + bot.reply = function(src, resp, cb) { + var msg = {}; + + if (typeof(resp) == 'string') { + msg.text = resp; + } else { + msg = resp; + } + + msg.channel = src.channel; + msg.to = src.user; + + bot.say(msg, cb); + }; + + bot.findConversation = function(message, cb) { + botkit.debug('CUSTOM FIND CONVO', message.user, message.channel); + for (var t = 0; t < botkit.tasks.length; t++) { + for (var c = 0; c < botkit.tasks[t].convos.length; c++) { + if ( + botkit.tasks[t].convos[c].isActive() && + botkit.tasks[t].convos[c].source_message.user == message.user + ) { + botkit.debug('FOUND EXISTING CONVO!'); + cb(botkit.tasks[t].convos[c]); + return; + } + } + } + + cb(); + }; + return bot; + }); + + // set up a web route for receiving outgoing webhooks and/or slash commands + facebook_botkit.createWebhookEndpoints = function(webserver, bot, cb) { + + facebook_botkit.log( + '** Serving webhook endpoints for Messenger Platform at: ' + + 'http://' + facebook_botkit.config.hostname + ':' + facebook_botkit.config.port + '/facebook/receive'); + webserver.post('/facebook/receive', function(req, res) { + res.send('ok'); + facebook_botkit.handleWebhookPayload(req, res, bot); + }); + + webserver.get('/facebook/receive', function(req, res) { + if (req.query['hub.mode'] == 'subscribe') { + if (req.query['hub.verify_token'] == configuration.verify_token) { + res.send(req.query['hub.challenge']); + } else { + res.send('OK'); + } + } + }); + + if (cb) { + cb(); + } + + return facebook_botkit; + }; + + facebook_botkit.handleWebhookPayload = function(req, res, bot) { + + var payload = req.body; + + // facebook may send more than 1 message payload at a time + // we split these up into multiple message objects for ingestion + if (payload.entry) { + for (var e = 0; e < payload.entry.length; e++) { + for (var m = 0; m < payload.entry[e].messaging.length; m++) { + facebook_botkit.ingest(bot, payload.entry[e].messaging[m], res); + } + } + } + + }; + + // universal normalizing steps + // handle normal messages from users (text, stickers, files, etc count!) + facebook_botkit.middleware.normalize.use(function normalizeMessage(bot, message, next) { + + // capture the user ID + message.user = message.sender.id; + + // since there are only 1:1 channels on Facebook, the channel id is set to the user id + message.channel = message.sender.id; + + // copy over some facebook specific features + message.page = message.recipient.id; + next(); + + }); + + // handle normal messages from users (text, stickers, files, etc count!) + facebook_botkit.middleware.normalize.use(function handleMessage(bot, message, next) { + if (message.message) { + + // capture the message text + message.text = message.message.text; + + // copy over some facebook specific features + message.seq = message.message.seq; + message.is_echo = message.message.is_echo; + message.mid = message.message.mid; + message.sticker_id = message.message.sticker_id; + message.attachments = message.message.attachments; + message.quick_reply = message.message.quick_reply; + message.nlp = message.message.nlp; + } + + next(); + + }); + + // handle postback messages (when a user clicks a button) + facebook_botkit.middleware.normalize.use(function handlePostback(bot, message, next) { + + if (message.postback) { + + message.text = message.postback.payload; + message.payload = message.postback.payload; + + message.referral = message.postback.referral; + + message.type = 'facebook_postback'; + + } + + next(); + + }); + + // handle message sub-types + facebook_botkit.middleware.categorize.use(function handleOptIn(bot, message, next) { + + if (message.optin) { + message.type = 'facebook_optin'; + } + if (message.delivery) { + message.type = 'message_delivered'; + } + if (message.read) { + message.type = 'message_read'; + } + if (message.referral) { + message.type = 'facebook_referral'; + } + if (message.account_linking) { + message.type = 'facebook_account_linking'; + } + + next(); + + }); + + facebook_botkit.on('webserver_up', function(webserver) { + + // Validate that requests come from facebook, and abort on validation errors + if (facebook_botkit.config.validate_requests === true) { + // Load verify middleware just for post route on our receive webhook, and catch any errors it might throw to prevent the request from being parsed further. + webserver.post('/facebook/receive', bodyParser.json({verify: verifyRequest})); + webserver.use(abortOnValidationError); + } + + }); + + var messenger_profile_api = { + greeting: function(payload) { + var message = { + 'greeting': [] + }; + if (Array.isArray(payload)) { + message.greeting = payload; + } else { + message.greeting.push({ + 'locale': 'default', + 'text': payload + }); + } + facebook_botkit.api.messenger_profile.postAPI(message); + }, + delete_greeting: function() { + facebook_botkit.api.messenger_profile.deleteAPI('greeting'); + }, + get_greeting: function(cb) { + facebook_botkit.api.messenger_profile.getAPI('greeting', cb); + }, + get_started: function(payload) { + var message = { + 'get_started': { + 'payload': payload + } + }; + facebook_botkit.api.messenger_profile.postAPI(message); + }, + delete_get_started: function() { + facebook_botkit.api.messenger_profile.deleteAPI('get_started'); + }, + get_get_started: function(cb) { + facebook_botkit.api.messenger_profile.getAPI('get_started', cb); + }, + menu: function(payload) { + var messege = { + persistent_menu: payload + }; + facebook_botkit.api.messenger_profile.postAPI(messege); + }, + delete_menu: function() { + facebook_botkit.api.messenger_profile.deleteAPI('persistent_menu'); + }, + get_menu: function(cb) { + facebook_botkit.api.messenger_profile.getAPI('persistent_menu', cb); + }, + account_linking: function(payload) { + var message = { + 'account_linking_url': payload + }; + facebook_botkit.api.messenger_profile.postAPI(message); + }, + delete_account_linking: function() { + facebook_botkit.api.messenger_profile.deleteAPI('account_linking_url'); + }, + get_account_linking: function(cb) { + facebook_botkit.api.messenger_profile.getAPI('account_linking_url', cb); + }, + domain_whitelist: function(payload) { + var message = { + 'whitelisted_domains': Array.isArray(payload) ? payload : [payload] + }; + facebook_botkit.api.messenger_profile.postAPI(message); + }, + delete_domain_whitelist: function() { + facebook_botkit.api.messenger_profile.deleteAPI('whitelisted_domains'); + }, + get_domain_whitelist: function(cb) { + facebook_botkit.api.messenger_profile.getAPI('whitelisted_domains', cb); + }, + target_audience: function(payload) { + var message = { + 'target_audience': payload + }; + facebook_botkit.api.messenger_profile.postAPI(message); + }, + delete_target_audience: function() { + facebook_botkit.api.messenger_profile.deleteAPI('target_audience'); + }, + get_target_audience: function(cb) { + facebook_botkit.api.messenger_profile.getAPI('target_audience', cb); + }, + home_url: function(payload) { + var message = { + home_url: payload + }; + facebook_botkit.api.messenger_profile.postAPI(message); + }, + delete_home_url: function() { + facebook_botkit.api.messenger_profile.deleteAPI('home_url'); + }, + get_home_url: function(cb) { + facebook_botkit.api.messenger_profile.getAPI('home_url', cb); + }, + postAPI: function(message) { + request.post('https://' + api_host + '/v2.6/me/messenger_profile?access_token=' + configuration.access_token, + {form: message}, + function(err, res, body) { + if (err) { + facebook_botkit.log('Could not configure messenger profile'); + } else { + + var results = null; + try { + results = JSON.parse(body); + } catch (err) { + facebook_botkit.log('ERROR in messenger profile API call: Could not parse JSON', err, body); + } + + if (results) { + if (results.error) { + facebook_botkit.log('ERROR in messenger profile API call: ', results.error.message); + } else { + facebook_botkit.debug('Successfully configured messenger profile', body); + } + } + } + }); + }, + deleteAPI: function(type) { + var message = { + 'fields': [type] + }; + request.delete('https://' + api_host + '/v2.6/me/messenger_profile?access_token=' + configuration.access_token, + {form: message}, + function(err, res, body) { + if (err) { + facebook_botkit.log('Could not configure messenger profile'); + } else { + facebook_botkit.debug('Successfully configured messenger profile', message); + } + }); + }, + getAPI: function(fields, cb) { + request.get('https://' + api_host + '/v2.6/me/messenger_profile?fields=' + fields + '&access_token=' + configuration.access_token, + function(err, res, body) { + if (err) { + facebook_botkit.log('Could not get messenger profile'); + cb(err); + } else { + facebook_botkit.debug('Successfully got messenger profile ', body); + cb(null, body); + } + }); + }, + get_messenger_code: function(image_size, cb, ref) { + var message = { + 'type': 'standard', + 'image_size': image_size || 1000 + }; + + if (ref) { + message.data = {'ref': ref}; + } + + request.post('https://' + api_host + '/v2.6/me/messenger_codes?access_token=' + configuration.access_token, + + {form: message}, + function(err, res, body) { + if (err) { + facebook_botkit.log('Could not configure get messenger code'); + cb(err); + } else { + + var results = null; + try { + results = JSON.parse(body); + } catch (err) { + facebook_botkit.log('ERROR in messenger code API call: Could not parse JSON', err, body); + cb(err); + } + + if (results) { + if (results.error) { + facebook_botkit.log('ERROR in messenger code API call: ', results.error.message); + cb(results.error); + } else { + var uri = results.uri; + facebook_botkit.log('Successfully got messenger code', uri); + cb(null, uri); + } + } + } + }); + } + }; + + var attachment_upload_api = { + upload: function(attachment, cb) { + var message = { + message: { + attachment: attachment + } + }; + + request.post('https://' + api_host + '/v2.6/me/message_attachments?access_token=' + configuration.access_token, + { form: message }, + function(err, res, body) { + if (err) { + facebook_botkit.log('Could not upload attachment'); + cb(err); + } else { + + var results = null; + try { + results = JSON.parse(body); + } catch (err) { + facebook_botkit.log('ERROR in attachment upload API call: Could not parse JSON', err, body); + cb(err); + } + + if (results) { + if (results.error) { + facebook_botkit.log('ERROR in attachment upload API call: ', results.error.message); + cb(results.error); + } else { + var attachment_id = results.attachment_id; + facebook_botkit.log('Successfully got attachment id ', attachment_id); + cb(null, attachment_id); + } + } + } + }); + } + + }; + + var tags = { + get_all: function(cb) { + request.get('https://' + api_host + '/v2.6/page_message_tags?access_token=' + configuration.access_token, + function(err, res, body) { + if (err) { + facebook_botkit.log('Could not get tags list'); + } else { + + var results = null; + try { + results = JSON.parse(body); + } catch (err) { + facebook_botkit.log('ERROR in page message tags call: Could not parse JSON', err, body); + } + + if (results) { + if (results.error) { + facebook_botkit.log('ERROR in page message tags: ', results.error.message); + } else { + facebook_botkit.debug('Successfully call page message tags', body); + cb(results); + } + } + } + }); + } + }; + + var nlp = { + enable: function(custom_token) { + facebook_botkit.api.nlp.postAPI(true, custom_token); + }, + disable: function() { + facebook_botkit.api.nlp.postAPI(false); + }, + postAPI: function(value, custom_token) { + var uri = 'https://' + api_host + '/v2.8/me/nlp_configs?nlp_enabled=' + value + '&access_token=' + configuration.access_token; + if (custom_token) { + uri += '&custom_token=' + custom_token; + } + request.post(uri, {}, + function(err, res, body) { + if (err) { + facebook_botkit.log('Could not enable/disable build-in NLP'); + } else { + + var results = null; + try { + results = JSON.parse(body); + } catch (err) { + facebook_botkit.log('ERROR in build-in NLP API call: Could not parse JSON', err, body); + } + + if (results) { + if (results.error) { + facebook_botkit.log('ERROR in build-in API call: ', results.error.message); + } else { + facebook_botkit.debug('Successfully enable/disable build-in NLP', body); + } + } + } + }); + } + }; + + facebook_botkit.api = { + 'messenger_profile': messenger_profile_api, + 'thread_settings': messenger_profile_api, + 'attachment_upload': attachment_upload_api, + 'nlp': nlp, + 'tags': tags, + }; + + // Verifies the SHA1 signature of the raw request payload before bodyParser parses it + // Will abort parsing if signature is invalid, and pass a generic error to response + function verifyRequest(req, res, buf, encoding) { + var expected = req.headers['x-hub-signature']; + var calculated = getSignature(buf); + if (expected !== calculated) { + throw new Error('Invalid signature on incoming request'); + } else { + // facebook_botkit.debug('** X-Hub Verification successful!') + } + } + + function getSignature(buf) { + var hmac = crypto.createHmac('sha1', facebook_botkit.config.app_secret); + hmac.update(buf, 'utf-8'); + return 'sha1=' + hmac.digest('hex'); + } + + function abortOnValidationError(err, req, res, next) { + if (err) { + facebook_botkit.log('** Invalid X-HUB signature on incoming request!'); + facebook_botkit.debug('** X-HUB Validation Error:', err); + res.status(400).send({ + error: 'Invalid signature.' + }); + } else { + next(); + } + } + + return facebook_botkit; +}; + +module.exports = Facebookbot; diff --git a/lib/SlackBot.js b/lib/SlackBot.js index 33e34044c..aeff629fe 100755 --- a/lib/SlackBot.js +++ b/lib/SlackBot.js @@ -1,17 +1,60 @@ var Botkit = require(__dirname + '/CoreBot.js'); var request = require('request'); -var express = require('express'); -var bodyParser = require('body-parser'); +var querystring = require('querystring'); +var async = require('async'); function Slackbot(configuration) { // Create a core botkit bot var slack_botkit = Botkit(configuration || {}); + // Set some default configurations unless they've already been set. + + // Should the RTM connections ingest received messages + // Developers using the new Events API will set this to false + // This allows an RTM connection to be kept alive (so bot appears online) + // but receive messages only via events api + if (slack_botkit.config.rtm_receive_messages === undefined) { + slack_botkit.config.rtm_receive_messages = true; + } + + var spawned_bots = []; + // customize the bot definition, which will be used when new connections // spawn! slack_botkit.defineBot(require(__dirname + '/Slackbot_worker.js')); + // Middleware to track spawned bots and connect existing RTM bots to incoming webhooks + slack_botkit.middleware.spawn.use(function(worker, next) { + + // lets first check and make sure we don't already have a bot + // for this team! If we already have an RTM connection, copy it + // into the new bot so it can be used for replies. + + var existing_bot = null; + if (worker.config.id) { + for (var b = 0; b < spawned_bots.length; b++) { + if (spawned_bots[b].config.id) { + if (spawned_bots[b].config.id == worker.config.id) { + // WAIT! We already have a bot spawned here. + // so instead of using the new one, use the exist one. + existing_bot = spawned_bots[b]; + } + } + } + } + + if (!existing_bot && worker.config.id) { + spawned_bots.push(worker); + } else if (existing_bot) { + if (existing_bot.rtm) { + worker.rtm = existing_bot.rtm; + } + } + next(); + + }); + // set up configuration for oauth // slack_app_config should contain // { clientId, clientSecret, scopes} @@ -21,7 +64,7 @@ function Slackbot(configuration) { slack_botkit.log('** Configuring app as a Slack App!'); if (!slack_app_config || !slack_app_config.clientId || !slack_app_config.clientSecret || !slack_app_config.scopes) { - throw new Error('Missing oauth config details', bot); + throw new Error('Missing oauth config details'); } else { slack_botkit.config.clientId = slack_app_config.clientId; slack_botkit.config.clientSecret = slack_app_config.clientSecret; @@ -31,7 +74,7 @@ function Slackbot(configuration) { } else { slack_botkit.config.scopes = slack_app_config.scopes; } - if (cb) cb(null, bot); + if (cb) cb(null); } return slack_botkit; @@ -41,7 +84,8 @@ function Slackbot(configuration) { // set up a web route that is a landing page slack_botkit.createHomepageEndpoint = function(webserver) { - slack_botkit.log('** Serving app landing page at : http://MY_HOST:' + slack_botkit.config.port + '/'); + slack_botkit.log('** Serving app landing page at : http://' + + slack_botkit.config.hostname + ':' + slack_botkit.config.port + '/'); // FIX THIS!!! // this is obvs not right. @@ -55,149 +99,421 @@ function Slackbot(configuration) { }; + + // adds the webhook authentication middleware module to the webserver + function secureWebhookEndpoints() { + var authenticationMiddleware = require(__dirname + '/middleware/slack_authentication.js'); + // convert a variable argument list to an array, drop the webserver argument + var tokens = Array.prototype.slice.call(arguments); + var webserver = tokens.shift(); + + slack_botkit.log( + '** Requiring token authentication for webhook endpoints for Slash commands ' + + 'and outgoing webhooks; configured ' + tokens.length + ' token(s)' + ); + + webserver.use(authenticationMiddleware(tokens)); + } + // set up a web route for receiving outgoing webhooks and/or slash commands - slack_botkit.createWebhookEndpoints = function(webserver) { + slack_botkit.createWebhookEndpoints = function(webserver, authenticationTokens) { + + if (authenticationTokens !== undefined && arguments.length > 1 && arguments[1].length) { + secureWebhookEndpoints.apply(null, arguments); + } slack_botkit.log( '** Serving webhook endpoints for Slash commands and outgoing ' + - 'webhooks at: http://MY_HOST:' + slack_botkit.config.port + '/slack/receive'); + 'webhooks at: http://' + slack_botkit.config.hostname + ':' + slack_botkit.config.port + '/slack/receive'); webserver.post('/slack/receive', function(req, res) { - // this is a slash command - if (req.body.command) { - var message = {}; + // respond to Slack that the webhook has been received. + res.status(200); + + // Now, pass the webhook into be processed + slack_botkit.handleWebhookPayload(req, res); + + }); + + return slack_botkit; + }; + + slack_botkit.findAppropriateTeam = function(payload, cb) { + + var found_team = null; - for (var key in req.body) { - message[key] = req.body[key]; + var team_id = payload.team_id || (payload.team && payload.team.id) || null; + slack_botkit.findTeamById(team_id, function(err, team) { + if (team) { + cb(err, team); + } else { + if (payload.authed_teams) { + async.eachSeries(payload.authed_teams, function(team_id, next) { + slack_botkit.findTeamById(team_id, function(err, team) { + if (team) { + found_team = team; + next(); + } else { + next(err); + } + }); + }, function(err) { + if (!found_team) { + cb(err); + } else { + cb(null, found_team); + } + }); + } else { + cb(new Error(`could not find team ${team_id}`)); } + } + }); + }; - // let's normalize some of these fields to match the rtm message format - message.user = message.user_id; - message.channel = message.channel_id; + slack_botkit.handleWebhookPayload = function(req, res) { - slack_botkit.findTeamById(message.team_id, function(err, team) { - // FIX THIS - // this won't work for single team bots because the team info - // might not be in a db - if (err || !team) { - slack_botkit.log.error('Received slash command, but could not load team'); - } else { - message.type = 'slash_command'; - // HEY THERE - // Slash commands can actually just send back a response - // and have it displayed privately. That means - // the callback needs access to the res object - // to send an optional response. + // is this an events api url handshake? + if (req.body.type === 'url_verification') { + slack_botkit.debug('Received url handshake'); + res.json({ challenge: req.body.challenge }); + return; + } - res.status(200); + var payload = req.body; + if (payload.payload) { + payload = JSON.parse(payload.payload); + } - var bot = slack_botkit.spawn(team); + slack_botkit.findAppropriateTeam(payload, function(err, team) { + if (err) { + slack_botkit.log.error('Could not load team while processing webhook: ', err); + return; + } else if (!team) { + // if this is NOT a slack app, it is ok to spawn a generic bot + // this is only likely to happen with custom slash commands + if (!slack_botkit.config.clientId) { + bot = slack_botkit.spawn({}); + } else { + return; + } + } else { + // spawn a bot + bot = slack_botkit.spawn(team); - bot.team_info = team; - bot.res = res; + // Identify the bot from either team storage or identifyBot() + bot.team_info = team; - slack_botkit.receiveMessage(bot, message); + // The bot identity is only used in handleEventsAPI during this flow + // Recent changes in Slack will break other integrations as they no longer + // require a bot and therefore Slack won't send the bot information. + if (payload.type === 'event_callback') { + if (!team.bot) { + slack_botkit.log.error('No bot identity found.'); + return; } - }); - } else if (req.body.trigger_word) { + bot.identity = { + id: team.bot.user_id, + name: team.bot.name + }; + } + } + + // include the response channel so that they can be used in + // responding to slash commands and outgoing webhooks + bot.res = res; + + // pass the payload into Botkit's message handling pipeline! + slack_botkit.ingest(bot, payload, res); + + }); + }; + + - var message = {}; + // Send a 200 response back to Slack to acknowledge the message. + slack_botkit.middleware.ingest.use(function sendResponse(bot, message, res, next) { - for (var key in req.body) { - message[key] = req.body[key]; + if (res && res.statusCode) { + // this is an http response + // always send a 200 + res.status(200); + + // conditionally send a response back to Slack to acknowledge the message. + // we do NOT want to respond to incoming webhooks or slash commands + // as the response can be used by developers to actually deliver a reply + if (!message.command && !message.trigger_word) { + res.send(''); + } + } + next(); + + }); + + /* do delivery confirmations for RTM messages */ + slack_botkit.middleware.ingest.use(function requireDelivery(bot, message, res, next) { + if (message.ok != undefined) { + // this is a confirmation of something we sent. + if (slack_botkit.config.require_delivery) { + // loop through all active conversations this bot is having + // and mark messages in conversations as delivered = true + for (var t = 0; t < slack_botkit.tasks.length; t++) { + var task = slack_botkit.tasks[t]; + if (task.isActive()) { + for (var c = 0; c < task.convos.length; c++) { + var convo = task.convos[c]; + for (var s = 0; s < convo.sent.length; s++) { + var sent = convo.sent[s]; + if (sent.api_response && sent.api_response.id == message.reply_to) { + sent.delivered = true; + sent.api_response.ts = message.ts; + } + } + } + } } + } + return false; + } - // let's normalize some of these fields to match the rtm message format - message.user = message.user_id; - message.channel = message.channel_id; + next(); + }); - slack_botkit.findTeamById(message.team_id, function(err, team) { - // FIX THIS - // this won't work for single team bots because the team info - // might not be in a db - if (err || !team) { - slack_botkit.log.error('Received outgoing webhook but could not load team'); - } else { - message.type = 'outgoing_webhook'; + slack_botkit.middleware.categorize.use(function(bot, message, next) { - res.status(200); + var mentionSyntax = '<@' + bot.identity.id + '(\\|' + bot.identity.name.replace('.', '\\.') + ')?>'; + var mention = new RegExp(mentionSyntax, 'i'); + var direct_mention = new RegExp('^' + mentionSyntax, 'i'); - var bot = slack_botkit.spawn(team); - bot.res = res; - bot.team_info = team; + if ('message' == message.type) { + if (message.text) { + message.text = message.text.trim(); + } - slack_botkit.receiveMessage(bot, message); + // set up a couple of special cases based on subtype + if (message.subtype && message.subtype == 'channel_join') { + // someone joined. maybe do something? + if (message.user == bot.identity.id) { + message.type = 'bot_channel_join'; + } else { + message.type = 'user_channel_join'; + } + } else if (message.subtype && message.subtype == 'group_join') { + // someone joined. maybe do something? + if (message.user == bot.identity.id) { + message.type = 'bot_group_join'; + } else { + message.type = 'user_group_join'; + } + } else if (message.subtype) { + message.type = message.subtype; + } else if (message.channel.match(/^D/)) { + // this is a direct message + message.type = 'direct_message'; + + if (message.user == bot.identity.id && message.bot_id) { + message.type = 'self_message'; + } + if (!message.text) { + // message without text is probably an edit + return false; + } - // outgoing webhooks are also different. They can simply return - // a response instead of using the API to reply. Maybe this is - // a different type of event!! + // remove direct mention so the handler doesn't have to deal with it + message.text = message.text.replace(direct_mention, '') + .replace(/^\s+/, '').replace(/^\:\s+/, '').replace(/^\s+/, ''); + + + } else { + if (!message.text) { + // message without text is probably an edit + return false; + } + + if (message.text.match(direct_mention)) { + // this is a direct mention + message.text = message.text.replace(direct_mention, '') + .replace(/^\s+/, '').replace(/^\:\s+/, '').replace(/^\s+/, ''); + message.type = 'direct_mention'; + + } else if (message.text.match(mention)) { + message.type = 'mention'; + } else { + message.type = 'ambient'; + } + + if (message.user == bot.identity.id && message.bot_id) { + message.type = 'self_message'; + } - } - }); } + } - }); + // move on to the next stage of the pipeline + next(); - return slack_botkit; - }; + }); - slack_botkit.saveTeam = function(team, cb) { - slack_botkit.storage.teams.save(team, cb); - }; - // look up a team's memory and configuration and return it, or - // return an error! - slack_botkit.findTeamById = function(id, cb) { - slack_botkit.storage.teams.get(id, cb); - }; + /* Handler functions for the various ways Slack might send a message to + * Botkit via webhooks. These include interactive messages (button clicks), + * events api (messages sent over web hook), slash commands, and outgoing webhooks + * (patterns matched in slack that result in a webhook) + */ + slack_botkit.middleware.normalize.use(function handleInteractiveMessage(bot, message, next) { + + if (message.callback_id) { - slack_botkit.setupWebserver = function(port, cb) { + // let's normalize some of these fields to match the rtm message format + message.user = message.user.id; + message.channel = message.channel.id; + + // put the action value in the text field + // this allows button clicks to respond to asks + message.text = message.actions[0].value; + + // handle menus too! + // take the first selected item + // TODO: When Slack supports multi-select menus, this will need an update! + if (message.actions[0].selected_options) { + message.text = message.actions[0].selected_options[0].value; + } + + message.type = 'interactive_message_callback'; - if (!port) { - throw new Error('Cannot start webserver without a port'); } - if (isNaN(port)) { - throw new Error('Specified port is not a valid number'); + + + next(); + + }); + + slack_botkit.middleware.normalize.use(function handleEventsAPI(bot, message, next) { + + if (message.type == 'event_callback') { + + // var message = {}; + for (var key in message.event) { + message[key] = message.event[key]; + } + + // let's normalize some of these fields to match the rtm message format + message.team = message.team_id; + message.events_api = true; + message.authed_users = message.authed_users; + + if (bot.identity == undefined || bot.identity.id == null) { + console.error('Could not identify bot'); + return; + } else if (bot.identity.id === message.user && message.subtype !== 'channel_join' && message.subtype !== 'group_join') { + console.error('Got event from this bot user, ignoring it'); + return; + } + } + next(); + }); - slack_botkit.config.port = port; + slack_botkit.middleware.normalize.use(function handleSlashCommand(bot, message, next) { - slack_botkit.webserver = express(); - slack_botkit.webserver.use(bodyParser.json()); - slack_botkit.webserver.use(bodyParser.urlencoded({ extended: true })); - slack_botkit.webserver.use(express.static(__dirname + '/public')); - var server = slack_botkit.webserver.listen( - slack_botkit.config.port, - function() { - slack_botkit.log('** Starting webserver on port ' + - slack_botkit.config.port); - if (cb) { cb(null, slack_botkit.webserver); } - }); + if (message.command) { + + message.user = message.user_id; + message.channel = message.channel_id; + + message.type = 'slash_command'; + } + + next(); + + }); + + slack_botkit.middleware.normalize.use(function handleOutgoingWebhook(bot, message, next) { + + if (message.trigger_word) { + + message.user = message.user_id; + message.channel = message.channel_id; + + message.type = 'outgoing_webhook'; + } + + next(); + }); + - return slack_botkit; + slack_botkit.middleware.format.use(function formatForSlack(bot, message, platform_message, next) { + + platform_message.type = message.type || 'message'; + platform_message.channel = message.channel; + platform_message.text = message.text || null; + platform_message.username = message.username || null; + platform_message.thread_ts = message.thread_ts || null; + platform_message.reply_broadcast = message.reply_broadcast || null; + platform_message.parse = message.parse || null; + platform_message.link_names = message.link_names || null; + platform_message.attachments = message.attachments ? + JSON.stringify(message.attachments) : null; + platform_message.unfurl_links = typeof message.unfurl_links !== 'undefined' ? message.unfurl_links : null; + platform_message.unfurl_media = typeof message.unfurl_media !== 'undefined' ? message.unfurl_media : null; + platform_message.icon_url = message.icon_url || null; + platform_message.icon_emoji = message.icon_emoji || null; + + + if (platform_message.icon_url || platform_message.icon_emoji || platform_message.username) { + platform_message.as_user = false; + } else { + platform_message.as_user = platform_message.as_user || true; + } + + next(); + + }); + + + + + /* End of webhook handler functions + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ + + slack_botkit.saveTeam = function(team, cb) { + slack_botkit.storage.teams.save(team, cb); + }; + + // look up a team's memory and configuration and return it, or + // return an error! + slack_botkit.findTeamById = function(id, cb) { + slack_botkit.storage.teams.get(id, cb); }; // get a team url to redirect the user through oauth process - slack_botkit.getAuthorizeURL = function(team_id) { + slack_botkit.getAuthorizeURL = function(team_id, redirect_params) { - var url = 'https://slack.com/oauth/authorize'; var scopes = slack_botkit.config.scopes; - url = url + '?client_id=' + slack_botkit.config.clientId + '&scope=' + - scopes.join(',') + '&state=botkit'; + var api_root = slack_botkit.config.api_root ? slack_botkit.config.api_root : 'https://slack.com'; + + var url = api_root + '/oauth/authorize' + '?client_id=' + + slack_botkit.config.clientId + '&scope=' + scopes.join(',') + '&state=botkit'; + + if (team_id) + url += '&team=' + team_id; - if (team_id) { - url = url + '&team=' + team_id; - } if (slack_botkit.config.redirectUri) { - url = url + '&redirect_uri=' + slack_botkit.config.redirectUri; + var redirect_query = ''; + var redirect_uri = slack_botkit.config.redirectUri; + if (redirect_params) { + redirect_query += encodeURIComponent(querystring.stringify(redirect_params)); + redirect_uri = redirect_uri + '?' + redirect_query; + } + url += '&redirect_uri=' + redirect_uri; } return url; @@ -210,7 +526,8 @@ function Slackbot(configuration) { // https://api.slack.com/docs/oauth-scopes slack_botkit.createOauthEndpoints = function(webserver, callback) { - slack_botkit.log('** Serving login URL: http://MY_HOST:' + slack_botkit.config.port + '/login'); + slack_botkit.log('** Serving login URL: http://' + + slack_botkit.config.hostname + ':' + slack_botkit.config.port + '/login'); if (!slack_botkit.config.clientId) { throw new Error( @@ -226,8 +543,12 @@ function Slackbot(configuration) { } var call_api = function(command, options, cb) { - slack_botkit.log('** API CALL: ' + 'https://slack.com/api/' + command); - request.post('https://slack.com/api/' + command, function(error, response, body) { + + var api_root = slack_botkit.config.api_root ? slack_botkit.config.api_root : 'https://slack.com'; + + + slack_botkit.log('** API CALL: ' + api_root + '/api/' + command); + request.post(api_root + '/api/' + command, function(error, response, body) { slack_botkit.debug('Got response', error, body); if (!error && response.statusCode == 200) { var json = JSON.parse(body); @@ -254,7 +575,8 @@ function Slackbot(configuration) { res.redirect(slack_botkit.getAuthorizeURL()); }); - slack_botkit.log('** Serving oauth return endpoint: http://MY_HOST:' + slack_botkit.config.port + '/oauth'); + slack_botkit.log('** Serving oauth return endpoint: http://' + + slack_botkit.config.hostname + ':' + slack_botkit.config.port + '/oauth'); webserver.get('/oauth', function(req, res) { @@ -267,7 +589,19 @@ function Slackbot(configuration) { code: code }; - if (slack_botkit.config.redirectUri) opts.redirect_uri = slack_botkit.config.redirectUri; + var redirect_params = {}; + if (slack_botkit.config.redirectUri) { + Object.assign(redirect_params, req.query); + delete redirect_params.code; + delete redirect_params.state; + + var redirect_query = querystring.stringify(redirect_params); + var redirect_uri = slack_botkit.config.redirectUri; + if (redirect_query) { + redirect_uri = redirect_uri + '?' + redirect_query; + } + opts.redirect_uri = redirect_uri; + } oauth_access(opts, function(err, auth) { @@ -308,6 +642,7 @@ function Slackbot(configuration) { slack_botkit.trigger('oauth_error', [err]); } else { + req.identity = identity; // we need to deal with any team-level provisioning info // like incoming webhooks and bot users @@ -326,6 +661,8 @@ function Slackbot(configuration) { }; } + team.state = state; + var bot = slack_botkit.spawn(team); if (auth.incoming_webhook) { @@ -337,10 +674,12 @@ function Slackbot(configuration) { } if (auth.bot) { + team.bot = { token: auth.bot.bot_access_token, user_id: auth.bot.bot_user_id, createdBy: identity.user_id, + app_token: auth.access_token, }; bot.configureRTM(team.bot); slack_botkit.trigger('create_bot', [bot, team.bot]); @@ -362,18 +701,38 @@ function Slackbot(configuration) { slack_botkit.trigger('update_team', [bot, team]); } + if (team.bot) { + // call auth test on the bot token + // to capture its name + auth_test({ + token: team.bot.token + }, function(err, auth_data) { + team.bot.name = auth_data.user; + slack_botkit.saveTeam(team, function(err, id) { + if (err) { + slack_botkit.log.error('An error occurred while saving a team: ', err); + } + }); + + }); + } + slack_botkit.storage.users.get(identity.user_id, function(err, user) { isnew = false; if (!user) { isnew = true; user = { id: identity.user_id, - access_token: auth.access_token, - scopes: scopes, team_id: identity.team_id, user: identity.user, }; } + + // Always update these because the token could become invalid + // and scopes could change. + user.access_token = auth.access_token; + user.scopes = scopes; + slack_botkit.storage.users.save(user, function(err, id) { if (err) { @@ -387,9 +746,15 @@ function Slackbot(configuration) { slack_botkit.trigger('error', [err]); } else { if (isnew) { - slack_botkit.trigger('create_user', [bot, user]); + slack_botkit.trigger( + 'create_user', + [bot, user, redirect_params] + ); } else { - slack_botkit.trigger('update_user', [bot, user]); + slack_botkit.trigger( + 'update_user', + [bot, user, redirect_params] + ); } if (callback) { callback(null, req, res); @@ -412,106 +777,6 @@ function Slackbot(configuration) { }; - slack_botkit.handleSlackEvents = function() { - - slack_botkit.log('** Setting up custom handlers for processing Slack messages'); - slack_botkit.on('message_received', function(bot, message) { - - - if (message.ok != undefined) { - // this is a confirmation of something we sent. - return false; - } - - slack_botkit.debug('DEFAULT SLACK MSG RECEIVED RESPONDER'); - if ('message' == message.type) { - - if (message.text) { - message.text = message.text.trim(); - } - - // set up a couple of special cases based on subtype - if (message.subtype && message.subtype == 'channel_join') { - // someone joined. maybe do something? - if (message.user == bot.identity.id) { - slack_botkit.trigger('bot_channel_join', [bot, message]); - return false; - } else { - slack_botkit.trigger('user_channel_join', [bot, message]); - return false; - } - } else if (message.subtype && message.subtype == 'group_join') { - // someone joined. maybe do something? - if (message.user == bot.identity.id) { - slack_botkit.trigger('bot_group_join', [bot, message]); - return false; - } else { - slack_botkit.trigger('user_group_join', [bot, message]); - return false; - } - } else if (message.subtype) { - slack_botkit.trigger(message.subtype, [bot, message]); - return false; - } else if (message.channel.match(/^D/)) { - // this is a direct message - if (message.user == bot.identity.id) { - return false; - } - if (!message.text) { - // message without text is probably an edit - return false; - } - - // remove direct mention so the handler doesn't have to deal with it - var direct_mention = new RegExp('^\<\@' + bot.identity.id + '\>', 'i'); - message.text = message.text.replace(direct_mention, '') - .replace(/^\s+/, '').replace(/^\:\s+/, '').replace(/^\s+/, ''); - - message.event = 'direct_message'; - - slack_botkit.trigger('direct_message', [bot, message]); - return false; - - } else { - if (message.user == bot.identity.id) { - return false; - } - if (!message.text) { - // message without text is probably an edit - return false; - } - - var direct_mention = new RegExp('^\<\@' + bot.identity.id + '\>', 'i'); - var mention = new RegExp('\<\@' + bot.identity.id + '\>', 'i'); - - if (message.text.match(direct_mention)) { - // this is a direct mention - message.text = message.text.replace(direct_mention, '') - .replace(/^\s+/, '').replace(/^\:\s+/, '').replace(/^\s+/, ''); - message.event = 'direct_mention'; - - slack_botkit.trigger('direct_mention', [bot, message]); - return false; - } else if (message.text.match(mention)) { - message.event = 'mention'; - slack_botkit.trigger('mention', [bot, message]); - return false; - } else { - message.event = 'ambient'; - slack_botkit.trigger('ambient', [bot, message]); - return false; - - } - } - } else { - // this is a non-message object, so trigger a custom event based on the type - slack_botkit.trigger(message.type, [bot, message]); - } - }); - }; - - // set up the RTM message handlers once - slack_botkit.handleSlackEvents(); return slack_botkit; }; diff --git a/lib/Slack_web_api.js b/lib/Slack_web_api.js index 3d6f3e364..e9368b57a 100755 --- a/lib/Slack_web_api.js +++ b/lib/Slack_web_api.js @@ -1,261 +1,259 @@ var request = require('request'); +/** + * Does nothing. Takes no params, returns nothing. It's a no-op! + */ +function noop() { } + +/** + * Returns an interface to the Slack API in the context of the given bot + * + * @param {Object} bot The botkit bot object + * @param {Object} config A config containing auth credentials. + * @returns {Object} A callback-based Slack API interface. + */ module.exports = function(bot, config) { - // create a nice wrapper for the Slack API + var api_root = bot.config.api_root ? bot.config.api_root : 'https://slack.com'; + var slack_api = { - api_url: 'https://slack.com/api/', - // this is a simple function used to call the slack web API - callAPI: function(command, options, cb) { - bot.log('** API CALL: ' + slack_api.api_url + command); - if (!options.token) { - options.token = config.token; - } - bot.debug(command, options); - request.post(this.api_url + command, function(error, response, body) { - bot.debug('Got response', error, body); - if (!error && response.statusCode == 200) { - var json = JSON.parse(body); - if (json.ok) { - if (cb) cb(null, json); - } else { - if (cb) cb(json.error, json); - } - } else { - if (cb) cb(error); - } - }).form(options); - }, - auth: { - test: function(options, cb) { - slack_api.callAPI('auth.test', options, cb); - } - }, - oauth: { - access: function(options, cb) { - slack_api.callAPIWithoutToken('oauth.access', options, cb); - } - }, - channels: { - archive: function(options, cb) { - slack_api.callAPI('channels.archive', options, cb); - }, - create: function(options, cb) { - slack_api.callAPI('channels.create', options, cb); - }, - history: function(options, cb) { - slack_api.callAPI('channels.history', options, cb); - }, - info: function(options, cb) { - slack_api.callAPI('channels.info', options, cb); - }, - invite: function(options, cb) { - slack_api.callAPI('channels.invite', options, cb); - }, - join: function(options, cb) { - slack_api.callAPI('channels.join', options, cb); - }, - kick: function(options, cb) { - slack_api.callAPI('channels.kick', options, cb); - }, - leave: function(options, cb) { - slack_api.callAPI('channels.leave', options, cb); - }, - list: function(options, cb) { - slack_api.callAPI('channels.list', options, cb); - }, - mark: function(options, cb) { - slack_api.callAPI('channels.mark', options, cb); - }, - rename: function(options, cb) { - slack_api.callAPI('channels.rename', options, cb); - }, - setPurpose: function(options, cb) { - slack_api.callAPI('channels.setPurpose', options, cb); - }, - setTopic: function(options, cb) { - slack_api.callAPI('channels.setTopic', options, cb); - }, - unarchive: function(options, cb) { - slack_api.callAPI('channels.unarchive', options, cb); - } - }, - chat: { - delete: function(options, cb) { - slack_api.callAPI('chat.delete', options, cb); - }, - postMessage: function(options, cb) { - slack_api.callAPI('chat.postMessage', options, cb); - }, - update: function(options, cb) { - slack_api.callAPI('chat.update', options, cb); - } - }, - emoji: { - list: function(options, cb) { - slack_api.callAPI('emoji.list', options, cb); - } - }, - files: { - delete: function(options, cb) { - slack_api.callAPI('files.delete', options, cb); - }, - info: function(options, cb) { - slack_api.callAPI('files.info', options, cb); - }, - list: function(options, cb) { - slack_api.callAPI('files.list', options, cb); - }, - upload: function(options, cb) { - slack_api.callAPI('files.upload', options, cb); - }, - }, - groups: { - archive: function(options, cb) { - slack_api.callAPI('groups.archive', options, cb); - }, - close: function(options, cb) { - slack_api.callAPI('groups.close', options, cb); - }, - create: function(options, cb) { - slack_api.callAPI('groups.create', options, cb); - }, - createChild: function(options, cb) { - slack_api.callAPI('groups.createChild', options, cb); - }, - history: function(options, cb) { - slack_api.callAPI('groups.history', options, cb); - }, - info: function(options, cb) { - slack_api.callAPI('groups.info', options, cb); - }, - invite: function(options, cb) { - slack_api.callAPI('groups.invite', options, cb); - }, - kick: function(options, cb) { - slack_api.callAPI('groups.kick', options, cb); - }, - leave: function(options, cb) { - slack_api.callAPI('groups.leave', options, cb); - }, - list: function(options, cb) { - slack_api.callAPI('groups.list', options, cb); - }, - mark: function(options, cb) { - slack_api.callAPI('groups.mark', options, cb); - }, - open: function(options, cb) { - slack_api.callAPI('groups.open', options, cb); - }, - rename: function(options, cb) { - slack_api.callAPI('groups.rename', options, cb); - }, - setPurpose: function(options, cb) { - slack_api.callAPI('groups.setPurpose', options, cb); - }, - setTopic: function(options, cb) { - slack_api.callAPI('groups.setTopic', options, cb); - }, - unarchive: function(options, cb) { - slack_api.callAPI('groups.unarchive', options, cb); - }, - }, - im: { - close: function(options, cb) { - slack_api.callAPI('im.close', options, cb); - }, - history: function(options, cb) { - slack_api.callAPI('im.history', options, cb); - }, - list: function(options, cb) { - slack_api.callAPI('im.list', options, cb); - }, - mark: function(options, cb) { - slack_api.callAPI('im.mark', options, cb); - }, - open: function(options, cb) { - slack_api.callAPI('im.open', options, cb); - } - }, - mpim: { - close: function(options, cb) { - slack_api.callAPI('mpim.close', options, cb); - }, - history: function(options, cb) { - slack_api.callAPI('mpim.history', options, cb); - }, - list: function(options, cb) { - slack_api.callAPI('mpim.list', options, cb); - }, - mark: function(options, cb) { - slack_api.callAPI('mpim.mark', options, cb); - }, - open: function(options, cb) { - slack_api.callAPI('mpim.open', options, cb); + api_url: api_root + '/api/' + }; + + // Slack API methods: https://api.slack.com/methods + var slackApiMethods = [ + 'auth.test', + 'oauth.access', + 'channels.archive', + 'channels.create', + 'channels.history', + 'channels.info', + 'channels.invite', + 'channels.join', + 'channels.kick', + 'channels.leave', + 'channels.list', + 'channels.mark', + 'channels.rename', + 'channels.replies', + 'channels.setPurpose', + 'channels.setTopic', + 'channels.unarchive', + 'chat.delete', + 'chat.postMessage', + 'chat.postEphemeral', + 'chat.update', + 'chat.unfurl', + 'dnd.endDnd', + 'dnd.endSnooze', + 'dnd.info', + 'dnd.setSnooze', + 'dnd.teamInfo', + 'emoji.list', + 'files.delete', + 'files.info', + 'files.list', + 'files.upload', + 'files.sharedPublicURL', + 'groups.archive', + 'groups.close', + 'groups.create', + 'groups.createChild', + 'groups.history', + 'groups.info', + 'groups.invite', + 'groups.kick', + 'groups.leave', + 'groups.list', + 'groups.mark', + 'groups.open', + 'groups.rename', + 'groups.replies', + 'groups.setPurpose', + 'groups.setTopic', + 'groups.unarchive', + 'im.close', + 'im.history', + 'im.list', + 'im.mark', + 'im.open', + 'im.replies', + 'mpim.close', + 'mpim.history', + 'mpim.list', + 'mpim.mark', + 'mpim.open', + 'mpim.replies', + 'pins.add', + 'pins.list', + 'pins.remove', + 'reactions.add', + 'reactions.get', + 'reactions.list', + 'reactions.remove', + 'reminders.add', + 'reminders.complete', + 'reminders.delete', + 'reminders.info', + 'reminders.list', + 'rtm.start', + 'rtm.connect', + 'search.all', + 'search.files', + 'search.messages', + 'stars.add', + 'stars.list', + 'stars.remove', + 'team.accessLogs', + 'team.info', + 'team.billableInfo', + 'team.integrationLogs', + 'team.profile.get', + 'users.getPresence', + 'users.info', + 'users.identity', + 'users.list', + 'users.setActive', + 'users.setPresence', + 'users.deletePhoto', + 'users.identity', + 'users.setPhoto', + 'users.profile.get', + 'users.profile.set' + ]; + + /** + * Calls Slack using a Token for authentication/authorization + * + * @param {string} command The Slack API command to call + * @param {Object} data The data to pass to the API call + * @param {function} cb A NodeJS-style callback + */ + slack_api.callAPI = function(command, data, cb, multipart) { + data.token = data.token || config.token; + bot.debug(command, data); + postForm(slack_api.api_url + command, data, cb, multipart); + }; + + /** + * Calls Slack using OAuth for authentication/authorization + * + * @param {string} command The Slack API command to call + * @param {Object} data The data to pass to the API call + * @param {function} cb A NodeJS-style callback + */ + slack_api.callAPIWithoutToken = function(command, data, cb) { + data.client_id = data.client_id || bot.config.clientId; + data.client_secret = data.client_secret || bot.config.clientSecret; + data.redirect_uri = data.redirect_uri || bot.config.redirectUri; + // DON'T log options: that could expose the client secret! + postForm(slack_api.api_url + command, data, cb); + }; + + + // generate all API methods + slackApiMethods.forEach(function(slackMethod) { + // most slack api methods are only two parts, of the form group.method, e.g. auth.test + // some have three parts: group.subgroup.method, e.g, users.profile.get + // this method loops through all groups in a method, ensures they exist, + // then adds the method to the terminal group + + var groups = slackMethod.split('.'); + var method = groups.pop(); + var currentGroup = slack_api; + + groups.forEach(function(nextGroupName) { + currentGroup[nextGroupName] = currentGroup[nextGroupName] || {}; + currentGroup = currentGroup[nextGroupName]; + }); + + currentGroup[method] = function(options, cb) { + slack_api.callAPI(slackMethod, options, cb); + }; + + }); + + // overwrite default behavior + slack_api.chat.postMessage = function(options, cb) { + sanitizeOptions(options); + slack_api.callAPI('chat.postMessage', options, cb); + }; + + slack_api.chat.postEphemeral = function(options, cb) { + sanitizeOptions(options); + slack_api.callAPI('chat.postEphemeral', options, cb); + }; + + slack_api.chat.update = function(options, cb) { + sanitizeOptions(options); + slack_api.callAPI('chat.update', options, cb); + }; + + // specify that files get uploaded using multipart + slack_api.files.upload = function(options, cb) { + slack_api.callAPI('files.upload', options, cb, !!options.file); + }; + + function sanitizeOptions(options) { + if (options.attachments && typeof (options.attachments) != 'string') { + try { + options.attachments = JSON.stringify(options.attachments); + } catch (err) { + delete options.attachments; + bot.log.error('Could not parse attachments', err); } - }, - reactions: { - add: function(options, cb) { - slack_api.callAPI('reactions.add', options, cb); - }, - get: function(options, cb) { - slack_api.callAPI('reactions.get', options, cb); - }, - list: function(options, cb) { - slack_api.callAPI('reactions.list', options, cb); - }, - remove: function(options, cb) { - slack_api.callAPI('reactions.remove', options, cb); - }, - }, - rtm: { - start: function(options, cb) { - slack_api.callAPI('rtm.start', options, cb); - }, - }, - search: { - all: function(options, cb) { - slack_api.callAPI('search.all', options, cb); - }, - files: function(options, cb) { - slack_api.callAPI('search.files', options, cb); - }, - messages: function(options, cb) { - slack_api.callAPI('search.messages', options, cb); - }, - }, - stars: { - list: function(options, cb) { - slack_api.callAPI('stars.list', options, cb); - }, - }, - team: { - accessLogs: function(options, cb) { - slack_api.callAPI('team.accessLogs', options, cb); - }, - info: function(options, cb) { - slack_api.callAPI('team.info', options, cb); - }, - }, - users: { - getPresence: function(options, cb) { - slack_api.callAPI('users.getPresence', options, cb); - }, - info: function(options, cb) { - slack_api.callAPI('users.info', options, cb); - }, - list: function(options, cb) { - slack_api.callAPI('users.list', options, cb); - }, - setActive: function(options, cb) { - slack_api.callAPI('users.setActive', options, cb); - }, - setPresence: function(options, cb) { - slack_api.callAPI('users.setPresence', options, cb); - }, } - }; + } + return slack_api; + + /** + * Makes a POST request as a form to the given url with the options as data + * + * @param {string} url The URL to POST to + * @param {Object} formData The data to POST as a form + * @param {function=} cb An optional NodeJS style callback when the POST completes or errors out. + */ + function postForm(url, formData, cb, multipart) { + cb = cb || noop; + + bot.log('** API CALL: ' + url); + var params = { + url: url, + headers: { + 'User-Agent': bot.userAgent(), + } + }; + + if (multipart === true) { + params.formData = formData; + } else { + params.form = formData; + } + + request.post(params, function(error, response, body) { + bot.debug('Got response', error, body); + + if (error) { + return cb(error); + } + + if (response.statusCode == 200) { + var json; + try { + json = JSON.parse(body); + } catch (parseError) { + return cb(parseError); + } + + return cb((json.ok ? null : json.error), json); + } else if (response.statusCode == 429) { + return cb(new Error('Rate limit exceeded')); + } else { + return cb(new Error('Invalid response')); + } + }); + } }; diff --git a/lib/Slackbot_worker.js b/lib/Slackbot_worker.js index c068e6afb..eb5692a07 100755 --- a/lib/Slackbot_worker.js +++ b/lib/Slackbot_worker.js @@ -1,15 +1,37 @@ var Ws = require('ws'); var request = require('request'); var slackWebApi = require(__dirname + '/Slack_web_api.js'); +var HttpsProxyAgent = require('https-proxy-agent'); +var Back = require('back'); module.exports = function(botkit, config) { var bot = { + type: 'slack', botkit: botkit, config: config || {}, utterances: botkit.utterances, - api: slackWebApi(botkit, config || {}) + api: slackWebApi(botkit, config || {}), + identity: { // default identity values + id: null, + name: '', + } }; + // Set when destroy() is called - prevents a reconnect from completing + // if it was fired off prior to destroy being called + var destroyed = false; + var pingTimeoutId = null; + var retryBackoff = null; + + // config.retry, can be Infinity too + var retryEnabled = bot.config.retry ? true : (botkit.config.retry ? true : false); + var maxRetry = null; + if (bot.config.retry) { + maxRetry = isNaN(bot.config.retry) || bot.config.retry <= 0 ? 3 : bot.config.retry; + } else if (botkit.config.retry) { + maxRetry = isNaN(botkit.config.retry) || botkit.config.retry <= 0 ? 3 : botkit.config.retry; + } + /** * Set up API to send incoming webhook */ @@ -29,14 +51,16 @@ module.exports = function(botkit, config) { return cb && cb('No webhook url specified'); } - request.post(bot.config.incoming_webhook.url, function(err, res, body) { - if (err) { - botkit.debug('WEBHOOK ERROR', err); - return cb && cb(err); - } - botkit.debug('WEBHOOK SUCCESS', body); - cb && cb(null, body); - }).form({ payload: JSON.stringify(options) }); + botkit.middleware.send.run(bot, options, function(err, bot, options) { + request.post(bot.config.incoming_webhook.url, function(err, res, body) { + if (err) { + botkit.debug('WEBHOOK ERROR', err); + return cb && cb(err); + } + botkit.debug('WEBHOOK SUCCESS', body); + cb && cb(null, body); + }).form({ payload: JSON.stringify(options) }); + }); }; bot.configureRTM = function(config) { @@ -44,16 +68,69 @@ module.exports = function(botkit, config) { return bot; }; - bot.closeRTM = function() { - if (bot.rtm) + bot.closeRTM = function(err) { + if (bot.rtm) { + bot.rtm.removeAllListeners(); bot.rtm.close(); + } + + if (pingTimeoutId) { + clearTimeout(pingTimeoutId); + } + + botkit.trigger('rtm_close', [bot, err]); + + // only retry, if enabled, when there was an error + if (err && retryEnabled) { + reconnect(); + } + }; + + + function reconnect(err) { + var options = { + minDelay: 1000, + maxDelay: 30000, + retries: maxRetry + }; + var back = retryBackoff || (retryBackoff = new Back(options)); + return back.backoff(function(fail) { + if (fail) { + botkit.log.error('** BOT ID:', bot.identity.name, '...reconnect failed after #' + + back.settings.attempt + ' attempts and ' + back.settings.timeout + 'ms'); + botkit.trigger('rtm_reconnect_failed', [bot, err]); + return; + } + + botkit.log.notice('** BOT ID:', bot.identity.name, '...reconnect attempt #' + + back.settings.attempt + ' of ' + options.retries + ' being made after ' + back.settings.timeout + 'ms'); + bot.startRTM(function(err) { + if (err && !destroyed) { + return reconnect(err); + } + retryBackoff = null; + }); + }); + } + + /** + * Shutdown and cleanup the spawned worker + */ + bot.destroy = function() { + // this prevents a startRTM from completing if it was fired off + // prior to destroy being called + destroyed = true; + if (retryBackoff) { + retryBackoff.close(); + retryBackoff = null; + } + bot.closeRTM(); + botkit.shutdown(); }; bot.startRTM = function(cb) { - bot.api.rtm.start({ - no_unreads: true, - simple_latest: true, - }, function(err, res) { + var lastPong = 0; + bot.api.rtm.connect({}, function(err, res) { if (err) { return cb && cb(err); } @@ -65,33 +142,66 @@ module.exports = function(botkit, config) { bot.identity = res.self; bot.team_info = res.team; - /** - * Also available: - * res.users, res.channels, res.groups, res.ims, - * res.bots - * - * Could be stored & cached for later use. - */ + // Bail out if destroy() was called + if (destroyed) { + botkit.log.notice('Ignoring rtm.start response, bot was destroyed'); + return cb('Ignoring rtm.start response, bot was destroyed'); + } + + botkit.log.notice('** BOT ID:', bot.identity.name, '...attempting to connect to RTM!'); - botkit.log.notice('** BOT ID: ', bot.identity.name, ' ...attempting to connect to RTM!'); + var agent = null; + var proxyUrl = process.env.https_proxy || process.env.http_proxy; + if (proxyUrl) { + agent = new HttpsProxyAgent(proxyUrl); + } - bot.rtm = new Ws(res.url); + bot.rtm = new Ws(res.url, null, { + agent: agent + }); bot.msgcount = 1; + bot.rtm.on('pong', function(obj) { + lastPong = Date.now(); + }); + bot.rtm.on('open', function() { - botkit.trigger('rtm_open', [this]); + botkit.log.notice('RTM websocket opened'); + + var pinger = function() { + var pongTimeout = bot.botkit.config.stale_connection_timeout || 12000; + if (lastPong && lastPong + pongTimeout < Date.now()) { + var err = new Error('Stale RTM connection, closing RTM'); + botkit.log.error(err); + bot.closeRTM(err); + clearTimeout(pingTimeoutId); + return; + } + bot.rtm.ping(); + pingTimeoutId = setTimeout(pinger, 5000); + }; + + pingTimeoutId = setTimeout(pinger, 5000); + + botkit.trigger('rtm_open', [bot]); bot.rtm.on('message', function(data, flags) { - var message = JSON.parse(data); + var message = null; + try { + message = JSON.parse(data); + } catch (err) { + console.log('** RECEIVED BAD JSON FROM SLACK'); + } /** * Lets construct a nice quasi-standard botkit message * it leaves the main slack message at the root * but adds in additional fields for internal use! * (including the teams api details) */ - botkit.receiveMessage(bot, message); - + if (message != null && bot.botkit.config.rtm_receive_messages) { + botkit.ingest(bot, message, bot.rtm); + } }); botkit.startTicking(); @@ -101,11 +211,27 @@ module.exports = function(botkit, config) { bot.rtm.on('error', function(err) { botkit.log.error('RTM websocket error!', err); + if (pingTimeoutId) { + clearTimeout(pingTimeoutId); + } botkit.trigger('rtm_close', [bot, err]); }); - bot.rtm.on('close', function() { + bot.rtm.on('close', function(code, message) { + botkit.log.notice('RTM close event: ' + code + ' : ' + message); + if (pingTimeoutId) { + clearTimeout(pingTimeoutId); + } botkit.trigger('rtm_close', [bot]); + + /** + * CLOSE_ABNORMAL error + * wasn't closed explicitly, should attempt to reconnect + */ + if (code === 1006) { + botkit.log.error('Abnormal websocket close event, attempting to reconnect'); + reconnect(); + } }); }); @@ -113,52 +239,83 @@ module.exports = function(botkit, config) { }; bot.identifyBot = function(cb) { + var data; if (bot.identity) { - bot.identifyTeam(function(err, team) { - cb(null, { - name: bot.identity.name, - id: bot.identity.id, - team_id: team - }); - }); + data = { + name: bot.identity.name, + id: bot.identity.id, + team_id: bot.identifyTeam() + }; + cb && cb(null, data); + return data; } else { /** * Note: Are there scenarios other than the RTM * where we might pull identity info, perhaps from * bot.api.auth.test on a given token? */ - cb('Identity Unknown: Not using RTM api'); + cb && cb('Identity Unknown: Not using RTM api'); + return null; }; }; bot.identifyTeam = function(cb) { - if (bot.team_info) - return cb(null, bot.team_info.id); + if (bot.team_info) { + cb && cb(null, bot.team_info.id); + return bot.team_info.id; + } /** * Note: Are there scenarios other than the RTM * where we might pull identity info, perhaps from * bot.api.auth.test on a given token? */ - cb('Unknown Team!'); + cb && cb('Unknown Team!'); + return null; }; /** * Convenience method for creating a DM convo. */ bot.startPrivateConversation = function(message, cb) { - botkit.startTask(this, message, function(task, convo) { - bot._startDM(task, message.user, function(err, dm) { - convo.stop(); - cb(err, dm); + bot.api.im.open({ user: message.user }, function(err, channel) { + if (err) return cb(err); + + message.channel = channel.channel.id; + + botkit.startTask(bot, message, function(task, convo) { + cb(null, convo); }); }); }; - bot.startConversation = function(message, cb) { + bot.startConversationInThread = function(message, cb) { + // make replies happen in a thread + if (!message.thread_ts) { + message.thread_ts = message.ts; + } botkit.startConversation(this, message, cb); }; + bot.createPrivateConversation = function(message, cb) { + bot.api.im.open({ user: message.user }, function(err, channel) { + if (err) return cb(err); + + message.channel = channel.channel.id; + + botkit.createConversation(bot, message, cb); + }); + }; + + bot.createConversationInThread = function(message, cb) { + // make replies happen in a thread + if (!message.thread_ts) { + message.thread_ts = message.ts; + } + botkit.createConversation(this, message, cb); + }; + + /** * Convenience method for creating a DM convo. */ @@ -173,47 +330,29 @@ module.exports = function(botkit, config) { }); }; - bot.say = function(message, cb) { - botkit.debug('SAY ', message); + bot.send = function(message, cb) { + if (message.ephemeral) { + bot.sendEphemeral(message, cb); + return; + } + botkit.debug('SAY', message); - /** - * Construct a valid slack message. - */ - var slack_message = { - id: message.id || bot.msgcount, - type: message.type || 'message', - channel: message.channel, - text: message.text || null, - username: message.username || null, - parse: message.parse || null, - link_names: message.link_names || null, - attachments: message.attachments ? - JSON.stringify(message.attachments) : null, - unfurl_links: message.unfurl_links || null, - unfurl_media: message.unfurl_media || null, - icon_url: message.icon_url || null, - icon_emoji: message.icon_emoji || null, - }; bot.msgcount++; - if (message.icon_url || message.icon_emoji || message.username) { - slack_message.as_user = false; - } else { - slack_message.as_user = message.as_user || true; - } - /** - * These options are not supported by the RTM - * so if they are specified, we use the web API to send messages. + * Use the web api to send messages unless otherwise specified + * OR if one of the fields that is only supported by the web api is present */ - if (message.attachments || message.icon_emoji || + if ( + botkit.config.send_via_rtm !== true && message.type !== 'typing' || + message.attachments || message.icon_emoji || message.username || message.icon_url) { if (!bot.config.token) { throw new Error('Cannot use web API to send messages.'); } - bot.api.chat.postMessage(slack_message, function(err, res) { + bot.api.chat.postMessage(message, function(err, res) { if (err) { cb && cb(err); } else { @@ -225,12 +364,15 @@ module.exports = function(botkit, config) { if (!bot.rtm) throw new Error('Cannot use the RTM API to send messages.'); + message.id = message.id || bot.msgcount; + + try { - bot.rtm.send(JSON.stringify(slack_message), function(err) { + bot.rtm.send(JSON.stringify(message), function(err) { if (err) { cb && cb(err); } else { - cb && cb(); + cb && cb(null, message); } }); } catch (err) { @@ -244,6 +386,54 @@ module.exports = function(botkit, config) { } } }; + bot.sendEphemeral = function(message, cb) { + botkit.debug('SAY EPHEMERAL', message); + + /** + * Construct a valid slack message. + */ + var slack_message = { + type: message.type || 'message', + channel: message.channel, + text: message.text || null, + user: message.user, + as_user: message.as_user || false, + parse: message.parse || null, + link_names: message.link_names || null, + attachments: message.attachments ? + JSON.stringify(message.attachments) : null, + }; + bot.msgcount++; + + if (!bot.config.token) { + throw new Error('Cannot use web API to send messages.'); + } + + bot.api.chat.postEphemeral(slack_message, function(err, res) { + if (err) { + cb && cb(err); + } else { + cb && cb(null, res); + } + }); + }; + + /** + * Allows responding to slash commands and interactive messages with a plain + * 200 OK (without any text or attachments). + * + * @param {function} cb - An optional callback function called at the end of execution. + * The callback is passed an optional Error object. + */ + bot.replyAcknowledge = function(cb) { + if (!bot.res) { + cb && cb(new Error('No web response object found')); + } else { + bot.res.end(); + + cb && cb(); + } + }; bot.replyPublic = function(src, resp, cb) { if (!bot.res) { @@ -259,9 +449,18 @@ module.exports = function(botkit, config) { msg.channel = src.channel; + // if source message is in a thread, reply should also be in the thread + if (src.thread_ts) { + msg.thread_ts = src.thread_ts; + } + msg.response_type = 'in_channel'; - bot.res.json(msg); - cb && cb(); + msg.to = src.user; + + botkit.middleware.send.run(bot, msg, function(err, bot, msg) { + bot.res.json(msg); + cb && cb(); + }); } }; @@ -278,19 +477,34 @@ module.exports = function(botkit, config) { } msg.channel = src.channel; + msg.to = src.user; + + // if source message is in a thread, reply should also be in the thread + if (src.thread_ts) { + msg.thread_ts = src.thread_ts; + } msg.response_type = 'in_channel'; - request.post(src.response_url, function(err, resp, body) { - /** - * Do something? - */ - if (err) { - botkit.log.error('Error sending slash command response:', err); - cb && cb(err); - } else { - cb && cb(); - } - }).form(JSON.stringify(msg)); + + botkit.middleware.send.run(bot, msg, function(err, bot, msg) { + + var requestOptions = { + uri: src.response_url, + method: 'POST', + json: msg + }; + request(requestOptions, function(err, resp, body) { + /** + * Do something? + */ + if (err) { + botkit.log.error('Error sending slash command response:', err); + cb && cb(err); + } else { + cb && cb(); + } + }); + }); } }; @@ -307,11 +521,18 @@ module.exports = function(botkit, config) { } msg.channel = src.channel; + msg.to = src.user; - msg.response_type = 'ephemeral'; - bot.res.json(msg); + // if source message is in a thread, reply should also be in the thread + if (src.thread_ts) { + msg.thread_ts = src.thread_ts; + } - cb && cb(); + msg.response_type = 'ephemeral'; + botkit.middleware.send.run(bot, msg, function(err, bot, msg) { + bot.res.json(msg); + cb && cb(); + }); } }; @@ -328,19 +549,74 @@ module.exports = function(botkit, config) { } msg.channel = src.channel; + msg.to = src.user; + + // if source message is in a thread, reply should also be in the thread + if (src.thread_ts) { + msg.thread_ts = src.thread_ts; + } msg.response_type = 'ephemeral'; - request.post(src.response_url, function(err, resp, body) { - /** - * Do something? - */ - if (err) { - botkit.log.error('Error sending slash command response:', err); - cb && cb(err); - } else { - cb && cb(); - } - }).form(JSON.stringify(msg)); + + botkit.middleware.send.run(bot, msg, function(err, bot, msg) { + var requestOptions = { + uri: src.response_url, + method: 'POST', + json: msg + }; + request(requestOptions, function(err, resp, body) { + /** + * Do something? + */ + if (err) { + botkit.log.error('Error sending slash command response:', err); + cb && cb(err); + } else { + cb && cb(); + } + }); + }); + } + }; + + bot.replyInteractive = function(src, resp, cb) { + if (!src.response_url) { + cb && cb('No response_url found'); + } else { + var msg = {}; + + if (typeof(resp) == 'string') { + msg.text = resp; + } else { + msg = resp; + } + + msg.channel = src.channel; + msg.to = src.user; + + // if source message is in a thread, reply should also be in the thread + if (src.thread_ts) { + msg.thread_ts = src.thread_ts; + } + + botkit.middleware.send.run(bot, msg, function(err, bot, msg) { + var requestOptions = { + uri: src.response_url, + method: 'POST', + json: msg + }; + request(requestOptions, function(err, resp, body) { + /** + * Do something? + */ + if (err) { + botkit.log.error('Error sending interactive message response:', err); + cb && cb(err); + } else { + cb && cb(); + } + }); + }); } }; @@ -354,6 +630,51 @@ module.exports = function(botkit, config) { } msg.channel = src.channel; + msg.to = src.user; + + // if source message is in a thread, reply should also be in the thread + if (src.thread_ts) { + msg.thread_ts = src.thread_ts; + } + if (msg.ephemeral && !msg.user) { + msg.user = src.user; + msg.as_user = true; + } + + bot.say(msg, cb); + }; + + bot.whisper = function(src, resp, cb) { + var msg = {}; + + if (typeof(resp) == 'string') { + msg.text = resp; + } else { + msg = resp; + } + + msg.channel = src.channel; + msg.user = src.user; + msg.as_user = true; + msg.ephemeral = true; + + bot.say(msg, cb); + }; + + bot.replyInThread = function(src, resp, cb) { + var msg = {}; + + if (typeof(resp) == 'string') { + msg.text = resp; + } else { + msg = resp; + } + + msg.channel = src.channel; + msg.to = src.user; + + // to create a thread, set the original message as the parent + msg.thread_ts = src.thread_ts ? src.thread_ts : src.ts; bot.say(msg, cb); }; @@ -393,20 +714,60 @@ module.exports = function(botkit, config) { }, typingLength); }; + /** + * replies with message, performs arbitrary task, then updates reply message + * note: don't use this as a replacement for the `typing` event + * + * @param {Object} src - message source + * @param {(string|Object)} resp - response string or object + * @param {function} [cb] - updater callback + */ + bot.replyAndUpdate = function(src, resp, cb) { + try { + resp = typeof resp === 'string' ? { text: resp } : resp; + // trick bot.reply into using web API instead of RTM + resp.attachments = resp.attachments || []; + } catch (err) { + return cb && cb(err); + } + // send the "updatable" message + return bot.reply(src, resp, function(err, src) { + if (err) return cb && cb(err); + + // if provided, call the updater callback - it controls how and when to update the "updatable" message + return cb && cb(null, src, function(resp, cb) { + try { + // format the "update" message to target the "updatable" message + resp = typeof resp === 'string' ? { text: resp } : resp; + resp.ts = src.ts; + resp.channel = src.channel; + resp.attachments = JSON.stringify(resp.attachments || []); + } catch (err) { + return cb && cb(err); + } + // update the "updatable" message with the "update" message + return bot.api.chat.update(resp, function(err, json) { + return cb && cb(err, json); + }); + }); + }); + }; + /** * This handles the particulars of finding an existing conversation or * topic to fit the message into... */ bot.findConversation = function(message, cb) { - botkit.debug('CUSTOM FIND CONVO', message.user, message.channel); - if (message.type == 'message' || message.type == 'slash_command' || - message.type == 'outgoing_webhook') { + botkit.debug('CUSTOM FIND CONVO', message.user, message.channel, message.type); + if (message.type == 'direct_message' || message.type == 'direct_mention' || message.type == 'ambient' || message.type == 'mention' || message.type == 'slash_command' || + message.type == 'outgoing_webhook' || message.type == 'interactive_message_callback') { for (var t = 0; t < botkit.tasks.length; t++) { for (var c = 0; c < botkit.tasks[t].convos.length; c++) { if ( botkit.tasks[t].convos[c].isActive() && botkit.tasks[t].convos[c].source_message.user == message.user && - botkit.tasks[t].convos[c].source_message.channel == message.channel + botkit.tasks[t].convos[c].source_message.channel == message.channel && + botkit.tasks[t].convos[c].source_message.thread_ts == message.thread_ts ) { botkit.debug('FOUND EXISTING CONVO!'); cb(botkit.tasks[t].convos[c]); diff --git a/lib/Studio.js b/lib/Studio.js new file mode 100644 index 000000000..83bb7d0fa --- /dev/null +++ b/lib/Studio.js @@ -0,0 +1,757 @@ +var request = require('request'); +var Promise = require('promise'); +var md5 = require('md5'); +var SDK = require('botkit-studio-sdk'); + +module.exports = function(controller) { + var before_hooks = {}; + var after_hooks = {}; + var answer_hooks = {}; + var thread_hooks = {}; + + // define a place for the studio specific features to live. + controller.studio = {}; + + /* ---------------------------------------------------------------- + * Botkit Studio Script Services + * The features in this section grant access to Botkit Studio's + * script and trigger services + * ---------------------------------------------------------------- */ + + function genConfig(bot) { + var config = {}; + + if (bot.config && bot.config.studio_token) { + config.studio_token = bot.config.studio_token; + } + + if (bot.config && bot.config.studio_command_uri) { + config.studio_command_uri = bot.config.studio_command_uri; + } + + if (controller.config && controller.config.studio_token) { + config.studio_token = controller.config.studio_token; + } + + if (controller.config && controller.config.studio_command_uri) { + config.studio_command_uri = controller.config.studio_command_uri; + } + + return config; + } + + controller.studio.evaluateTrigger = function(bot, text, user) { + + var userHash = md5(user); + var sdk = new SDK(genConfig(bot)); + return sdk.evaluateTrigger(text, userHash); + + }; + + + + + // load a script from the pro service + controller.studio.getScript = function(bot, text, user) { + + var userHash = md5(user); + var sdk = new SDK(genConfig(bot)); + return sdk.getScript(text, user); + }; + + + // these are middleware functions + controller.studio.validate = function(command_name, key, func) { + + if (!answer_hooks[command_name]) { + answer_hooks[command_name] = []; + + } + if (key && !answer_hooks[command_name][key]) { + answer_hooks[command_name][key] = []; + } + + answer_hooks[command_name][key].push(func); + + return controller.studio; + }; + + + controller.studio.beforeThread = function(command_name, thread_name, func) { + + if (!thread_hooks[command_name]) { + thread_hooks[command_name] = []; + + } + if (thread_name && !thread_hooks[command_name][thread_name]) { + thread_hooks[command_name][thread_name] = []; + } + + thread_hooks[command_name][thread_name].push(func); + + return controller.studio; + }; + + + + controller.studio.before = function(command_name, func) { + + if (!before_hooks[command_name]) { + before_hooks[command_name] = []; + } + + before_hooks[command_name].push(func); + + return controller.studio; + }; + + controller.studio.after = function(command_name, func) { + + if (!after_hooks[command_name]) { + after_hooks[command_name] = []; + } + + after_hooks[command_name].push(func); + + return controller.studio; + + }; + + function runHooks(hooks, convo, cb) { + + if (!hooks || !hooks.length) { + return cb(convo); + } + + var func = hooks.shift(); + + func(convo, function() { + if (hooks.length) { + runHooks(hooks, convo, cb); + } else { + return cb(convo); + } + }); + } + + + /* Fetch a script from Botkit Studio by name, then execute it. + * returns a promise that resolves when the conversation is loaded and active */ + controller.studio.run = function(bot, input_text, user, channel, original_message) { + + return new Promise(function(resolve, reject) { + + controller.studio.get(bot, input_text, user, channel, original_message).then(function(convo) { + convo.activate(); + resolve(convo); + }).catch(function(err) { + reject(err); + }); + }); + + }; + + /* Fetch a script from Botkit Studio by name, but do not execute it. + * returns a promise that resolves when the conversation is loaded + * but developer still needs to call convo.activate() to put it in motion */ + controller.studio.get = function(bot, input_text, user, channel, original_message) { + var context = { + text: input_text, + user: user, + channel: channel, + raw_message: original_message ? original_message.raw_message : null, + original_message: original_message || null + }; + return new Promise(function(resolve, reject) { + controller.studio.getScript(bot, input_text, user).then(function(command) { + controller.trigger('command_triggered', [bot, context, command]); + controller.studio.compileScript( + bot, + context, + command.command, + command.script, + command.variables + ).then(function(convo) { + convo.on('end', function(convo) { + runHooks( + after_hooks[command.command] ? after_hooks[command.command].slice() : [], + convo, + function(convo) { + controller.trigger('remote_command_end', [bot, context, command, convo]); + } + ); + }); + runHooks( + before_hooks[command.command] ? before_hooks[command.command].slice() : [], + convo, + function(convo) { + resolve(convo); + } + ); + }).catch(function(err) { + reject(err); + }); + }).catch(function(err) { + reject(err); + }); + }); + }; + + + controller.studio.runTrigger = function(bot, input_text, user, channel, original_message) { + var context = { + text: input_text, + user: user, + channel: channel, + raw_message: original_message ? original_message.raw_message : null, + original_message: original_message || null + }; + return new Promise(function(resolve, reject) { + controller.studio.evaluateTrigger(bot, input_text, user).then(function(command) { + if (command !== {} && command.id) { + controller.trigger('command_triggered', [bot, context, command]); + controller.studio.compileScript( + bot, + context, + command.command, + command.script, + command.variables + ).then(function(convo) { + + convo.on('end', function(convo) { + runHooks( + after_hooks[command.command] ? after_hooks[command.command].slice() : [], + convo, + function(convo) { + controller.trigger('remote_command_end', [bot, context, command, convo]); + } + ); + }); + + runHooks( + before_hooks[command.command] ? before_hooks[command.command].slice() : [], + convo, + function(convo) { + convo.activate(); + resolve(convo); + } + ); + }).catch(function(err) { + reject(err); + }); + } else { + // return with no conversation + // allow developer to run a default script + resolve(null); + } + }).catch(function(err) { + reject(err); + }); + }); + + }; + + + controller.studio.testTrigger = function(bot, input_text, user, channel) { + var context = { + text: input_text, + user: user, + channel: channel, + }; + return new Promise(function(resolve, reject) { + controller.studio.evaluateTrigger(bot, input_text, user).then(function(command) { + if (command !== {} && command.id) { + resolve(true); + } else { + resolve(false); + } + }).catch(function(err) { + reject(err); + }); + }); + + }; + + controller.studio.compileScript = function(bot, message, command_name, topics, vars) { + function makeHandler(options, field) { + var pattern = ''; + + if (options.type == 'utterance') { + pattern = controller.utterances[options.pattern]; + } else if (options.type == 'string') { + pattern = '^' + options.pattern + '$'; + } else if (options.type == 'regex') { + pattern = options.pattern; + } + + return { + pattern: pattern, + default: options.default, + callback: function(response, convo) { + var hooks = []; + if (field.key && answer_hooks[command_name] && answer_hooks[command_name][field.key]) { + hooks = answer_hooks[command_name][field.key].slice(); + } + if (options.action != 'wait' && field.multiple) { + convo.responses[field.key].pop(); + } + + runHooks(hooks, convo, function(convo) { + switch (options.action) { + case 'next': + convo.next(); + break; + case 'repeat': + // before continuing, repeat the last send message + // use sayFirst, so that it prepends it to the front of script + convo.sayFirst(convo.sent[convo.sent.length - 1]); + convo.next(); + break; + case 'stop': + convo.stop(); + break; + case 'wait': + convo.silentRepeat(); + break; + default: + convo.changeTopic(options.action); + break; + } + }); + } + }; + + } + + return new Promise(function(resolve, reject) { + bot.createConversation(message, function(err, convo) { + + if (err) { + return reject(err); + } + + // 15 minute default timeout + convo.setTimeout(controller.config.default_timeout || (15 * 60 * 1000)); + + // process any variables values and entities that came pre-defined as part of the script + if (vars && vars.length) { + for (var v = 0; v < vars.length; v++) { + if (vars[v].value) { + + // set the key/value as a mustache variable + // accessible as {{vars.name}} in the templates + convo.setVar(vars[v].name, vars[v].value); + + // also add this as an "answer" to a question + // thus making it available at {{responses.name}} and + // convo.extractResponse(name); + convo.responses[vars[v].name] = { + question: vars[v].name, + text: vars[v].value, + }; + } + } + } + + for (var t = 0; t < topics.length; t++) { + var topic = topics[t].topic; + for (var m = 0; m < topics[t].script.length; m++) { + + var message = {}; + + if (topics[t].script[m].text) { + message.text = topics[t].script[m].text; + } + + // handle platform specific fields + if (bot.type == 'ciscospark') { + if (topics[t].script[m].platforms && topics[t].script[m].platforms.ciscospark) { + // attach files. + if (topics[t].script[m].platforms.ciscospark.files) { + message.files = []; + for (var f = 0; f < topics[t].script[m].platforms.ciscospark.files.length; f++) { + message.files.push(topics[t].script[m].platforms.ciscospark.files[f].url); + } + } + } + } + + if (bot.type == 'teams') { + if (topics[t].script[m].platforms && topics[t].script[m].platforms.teams) { + // create attachments in the Botkit message + + if (topics[t].script[m].platforms && topics[t].script[m].platforms.teams.attachmentLayout) { + message.attachmentLayout = topics[t].script[m].platforms && topics[t].script[m].platforms.teams.attachmentLayout; + } + + if (topics[t].script[m].platforms.teams.attachments) { + message.attachments = []; + for (var a = 0; a < topics[t].script[m].platforms.teams.attachments.length; a++) { + var data = topics[t].script[m].platforms.teams.attachments[a]; + var attachment = {}; + if (data.type == 'o365') { + attachment.contentType = 'application/vnd.microsoft.card.O365Connector'; // + data.type, + data['@type'] = 'MessageCard'; + data['@context'] = 'http://schema.org/extensions'; + delete(data.type); + attachment.content = data; + } else if (data.type != 'file') { + attachment = bot.createAttachment(data.type, data); + } else { + attachment.contentType = data.contentType; + attachment.contentUrl = data.contentUrl; + attachment.name = data.name; + + } + message.attachments.push(attachment); + } + } + } + } + + // handle Slack attachments + if (topics[t].script[m].attachments) { + message.attachments = topics[t].script[m].attachments; + + + // enable mrkdwn formatting in all fields of the attachment + for (var a = 0; a < message.attachments.length; a++) { + message.attachments[a].mrkdwn_in = ['text', 'pretext', 'fields']; + message.attachments[a].mrkdwn = true; + } + } + + // handle Facebook attachments + if (topics[t].script[m].fb_attachment) { + var attachment = topics[t].script[m].fb_attachment; + if (attachment.template_type) { + if (attachment.template_type == 'button') { + attachment.text = message.text; + } + message.attachment = { + type: 'template', + payload: attachment + }; + } else if (attachment.type) { + message.attachment = attachment; + } + + // blank text, not allowed with attachment + message.text = null; + + // remove blank button array if specified + if (message.attachment.payload.elements) { + for (var e = 0; e < message.attachment.payload.elements.length; e++) { + if (!message.attachment.payload.elements[e].buttons || !message.attachment.payload.elements[e].buttons.length) { + delete(message.attachment.payload.elements[e].buttons); + } + } + } + + } + + // handle Facebook quick replies + if (topics[t].script[m].quick_replies) { + var options = topics[t].script[m].quick_replies; + if (!message.quick_replies) { + message.quick_replies = []; + } + for (var o = 0; o < options.length; o++) { + message.quick_replies.push(options[o]); + } + } + + // handle Facebook quick replies that are embedded in question options + if (topics[t].script[m].collect) { + + var options = topics[t].script[m].collect.options || []; + if (options.length > 0) { + for (var o = 0; o < options.length; o++) { + if (options[o].fb_quick_reply) { + if (!message.quick_replies) { + message.quick_replies = []; + } + message.quick_replies.push({ + title: options[o].pattern, + payload: options[o].fb_quick_reply_payload, + image_url: options[o].fb_quick_reply_image_url, + content_type: options[o].fb_quick_reply_content_type, + }); + } + } + } + } + + if (topics[t].script[m].action) { + message.action = topics[t].script[m].action; + } + + if (topics[t].script[m].collect) { + // this is a question message + var capture_options = {}; + var handlers = []; + var options = topics[t].script[m].collect.options || []; + if (topics[t].script[m].collect.key) { + capture_options.key = topics[t].script[m].collect.key; + } + + if (topics[t].script[m].collect.multiple) { + capture_options.multiple = true; + } + + var default_found = false; + for (var o = 0; o < options.length; o++) { + var handler = makeHandler(options[o], capture_options); + handlers.push(handler); + if (options[o].default) { + default_found = true; + } + } + + // make sure there is a default + if (!default_found) { + handlers.push({ + default: true, + callback: function(r, c) { + + runHooks( + answer_hooks[command_name] ? answer_hooks[command_name].slice() : [], + convo, + function(convo) { + c.next(); + } + ); + } + }); + } + + convo.addQuestion(message, handlers, capture_options, topic); + + } else { + + // this is a simple message + convo.addMessage(message, topic); + } + } + + // add thread hooks if they have been defined. + if (thread_hooks[command_name] && thread_hooks[command_name][topic]) { + for (var h = 0; h < thread_hooks[command_name][topic].length; h++) { + convo.beforeThread(topic, thread_hooks[command_name][topic][h]); + } + } + + } + + + resolve(convo); + }); + }); + }; + + /* ---------------------------------------------------------------- + * Botkit Studio Stats + * The features below this line pertain to communicating with Botkit Studio's + * stats feature. + * ---------------------------------------------------------------- */ + + + + function statsAPI(bot, options, message) { + var _STUDIO_STATS_API = controller.config.studio_stats_uri || 'https://stats.botkit.ai'; + options.uri = _STUDIO_STATS_API + '/api/v1/stats'; + + return new Promise(function(resolve, reject) { + + var headers = { + 'content-type': 'application/json', + }; + + if (bot.config && bot.config.studio_token) { + options.uri = options.uri + '?access_token=' + bot.config.studio_token; + } else if (controller.config && controller.config.studio_token) { + options.uri = options.uri + '?access_token=' + controller.config.studio_token; + } else { + // console.log('DEBUG: Making an unathenticated request to stats api'); + } + + options.headers = headers; + var now = new Date(); + if (options.now) { + now = options.now; + } + + + var stats_body = {}; + stats_body.botHash = botHash(bot); + if (bot.type == 'slack' && bot.team_info) { + stats_body.team = md5(bot.team_info.id); + } + + if (bot.type == 'ciscospark' && message && message.raw_message && message.raw_message.orgId) { + stats_body.team = md5(message.raw_message.orgId); + } + + if (bot.type == 'teams' && bot.config.team) { + stats_body.team = md5(bot.config.team); + } + + stats_body.channel = options.form.channel; + stats_body.user = options.form.user; + stats_body.type = options.form.type; + stats_body.time = now; + stats_body.meta = {}; + stats_body.meta.user = options.form.user; + stats_body.meta.channel = options.form.channel; + if (options.form.final_thread) { + stats_body.meta.final_thread = options.form.final_thread; + } + if (bot.botkit.config.clientId) { + stats_body.meta.app = md5(bot.botkit.config.clientId); + } + stats_body.meta.timestamp = options.form.timestamp; + stats_body.meta.bot_type = options.form.bot_type; + stats_body.meta.conversation_length = options.form.conversation_length; + stats_body.meta.status = options.form.status; + stats_body.meta.type = options.form.type; + stats_body.meta.command = options.form.command; + options.form = stats_body; + stats_body.meta.timestamp = options.now || now; + request(options, function(err, res, body) { + if (err) { + return reject(err); + } + + var json = null; + try { + json = JSON.parse(body); + } catch (e) { + } + + if (!json || json == null) { + return reject('Response from Botkit Studio API was empty or invalid JSON'); + } else if (json.error) { + return reject(json.error); + } else { + resolve(json); + } + + }); + }); + } + + /* generate an anonymous hash to uniquely identify this bot instance */ + function botHash(bot) { + var x = ''; + switch (bot.type) { + case 'slack': + if (bot.config.token) { + x = md5(bot.config.token); + } else { + x = 'non-rtm-bot'; + } + break; + + case 'teams': + x = md5(bot.identity.id); + break; + + case 'fb': + x = md5(bot.botkit.config.access_token); + break; + + case 'twilioipm': + x = md5(bot.config.TWILIO_IPM_SERVICE_SID); + break; + + case 'twiliosms': + x = md5(bot.botkit.config.account_sid); + break; + + + case 'ciscospark': + x = md5(bot.botkit.config.ciscospark_access_token); + break; + + default: + x = 'unknown-bot-type'; + break; + } + return x; + }; + + + /* Every time a bot spawns, Botkit calls home to identify this unique bot + * so that the maintainers of Botkit can measure the size of the installed + * userbase of Botkit-powered bots. */ + if (!controller.config.stats_optout) { + + controller.on('spawned', function(bot) { + + var data = { + type: 'spawn', + bot_type: bot.type, + }; + controller.trigger('stats:spawned', bot); + return statsAPI(bot, { + method: 'post', + form: data, + }); + }); + + + controller.on('heard_trigger', function(bot, keywords, message) { + var data = { + type: 'heard_trigger', + user: md5(message.user), + channel: md5(message.channel), + bot_type: bot.type, + }; + controller.trigger('stats:heard_trigger', message); + return statsAPI(bot, { + method: 'post', + form: data, + }, message); + }); + + controller.on('command_triggered', function(bot, message, command) { + var data = { + type: 'command_triggered', + now: message.now, + user: md5(message.user), + channel: md5(message.channel), + command: command.command, + timestamp: command.created, + bot_type: bot.type, + }; + controller.trigger('stats:command_triggered', message); + return statsAPI(bot, { + method: 'post', + form: data, + }, message); + }); + + controller.on('remote_command_end', function(bot, message, command, convo) { + var data = { + now: message.now, + user: md5(message.user), + channel: md5(message.channel), + command: command.command, + timestamp: command.created, + conversation_length: convo.lastActive - convo.startTime, + status: convo.status, + type: 'remote_command_end', + final_thread: convo.thread, + bot_type: bot.type, + }; + controller.trigger('stats:remote_command_end', message); + return statsAPI(bot, { + method: 'post', + form: data, + }, message); + + }); + + } + +}; diff --git a/lib/Teams.js b/lib/Teams.js new file mode 100644 index 000000000..8128fd934 --- /dev/null +++ b/lib/Teams.js @@ -0,0 +1,543 @@ +var Botkit = require(__dirname + '/CoreBot.js'); +var express = require('express'); +var bodyParser = require('body-parser'); +var querystring = require('querystring'); +var request = require('requestretry'); +var clone = require('clone'); +var async = require('async'); +var TeamsAPI = require(__dirname + '/TeamsAPI.js'); + +function TeamsBot(configuration) { + var controller = Botkit(configuration || {}); + + controller.api = TeamsAPI(configuration || {}); + + controller.api.getToken(function(err) { + if (err) { + // this is a fatal error - could not create a Teams API client + throw new Error(err); + } + }); + + + controller.defineBot(function(botkit, config) { + var bot = { + type: 'teams', + botkit: botkit, + config: config || {}, + utterances: botkit.utterances + }; + + bot.startConversation = function(message, cb) { + botkit.startConversation(this, message, cb); + }; + + bot.createConversation = function(message, cb) { + botkit.createConversation(this, message, cb); + }; + + + bot.channelLink = function(channel_info) { + return '' + channel_info.name + ''; + }; + + + bot.startPrivateConversation = function(message, cb) { + + bot.createPrivateConversation(message, function(err, new_convo) { + + if (err) { + cb(err); + } else { + new_convo.activate(); + cb(null, new_convo); + } + + }); + + }; + + bot.createPrivateConversation = function(message, cb) { + + bot.openPrivateConvo(message, function(err, new_convo) { + if (err) { + cb(err); + } else { + message.raw_message.conversation = new_convo; + bot.createConversation(message, cb); + } + }); + + }; + + bot.openPrivateConvo = function(src, cb) { + + var data = { + bot: src.recipient, + members: [src.raw_message.from], + channelData: src.channelData, + }; + + bot.api.createConversation(data, cb); + + }; + + bot.openConvo = function(src, members, cb) { + + var data = { + isGroup: true, + bot: src.recipient, + members: members, + channelData: src.channelData, + }; + + bot.api.createConveration(data, cb); + }; + + + bot.send = function(message, cb) { + bot.api.addMessageToConversation(message.conversation.id, message, cb); + }; + + bot.replyWithActivity = function(src, message, cb) { + + var data = { + type: 'message', + recipient: src.raw_message.from, + from: src.raw_message.recipient, + conversation: src.conversation, + channelData: { + notification: { + alert: true + } + }, + text: message.text, + summary: message.summary, + attachments: message.attachments || null, + attachmentLayout: message.attachmentLayout || 'list', + }; + + bot.api.addMessageToConversation(src.conversation.id, data, cb); + }; + + + bot.replyToComposeExtension = function(src, attachments, cb) { + + // attachments will be an array of attachments + // need to wrap it in necessary stuff + var resp = { + composeExtension: { + type: 'result', + attachmentLayout: 'list', + attachments: attachments, + } + }; + + src.http_res.send(resp); + if (cb) { + cb(); + } + }; + + bot.replyInThread = function(src, resp, cb) { + + // can't clone theis, not needed for this type of messages. + delete(src.http_res); + var copy = clone(src); + + // make sure this does NOT include the activity id + copy.raw_message.conversation = src.raw_message.channelData.channel; + + bot.reply(copy, resp, cb); + + }; + + bot.reply = function(src, resp, cb) { + if (src.type === 'composeExtension') { + bot.replyToComposeExtension(src, resp, cb); + } + if (typeof resp == 'string') { + resp = { + text: resp + }; + } + + resp.serviceUrl = src.raw_message.serviceUrl; + resp.from = src.raw_message.recipient; + resp.recipient = src.raw_message.from; + resp.to = src.user; + resp.channel = src.channel; + resp.conversation = src.raw_message.conversation; + + bot.say(resp, cb); + }; + + bot.findConversation = function(message, cb) { + botkit.debug('CUSTOM FIND CONVO', message.user, message.channel); + for (var t = 0; t < botkit.tasks.length; t++) { + for (var c = 0; c < botkit.tasks[t].convos.length; c++) { + if ( + botkit.tasks[t].convos[c].isActive() && + botkit.tasks[t].convos[c].source_message.user == message.user + ) { + botkit.debug('FOUND EXISTING CONVO!'); + cb(botkit.tasks[t].convos[c]); + return; + } + } + } + + cb(); + }; + + + /* helper functions for creating attachments */ + bot.createAttachment = function(type, title, subtitle, text, images, buttons, tap) { + + var obj = { + content: (typeof(title) === 'object') ? title : { + title: title || null, + subtitle: subtitle || null, + text: text || null, + buttons: buttons || [], + images: images || [], + tap: tap || null, + }, + contentType: 'application/vnd.microsoft.card.' + type, + title: function(v) { + this.content.title = v; + return this; + }, + subtitle: function(v) { + this.content.subtitle = v; + return this; + }, + text: function(v) { + this.content.text = v; + return this; + }, + button: function(type, title, payload) { + if (!this.content.buttons) { + this.content.buttons = []; + } + + var button_obj = (typeof(type) === 'object') ? type : { + type: type, + title: title, + payload: payload, + }; + + this.content.buttons.push(button_obj); + return this; + }, + image: function(url, alt) { + if (!this.content.images) { + this.content.images = []; + } + + var img_obj = (typeof(url) === 'object') ? type : { + url: url, + alt: alt || null + }; + + this.content.images.push(img_obj); + return this; + }, + tap: function(type, title, payload) { + var tap_action = (typeof(type) === 'object') ? type : { + type: type, + title: title, + payload: payload, + }; + + this.content.tap = tap_action; + + return this; + }, + asString: function() { + return JSON.stringify(this, null, 2); + } + }; + + return obj; + + }; + + bot.createHero = function(title, subtitle, text, buttons, images, tap) { + return bot.createAttachment('hero', title, subtitle, text, buttons, images, tap); + }; + + bot.createThumbnail = function(title, subtitle, text, buttons, images, tap) { + return bot.createAttachment('thumbnail', title, subtitle, text, buttons, images, tap); + }; + + return bot; + }); + + + controller.createWebhookEndpoints = function() { + controller.webserver.post('/teams/receive', function(req, res) { + + var message = req.body; + + var options = { + serviceUrl: message.serviceUrl, + }; + + if (message.channelData && message.channelData.team && message.channelData.team.id) { + options.team = message.channelData.team.id; + } + + var bot = controller.spawn(options); + + if (message.recipient) { + bot.identity = message.recipient; + } + + controller.ingest(bot, message, res); + + }); + }; + + controller.middleware.spawn.use(function(bot, next) { + + if (!bot.config.serviceUrl) { + throw new Error('Cannot spawn a bot without a serviceUrl in the configuration'); + } + + // set up the teams api client + bot.api = TeamsAPI({ + clientId: controller.config.clientId, + clientSecret: controller.config.clientSecret, + token: controller.config.token, + serviceUrl: bot.config.serviceUrl, + team: bot.config.team, + }); + + next(); + + }); + + controller.middleware.ingest.use(function(bot, message, res, next) { + + res.status(200); + if (message.name != 'composeExtension/query') { + // send a result back immediately + res.send(''); + } + + message.http_res = res; + next(); + + }); + + controller.middleware.normalize.use(function(bot, message, next) { + + message.user = message.raw_message.from.id; + message.channel = message.raw_message.conversation.id; + + next(); + + }); + + + controller.middleware.categorize.use(function(bot, message, next) { + + if (message.type === 'invoke' && message.name === 'composeExtension/query') { + message.type = 'composeExtension'; + + // teams only supports a single parameter, it either exists or doesn't! + message.text = message.value.parameters[0].value; + + } + + next(); + + }); + + + controller.middleware.categorize.use(function(bot, message, next) { + + if (message.type == 'conversationUpdate') { + + if (message.raw_message.membersAdded) { + // replies to these end up in the right place + for (var m = 0; m < message.raw_message.membersAdded.length; m++) { + + // clone the message + // and copy this member into the from list + delete(message.http_res); // <-- that can't be cloned safely + var copy = clone(message); + copy.from = message.raw_message.membersAdded[m]; + copy.user = copy.from.id; + + if (copy.user == message.raw_message.recipient.id) { + copy.type = 'bot_channel_join'; + } else { + copy.type = 'user_channel_join'; + } + + // restart the categorize process for the newly cloned messages + controller.categorize(bot, copy); + + } + + } else if (message.raw_message.membersRemoved) { + + // replies to these end up in the right place + for (var m = 0; m < message.raw_message.membersRemoved.length; m++) { + + // clone the message + // and copy this member into the from list + delete(message.http_res); // <-- that can't be cloned safely + var copy = clone(message); + copy.from = message.raw_message.membersRemoved[m]; + copy.user = copy.from.id; + + if (copy.user == message.raw_message.recipient.id) { + copy.type = 'bot_channel_leave'; + } else { + copy.type = 'user_channel_leave'; + } + + // restart the categorize process for the newly cloned messages + controller.categorize(bot, copy); + } + + next(); + } else if (message.raw_message.channelData && message.raw_message.channelData.eventType) { + // channelCreated + // channelDeleted + // channelRenamed + // teamRenamed + message.type = message.raw_message.channelData.eventType; + + // replies to these end up in general + next(); + } + + } else { + next(); + } + }); + + + controller.middleware.categorize.use(function(bot, message, next) { + + if (message.type == 'message') message.type = 'message_received'; + + if (!message.conversation.isGroup && message.type == 'message_received') { + message.type = 'direct_message'; + } else if (message.conversation.isGroup && message.type == 'message_received') { + + // start by setting this to a mention, meaning that the bot's name was _somewhere_ in the string + message.type = 'mention'; + + // check to see if this is a direct mention ,meaning bot was mentioned at start of string + for (var e = 0; e < message.entities.length; e++) { + var entity = message.entities[0]; + if (entity.type == 'mention' && message.text) { + var pattern = new RegExp(message.recipient.id); + if (entity.mentioned.id.match(pattern)) { + var clean = new RegExp('^' + entity.text + '\\s+'); + + if (message.text.match(clean)) { + + message.text = message.text.replace(clean, ''); + message.type = 'direct_mention'; + } + } + } + } + } + + next(); + }); + + + // This middleware looks for Slack-style user mentions in a message + // <@USERID> and translates them into Microsoft Teams style mentions + // which look like @User Name and have a matching row in the + // message.entities field. + controller.middleware.send.use(function(bot, message, next) { + + var matches; + var uniques = []; + + // extract all the <@USERID> patterns + if (matches = message.text.match(/\<\@(.*?)\>/igm)) { + + // get a set of UNIQUE mentions - since the lookup of profile data is expensive + for (var m = 0; m < matches.length; m++) { + if (uniques.indexOf(matches[m]) == -1) { + uniques.push(matches[m]); + } + } + + // loop over each mention + async.each(uniques, function(match, next_match) { + + var uid = match.replace(/^\<\@/, '').replace(/\>$/, ''); + + // use the teams API to load the latest profile information for the user + bot.api.getUserById(message.channel, uid, function(err, user_profile) { + + // if user is valid, replace the Slack-style mention and append to entities list + if (user_profile) { + var pattern = new RegExp('<@' + uid + '>', 'g'); + message.text = message.text.replace(pattern, '@' + user_profile.name + ''); + + if (!message.entities) { + message.entities = []; + } + + message.entities.push({ + type: 'mention', + mentioned: { + id: uid, + name: user_profile.name, + }, + text: '@' + user_profile.name + '', + }); + } + + next_match(); + + }); + + }, function() { + + // we've processed all the matches, continue + next(); + + }); + + } else { + + // if there were no matches, continue + next(); + + } + + }); + + controller.middleware.format.use(function(bot, message, platform_message, next) { + + platform_message.type = 'message'; + platform_message.recipient = message.recipient; + platform_message.from = message.from; + platform_message.text = message.text; + platform_message.textFormat = 'markdown'; + platform_message.entities = message.entities; + platform_message.attachments = message.attachments || null; + platform_message.attachmentLayout = message.attachmentLayout || 'list'; + platform_message.conversation = message.conversation; + + next(); + }); + + controller.startTicking(); + return controller; +} + +module.exports = TeamsBot; diff --git a/lib/TeamsAPI.js b/lib/TeamsAPI.js new file mode 100644 index 000000000..8c4128103 --- /dev/null +++ b/lib/TeamsAPI.js @@ -0,0 +1,176 @@ +var request = require('requestretry'); + +module.exports = function(configuration) { + + var api = { + + request: function(options, cb) { + + if (!options.headers) { + options.headers = { + 'content-type': 'application/json', + Authorization: 'Bearer ' + configuration.token + }; + } + + request(options, function(err, res, body) { + + if (err && cb) { + return cb(err); + } + if (!body) { + if (cb) { return cb('Error parsing json response'); } + } + + if (body.error) { + if (cb) { return cb(body.error); } + } + + if (cb) { cb(null, body); } + + }); + }, + createConversation: function(data, cb) { + var uri = configuration.serviceUrl + 'v3/conversations'; + api.request({ + method: 'POST', + json: true, + body: data, + uri: uri + }, cb); + }, + updateMessage: function(conversationId, messageId, replacement, cb) { + + var uri = configuration.serviceUrl + 'v3/conversations/' + encodeURIComponent(conversationId) + '/activities/' + encodeURIComponent(messageId); + + api.request( + { + method: 'PUT', + json: true, + body: replacement, + uri: uri + }, cb + ); + + }, + addMessageToConversation: function(conversationId, message, cb) { + + var uri = configuration.serviceUrl + 'v3/conversations/' + conversationId + '/activities'; + + api.request( + { + method: 'POST', + json: true, + body: message, + uri: uri + }, cb); + + }, + getChannels: function(teamId, cb) { + + var uri = configuration.serviceUrl + 'v3/teams/' + teamId + '/conversations/'; + api.request({ + method: 'GET', + json: true, + uri: uri + }, function(err, list) { + if (err) { + cb(err); + } else { + for (var c = 0; c < list.conversations.length; c++) { + if (list.conversations[c].id == teamId) { + list.conversations[c].name = 'General'; + } + } + cb(null, list.conversations); + } + }); + + }, + getUserById: function(conversationId, userId, cb) { + api.getTeamRoster(conversationId, function(err, roster) { + if (err) { + return cb(err); + } else { + for (var u = 0; u < roster.length; u++) { + if (roster[u].id == userId) { + return cb(null, roster[u]); + } + } + } + + cb('User not found'); + + }); + }, + getUserByUpn: function(conversationId, upn, cb) { + api.getTeamRoster(conversationId, function(err, roster) { + if (err) { + return cb(err); + } else { + for (var u = 0; u < roster.length; u++) { + if (roster[u].userPrincipalName == upn) { + return cb(null, roster[u]); + } + } + } + cb('User not found'); + }); + }, + getConversationMembers: function(conversationId, cb) { + + var uri = configuration.serviceUrl + 'v3/conversations/' + conversationId + '/members/'; + api.request({ + method: 'GET', + json: true, + uri: uri + }, cb); + + }, + getTeamRoster: function(conversation_id, cb) { + + var uri = configuration.serviceUrl + 'v3/conversations/' + conversation_id + '/members/'; + api.request({ + method: 'GET', + json: true, + uri: uri + }, cb); + + }, + getToken: function(cb) { + request( + { + uri: 'https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token', + form: { + grant_type: 'client_credentials', + client_id: configuration.clientId, + client_secret: configuration.clientSecret, + scope: 'https://api.botframework.com/.default' + } + }, + function(err, res, body) { + if (err) { + cb(err); + } else { + var json = null; + try { + var json = JSON.parse(body); + } catch (err) { + return cb(err); + } + if (json.error) { + return cb(json.error_description); + } + configuration.token = json.access_token; + cb(null); + } + } + ); + }, + + + }; + + return api; + +}; diff --git a/lib/TwilioIPMBot.js b/lib/TwilioIPMBot.js new file mode 100644 index 000000000..dde9da8f6 --- /dev/null +++ b/lib/TwilioIPMBot.js @@ -0,0 +1,367 @@ +var Botkit = require(__dirname + '/CoreBot.js'); +var request = require('request'); +var twilio = require('twilio'); +var async = require('async'); + +var AccessToken = twilio.AccessToken; +var IpMessagingGrant = AccessToken.IpMessagingGrant; + +function Twiliobot(configuration) { + + // Create a core botkit bot + var twilio_botkit = Botkit(configuration || {}); + + // customize the bot definition, which will be used when new connections + // spawn! + twilio_botkit.defineBot(function(botkit, config) { + var bot = { + type: 'twilioipm', + botkit: botkit, + config: config || {}, + utterances: botkit.utterances, + }; + + bot.send = function(message, cb) { + botkit.debug('SEND ', message); + + if (bot.identity === null || bot.identity === '') { + bot.api.channels(message.channel).messages.create({ + body: message.text, + }).then(function(response) { + cb(null, response); + }).catch(function(err) { + cb(err); + }); + } else { + bot.api.channels(message.channel).messages.create({ + body: message.text, + from: bot.identity + }).then(function(response) { + cb(null, response); + }).catch(function(err) { + cb(err); + }); + } + }; + + bot.reply = function(src, resp, cb) { + var msg = {}; + + if (typeof(resp) == 'string') { + msg.text = resp; + } else { + msg = resp; + } + + msg.user = src.user; + msg.channel = src.channel; + + bot.say(msg, cb); + }; + + bot.autoJoinChannels = function() { + bot.api.channels.list().then(function(full_channel_list) { + if (bot.config.autojoin === true) { + bot.channels = full_channel_list; + bot.channels.channels.forEach(function(chan) { + bot.api.channels(chan.sid).members.create({ + identity: bot.identity + }).then(function(response) { + botkit.debug('added ' + + bot.identity + ' as a member of the ' + chan.friendly_name); + }).fail(function(error) { + botkit.debug('Couldn\'t join the channel: ' + + chan.friendly_name + ': ' + error); + }); + }); + } else if (bot.identity) { + + // load up a list of all the channels that the bot is currently + + bot.channels = { + channels: [] + }; + + async.each(full_channel_list.channels, function(chan, next) { + bot.api.channels(chan.sid).members.list().then(function(members) { + for (var x = 0; x < members.members.length; x++) { + if (members.members[x].identity == bot.identity) { + bot.channels.channels.push(chan); + } + } + next(); + }).fail(function(error) { + botkit.log('Error loading channel member list: ', error); + next(); + }); + }); + } + }).fail(function(error) { + botkit.log('Error loading channel list: ' + error); + // fails if no channels exist + // set the channels to empty + bot.channels = { channels: [] }; + }); + + }; + + bot.configureBotIdentity = function() { + if (bot.identity !== null || bot.identity !== '') { + var userRespIter = 0; + var existingIdentity = null; + + // try the get by identity thing + bot.api.users(bot.identity).get().then(function(response) { + bot.autoJoinChannels(); + }).fail(function(error) { + // if not make the new user and see if they need to be added to all the channels + bot.api.users.create({ + identity: bot.identity + }).then(function(response) { + bot.autoJoinChannels(); + }).fail(function(error) { + botkit.log('Could not get Bot Identity:'); + botkit.log(error); + process.exit(1); + }); + }); + } + }; + + /** + * This handles the particulars of finding an existing conversation or + * topic to fit the message into... + */ + bot.findConversation = function(message, cb) { + botkit.debug('CUSTOM FIND CONVO', message.user, message.channel); + for (var t = 0; t < botkit.tasks.length; t++) { + for (var c = 0; c < botkit.tasks[t].convos.length; c++) { + if ( + botkit.tasks[t].convos[c].isActive() && + botkit.tasks[t].convos[c].source_message.user == message.user && + botkit.tasks[t].convos[c].source_message.channel == message.channel + ) { + botkit.debug('FOUND EXISTING CONVO!'); + cb(botkit.tasks[t].convos[c]); + return; + } + } + } + + cb(); + }; + + + bot.client = new twilio.IpMessagingClient(config.TWILIO_ACCOUNT_SID, config.TWILIO_AUTH_TOKEN); + bot.api = bot.client.services(config.TWILIO_IPM_SERVICE_SID); + + if (config.identity) { + bot.identity = config.identity; + bot.configureBotIdentity(); + } + + return bot; + + }); + + // set up a web route for receiving outgoing webhooks and/or slash commands + twilio_botkit.createWebhookEndpoints = function(webserver, bot) { + + twilio_botkit.log( + '** Serving webhook endpoints for receiving messages ' + + 'webhooks at: http://' + twilio_botkit.config.hostname + ':' + + twilio_botkit.config.port + '/twilio/receive'); + webserver.post('/twilio/receive', function(req, res) { + + res.status(200); + res.send('ok'); + twilio_botkit.handleWebhookPayload(req, res, bot); + + }); + + twilio_botkit.startTicking(); + + return twilio_botkit; + }; + + twilio_botkit.handleWebhookPayload = function(req, res, bot) { + // ensure all messages + // have a user & channel + var message = req.body; + if (req.body.EventType == 'onMessageSent') { + + // customize fields to be compatible with Botkit + message.text = req.body.Body; + message.from = req.body.From; + message.to = req.body.To; + message.user = req.body.From; + message.channel = req.body.ChannelSid; + + twilio_botkit.receiveMessage(bot, message); + + }else if (req.body.EventType == 'onChannelAdded' || req.body.EventType == 'onChannelAdd') { + // this event has a channel sid but not a user + message.channel = req.body.ChannelSid; + twilio_botkit.trigger(req.body.EventType, [bot, message]); + + }else if (req.body.EventType == 'onChannelDestroyed' || req.body.EventType == 'onChannelDestroy') { + // this event has a channel sid but not a user + message.channel = req.body.ChannelSid; + twilio_botkit.trigger(req.body.EventType, [bot, message]); + + }else if (req.body.EventType == 'onMemberAdded' || req.body.EventType == 'onMemberAdd') { + // should user be MemberSid the The Member Sid of the newly added Member + message.user = req.body.Identity; + message.channel = req.body.ChannelSid; + twilio_botkit.trigger(req.body.EventType, [bot, message]); + } else if (req.body.EventType == 'onMemberRemoved' || req.body.EventType == 'onMemberRemove') { + message.user = req.body.Identity; + message.channel = req.body.ChannelSid; + twilio_botkit.trigger(req.body.EventType, [bot, message]); + + if (req.body.EventType == 'onMemberRemoved') { + + } + } else { + twilio_botkit.trigger(req.body.EventType, [bot, message]); + } + }; + + // handle events here + twilio_botkit.handleTwilioEvents = function() { + twilio_botkit.log('** Setting up custom handlers for processing Twilio messages'); + twilio_botkit.on('message_received', function(bot, message) { + + + + if (bot.identity && message.from == bot.identity) { + return false; + } + + if (!message.text) { + // message without text is probably an edit + return false; + } + + if (bot.identity) { + var channels = bot.channels.channels; + + // if its not in a channel with the bot + var apprChan = channels.filter(function(ch) { + return ch.sid == message.channel; + }); + + if (apprChan.length === 0) { + return false; + } + } + }); + + + // if a member is removed from a channel, check to see if it matches the bot's identity + // and if so remove it from the list of channels the bot listens to + twilio_botkit.on('onMemberRemoved', function(bot, message) { + if (bot.identity && message.user == bot.identity) { + // remove that channel from bot.channels.channels + var chan_to_rem = bot.channels.channels.map(function(ch) { + return ch.sid; + }).indexOf(message.channel); + + if (chan_to_rem != -1) { + bot.channels.channels.splice(chan_to_rem, 1); + twilio_botkit.debug('Unsubscribing from channel because of memberremove.'); + + } + } else if (bot.identity) { + var channels = bot.channels.channels; + + // if its not in a channel with the bot + var apprChan = channels.filter(function(ch) { + return ch.sid == message.channel; + }); + + if (apprChan.length === 0) { + return false; + } + } + + if (bot.identity && bot.identity == message.user) { + twilio_botkit.trigger('bot_channel_leave', [bot, message]); + } else { + twilio_botkit.trigger('user_channel_leave', [bot, message]); + } + }); + + twilio_botkit.on('onMemberAdded', function(bot, message) { + if (bot.identity && message.user == bot.identity) { + bot.api.channels(message.channel).get().then(function(response) { + bot.channels.channels.push(response); + twilio_botkit.debug('Subscribing to channel because of memberadd.'); + + }).fail(function(error) { + botkit.log(error); + }); + } else if (bot.identity) { + var channels = bot.channels.channels; + + // if its not in a channel with the bot + var apprChan = channels.filter(function(ch) { + return ch.sid == message.channel; + }); + + if (apprChan.length === 0) { + return false; + } + } + + if (bot.identity && bot.identity == message.user) { + twilio_botkit.trigger('bot_channel_join', [bot, message]); + } else { + twilio_botkit.trigger('user_channel_join', [bot, message]); + } + + }); + + + // if a channel is destroyed, remove it from the list of channels this bot listens to + twilio_botkit.on('onChannelDestroyed', function(bot, message) { + if (bot.identity) { + var chan_to_rem = bot.channels.channels.map(function(ch) { + return ch.sid; + }).indexOf(message.channel); + if (chan_to_rem != -1) { + bot.channels.channels.splice(chan_to_rem, 1); + twilio_botkit.debug('Unsubscribing from destroyed channel.'); + } + } + }); + + // if a channel is created, and the bot is set in autojoin mode, join the channel + twilio_botkit.on('onChannelAdded', function(bot, message) { + if (bot.identity && bot.config.autojoin === true) { + // join the channel + bot.api.channels(message.channel).members.create({ + identity: bot.identity + }).then(function(response) { + bot.api.channels(message.channel).get().then(function(response) { + bot.channels.channels.push(response); + twilio_botkit.debug('Subscribing to new channel.'); + + }).fail(function(error) { + botkit.log(error); + }); + }).fail(function(error) { + botkit.log(error); + }); + } + }); + + }; + + twilio_botkit.handleTwilioEvents(); + + return twilio_botkit; + +} + +module.exports = Twiliobot; diff --git a/lib/TwilioSMSBot.js b/lib/TwilioSMSBot.js new file mode 100644 index 000000000..5a7b92d1b --- /dev/null +++ b/lib/TwilioSMSBot.js @@ -0,0 +1,183 @@ +var path = require('path'); +var os = require('os'); +var Botkit = require('./CoreBot'); +var express = require('express'); +var bodyParser = require('body-parser'); +var twilio = require('twilio'); + +function TwilioSMS(configuration) { + + var twilioSMS = Botkit(configuration || {}); + + if (!configuration) { + throw Error('Specify your \'account_sid\', \'auth_token\', and ' + + '\'twilio_number\' as properties of the \'configuration\' object'); + } + + if (configuration && !configuration.account_sid) { + throw Error('Specify an \'account_sid\' in your configuration object'); + } + + if (configuration && !configuration.auth_token) { + throw Error('Specify an \'auth_token\''); + } + + if (configuration && !configuration.twilio_number) { + throw Error('Specify a \'twilio_number\''); + } + + twilioSMS.defineBot(function(botkit, config) { + + var bot = { + type: 'twiliosms', + botkit: botkit, + config: config || {}, + utterances: botkit.utterances + }; + + bot.startConversation = function(message, cb) { + botkit.startConversation(bot, message, cb); + }; + + bot.createConversation = function(message, cb) { + botkit.createConversation(bot, message, cb); + }; + + + bot.send = function(sms, cb) { + + var client = new twilio.RestClient( + configuration.account_sid, + configuration.auth_token + ); + + + client.messages.create(sms, function(err, message) { + + if (err) { + cb(err); + } else { + cb(null, message); + } + + }); + + }; + + bot.reply = function(src, resp, cb) { + var msg = {}; + + if (typeof resp === 'string') { + msg.text = resp; + } else { + msg = resp; + } + + msg.channel = src.channel; + + if (typeof cb === 'function') { + bot.say(msg, cb); + } else { + bot.say(msg, function() {}); + } + + }; + + bot.findConversation = function(message, cb) { + + botkit.debug('CUSTOM FIND CONVO', message.user, message.channel); + + for (var t = 0; t < botkit.tasks.length; t++) { + for (var c = 0; c < botkit.tasks[t].convos.length; c++) { + + var convo = botkit.tasks[t].convos[c]; + var matchesConvo = ( + convo.source_message.channel === message.channel || + convo.source_message.user === message.user + ); + + if (convo.isActive() && matchesConvo) { + botkit.debug('FOUND EXISTING CONVO!'); + cb(botkit.tasks[t].convos[c]); + return; + } + + } + } + + cb(); + }; + + return bot; + }); + + twilioSMS.handleWebhookPayload = function(req, res, bot) { + + twilioSMS.log('=> Got a message hook'); + + var payload = req.body; + twilioSMS.ingest(bot, payload, res); + + }; + + twilioSMS.middleware.normalize.use(function(bot, message, next) { + + message.text = message.Body; + message.user = message.From; + message.channel = message.From; + + + message.from = message.From; + message.to = message.To; + + message.timestamp = Date.now(); + message.sid = message.MessageSid; + + next(); + + }); + + twilioSMS.middleware.format.use(function(bot, message, platform_message, next) { + + platform_message.body = message.text; + platform_message.from = configuration.twilio_number; + platform_message.to = message.channel; + + if (message.hasOwnProperty('medaUrl')) { + platform_message.mediaUrl = message.mediaUrl; + } + + next(); + + }); + + + + // set up a web route for receiving outgoing webhooks + twilioSMS.createWebhookEndpoints = function(webserver, bot, cb) { + + twilioSMS.log('** Serving webhook endpoints for Twilio Programmable SMS' + + ' at: ' + os.hostname() + ':' + twilioSMS.config.port + '/sms/receive'); + + var endpoint = twilioSMS.config.endpoint || '/sms/receive'; + + webserver.post(endpoint, function(req, res) { + twilioSMS.handleWebhookPayload(req, res, bot); + + // Send empty TwiML response to Twilio + var twiml = new twilio.TwimlResponse(); + res.type('text/xml'); + res.send(twiml.toString()); + }); + + if (cb) cb(); + + return twilioSMS; + }; + + twilioSMS.startTicking(); + + return twilioSMS; +} + +module.exports = TwilioSMS; diff --git a/lib/middleware/slack_authentication.js b/lib/middleware/slack_authentication.js new file mode 100644 index 000000000..8eccb11e0 --- /dev/null +++ b/lib/middleware/slack_authentication.js @@ -0,0 +1,73 @@ +/** + * Authentication module composed of an Express middleware used to validate + * incoming requests from the Slack API for Slash commands and outgoing + * webhooks. + */ + +// Comparison constant +var TOKEN_NOT_FOUND = -1; + +function init(tokens) { + var authenticationTokens = flatten(tokens); + + if (authenticationTokens.length === 0) { + console.warn('No auth tokens provided, webhook endpoints will always reply HTTP 401.'); + } + + /** + * Express middleware that verifies a Slack token is passed; + * if the expected token value is not passed, end with request test + * with a 401 HTTP status code. + * + * Note: Slack is totally wacky in that the auth token is sent in the body + * of the request instead of a header value. + * + * @param {object} req - Express request object + * @param {object} res - Express response object + * @param {function} next - Express callback + */ + function authenticate(req, res, next) { + var token = getToken(req.body); + if (!token || authenticationTokens.indexOf(token) === TOKEN_NOT_FOUND) { + res.status(401).send({ + 'code': 401, + 'message': 'Unauthorized' + }); + + return; + } + + next(); + } + + return authenticate; +} +/** + * Function that flattens a series of arguments into an array. + * + * @param {Array} args - No token (null), single token (string), or token array (array) + * @returns {Array} - Every element of the array is an authentication token + */ +function flatten(args) { + var result = []; + + // convert a variable argument list to an array + args.forEach(function(arg) { + result = result.concat(arg); + }); + return result; +} +module.exports = init; + +function getToken(body) { + if (!body) return null; + if (body.token) return body.token; + if (!body.payload) return null; + + var payload = body.payload; + if (typeof payload === 'string') { + payload = JSON.parse(payload); + } + + return payload.token; +} diff --git a/lib/storage/firebase_storage.js b/lib/storage/firebase_storage.js deleted file mode 100644 index 7bf320c98..000000000 --- a/lib/storage/firebase_storage.js +++ /dev/null @@ -1,100 +0,0 @@ -/* -Firebase storage module for bots. - -Note that this storage module does not specify how to authenticate to Firebase. -There are many methods of user authentication for Firebase. -Please read: https://www.firebase.com/docs/web/guide/user-auth.html - -Supports storage of data on a team-by-team, user-by-user, and chnnel-by-channel basis. - -save can be used to store arbitrary object. -These objects must include an id by which they can be looked up. -It is recommended to use the team/user/channel id for this purpose. -Example usage of save: -controller.storage.teams.save({id: message.team, foo:"bar"}, function(err){ - if (err) - console.log(err)` -}); - -get looks up an object by id. -Example usage of get: -controller.storage.teams.get(message.team, function(err, team_data){ - if (err) - console.log(err) - else - console.log(team_data) -}); -*/ - -var Firebase = require('firebase'); - -module.exports = function(config) { - - if (!config && !config.firebase_uri) - throw new Error('Need to provide firebase address. This should look something like ' + - '"https://botkit-example.firebaseio.com/"'); - - var rootRef = new Firebase(config.firebase_uri); - var teamsRef = rootRef.child('teams'); - var usersRef = rootRef.child('users'); - var channelsRef = rootRef.child('channels'); - - var get = function(firebaseRef) { - return function(id, cb) { - firebaseRef.child(id).once('value', - function(records) { - cb(undefined, records.val()); - }, - function(err) { - cb(err, undefined); - } - ); - }; - }; - - var save = function(firebaseRef) { - return function(data, cb) { - var firebase_update = {}; - firebase_update[data.id] = data; - firebaseRef.update(firebase_update, cb); - }; - }; - - var all = function(firebaseRef) { - return function(cb) { - firebaseRef.once('value', - function(records) { - var list = []; - for (key of Object.keys(records.val())) { - list.push(records.val()[key]); - } - cb(undefined, list); - }, - function(err) { - cb(err, undefined); - } - ); - }; - }; - - var storage = { - teams: { - get: get(teamsRef), - save: save(teamsRef), - all: all(teamsRef) - }, - channels: { - get: get(channelsRef), - save: save(channelsRef), - all: all(channelsRef) - }, - users: { - get: get(usersRef), - save: save(usersRef), - all: all(usersRef) - } - }; - - return storage; - -}; diff --git a/lib/storage/redis_storage.js b/lib/storage/redis_storage.js deleted file mode 100644 index 74fb07132..000000000 --- a/lib/storage/redis_storage.js +++ /dev/null @@ -1,64 +0,0 @@ -var redis = require('redis'); //https://github.com/NodeRedis/node_redis - -/* - * All optional - * - * config = { - * namespace: namespace, - * host: host, - * port: port - * } - * // see - * https://github.com/NodeRedis/node_redis - * #options-is-an-object-with-the-following-possible-properties for a full list of the valid options - */ -module.exports = function(config) { - config = config || {}; - config.namespace = config.namespace || 'botkit:store'; - - var storage = {}, - client = redis.createClient(config), // could pass specific redis config here - methods = config.methods || ['teams', 'users', 'channels']; - - // Implements required API methods - for (var i = 0; i < methods.length; i++) { - storage[methods[i]] = function(hash) { - return { - get: function(id, cb) { - client.hget(config.namespace + ':' + hash, id, function(err, res) { - cb(err, JSON.parse(res)); - }); - }, - save: function(object, cb) { - if (!object.id) // Silently catch this error? - return cb(new Error('The given object must have an id property'), {}); - client.hset(config.namespace + ':' + hash, object.id, JSON.stringify(object), cb); - }, - all: function(cb, options) { - client.hgetall(config.namespace + ':' + hash, function(err, res) { - if (err) - return cb(err, {}); - - if (null === res) - return cb(err, res); - - var parsed; - var array = []; - - for (var i in res) { - parsed = JSON.parse(res[i]); - res[i] = parsed; - array.push(parsed); - } - - cb(err, options && options.type === 'object' ? res : array); - }); - }, - allById: function(cb) { - this.all(cb, {type: 'object'}); - } - }; - }(methods[i]); - } - return storage; -}; diff --git a/lib/storage/simple_storage.js b/lib/storage/simple_storage.js index f91063e33..fa6c6e01e 100755 --- a/lib/storage/simple_storage.js +++ b/lib/storage/simple_storage.js @@ -56,6 +56,9 @@ module.exports = function(config) { save: function(team_data, cb) { teams_db.save(team_data.id, team_data, cb); }, + delete: function(team_id, cb) { + teams_db.delete(team_id, cb); + }, all: function(cb) { teams_db.all(objectsToList(cb)); } @@ -67,6 +70,9 @@ module.exports = function(config) { save: function(user, cb) { users_db.save(user.id, user, cb); }, + delete: function(user_id, cb) { + users_db.delete(user_id, cb); + }, all: function(cb) { users_db.all(objectsToList(cb)); } @@ -78,6 +84,9 @@ module.exports = function(config) { save: function(channel, cb) { channels_db.save(channel.id, channel, cb); }, + delete: function(channel_id, cb) { + channels_db.delete(channel_id, cb); + }, all: function(cb) { channels_db.all(objectsToList(cb)); } diff --git a/lib/storage/storage_test.js b/lib/storage/storage_test.js index 45a7c202d..5a868b2ec 100644 --- a/lib/storage/storage_test.js +++ b/lib/storage/storage_test.js @@ -1,6 +1,6 @@ /* Tests for storage modules. -This file currently test simple_storage.js and mongo_storage.js. +This file currently test simple_storage.js, redis_storage, and firebase_storage. If you build a new storage module, you must add it to this test file before your PR will be considered. @@ -30,6 +30,10 @@ var testStorageMethod = function(storageMethod) { console.log(data); test.assert(data.foo === testObj0.foo); }); + storageMethod.get('shouldnt-be-here', function(err, data) { + test.assert(err.displayName === 'NotFound'); + test.assert(!data); + }); storageMethod.all(function(err, data) { test.assert(!err); console.log(data); diff --git a/package.json b/package.json index 78d8d3a00..2c6556137 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,47 @@ { "name": "botkit", - "version": "0.0.5", + "version": "0.6.0", "description": "Building blocks for Building Bots", "main": "lib/Botkit.js", + "types": "lib/Botkit.d.ts", "dependencies": { - "body-parser": "^1.14.2", - "express": "^4.13.3", + "async": "^2.1.5", + "back": "^1.0.1", + "body-parser": "^1.17.1", + "botbuilder": "^3.8.4", + "botkit-studio-sdk": "^1.0.2", + "ciscospark": "1.8.0", + "clone": "2.1.1", + "command-line-args": "^4.0.2", + "crypto": "0.0.3", + "debug": "^2.6.6", + "express": "^4.15.2", + "https-proxy-agent": "^2.0.0", "jfs": "^0.2.6", - "mustache": "^2.2.1", - "request": "^2.67.0", - "ws": "^1.0.0" + "localtunnel": "^1.8.2", + "md5": "^2.2.1", + "mustache": "^2.3.0", + "promise": "^8.0.0", + "request": "^2.81.0", + "requestretry": "^1.12.0", + "twilio": "^2.11.1", + "ware": "^1.3.0", + "ws": "^2.2.2" }, "devDependencies": { - "jscs": "^2.7.0", - "node-env-file": "^0.1.8", - "should": "^8.0.2", - "tap-spec": "^4.1.1", - "tape": "^4.4.0", - "winston": "^2.1.1" + "jest-cli": "^20.0.1", + "jscs": "^3.0.7", + "mocha": "^3.2.0", + "should": "^11.2.1", + "should-sinon": "0.0.5", + "sinon": "^2.1.0", + "winston": "^2.3.1" }, "scripts": { - "pretest": "jscs ./lib/", - "test": "mocha tests/*.js" + "pretest": "jscs ./lib/ ./__test__ --fix", + "test": "jest --coverage", + "test-legacy": "mocha ./tests/*.js", + "test-watch": "jest --watch" }, "repository": { "type": "git", @@ -34,8 +54,15 @@ "keywords": [ "bots", "chatbots", - "slack" + "slack", + "cisco spark", + "facebook messenger", + "twilio ipm", + "microsoft bot framework" ], - "author": "ben@xoxco.com", - "license": "MIT" + "author": "ben@howdy.ai", + "license": "MIT", + "jest": { + "testEnvironment": "node" + } } diff --git a/readme.md b/readme.md old mode 100755 new mode 100644 index 5b9ebe807..d6c52514e --- a/readme.md +++ b/readme.md @@ -1,1130 +1,197 @@ -# [Botkit](http://howdy.ai/botkit) - Building Blocks for Building Bots +# [Botkit](https://botkit.ai) - Building Blocks for Building Bots -Botkit designed to ease the process of designing and running useful, creative or -just plain weird bots (and other types of applications) that live inside [Slack](http://slack.com)! +[![npm](https://img.shields.io/npm/v/botkit.svg)](https://www.npmjs.com/package/botkit) +[![David](https://img.shields.io/david/howdyai/botkit.svg)](https://david-dm.org/howdyai/botkit) +[![npm](https://img.shields.io/npm/l/botkit.svg)](https://spdx.org/licenses/MIT) +[![bitHound Overall Score](https://www.bithound.io/github/howdyai/botkit/badges/score.svg)](https://www.bithound.io/github/howdyai/botkit) -It provides a semantic interface to sending and receiving messages -so that developers can focus on creating novel applications and experiences -instead of dealing with API endpoints. +Botkit is designed to ease the process of designing and running useful, creative bots that live inside messaging platforms. +Bots are applications that can send and receive messages, and in many cases, appear alongside their human counterparts as _users._ -Botkit features a comprehensive set of tools -to deal with [Slack's integration platform](http://api.slack.com), and allows -developers to build both custom integrations for their -team, as well as public "Slack Button" applications that can be -run from a central location, and be used by many teams at the same time. +Some bots talk like people, others silently work in the background, while others present interfaces much like modern mobile applications. +Botkit gives developers the necessary tools for building bots of any kind! It provides an easy-to-understand interface for sending and receiving messages so that developers can focus on creating novel applications and experiences instead of dealing with API endpoints. -## Installation +Our goal with Botkit is to make bot building easy, fun, and accessible to anyone with the desire to create +a future filled with talking machines! We provide several tools to make this vision a reality: -Botkit is available via NPM. +* [Botkit Studio](#start-with-botkit-studio), an integrated development environment for designing and building bots +* [Starter Kits](#start-with-a-starter-kit), boilerplate applications pre-configured to work with popular platforms +* [Botkit Core Library](#botkit-core-library), an SDK for creating conversational software +* [Plugins and Middlewares](docs/readme-middlewares.md) that can extend and enhance your bot -```bash -npm install --save botkit -``` +Botkit features a comprehensive set of tools to deal with popular messaging platforms, including: -You can also check out Botkit directly from Git. -If you want to use the example code and included bots, it may be preferable to use Github over NPM. +* [Slack](docs/readme-slack.md) +* [Cisco Spark](docs/readme-ciscospark.md) +* [Microsoft Teams](docs/readme-teams.md) +* [Facebook Messenger and Facebook @Workplace](docs/readme-facebook.md) +* [Twilio SMS Messaging](docs/readme-twiliosms.md) +* [Twilio IP Messaging](docs/readme-twilioipm.md) +* [Microsoft Bot Framework](docs/readme-botframework.md) +* Yours? [info@howdy.ai](mailto:info@howdy.ai) -```bash -git clone git@github.com:howdyai/botkit.git -``` +--- -After cloning the Git repository, you have to install the node dependencies. Navigate to the root of your cloned repository and use npm to install all necessary dependencies. -```bash -npm install -``` +## [Start with Botkit Studio](https://studio.botkit.ai/signup) -Use the `--production` flag to skip the installation of devDependencies from Botkit. Useful if you just wish to run the example bot. -```bash -npm install --production -``` - - -## Getting Started +Botkit Studio is a hosted development environment for building bots with Botkit. +Developers using Botkit Studio get the full capabilities of Botkit, with the addition of many powerful bot-building features such as: -1) Install Botkit. See [Installation](#installation) instructions. +* All the code you need to get your bot online in minutes +* A visual authoring environment for designing and managing dialog +* A real-time message console for monitoring activity +* APIs that enable content and features to be added to bots without additional code +* Role-based, multi-user teams support +* Detailed usage statistics +* Built-in integrations with top plugins and platform tools -2) First make a bot integration inside of your Slack channel. Go here: +Click below to sign up for a free developer account, [and please contact us if you have any questions.](mailto:info@howdy.ai) -https://my.slack.com/services/new/bot +**[![Sign up for Botkit Studio](docs/studio.png)](https://studio.botkit.ai/signup?code=readme)** -Enter a name for your bot. -Make it something fun and friendly, but avoid a single task specific name. -Bots can do lots! Let's not pigeonhole them. -3) When you click "Add Bot Integration", you are taken to a page where you can add additional details about your bot, like an avatar, as well as customize its name & description. +## Start with a Starter Kit -Copy the API token that Slack gives you. You'll need it. +Based on the best practices we've established since the release of Botkit, our starter kits include +everything you need to bring a Botkit bot online in minutes. Don't start from scratch -- start with a +well structured, extensible application boilerplate! -4) Run the example bot app, using the token you just copied: -​ -``` -token=REPLACE_THIS_WITH_YOUR_TOKEN node bot.js -``` -​ -5) Your bot should be online! Within Slack, send it a quick direct message to say hello. It should say hello back! - -Try: - * who are you? - * call me Bob - * shutdown -​ +These starter kits are easy to set up and run on your own hosting service, but the fastest (and cheapest) way to get +started is to deploy directly to [Glitch](http://glitch.com), a free-to-use code editor and hosting system! -### Things to note -​ -Much like a vampire, a bot has to be invited into a channel. DO NOT WORRY bots are not vampires. +Note: While [using Botkit Studio](https://studio.botkit.ai) is highly recommended, these starter kits can be used without registering for Studio as well. -Type: `/invite @` to invite your bot into another channel. +> ### [Slack Bot Starter Kit](https://github.com/howdyai/botkit-starter-slack) +> The Slack starter kit contains everything you need to create a multi-team Slack application, +suitable for internal use or submission to [Slack's app store.](https://slack.com/apps) +> #### [![Remix on Glitch](docs/glitch.png)](https://glitch.com/~botkit-slack) +> ### [Cisco Spark Bot Starter Kit](https://github.com/howdyai/botkit-starter-ciscospark) +> Build a bot inside Cisco Spark's collaboration and messaging platform. Bots built with the starter kit +are ready to submit to [Cisco Spark's Depot app store](https://depot.ciscospark.com/). +> #### [![Remix on Glitch](docs/glitch.png)](https://glitch.com/~botkit-ciscospark) -## Core Concepts +> ### [Microsoft Teams Bot Starter Kit](https://github.com/howdyai/botkit-starter-teams) +> Connect your bot to Microsoft Teams, and it can do things like no other bot, like create tabs, compose extensions, and other deep integrations into the messaging UI. +> #### [![Remix on Glitch](docs/glitch.png)](https://glitch.com/~botkit-teams) -Bots built with Botkit have a few key capabilities, which can be used -to create clever, conversational applications. These capabilities -map to the way real human people talk to each other. +> ### [Facebook Bot Starter Kit](https://github.com/howdyai/botkit-starter-facebook) +> The Facebook starter kit contains all the code necessary to stand up a Facebook bot on either Facebook Messenger, or Facebook Work Chat. With just a few pieces of configuration, set up a bot that automatically responds to messages sent to your Facebook page. +> #### [![Remix on Glitch](docs/glitch.png)](https://glitch.com/~botkit-facebook) -Bots can [hear things](#receiving-messages). Bots can [say things and reply](#sending-messages) to what they hear. +# Developer & Support Community -With these two building blocks, almost any type of conversation can be created. +Join our thriving community of Botkit developers and bot enthusiasts at large. +Over 4500 members strong, [our open Slack group](http://community.botkit.ai) is +_the place_ for people interested in the art and science of making bots. +Come to ask questions, share your progress, and commune with your peers! -To organize the things a bot says and does into useful units, Botkit bots have a subsystem available for managing [multi-message conversations](#multi-message-replies-to-incoming-messages). Conversations add features like the ability to ask a question, queue several messages at once, and track when an interaction has ended. Handy! +You can also find help from members of the Botkit team [in our dedicated Cisco Spark room](https://eurl.io/#SyNZuomKx)! -After a bot has been told what to listen for and how to respond, -it is ready to be connected to a stream of incoming messages. Currently, Botkit can handle [3 different types of incoming messages from Slack](#connecting-your-bot-to-slack). +We also host a [regular meetup and annual conference called TALKABOT.](http://talkabot.ai) +Come meet and learn from other bot developers! [Full video of our 2016 event is available on Youtube.](https://www.youtube.com/playlist?list=PLD3JNfKLDs7WsEHSal2cfwG0Fex7A6aok) -## Basic Usage -Here's an example of using Botkit with Slack's [real time API](https://api.slack.com/rtm), which is the coolest one because your bot will look and act like a real user inside Slack. +# Botkit Core Library -This sample bot listens for the word "hello" to be said to it -- either as a direct mention ("@bot hello") or an indirect mention ("hello @bot") or a direct message (a private message inside Slack between the user and the bot). +Botkit is designed around the idea of giving developers a language-like interface for building bots. +Instead of dealing directly with messaging platform protocols and APIs, Botkit provides semantic functions +designed around the normal parts of human conversation: _hearing things_ and _saying things_. -The Botkit constructor returns a `controller` object. By attaching event handlers -to the controller object, developers can specify what their bot should look for and respond to, -including keywords, patterns and various [messaging and status events](#responding-to-events). -These event handlers can be thought of metaphorically as skills or features the robot brain has -- each event handler defines a new "When a human say THIS the bot does THAT." - -The `controller` object is then used to `spawn()` bot instances that represent -a specific bot identity and connection to Slack. Once spawned and connected to -the API, the bot user will appear online in Slack, and can then be used to -send messages and conduct conversations with users. They are called into action by the `controller` when firing event handlers. +On top of these basic build blocks, Botkit offers a powerful system for creating and managing dynamic +conversational interfaces, and tapping into cutting edge technology like artificial intelligence (AI) +and natural language understanding (NLP/NLU) tools. +Practically speaking, this results in code that looks like this: ```javascript -var Botkit = require('botkit'); - -var controller = Botkit.slackbot({ - debug: false - //include "log: false" to disable logging - //or a "logLevel" integer from 0 to 7 to adjust logging verbosity +// respond when a user sends a DM to the bot that says "hello" +controller.hears('hello', 'direct_message', function(bot, message) { + bot.reply(message, 'Hello human.'); }); - -// connect the bot to a stream of messages -controller.spawn({ - token: , -}).startRTM() - -// give the bot something to listen for. -controller.hears('hello',['direct_message','direct_mention','mention'],function(bot,message) { - - bot.reply(message,'Hello yourself.'); - -}); - ``` -## Included Examples - -These examples are included in the Botkit [Github repo](https://github.com/howdyai/botkit). - -[bot.js](https://github.com/howdyai/botkit/blob/master/bot.js) An example bot that can be connected to your team. Useful as a basis for creating your first bot! - -[examples/demo_bot.js](https://github.com/howdyai/botkit/blob/master/examples/demo_bot.js) another example bot that uses different ways to send and receive messages. - -[examples/slackbutton_bot.js](https://github.com/howdyai/botkit/blob/master/examples/slackbutton_bot.js) an example of using the Slack Button to offer a bot integration. - -[examples/slackbutton_incomingwebhooks.js](https://github.com/howdyai/botkit/blob/master/examples/slackbutton_incomingwebhooks.js) an example of using the Slack Button to offer an incoming webhook integration. This example also includes a simple form which allows you to broadcast a message to any team who adds the integration. - -# Developing with Botkit - -Table of Contents - -* [Connecting Your Bot To Slack](#connecting-your-bot-to-slack) -* [Receiving Messages](#receiving-messages) -* [Sending Messages](#sending-messages) -* [Working with Slack Integrations](#working-with-slack-integrations) -* [Advanced Topics](#advanced-topics) - -## Connecting Your Bot to Slack - -Bot users connect to Slack using a real time API based on web sockets. -The bot connects to Slack using the same protocol that the native Slack clients use! - -To connect a bot to Slack, [get a Bot API token from the Slack integrations page](https://my.slack.com/services/new/bot). - -Note: Since API tokens can be used to connect to your team's Slack, it is best practices to handle API tokens with caution. For example, pass tokens in to your application via evironment variable or command line parameter rather than include it in the code itself. -This is particularly true if you store and use API tokens on behalf of users other than yourself! - -[Read Slack's Bot User documentation](https://api.slack.com/bot-users) - -#### controller.spawn() -| Argument | Description -|--- |--- -| config | Incoming message object - -Spawn an instance of your bot and connect it to Slack. -This function takes a configuration object which should contain -at least one method of talking to the Slack API. - -To use the real time / bot user API, pass in a token, preferably via -an environment variable. - -Controllers can also spawn bots that use [incoming webhooks](#incoming-webhooks). - -#### bot.startRTM() -| Argument | Description -|--- |--- -| callback | _Optional_ Callback in the form function(err,bot,payload) { ... } - -Opens a connection to Slack's real time API. This connection will remain -open until it fails or is closed using `closeRTM()`. +All Botkit bots, built for any platform, use these same building blocks. This means developers are not required +to learn the intricacies of each platform, and can build bots that port easily between them. -The optional callback function receives: - -* Any error that occurred while connecting to Slack -* An updated bot object -* The resulting JSON payload of the Slack API command [rtm.start](https://api.slack.com/methods/rtm.start) - -The payload that this callback function receives contains a wealth of information -about the bot and its environment, including a complete list of the users -and channels visible to the bot. This information should be cached and used -when possible instead of calling Slack's API. - -A successful connection the API will also cause a `rtm_open` event to be -fired on the `controller` object. - - -#### bot.closeRTM() - -Close the connection to the RTM. Once closed, an `rtm_close` event is fired -on the `controller` object. - - -```javascript -var Botkit = require('Botkit'); - -var controller = Botkit.slackbot(); - -var bot = controller.spawn({ - token: my_slack_bot_token -}) - -bot.startRTM(function(err,bot,payload) { - if (err) { - throw new Error('Could not connect to Slack'); - } -}); -``` - -### Responding to events - -Once connected to Slack, bots receive a constant stream of events - everything from the normal messages you would expect to typing notifications and presence change events. - -Botkit's message parsing and event system does a great deal of filtering on this -real time stream so developers do not need to parse every message. See [Receiving Messages](#receiving-messages) -for more information about listening for and responding to messages. - -It is also possible to bind event handlers directly to any of the enormous number of native Slack events, as well as a handful of custom events emitted by Botkit. - -You can receive and handle any of the [native events thrown by slack](https://api.slack.com/events). - -```javascript -controller.on('channel_joined',function(bot,message) { - - // message contains data sent by slack - // in this case: - // https://api.slack.com/events/channel_joined - -}); -``` - -You can also receive and handle a long list of additional events caused -by messages that contain a subtype field, [as listed here](https://api.slack.com/events/message) - -```javascript -controller.on('channel_leave',function(bot,message) { - - // message format matches this: - // https://api.slack.com/events/message/channel_leave - -}) -``` - -Finally, Botkit throws a handful of its own events! -Events related to the general operation of bots are below. -When used in conjunction with the Slack Button, Botkit also fires -a [few additional events](#using-the-slack-button). - -#### Message/User Activity Events: - -| Event | Description -|--- |--- -| message_received | a message was received by the bot -| bot_channel_join | the bot has joined a channel -| user_channel_join | a user has joined a channel -| bot_group_join | the bot has joined a group -| user_group_join | a user has joined a group -| direct_message | the bot received a direct message from a user -| direct_mention | the bot was addressed directly in a channel -| mention | the bot was mentioned by someone in a message -| ambient | the message received had no mention of the bot - - -#### Websocket Events: - -| Event | Description -|--- |--- -| rtm_open | a connection has been made to the RTM api -| rtm_close | a connection to the RTM api has closed - - -## Receiving Messages - -Botkit bots receive messages through a system of event handlers. Handlers can be set up to respond to specific types of messages, -or to messages that match a given keyword or pattern. - -For Slack, Botkit supports five type of message event: - -| Event | Description -|--- |--- -| message_received | This event is fired for any message of any kind that is received and can be used as a catch all -| ambient | Ambient messages are messages that the bot can hear in a channel, but that do not mention the bot in any way -| direct_mention| Direct mentions are messages that begin with the bot's name, as in "@bot hello" -| mention | Mentions are messages that contain the bot's name, but not at the beginning, as in "hello @bot" -| direct_message | Direct messages are sent via private 1:1 direct message channels - -These message events can be handled using by attaching an event handler to the main controller object. -These event handlers take two parameters: the name of the event, and a callback function which is invoked whenever the event occurs. -The callback function receives a bot object, which can be used to respond to the message, and a message object. - -```javascript -// reply to @bot hello -controller.on('direct_mention',function(bot,message) { - - // reply to _message_ by using the _bot_ object - bot.reply(message,'I heard you mention me!'); - -}); +Botkit can be used to build a stand-alone application, or it can be integrated into existing Node.js +apps to offer a bot experience, or to send application notifications into messaging apps. It is released +under the [MIT open source license](LICENSE.md), which means developers are free to use it any way they choose, +in any type of project. -// reply to a direct message -controller.on('direct_message',function(bot,message) { - // reply to _message_ by using the _bot_ object - bot.reply(message,'You are talking directly to me'); - -}); - -``` - -### Matching Patterns and Keywords with `hears()` - -In addition to these traditional event handlers, Botkit also provides the `hears()` function, -which configures event handlers based on matching specific keywords or phrases in the message text. -The hears function works just like the other event handlers, but takes a third parameter which -specifies the keywords to match. - -| Argument | Description -|--- |--- -| patterns | An _array_ or a _comma separated string_ containing a list of regular expressions to match -| types | An _array_ or a _comma separated string_ of the message events in which to look for the patterns -| callback | callback function that receives a message object - -```javascript -controller.hears(['keyword','^pattern$'],['direct_message','direct_mention','mention','ambient'],function(bot,message) { - - // do something to respond to message - // all of the fields available in a normal Slack message object are available - // https://api.slack.com/events/message - bot.reply(message,'You used a keyword!'); - -}); -``` -For example, - -```javascript -controller.hears('open the (.*) doors',['direct_message','mention'],function(bot,message) { - var doorType = message.match[1]; //match[1] is the (.*) group. match[0] is the entire group (open the (.*) doors). - if (doorType === 'pod bay') { - return bot.reply(message, 'I\'m sorry, Dave. I\'m afraid I can\'t do that.'); - } - return bot.reply(message, 'Okay'); -}); -``` - -## Sending Messages - -Bots have to send messages to deliver information and present an interface for their -functionality. Botkit bots can send messages in several different ways, depending -on the type and number of messages that will be sent. - -Single message replies to incoming commands can be sent using the `bot.reply()` function. - -Multi-message replies, particulary those that present questions for the end user to respond to, -can be sent using the `bot.startConversation()` function and the related conversation sub-functions. - -Bots can originate messages - that is, send a message based on some internal logic or external stimulus - -using `bot.say()` method. Note that bots that do not need to respond to messages or hold conversations -may be better served by using Slack's [Incoming Webhooks](#incoming-webhooks) feature. - -### Single Message Replies to Incoming Messages - -Once a bot has received a message using a `on()` or `hears()` event handler, a response -can be sent using `bot.reply()`. - -Messages sent using `bot.reply()` are sent immediately. If multiple messages are sent via -`bot.reply()` in a single event handler, they will arrive in the Slack client very quickly -and may be difficult for the user to process. We recommend using `bot.startConversation()` -if more than one message needs to be sent. - -You may pass either a string, or a message object to the function. Message objects may contain -any of the fields supported by [Slack's chat.postMessage](https://api.slack.com/methods/chat.postMessage) API. - -#### bot.reply() - -| Argument | Description -|--- |--- -| message | Incoming message object -| reply | _String_ or _Object_ Outgoing response -| callback | _Optional_ Callback in the form function(err,response) { ... } - -```javascript -controller.hears(['keyword','^pattern$'],['direct_message','direct_mention','mention'],function(bot,message) { - - // do something to respond to message - // ... - - bot.reply(message,"Tell me more!"); - -}); - -controller.on('ambient',function(bot,message) { - - // do something... - - // then respond with a message object - bot.reply(message,{ - text: "A more complex response", - username: "ReplyBot", - icon_emoji: ":dash:", - }); - -}) - -``` - -### Multi-message Replies to Incoming Messages - -For more complex commands, multiple messages may be necessary to send a response, -particularly if the bot needs to collect additional information from the user. - -Botkit provides a `Conversation` object type that is used to string together several -messages, including questions for the user, into a cohesive unit. Botkit conversations -provide useful methods that enable developers to craft complex conversational -user interfaces that may span a several minutes of dialog with a user, without having to manage -the complexity of connecting multiple incoming and outgoing messages across -multiple API calls into a single function. - -Messages sent as part of a conversation are sent no faster than one message per second, -which roughly simulates the time it would take for the bot to "type" the message. -(It is possible to adjust this delay - see [special behaviors](#special-behaviors)) - -### Start a Conversation - -#### bot.startConversation() -| Argument | Description -|--- |--- -| message | incoming message to which the conversation is in response -| callback | a callback function in the form of function(err,conversation) { ... } - -`startConversation()` is a function that creates conversation in response to an incoming message. -The conversation will occur _in the same channel_ in which the incoming message was received. -Only the user who sent the original incoming message will be able to respond to messages in the conversation. - -#### bot.startPrivateConversation() -| Argument | Description -|--- |--- -| message | incoming message to which the conversation is in response -| callback | a callback function in the form of function(err,conversation) { ... } - -`startPrivateConversation()` works juts like `startConversation()`, but the resulting -conversation that is created will occur in a private direct message channel between -the user and the bot. - -### Control Conversation Flow - -#### conversation.say() -| Argument | Description -|--- |--- -| message | String or message object - -Call convo.say() several times in a row to queue messages inside the conversation. Only one message will be sent at a time, in the order they are queued. - -```javascript -controller.hears(['hello world'],['direct_message','direct_mention','mention','ambient'],function(bot,message) { - - // start a conversation to handle this response. - bot.startConversation(message,function(err,convo) { - - convo.say('Hello!'); - convo.say('Have a nice day!'); - - }) - -}); -``` - -#### conversation.ask() -| Argument | Description -|--- |--- -| message | String or message object containing the question -| callback _or_ array of callbacks | callback function in the form function(response_message,conversation), or array of objects in the form ``{ pattern: regular_expression, callback: function(response_message,conversation) { ... } }`` -| capture_options | _Optional_ Object defining options for capturing the response - -When passed a callback function, conversation.ask will execute the callback function for any response. -This allows the bot to respond to open ended questions, collect the responses, and handle them in whatever -manner it needs to. - -When passed an array, the bot will look first for a matching pattern, and execute only the callback whose -pattern is matched. This allows the bot to present multiple choice options, or to proceed -only when a valid response has been received. At least one of the patterns in the array must be marked as the default option, -which will be called should no other option match. Botkit comes pre-built with several useful patterns which can be used with this function. See [included utterances](#included-utterances) - -Callback functions passed to `ask()` receive two parameters - the first is a standard message object containing -the user's response to the question. The second is a reference to the conversation itself. - -Note that in order to continue the conversation, `convo.next()` must be called by the callback function. This -function tells Botkit to continue processing the conversation. If it is not called, the conversation will hang -and never complete causing memory leaks and instability of your bot application! - -The optional third parameter `capture_options` can be used to define different behaviors for collecting the user's response. -This object can contain the following fields: - -| Field | Description -|--- |--- -| key | _String_ If set, the response will be stored and can be referenced using this key -| multiple | _Boolean_ if true, support multi-line responses from the user (allow the user to respond several times and aggregate the response into a single multi-line value) - -##### Using conversation.ask with a callback: - -```javascript -controller.hears(['question me'],['direct_message','direct_mention','mention','ambient'],function(bot,message) { - - // start a conversation to handle this response. - bot.startConversation(message,function(err,convo) { - - convo.ask('How are you?',function(response,convo) { - - convo.say('Cool, you said: ' + response.text); - convo.next(); - - }); - - }) - -}); -``` - -##### Using conversation.ask with an array of callbacks: - -```javascript -controller.hears(['question me'],['direct_message','direct_mention','mention','ambient'],function(bot,message) { - - // start a conversation to handle this response. - bot.startConversation(message,function(err,convo) { - - convo.ask('Shall we proceed Say YES, NO or DONE to quit.',[ - { - pattern: 'done', - callback: function(response,convo) { - convo.say('OK you are done!'); - convo.next(); - } - }, - { - pattern: bot.utterances.yes, - callback: function(response,convo) { - convo.say('Great! I will continue...'); - // do something else... - convo.next(); - - } - }, - { - pattern: bot.utterances.no, - callback: function(response,convo) { - convo.say('Perhaps later.'); - // do something else... - convo.next(); - } - }, - { - default: true, - callback: function(response,convo) { - // just repeat the question - convo.repeat(); - convo.next(); - } - } - ]); - - }) - -}); -``` - -##### Multi-stage conversations - -![multi-stage convo example](https://www.evernote.com/shard/s321/sh/7243cadf-be40-49cf-bfa2-b0f524176a65/f9257e2ff5ee6869/res/bc778282-64a5-429c-9f45-ea318c729225/screenshot.png?resizeSmall&width=832) - -The recommended way to have multi-stage conversations is with multiple functions -which call eachother. Each function asks just one question. Example: - -```javascript -controller.hears(['pizzatime'],['ambient'],function(bot,message) { - bot.startConversation(message, askFlavor); -}); - -askFlavor = function(response, convo) { - convo.ask("What flavor of pizza do you want?", function(response, convo) { - convo.say("Awesome."); - askSize(response, convo); - convo.next(); - }); -} -askSize = function(response, convo) { - convo.ask("What size do you want?", function(response, convo) { - convo.say("Ok.") - askWhereDeliver(response, convo); - convo.next(); - }); -} -askWhereDeliver = function(response, convo) { - convo.ask("So where do you want it delivered?", function(response, convo) { - convo.say("Ok! Good by."); - convo.next(); - }); -} -``` +## Install Botkit from NPM or Github -The full code for this example can be found in ```examples/convo_bot.js```. - -##### Included Utterances - -| Pattern Name | Description -|--- |--- -| bot.utterances.yes | Matches phrases like yes, yeah, yup, ok and sure. -| bot.utterances.no | Matches phrases like no, nah, nope - -##### Conversation Control Functions - -In order to direct the flow of the conversation, several helper functions -are provided. These functions should only be called from within a convo.ask -handler function! - -`convo.sayFirst(message)` Works just like convo.say, but injects a message into the first spot in the queue -so that it is sent immediately, before any other queued messages. - -`convo.stop()` end the conversation immediately, and set convo.status to `stopped` - -`convo.repeat()` repeat the last question sent and continue to wait for a response. - -`convo.silentRepeat()` simply wait for another response without saying anything. - -`convo.next()` proceed to the next message in the conversation. *This must be called* at the end of each handler. - -### Handling End of Conversation - -Conversations trigger events during the course of their life. Currently, -only two events are fired, and only one is very useful: end. - -Conversations end naturally when the last message has been sent and no messages remain in the queue. -In this case, the value of `convo.status` will be `completed`. Other values for this field include `active`, `stopped`, and -`timeout`. - -```javascript -convo.on('end',function(convo) { - - if (convo.status=='completed') { - // do something useful with the users responses - var res = convo.extractResponses(); - - // reference a specific response by key - var value = convo.extractResponse('key'); - - // ... do more stuff... - - } else { - // something happened that caused the conversation to stop prematurely - } - -}); -``` - -#### convo.extractResponses() - -Returns an object containing all of the responses a user sent during the course of a conversation. - -```javascript -var values = convo.extractResponses(); -var value = values.key; -``` - -#### convo.extractResponse() - -Return one specific user response, identified by its key. - -```javascript -var value = convo.extractResponse('key'); -``` - -### Originating Messages - -#### bot.say() -| Argument | Description -|--- |--- -| message | A message object -| callback | _Optional_ Callback in the form function(err,response) { ... } - -Note: If your primary need is to spontaneously send messages rather than -respond to incoming messages, you may want to use [Slack's incoming webhooks feature](#incoming-webhooks) rather than the real time API. - -```javascript -bot.say( - { - text: 'my message text', - channel: 'C0H338YH4' - } -); -``` - -## Working with Slack Integrations - -There are a dizzying number of ways to integrate your application into Slack. -Up to this point, this document has mainly dealt with the real time / bot user -integration. In addition to this type of integration, Botkit also supports: - -* Incoming Webhooks - a way to send (but not receive) messages to Slack -* Outgoing Webhooks - a way to receive messages from Slack based on a keyword or phrase -* Slash Command - a way to add /slash commands to Slack -* Slack Web API - a full set of RESTful API tools to deal with Slack -* The Slack Button - a way to build Slack applications that can be used by multiple teams - - -```javascript -var Botkit = require('botkit'); -var controller = Botkit.slackbot({}) - -var bot = controller.spawn({ - token: my_slack_bot_token -}); - -// use RTM -bot.startRTM(function(err,bot,payload) { - // handle errors... -}); - - - -// send webhooks -bot.configureIncomingWebhook({url: webhook_url}); -bot.sendWebhook({ - text: 'Hey!', - channel: '#testing', -},function(err,res) { - // handle error -}); - - -// receive outgoing or slash commands -// if you are already using Express, you can use your own server instance... -controller.setupWebserver(process.env.port,function(err,webserver) { - - controller.createWebhookEndpoints(controller.webserver); - -}); - -controller.on('slash_command',function(bot,message) { - - // reply to slash command - bot.replyPublic(message,'Everyone can see the results of this slash command'); - -}); -``` - - - -### Incoming webhooks - -Incoming webhooks allow you to send data from your application into Slack. -To configure Botkit to send an incoming webhook, first set one up -via [Slack's integration page](https://my.slack.com/services/new/incoming-webhook/). - -Once configured, use the `sendWebhook` function to send messages to Slack. - -[Read official docs](https://api.slack.com/incoming-webhooks) - -#### bot.configureIncomingWebhook() -| Argument | Description -|--- |--- -| config | Configure a bot to send webhooks - -Add a webhook configuration to an already spawned bot. -It is preferable to spawn the bot pre-configured, but hey, sometimes -you need to do it later. - -#### bot.sendWebhook() -| Argument | Description -|--- |--- -| message | A message object -| callback | _Optional_ Callback in the form function(err,response) { ... } - -Pass `sendWebhook` an object that contains at least a `text` field. - This object may also contain other fields defined [by Slack](https://api.slack.com/incoming-webhooks) which can alter the - appearance of your message. - -```javascript -var bot = controller.spawn({ - incoming_webhook: { - url: - } -}) - -bot.sendWebhook({ - text: 'This is an incoming webhook', - channel: '#general', -},function(err,res) { - if (err) { - // ... - } -}); -``` - - -### Outgoing Webhooks and Slash commands - -Outgoing webhooks and Slash commands allow you to send data out of Slack. - -Outgoing webhooks are used to match keywords or phrases in Slack. [Read Slack's official documentation here.](https://api.slack.com/outgoing-webhooks) - -Slash commands are special commands triggered by typing a "/" then a command. -[Read Slack's official documentation here.](https://api.slack.com/slash-commands) - -Though these integrations are subtly different, Botkit normalizes the details -so developers may focus on providing useful functionality rather than peculiarities -of the Slack API parameter names. - -Note that since these integrations use send webhooks from Slack to your application, -your application will have to be hosted at a public IP address or domain name, -and properly configured within Slack. - -[Set up an outgoing webhook](https://xoxco.slack.com/services/new/outgoing-webhook) - -[Set up a Slash command](https://xoxco.slack.com/services/new/slash-commands) - -```javascript -controller.setupWebserver(port,function(err,express_webserver) { - controller.createWebhookEndpoints(express_webserver) -}); - -controller.on('slash_command',function(bot,message) { - - // reply to slash command - bot.replyPublic(message,'Everyone can see this part of the slash command'); - bot.replyPrivate(message,'Only the person who used the slash command can see this.'); - -}) - -controller.on('outgoing_webhook',function(bot,message) { - - // reply to outgoing webhook command - bot.replyPublic(message,'Everyone can see the results of this webhook command'); - -}) -``` - -#### controller.setupWebserver() -| Argument | Description -|--- |--- -| port | port for webserver -| callback | callback function - -Setup an [Express webserver](http://expressjs.com/en/index.html) for -use with `createwWebhookEndpoints()` - -If you need more than a simple webserver to receive webhooks, -you should by all means create your own Express webserver! - -The callback function receives the Express object as a parameter, -which may be used to add further web server routes. - -#### controller.createWebhookEndpoints() - -This function configures the route `http://_your_server_/slack/receive` -to receive webhooks from Slack. - -This url should be used when configuring Slack. - -When a slash command is received from Slack, Botkit fires the `slash_command` event. - -When an outgoing webhook is recieved from Slack, Botkit fires the `outgoing_webhook` event. - - -#### bot.replyPublic() -| Argument | Description -|--- |--- -| src | source message as received from slash or webhook -| reply | reply message (string or object) -| callback | optional callback - -When used with outgoing webhooks, this function sends an immediate response that is visible to everyone in the channel. - -When used with slash commands, this function has the same functionality. However, -slash commands also support private, and delayed messages. See below. -[View Slack's docs here](https://api.slack.com/slash-commands) - -#### bot.replyPrivate() - -| Argument | Description -|--- |--- -| src | source message as received from slash -| reply | reply message (string or object) -| callback | optional callback - - -#### bot.replyPublicDelayed() - -| Argument | Description -|--- |--- -| src | source message as received from slash -| reply | reply message (string or object) -| callback | optional callback - -#### bot.replyPrivateDelayed() - -| Argument | Description -|--- |--- -| src | source message as received from slash -| reply | reply message (string or object) -| callback | optional callback - - - -### Using the Slack Web API - -All (or nearly all - they change constantly!) of Slack's current web api methods are supported -using a syntax designed to match the endpoints themselves. - -If your bot has the appropriate scope, it may call [any of these method](https://api.slack.com/methods) using this syntax: - -```javascript -bot.api.channels.list({},function(err,response) { - - -}) -``` - - -# Advanced Topics - - -## Storing Information - -Botkit has a built in storage system used to keep data on behalf of users and teams between sessions. Botkit uses this system automatically when storing information for Slack Button applications (see below). +Botkit is available via NPM. -By default, Botkit will use [json-file-store](https://github.com/flosse/json-file-store) to keep data in JSON files in the filesystem of the computer where the bot is executed. (Note this will not work on Heroku or other hosting systems that do not let node applications write to the file system.) Initialize this system when you create the bot: -```javascript -var controller = Botkit.slackbot({ - json_file_store: 'path_to_json_database' -}); +```bash +npm install --save botkit ``` -This system supports freeform storage on a team-by-team, user-by-user, and channel-by-channel basis. Basically ```controller.storage``` is a key value store. All access to this system is through the following nine functions. Example usage: -```javascript -controller.storage.users.save({id: message.user, foo:"bar"}, function(err) { ... }); -controller.storage.users.get(id, function(err, user_data) {...}); -controller.storage.users.all(function(err, all_user_data) {...}); - -controller.storage.channels.save({id: message.channel, foo:"bar"}, function(err) { ... }); -controller.storage.channels.get(id, function(err, channel_data) {...}); -controller.storage.channels.all(function(err, all_channel_data) {...}); +You can also check out Botkit directly from Git. +If you want to use the example code and included bots, it may be preferable to use Github over NPM. -controller.storage.teams.save({id: message.team, foo:"bar"}, function(err) { ... }); -controller.storage.teams.get(id, function(err, team_data) {...}); -controller.storage.teams.all(function(err, all_team_data) {...}); +```bash +git clone git@github.com:howdyai/botkit.git ``` -Note that save must be passed an object with an id. It is recommended to use the team/user/channel id for this purpose. -```[user/channel/team]_data``` will always be an object while ```all_[user/channel/team]_data``` will always be a list of objects. - -### Writing your own storage module - -If you want to use a database or do something else with your data, -you can write your own storage module and pass it in. - -Make sure your module returns an object with all the methods. See [simple_storage.js](https://github.com/howdyai/botkit/blob/master/lib/storage/simple_storage.js) for an example of how it is done! -Make sure your module passes the test in [storage_test.js](https://github.com/howdyai/botkit/blob/master/lib/storage/storage_test.js). - -Then, use it when you create your bot: -```javascript -var controller = Botkit.slackbot({ - storage: my_storage_provider -}) +After cloning the Git repository, you have to install the node dependencies. Navigate to the root of your cloned repository and use npm to install all necessary dependencies. +```bash +npm install ``` -### Writing your own logging module - -By default, your bot will log to the standard JavaScript `console` object -available in Node.js. This will synchronously print logging messages to stdout -of the running process. - -There may be some cases, such as remote debugging or rotating of large logs, -where you may want a more sophisticated logging solution. You can write your -own logging module that uses a third-party tool, like -[winston](https://github.com/winstonjs/winston) or -[Bristol](https://github.com/TomFrost/Bristol). Just create an object with a -`log` method. That method should take a severity level (such as `'error'` or -`'debug'`) as its first argument, and then any number of other arguments that -will be logged as messages. (Both Winston and Bristol create objects of this -description; it's a common interface.) - -Then, use it when you create your bot: -```javascript -var controller = Botkit.slackbot({ - logger: new winston.Logger({ - transports: [ - new (winston.transports.Console)(), - new (winston.transports.File)({ filename: './bot.log' }) - ] - }) -}); +Use the `--production` flag to skip the installation of devDependencies from Botkit. Useful if you just wish to run the example bot. +```bash +npm install --production ``` -## Use the Slack Button - -The [Slack Button](https://api.slack.com/docs/slack-button) is a way to offer a Slack -integration as a service available to multiple teams. Botkit includes a framework -on top of which Slack Button applications can be built. - -Slack button applications can use one or more of the [real time API](), -[incoming webhook]() and [slash command]() integrations, which can be -added *automatically* to a team using a special oauth scope. - -If special oauth scopes sounds scary, this is probably not for you! -The Slack Button is useful for developers who want to offer a service -to multiple teams. - -How many teams can a Slack button app built using Botkit handle? -This will largely be dependent on the environment it is hosted in and the -type of integrations used. A reasonably well equipped host server should -be able to easily handle _at least one hundred_ real time connections at once. - -To handle more than one hundred bots at once, [consider speaking to the -creators of Botkit at Howdy.ai](http://howdy.ai) - -For Slack button applications, Botkit provides: - -* A simple webserver -* OAuth Endpoints for login via Slack -* Storage of API tokens and team data via built-in Storage -* Events for when a team joins, a new integration is added, and others... - -See the [included examples](#included-examples) for several ready to use example apps. - -#### controller.configureSlackApp() - -| Argument | Description -|--- |--- -| config | configuration object containing clientId, clientSecret, redirectUri and scopes - -Configure Botkit to work with a Slack application. +## Running Tests -Get a clientId and clientSecret from [Slack's API site](https://api.slack.com/applications). -Configure Slash command, incoming webhook, or bot user integrations on this site as well. +To run tests, use the npm `test` command. Note: you will need dev dependencies installed using `npm install`. -Configuration must include: - -* clientId - Application clientId from Slack -* clientSecret - Application clientSecret from Slack -* redirectUri - the base url of your application -* scopes - an array of oauth permission scopes - -Slack has [_many, many_ oauth scopes](https://api.slack.com/docs/oauth-scopes) -that can be combined in different ways. There are also [_special oauth scopes_ -used when requesting Slack Button integrations](https://api.slack.com/docs/slack-button). -It is important to understand which scopes your application will need to function, -as without the proper permission, your API calls will fail. - -#### controller.createOauthEndpoints() -| Argument | Description -|--- |--- -| webserver | an Express webserver Object -| error_callback | function to handle errors that may occur during oauth - -Call this function to create two web urls that handle login via Slack. -Once called, the resulting webserver will have two new routes: `http://_your_server_/login` and `http://_your_server_/oauth`. The second url will be used when configuring -the "Redirect URI" field of your application on Slack's API site. - - -```javascript -var Botkit = require('botkit'); -var controller = Botkit.slackbot(); - -controller.configureSlackApp({ - clientId: process.env.clientId, - clientSecret: process.env.clientSecret, - redirectUri: 'http://localhost:3002', - scopes: ['incoming-webhook','team:read','users:read','channels:read','im:read','im:write','groups:read','emoji:read','chat:write:bot'] -}); - -controller.setupWebserver(process.env.port,function(err,webserver) { - - // set up web endpoints for oauth, receiving webhooks, etc. - controller - .createHomepageEndpoint(controller.webserver) - .createOauthEndpoints(controller.webserver,function(err,req,res) { ... }) - .createWebhookEndpoints(controller.webserver); - -}); - -``` - -### How to identify what team your message came from -```javascript -bot.identifyTeam(function(err,team_id) { - -}) +```bash +npm test ``` +To run tests in watch mode run: -### How to identify the bot itself (for RTM only) -```javascript -bot.identifyBot(function(err,identity) { - // identity contains... - // {name, id, team_id} -}) +```bash +npm run test-watch ``` +Tests are run with [Jest](https://facebook.github.io/jest/docs/getting-started.html). You can pass Jest command line options after a `--`. +For example to have Jest bail on the first error you can run -### Slack Button specific events: - -| Event | Description -|--- |--- -| create_incoming_webhook | -| create_bot | -| update_team | -| create_team | -| create_user | -| update_user | -| oauth_error | - -# Chat with us at dev4slack.slack.com -You can get an invite here: http://dev4slack.xoxco.com/. +```bash +npm test -- --bail +``` + +## Documentation + +* [Get Started](docs/readme.md) +* [Botkit Studio API](docs/readme-studio.md) +* [Function index](docs/readme.md#developing-with-botkit) +* [Extending Botkit with Plugins and Middleware](docs/middleware.md) + * [Message Pipeline](docs/readme-pipeline.md) + * [List of current plugins](docs/readme-middlewares.md) +* [Storing Information](docs/storage.md) +* [Logging](docs/logging.md) +* Platforms + * [Slack](docs/readme-slack.md) + * [Cisco Spark](docs/readme-ciscospark.md) + * [Microsoft Teams](docs/readme-teams.md) + * [Facebook Messenger](docs/readme-facebook.md) + * [Twilio SMS](docs/readme-twiliosms.md) + * [Twilio IPM](docs/readme-twilioipm.md) + * [Microsoft Bot Framework](docs/readme-botframework.md) +* Contributing to Botkit + * [Contributing to Botkit Core](CONTRIBUTING.md) + * [Building Middleware/plugins](docs/howto/build_middleware.md) + * [Building platform connectors](docs/howto/build_connector.md) + +# About Botkit + +Botkit is a product of [Howdy](https://howdy.ai). + +For support, check out [the Developer Community](#developer--support-community) and find our team in the #Botkit channel. diff --git a/tests/Facebook.js b/tests/Facebook.js new file mode 100644 index 000000000..1abf4ac2a --- /dev/null +++ b/tests/Facebook.js @@ -0,0 +1,201 @@ +var should = require('should'); +var sinon = require('sinon'); +require('should-sinon'); +var winston = require('winston'); +var Botkit = require('../lib/Botkit.js'); + +describe('FacebookBot', function() { + describe('constructor()', function(done) { + it('Should have a facebookbot function', function(done) { + Botkit.should.have.property('facebookbot').which.is.a.Function(); + done(); + }); + + it('FacebookBot should be an Object', function(done) { + var facebook_bot = Botkit.facebookbot({}); + facebook_bot.should.be.an.Object(); + done(); + }); + }); + + describe('messenger profile api', function(done) { + var facebook_bot = Botkit.facebookbot({}); + describe('home_url', function(done) { + it('home_url should be a function', function(done) { + facebook_bot.api.messenger_profile.home_url.should.be.a.Function(); + done(); + }); + it('home_url should post a payload', function(done) { + var expectedPayload = { + home_url: { + url: 'https://testurl.com', + webview_height_ratio: 'tall', + in_test: true + } + }; + + var expectedApiCall = sinon.spy(); + facebook_bot.api.messenger_profile.postAPI = expectedApiCall; + facebook_bot.api.messenger_profile.home_url({ + url: 'https://testurl.com', + webview_height_ratio: 'tall', + in_test: true + }); + expectedApiCall.should.be.calledWith(expectedPayload); + done(); + }); + it('get_home_url should be a function', function(done) { + facebook_bot.api.messenger_profile.get_home_url.should.be.a.Function(); + done(); + }); + it('get_home_url should trigger a callback', function(done) { + var apiGet = sinon.stub(facebook_bot.api.messenger_profile, 'getAPI').callsFake(function fakeFn(fields, cb) { + return cb(null, { + "home_url" : { + "url": "http://petershats.com/send-a-hat", + "webview_height_ratio": "tall", + "in_test":true + } + }); + }); + facebook_bot.api.messenger_profile.get_home_url(function(err, result) { + done(); + }); + }); + it('delete_home_url should be a function', function(done) { + facebook_bot.api.messenger_profile.get_home_url.should.be.a.Function(); + done(); + }); + it('delete_home_url should trigger a delete api call', function(done) { + var expectedApiCall = sinon.spy(); + facebook_bot.api.messenger_profile.deleteAPI = expectedApiCall; + facebook_bot.api.messenger_profile.delete_home_url(); + expectedApiCall.should.be.calledWith('home_url'); + done(); + }) + }); + + + }); + + describe('handleWebhookPayload()', function(done) { + it('Should be function', function(done) { + //Setup + var facebook_bot = Botkit.facebookbot({}); + + //Assertions + facebook_bot.handleWebhookPayload.should.be.a.Function(); + done(); + }); + + function mock_entry() { + return { + sender: {id: "SENDER_ID"}, + recipient: {id: "RECIPIENT_ID"}, + timestamp: "TIMESTAMP" + } + }; + var res = {}; + + it('Should call receiveMessage on facebook_message.message', function(done) { + //Setup + var facebook_bot = Botkit.facebookbot({}); + + //Spies + facebook_bot.receiveMessage = sinon.spy(); + + //Request + var entry = mock_entry(); + entry.message = { + text: "TEXT", + seq:"SEQ", + is_echo:"IS_ECHO", + mid:"MID", + sticker_id:"STICKER_ID", + attachments:"ATTACHMENTS", + quick_reply:"QUICK_REPLY" + }; + var req = { body: { entry: [ { messaging: [ entry ] } ] } }; + facebook_bot.handleWebhookPayload(req, res, facebook_bot); + + //Assertions + facebook_bot.receiveMessage.should.be.called(); + done(); + }); + + it('Should trigger \'facebook_postback\' on facebook_message.postback', function(done) { + //Setup + var facebook_bot = Botkit.facebookbot({}); + + //Spies + facebook_bot.trigger = sinon.spy(); + + //Request + var entry = mock_entry(); + entry.postback = { + payload: "PAYLOAD", + referral: "REFERRAL" + }; + var req = { body: { entry: [ { messaging: [ entry ] } ] } }; + facebook_bot.handleWebhookPayload(req, res, facebook_bot); + + //Assertions + facebook_bot.trigger.should.be.calledWithMatch('facebook_postback'); + done(); + }); + + it('Should trigger \'facebook_optin\' on facebook_message.optin', function(done) { + //Setup + var facebook_bot = Botkit.facebookbot({}); + + //Spies + facebook_bot.trigger = sinon.spy(); + + //Request + var entry = mock_entry(); + entry.optin = true; + var req = { body: { entry: [ { messaging: [ entry ] } ] } }; + facebook_bot.handleWebhookPayload(req, res, facebook_bot); + + //Assertions + facebook_bot.trigger.should.be.calledWithMatch('facebook_optin'); + done(); + }); + + it('Should trigger \'message_delivered\' on facebook_message.delivery', function(done) { + //Setup + var facebook_bot = Botkit.facebookbot({}); + + //Spies + facebook_bot.trigger = sinon.spy(); + + //Request + var entry = mock_entry(); + entry.delivery = true; + var req = { body: { entry: [ { messaging: [ entry ] } ] } }; + facebook_bot.handleWebhookPayload(req, res, facebook_bot); + + //Assertions + facebook_bot.trigger.should.be.calledWithMatch('message_delivered'); + done(); + }); + + it('Should trigger \'message_read\' on facebook_message.referral', function(done) { + //Setup + var facebook_bot = Botkit.facebookbot({}); + + //Spies + facebook_bot.trigger = sinon.spy(); + + //Request + var entry = mock_entry(); + entry.referral = true; + var req = { body: { entry: [ { messaging: [ entry ] } ] } }; + facebook_bot.handleWebhookPayload(req, res, facebook_bot); + + //Assertions + facebook_bot.trigger.should.be.calledWithMatch('facebook_referral'); + done(); + }); + }); +}); diff --git a/tests/Slack_web_api.js b/tests/Slack_web_api.js index f9ae7a47b..cf3551e3c 100644 --- a/tests/Slack_web_api.js +++ b/tests/Slack_web_api.js @@ -23,6 +23,11 @@ describe('Test', function() { describe('Botkit', function() { this.timeout(5000); + it('should return a package version number', function(done){ + var controller = Botkit.slackbot({debug: false}); + should.exist(controller.version()); + done(); + }); it('should start and then stop', function(done) { var controller = Botkit.slackbot({debug: false});