diff --git a/changelog.md b/changelog.md index 06d00ff67..725924e7e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Change Log +## 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 diff --git a/examples/slackbutton_bot_interactivemsg.js b/examples/slackbutton_bot_interactivemsg.js new file mode 100644 index 000000000..2874a2edb --- /dev/null +++ b/examples/slackbutton_bot_interactivemsg.js @@ -0,0 +1,332 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ______ ______ ______ __ __ __ ______ + /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ + \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + + +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=<my client id> clientSecret=<my client secret> 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/', +}).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 <item>` 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/slackbutton_incomingwebhooks.js old mode 100755 new mode 100644 diff --git a/lib/CoreBot.js b/lib/CoreBot.js index ae12bb118..66cf03274 100755 --- a/lib/CoreBot.js +++ b/lib/CoreBot.js @@ -33,6 +33,7 @@ function Botkit(configuration) { botkit.middleware = { send: ware(), receive: ware(), + spawn: ware(), }; @@ -685,29 +686,6 @@ function Botkit(configuration) { }; - - - botkit.debug = function() { - if (configuration.debug) { - var args = []; - for (var k = 0; k < arguments.length; k++) { - args.push(arguments[k]); - } - console.log.apply(null, args); - } - }; - - 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); - } - }; - - /** * hears_regexp - default string matcher uses regular expressions * @@ -830,7 +808,12 @@ function Botkit(configuration) { }); }; - if (cb) { cb(worker); } + botkit.middleware.spawn.run(worker, function(err, worker, message) { + + if (cb) { cb(worker); } + + }); + return worker; }; diff --git a/lib/Facebook.js b/lib/Facebook.js index bb453d09c..723be9d1e 100644 --- a/lib/Facebook.js +++ b/lib/Facebook.js @@ -113,8 +113,8 @@ function Facebookbot(configuration) { facebook_botkit.createWebhookEndpoints = function(webserver, bot, cb) { facebook_botkit.log( - '** Serving webhook endpoints for Slash commands and outgoing ' + - 'webhooks at: http://MY_HOST:' + facebook_botkit.config.port + '/facebook/receive'); + '** Serving webhook endpoints for Messenger Platform at: ' + + 'http://MY_HOST:' + facebook_botkit.config.port + '/facebook/receive'); webserver.post('/facebook/receive', function(req, res) { facebook_botkit.debug('GOT A MESSAGE HOOK'); diff --git a/lib/SlackBot.js b/lib/SlackBot.js index b9ff31e40..2112676ae 100755 --- a/lib/SlackBot.js +++ b/lib/SlackBot.js @@ -8,10 +8,45 @@ function Slackbot(configuration) { // Create a core botkit bot var slack_botkit = Botkit(configuration || {}); + 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.rtm) { + worker.rtm = existing_bot.rtm; + } + } + next(); + + }); + + + // set up configuration for oauth // slack_app_config should contain // { clientId, clientSecret, scopes} @@ -73,17 +108,57 @@ function Slackbot(configuration) { // set up a web route for receiving outgoing webhooks and/or slash commands 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'); webserver.post('/slack/receive', function(req, res) { - if (authenticationTokens !== undefined && arguments.length > 1 && arguments[1].length) { - secureWebhookEndpoints.apply(null, arguments); - } + // is this an interactive message callback? + if (req.body.payload) { + + 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; + + message.type = 'interactive_message_callback'; + + 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 { + 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]); + + if (configuration.interactive_replies) { + message.type = 'message'; + slack_botkit.receiveMessage(bot, message); + } + } + }); // this is a slash command - if (req.body.command) { + } else if (req.body.command) { var message = {}; for (var key in req.body) { diff --git a/lib/Slackbot_worker.js b/lib/Slackbot_worker.js index 28cee1b89..e404b1d5b 100755 --- a/lib/Slackbot_worker.js +++ b/lib/Slackbot_worker.js @@ -158,6 +158,7 @@ module.exports = function(botkit, config) { }); bot.rtm.on('open', function() { + botkit.log.notice('RTM websocket opened'); pingIntervalId = setInterval(function() { if (lastPong && lastPong + 12000 < Date.now()) { @@ -204,11 +205,21 @@ module.exports = function(botkit, config) { 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 (pingIntervalId) { clearInterval(pingIntervalId); } 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(); + } }); }); @@ -460,6 +471,39 @@ module.exports = function(botkit, config) { } }; + 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; + + 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(); + } + }); + } + }; + bot.reply = function(src, resp, cb) { var msg = {}; diff --git a/package.json b/package.json index ca5c11bf3..01e1f398f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "botkit", - "version": "0.2.0", + "version": "0.2.2", "description": "Building blocks for Building Bots", "main": "lib/Botkit.js", "dependencies": { diff --git a/readme-slack.md b/readme-slack.md index 9a6125f7f..5c51cd57e 100644 --- a/readme-slack.md +++ b/readme-slack.md @@ -17,6 +17,7 @@ Table of Contents * [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 @@ -595,3 +596,168 @@ bot.identifyBot(function(err,identity) { | 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. + +``` +// 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 +``` +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 + +``` +// 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. +In order to do this, you must instantiate your Botkit controller with the `interactive_replies` option set to `true`: + +``` +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. + +``` +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 + } + } + ]); +}); +``` diff --git a/readme.md b/readme.md index a8bd72d42..b3c493ec6 100755 --- a/readme.md +++ b/readme.md @@ -108,7 +108,7 @@ This sample bot listens for the word "hello" to be said to it -- either as a dir 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." +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 diff --git a/slack_bot.js b/slack_bot.js index d71da52d2..09963a1ba 100644 --- a/slack_bot.js +++ b/slack_bot.js @@ -73,7 +73,7 @@ var Botkit = require('./lib/Botkit.js'); var os = require('os'); var controller = Botkit.slackbot({ - debug: true, + debug: true }); var bot = controller.spawn({