diff --git a/examples/slackbutton_bot.js b/examples/slackbutton_bot.js index fa610baa7..0f1794522 100755 --- a/examples/slackbutton_bot.js +++ b/examples/slackbutton_bot.js @@ -35,6 +35,7 @@ if (!process.env.clientId || !process.env.clientSecret || !process.env.port || ! var controller = Botkit.slackbot({ json_file_store: './db_slackbutton_bot/', + // rtm_receive_messages: false, // disable rtm_receive_messages if you enable events api }).configureSlackApp( { clientId: process.env.clientId, diff --git a/examples/slackbutton_bot_interactivemsg.js b/examples/slackbutton_bot_interactivemsg.js index 2874a2edb..03f79f6fd 100644 --- a/examples/slackbutton_bot_interactivemsg.js +++ b/examples/slackbutton_bot_interactivemsg.js @@ -36,6 +36,7 @@ if (!process.env.clientId || !process.env.clientSecret || !process.env.port) { 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, diff --git a/lib/CoreBot.js b/lib/CoreBot.js index 441bc502d..18df6008d 100755 --- a/lib/CoreBot.js +++ b/lib/CoreBot.js @@ -1037,7 +1037,7 @@ function Botkit(configuration) { 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 + * for node's http module. Developers can specify a hostname or IP * address to override this. **/ if (!botkit.config.hostname) { diff --git a/lib/SlackBot.js b/lib/SlackBot.js index f03ef1dc7..0a9ca8e63 100755 --- a/lib/SlackBot.js +++ b/lib/SlackBot.js @@ -9,6 +9,19 @@ 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 @@ -120,86 +133,139 @@ function Slackbot(configuration) { 'webhooks at: http://' + slack_botkit.config.hostname + ':' + slack_botkit.config.port + '/slack/receive'); webserver.post('/slack/receive', function(req, res) { - // is this an interactive message callback? - if (req.body.payload) { + // is this an events api url handshake? + if (req.body.type === 'url_verification') { + slack_botkit.debug('Received url handshake'); + res.status(200).json({ challenge: req.body.challenge }); + return; + } - var message = JSON.parse(req.body.payload); - for (var key in req.body) { - message[key] = req.body[key]; - } - // 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; + if (req.body.type === 'event_callback') { + // Receive messages and trigger events from the Events API + return handleEventsAPI(req, res); + } else if (req.body.payload) { + // is this an interactive message callback? + return handleInteractiveMessage(req,res); + } else if (req.body.command) { + // this is a slash command + return handleSlashCommand(req,res); + } else if (req.body.trigger_word) { + return handleOutgoingWebhook(req,res); + } - message.type = 'interactive_message_callback'; + }); - slack_botkit.findTeamById(message.team.id, function(err, team) { - if (err || !team) { - slack_botkit.log.error('Received interactive message, but could not load team'); - } else { - res.status(200); - res.send(''); + return slack_botkit; + }; - var bot = slack_botkit.spawn(team); + /* 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) + */ + function handleInteractiveMessage(req,res) { + var message = JSON.parse(req.body.payload); + for (var key in req.body) { + message[key] = req.body[key]; + } - bot.team_info = team; - bot.res = res; + // 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.trigger('interactive_message_callback', [bot, message]); + // put the action value in the text field + // this allows button clicks to respond to asks + message.text = message.actions[0].value; - if (configuration.interactive_replies) { - message.type = 'message'; - slack_botkit.receiveMessage(bot, message); - } - } - }); + message.type = 'interactive_message_callback'; - // this is a slash command - } else if (req.body.command) { - var message = {}; + slack_botkit.findTeamById(message.team.id, function(err, team) { + if (err || !team) { + slack_botkit.log.error('Received interactive message, but could not load team'); + } else { + res.status(200); + res.send(''); + + var bot = slack_botkit.spawn(team); + + bot.team_info = team; + bot.res = res; + + slack_botkit.trigger('interactive_message_callback', [bot, message]); - for (var key in req.body) { - message[key] = req.body[key]; + if (configuration.interactive_replies) { + message.type = 'message'; + slack_botkit.receiveMessage(bot, message); } + } + }); + } + function handleEventsAPI(req, res) { + // respond to events api with a 200 + res.sendStatus(200); - // let's normalize some of these fields to match the rtm message format - message.user = message.user_id; - message.channel = message.channel_id; + var message = {}; + for (var key in req.body.event) { + message[key] = req.body.event[key]; + } - // Is this configured to use Slackbutton? - // If so, validate this team before triggering the event! - // Otherwise, it's ok to just pass a generic bot in - if (slack_botkit.config.clientId && slack_botkit.config.clientSecret) { + // let's normalize some of these fields to match the rtm message format + message.team = req.body.team_id; + message.events_api = true; + message.authed_users = req.body.authed_users; - slack_botkit.findTeamById(message.team_id, function(err, team) { - 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. + slack_botkit.findTeamById(message.team, function(err, team) { + if (err || !team) { + slack_botkit.log.error('Received Events API message, but could not load team:', err); + return; + } else { + var bot = slack_botkit.spawn(team); + // Identify the bot from either team storage or identifyBot() + bot.team_info = team; + bot.identity = { + id: team.bot.user_id, + name: team.bot.name + }; - res.status(200); + if (team.bot.user_id === req.body.event.user) { + slack_botkit.debug('Got event from this bot user, ignoring it'); + return; + } + if (bot.identity == undefined || bot.identity.id == null) { + slack_botkit.log.error('Could not identify bot'); + return; + } else { + if (req.body.event.type === 'message') { + slack_botkit.receiveMessage(bot, message); + } else { + slack_botkit.trigger(message.type, [bot, message]); + } + } + } + }); + }; + function handleSlashCommand(req, res) { + var message = {}; - var bot = slack_botkit.spawn(team); + for (var key in req.body) { + message[key] = req.body[key]; + } - bot.team_info = team; - bot.res = res; + // 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.receiveMessage(bot, message); + // Is this configured to use Slackbutton? + // If so, validate this team before triggering the event! + // Otherwise, it's ok to just pass a generic bot in + if (slack_botkit.config.clientId && slack_botkit.config.clientSecret) { - } - }); + slack_botkit.findTeamById(message.team_id, function(err, team) { + 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 @@ -207,13 +273,9 @@ function Slackbot(configuration) { // the callback needs access to the res object // to send an optional response. - var team = { - id: message.team_id, - }; - res.status(200); - var bot = slack_botkit.spawn({}); + var bot = slack_botkit.spawn(team); bot.team_info = team; bot.res = res; @@ -221,46 +283,66 @@ function Slackbot(configuration) { slack_botkit.receiveMessage(bot, message); } + }); + } else { - } else if (req.body.trigger_word) { + 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. - var message = {}; + var team = { + id: message.team_id, + }; - for (var key in req.body) { - message[key] = req.body[key]; - } + res.status(200); + var bot = slack_botkit.spawn({}); - var team = { - id: message.team_id, - }; + bot.team_info = team; + bot.res = res; - // 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.receiveMessage(bot, message); - message.type = 'outgoing_webhook'; + } + } + function handleOutgoingWebhook(req, res) { + var message = {}; - res.status(200); + for (var key in req.body) { + message[key] = req.body[key]; + } - var bot = slack_botkit.spawn(team); - bot.res = res; - bot.team_info = team; + var team = { + id: message.team_id, + }; - slack_botkit.receiveMessage(bot, message); + // let's normalize some of these fields to match the rtm message format + message.user = message.user_id; + message.channel = message.channel_id; - // 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!! + message.type = 'outgoing_webhook'; - } + res.status(200); - }); + var bot = slack_botkit.spawn(team); + bot.res = res; + bot.team_info = team; - return slack_botkit; + + slack_botkit.receiveMessage(bot, message); + + // 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!! }; + /* End of webhook handler functions + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ + slack_botkit.saveTeam = function(team, cb) { slack_botkit.storage.teams.save(team, cb); }; @@ -474,6 +556,7 @@ function Slackbot(configuration) { } if (auth.bot) { + team.bot = { token: auth.bot.bot_access_token, user_id: auth.bot.bot_user_id, @@ -500,7 +583,23 @@ function Slackbot(configuration) { slack_botkit.trigger('update_team', [bot, team]); } - slack_botkit.storage.users.get(identity.user_id, function(err, user) { + 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; diff --git a/lib/Slackbot_worker.js b/lib/Slackbot_worker.js index eb0f514ba..ede2f7372 100755 --- a/lib/Slackbot_worker.js +++ b/lib/Slackbot_worker.js @@ -200,7 +200,7 @@ module.exports = function(botkit, config) { * but adds in additional fields for internal use! * (including the teams api details) */ - if (message != null) { + if (message != null && bot.botkit.config.rtm_receive_messages) { botkit.receiveMessage(bot, message); } }); diff --git a/lib/Studio.js b/lib/Studio.js index 98bafb9c4..820fd5260 100644 --- a/lib/Studio.js +++ b/lib/Studio.js @@ -1,6 +1,7 @@ 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 = {}; @@ -11,71 +12,51 @@ module.exports = function(controller) { // define a place for the studio specific features to live. controller.studio = {}; - - function studioAPI(bot, options) { - var _STUDIO_COMMAND_API = controller.config.studio_command_uri || 'https://api.botkit.ai'; - options.uri = _STUDIO_COMMAND_API + options.uri; - return new Promise(function(resolve, reject) { - var headers = { - 'content-type': 'application/json', - }; - if (bot.config.studio_token) { - options.uri = options.uri + '?access_token=' + bot.config.studio_token; - } else if (controller.config.studio_token) { - options.uri = options.uri + '?access_token=' + controller.config.studio_token; - } else { - throw new Error('No Botkit Studio Token'); - } - options.headers = headers; - request(options, function(err, res, body) { - if (err) { - console.log('Error in Botkit Studio:', err); - return reject(err); - } - try { - json = JSON.parse(body); - if (json.error) { - console.log('Error in Botkit Studio:', json.error); - reject(json.error); - } else { - resolve(json); - } - } catch (e) { - console.log('Error in Botkit Studio:', e); - return reject('Invalid JSON'); - } - }); - }); - } - /* ---------------------------------------------------------------- * 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); - controller.studio.evaluateTrigger = function(bot, text) { - var url = '/api/v1/commands/triggers'; - return studioAPI(bot, { - uri: url, - method: 'post', - form: { - triggers: text - }, - }); }; + + + // load a script from the pro service - controller.studio.getScript = function(bot, text) { - var url = '/api/v1/commands/name'; - return studioAPI(bot, { - uri: url, - method: 'post', - form: { - command: text - }, - }); + controller.studio.getScript = function(bot, text, user) { + + var userHash = md5(user); + var sdk = new SDK(genConfig(bot)); + return sdk.getScript(text, user); }; @@ -161,7 +142,7 @@ module.exports = function(controller) { channel: channel, }; return new Promise(function(resolve, reject) { - controller.studio.getScript(bot, input_text).then(function(command) { + controller.studio.getScript(bot, input_text, user).then(function(command) { controller.trigger('command_triggered', [bot, context, command]); controller.studio.compileScript( bot, @@ -201,7 +182,7 @@ module.exports = function(controller) { channel: channel, }; return new Promise(function(resolve, reject) { - controller.studio.evaluateTrigger(bot, input_text).then(function(command) { + controller.studio.evaluateTrigger(bot, input_text, user).then(function(command) { if (command !== {} && command.id) { controller.trigger('command_triggered', [bot, context, command]); controller.studio.compileScript( @@ -251,7 +232,7 @@ module.exports = function(controller) { channel: channel, }; return new Promise(function(resolve, reject) { - controller.studio.evaluateTrigger(bot, input_text).then(function(command) { + controller.studio.evaluateTrigger(bot, input_text, user).then(function(command) { if (command !== {} && command.id) { resolve(true); } else { @@ -442,9 +423,15 @@ module.exports = function(controller) { 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.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; @@ -560,6 +547,7 @@ module.exports = function(controller) { 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); diff --git a/package.json b/package.json index 8fcbe594c..06bfa0d60 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "back": "^1.0.1", "body-parser": "^1.14.2", "botbuilder": "^3.2.3", + "botkit-studio-sdk": "^1.0.0", "clone": "2.0.0", "command-line-args": "^3.0.0", "express": "^4.13.3", diff --git a/readme-slack.md b/readme-slack.md index 521b945a9..20bb6dc04 100644 --- a/readme-slack.md +++ b/readme-slack.md @@ -244,6 +244,7 @@ integration. In addition to this type of integration, Botkit also supports: * 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 @@ -616,15 +617,15 @@ 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 @@ -665,7 +666,7 @@ To receive callbacks, register a callback url as part of applications configurat 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) { @@ -679,7 +680,7 @@ controller.setupWebserver(process.env.port,function(err,webserver) { ``` ### Send an interactive message -``` +```javascript controller.hears('interactive', 'direct_message', function(bot, message) { bot.reply(message, { @@ -710,7 +711,7 @@ controller.hears('interactive', 'direct_message', function(bot, message) { ### 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) { @@ -756,14 +757,14 @@ controller.on('interactive_message_callback', function(bot, message) { It is possible to use interactive messages in conversations, with the `convo.ask` function. In order to do this, you must instantiate your Botkit controller with the `interactive_replies` option set to `true`: -``` +```javascript var controller = Botkit.slackbot({interactive_replies: true}); ``` This will cause Botkit to pass all interactive_message_callback messages into the normal conversation system. When used in conjunction with `convo.ask`, expect the response text to match the button `value` field. -``` +```javascript bot.startConversation(message, function(err, convo) { convo.ask({ @@ -813,3 +814,63 @@ bot.startConversation(message, function(err, convo) { ]); }); ``` + + +## 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. + +Note: 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 create an RTM connection in order to make the bot appear online - see note below. + +### 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/recieve`. 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. + +```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!'); + } + }); +}); +``` + +### 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 +}); +```