diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..21ff6bb99 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,46 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch", + "type": "node", + "request": "launch", + "program": "${workspaceRoot}/botframework_bot.js", + "stopOnEntry": false, + "args": [], + "cwd": "${workspaceRoot}", + "preLaunchTask": null, + "runtimeExecutable": null, + "runtimeArgs": [ + "--nolazy" + ], + "env": { + "NODE_ENV": "development" + }, + "console": "internalConsole", + "sourceMaps": false, + "outDir": null + }, + { + "name": "Attach", + "type": "node", + "request": "attach", + "port": 5858, + "address": "localhost", + "restart": false, + "sourceMaps": false, + "outDir": null, + "localRoot": "${workspaceRoot}", + "remoteRoot": null + }, + { + "name": "Attach to Process", + "type": "node", + "request": "attach", + "processId": "${command.PickProcess}", + "port": 5858, + "sourceMaps": false, + "outDir": null + } + ] +} \ No newline at end of file diff --git a/botframework_bot.js b/botframework_bot.js new file mode 100644 index 000000000..904091830 --- /dev/null +++ b/botframework_bot.js @@ -0,0 +1,207 @@ +/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ______ ______ ______ __ __ __ ______ + /\ == \ /\ __ \ /\__ _\ /\ \/ / /\ \ /\__ _\ + \ \ __< \ \ \/\ \ \/_/\ \/ \ \ _"-. \ \ \ \/_/\ \/ + \ \_____\ \ \_____\ \ \_\ \ \_\ \_\ \ \_\ \ \_\ + \/_____/ \/_____/ \/_/ \/_/\/_/ \/_/ \/_/ + + +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.'); + } + }); +}); + +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/changelog.md b/changelog.md index 725924e7e..dbe5c8944 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,35 @@ # Change Log +## 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. 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/lib/BotFramework.js b/lib/BotFramework.js new file mode 100644 index 000000000..2e6aacb0e --- /dev/null +++ b/lib/BotFramework.js @@ -0,0 +1,191 @@ +var Botkit = require(__dirname + '/CoreBot.js'); +var builder = require('botbuilder'); +var express = require('express'); +var bodyParser = require('body-parser'); + +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.startConversation = function(message, cb) { + botkit.startConversation(this, message, cb); + }; + + bot.send = function(message, cb) { + function done(err) { + 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; + + 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; + + }); + + + // 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://MY_HOST:' + 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++) { + // 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 bf_event = events[i]; + var prefix = bf_event.address.channelId + ':'; + bf_event.user = prefix + bf_event.address.user.id; + bf_event.channel = prefix + bf_event.address.conversation.id; + + // Dispatch event + if (bf_event.type === 'message') { + bf_botkit.receiveMessage(bot, bf_event); + } else { + bf_botkit.trigger(bf_event.type, [bot, bf_event]); + } + } + + if (done) { + done(null); + } + }); + + if (cb) { + cb(); + } + + bf_botkit.startTicking(); + + return bf_botkit; + }; + + bf_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'); + } + + bf_botkit.config.port = port; + + bf_botkit.webserver = express(); + bf_botkit.webserver.use(bodyParser.json()); + bf_botkit.webserver.use(bodyParser.urlencoded({ extended: true })); + bf_botkit.webserver.use(express.static(__dirname + '/public')); + + var server = bf_botkit.webserver.listen( + bf_botkit.config.port, + function() { + bf_botkit.log('** Starting webserver on port ' + + bf_botkit.config.port); + if (cb) { cb(null, bf_botkit.webserver); } + }); + + return bf_botkit; + + }; + + return bf_botkit; +}; + +module.exports = BotFrameworkBot; diff --git a/lib/Botkit.js b/lib/Botkit.js index 86e44d8fe..9d754c327 100755 --- a/lib/Botkit.js +++ b/lib/Botkit.js @@ -2,10 +2,12 @@ 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 BotFrameworkBot = require(__dirname + '/BotFramework.js'); module.exports = { core: CoreBot, slackbot: Slackbot, facebookbot: Facebookbot, twilioipmbot: TwilioIPMbot, + botframeworkbot: BotFrameworkBot, }; diff --git a/lib/CoreBot.js b/lib/CoreBot.js index 398386168..382ea7756 100755 --- a/lib/CoreBot.js +++ b/lib/CoreBot.js @@ -9,6 +9,8 @@ var ConsoleLogger = require(__dirname + '/console_logger.js'); var LogLevels = ConsoleLogger.LogLevels; var ware = require('ware'); +var studio = require('./Studio.js'); + function Botkit(configuration) { var botkit = { events: {}, // this will hold event handlers @@ -24,8 +26,9 @@ function Botkit(configuration) { }; botkit.utterances = { - yes: new RegExp(/^(yes|yea|yup|yep|ya|sure|ok|y(?!.)|yeah|yah)/i), - no: new RegExp(/^(no|nah|nope|n(?!.))/i) + 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|nevermind|never mind)/i) }; // define some middleware points where custom functions @@ -43,12 +46,18 @@ function Botkit(configuration) { 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; @@ -59,6 +68,10 @@ function Botkit(configuration) { this.startTime = new Date(); this.lastActive = new Date(); + this.collectResponse = function(key, value) { + this.responses[key] = value; + }; + this.capture = function(response) { var capture_key = this.sent[this.sent.length - 1].text; @@ -76,7 +89,7 @@ function Botkit(configuration) { // if text is an array, get 1st if (typeof(this.sent[this.sent.length - 1].text) == 'string') { response.question = this.sent[this.sent.length - 1].text; - } else if (Array.isArray(this.sent[this.sent.length - 1])) { + } else if (Array.isArray(this.sent[this.sent.length - 1].text)) { response.question = this.sent[this.sent.length - 1].text[0]; } else { response.question = ''; @@ -133,7 +146,16 @@ function Botkit(configuration) { } }; + 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'; }; @@ -208,7 +230,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, @@ -223,17 +245,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 = { @@ -244,24 +266,38 @@ 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); + }; - if (!this.topics[topic]) { - this.topics[topic] = []; + this.hasThread = function(thread) { + return (this.threads[thread] != undefined); + }; + + this.gotoThread = function(thread) { + this.thread = thread; + + if (!this.threads[thread]) { + this.threads[thread] = []; } - this.messages = this.topics[topic].slice(); + this.messages = this.threads[thread].slice(); this.handler = null; }; @@ -269,7 +305,12 @@ function Botkit(configuration) { this.combineMessages = function(messages) { if (!messages) { return ''; - }; + } + + if (Array.isArray(messages) && !messages.length) { + return ''; + } + if (messages.length > 1) { var txt = []; var last_user = null; @@ -345,6 +386,26 @@ function Botkit(configuration) { return this.combineMessages(this.responses[key]); }; + this.replaceAttachmentTokens = function(attachments) { + + if (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]); + } + } + } + } + + return attachments; + }; + + + + this.replaceTokens = function(text) { var vars = { @@ -374,6 +435,53 @@ function Botkit(configuration) { 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 = JSON.parse(JSON.stringify(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 (this.messages.length && !message.handler) { + outbound.continue_typing = true; + } + + if (typeof(message.attachments) == 'function') { + outbound.attachments = message.attachments(this); + } + + return outbound; + }; + this.tick = function() { var now = new Date(); @@ -390,9 +498,9 @@ function Botkit(configuration) { (lastActive > this.task.timeLimit) // nobody has typed for 60 seconds at least ) { - if (this.topics.timeout) { + if (this.threads.timeout) { this.status = 'ending'; - this.changeTopic('timeout'); + this.gotoThread('timeout'); } else { this.stop('timeout'); } @@ -430,25 +538,7 @@ function Botkit(configuration) { // or multiple attachments (slack) if (message.text || message.attachments || message.attachment) { - // clone this object so as not to modify source - var outbound = JSON.parse(JSON.stringify(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 (this.messages.length && !message.handler) { - outbound.continue_typing = true; - } - - if (typeof(message.attachments) == 'function') { - outbound.attachments = message.attachments(this); - } - + var outbound = this.cloneMessage(message); this.task.bot.reply(this.source_message, outbound, function(err) { if (err) { botkit.log('An error occurred while sending a message: ', err); @@ -466,8 +556,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 { @@ -477,22 +567,18 @@ 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); + 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) { @@ -510,15 +596,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; }; @@ -526,6 +616,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++) { @@ -699,9 +790,17 @@ function Botkit(configuration) { // the pattern might be a string to match (including regular expression syntax) // or it might be a prebuilt regular expression - var test; + var test = null; if (typeof(tests[t]) == 'string') { - test = new RegExp(tests[t], 'i'); + 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]; } @@ -750,6 +849,7 @@ function Botkit(configuration) { if (test_function && test_function(keywords, message)) { botkit.debug('I HEARD', keywords); cb.apply(this, [bot, message]); + botkit.trigger('heard_trigger', [bot, keywords, message]); return false; } }); @@ -791,6 +891,21 @@ 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'); @@ -799,16 +914,18 @@ function Botkit(configuration) { }; botkit.spawn = function(config, cb) { - var worker = new this.worker(this, config); + + var worker = new this.worker(this, config); // mutate the worker so that we can call middleware worker.say = function(message, cb) { botkit.middleware.send.run(worker, message, function(err, worker, message) { worker.send(message, cb); }); }; + botkit.middleware.spawn.run(worker, function(err, worker) { - botkit.middleware.spawn.run(worker, function(err, worker, message) { + botkit.trigger('spawned', [worker]); if (cb) { cb(worker); } @@ -975,6 +1092,9 @@ function Botkit(configuration) { // 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 index 4a32e141f..1ef540be7 100644 --- a/lib/Facebook.js +++ b/lib/Facebook.js @@ -13,6 +13,7 @@ function Facebookbot(configuration) { facebook_botkit.defineBot(function(botkit, config) { var bot = { + type: 'fb', botkit: botkit, config: config || {}, utterances: botkit.utterances, @@ -22,11 +23,13 @@ function Facebookbot(configuration) { botkit.startConversation(this, message, cb); }; + + bot.send = function(message, cb) { var facebook_message = { recipient: {}, - 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/)) { @@ -35,20 +38,25 @@ function Facebookbot(configuration) { facebook_message.recipient.id = message.channel; } - if (message.text) { - facebook_message.message.text = message.text; - } + if (!message.sender_action) { + if (message.text) { + facebook_message.message.text = message.text; + } - if (message.attachment) { - facebook_message.message.attachment = message.attachment; - } + if (message.attachment) { + facebook_message.message.attachment = message.attachment; + } - if (message.sticker_id) { - facebook_message.message.sticker_id = message.sticker_id; - } + if (message.sticker_id) { + facebook_message.message.sticker_id = message.sticker_id; + } - if (message.quick_replies) { - facebook_message.message.quick_replies = message.quick_replies; + if (message.quick_replies) { + facebook_message.message.quick_replies = message.quick_replies; + } + } + else { + facebook_message.sender_action = message.sender_action; } if (message.sender_action) { @@ -60,10 +68,10 @@ function Facebookbot(configuration) { facebook_message.access_token = configuration.access_token; request({ - method: "POST", + method: 'POST', json: true, headers: { - "content-type": "application/json", + 'content-type': 'application/json', }, body: facebook_message, uri: 'https://graph.facebook.com/v2.6/me/messages' @@ -83,9 +91,48 @@ function Facebookbot(configuration) { 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 text; + + if (typeof(resp) == 'string') { + text = resp; + } else { + text = resp.text; + } + + var avgWPM = 85 + var avgCPM = avgWPM * 7 + + var typingLength = Math.min(Math.floor(text.length / (avgCPM / 60)) * 1000, 5000) + + botkit.debug('typing takes', typingLength, 'ms') + 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 = {}; diff --git a/lib/SlackBot.js b/lib/SlackBot.js index 81a2318c1..281c40835 100755 --- a/lib/SlackBot.js +++ b/lib/SlackBot.js @@ -36,7 +36,7 @@ function Slackbot(configuration) { if (!existing_bot && worker.config.id) { spawned_bots.push(worker); - } else { + } else if (existing_bot) { if (existing_bot.rtm) { worker.rtm = existing_bot.rtm; } diff --git a/lib/Slack_web_api.js b/lib/Slack_web_api.js index 8a986ac76..2e46e3760 100755 --- a/lib/Slack_web_api.js +++ b/lib/Slack_web_api.js @@ -7,6 +7,7 @@ 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. @@ -97,6 +98,7 @@ module.exports = function(bot, config) { /** * 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 @@ -112,6 +114,7 @@ module.exports = function(bot, config) { /** * 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 @@ -161,7 +164,7 @@ module.exports = function(bot, config) { if (options.attachments && typeof(options.attachments) != 'string') { try { options.attachments = JSON.stringify(options.attachments); - } catch(err) { + } catch (err) { delete options.attachments; bot.log.error('Could not parse attachments', err); } @@ -174,6 +177,7 @@ module.exports = function(bot, config) { /** * 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. diff --git a/lib/Slackbot_worker.js b/lib/Slackbot_worker.js index 1b135799a..abc2f5a98 100755 --- a/lib/Slackbot_worker.js +++ b/lib/Slackbot_worker.js @@ -7,6 +7,7 @@ var Back = require('back'); module.exports = function(botkit, config) { var bot = { + type: 'slack', botkit: botkit, config: config || {}, utterances: botkit.utterances, @@ -162,7 +163,7 @@ module.exports = function(botkit, config) { pingIntervalId = setInterval(function() { if (lastPong && lastPong + 12000 < Date.now()) { var err = new Error('Stale RTM connection, closing RTM'); - botkit.log.error(err) + botkit.log.error(err); bot.closeRTM(err); clearInterval(pingIntervalId); return; @@ -173,7 +174,6 @@ module.exports = function(botkit, config) { }, 5000); botkit.trigger('rtm_open', [bot]); - bot.rtm.on('message', function(data, flags) { var message = null; @@ -267,10 +267,13 @@ module.exports = function(botkit, config) { * 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); }); }); }; @@ -279,6 +282,11 @@ module.exports = function(botkit, config) { botkit.startConversation(this, message, cb); }; + bot.createConversation = function(message, cb) { + botkit.createConversation(this, message, cb); + }; + + /** * Convenience method for creating a DM convo. */ diff --git a/lib/Studio.js b/lib/Studio.js new file mode 100644 index 000000000..98bafb9c4 --- /dev/null +++ b/lib/Studio.js @@ -0,0 +1,575 @@ +var request = require('request'); +var Promise = require('promise'); +var md5 = require('md5'); + +module.exports = function(controller) { + var before_hooks = {}; + var after_hooks = {}; + var answer_hooks = {}; + + + // 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 + * ---------------------------------------------------------------- */ + + + 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 + }, + }); + }; + + + // 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.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) { + + return new Promise(function(resolve, reject) { + + controller.studio.get(bot, input_text, user, channel).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) { + var context = { + text: input_text, + user: user, + channel: channel, + }; + return new Promise(function(resolve, reject) { + controller.studio.getScript(bot, input_text).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); + }); + }); + }); + }; + + + controller.studio.runTrigger = 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).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 { + // do nothing + } + }).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).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': + convo.repeat(); + 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) { + + convo.setTimeout(controller.config.default_timeout || (15 * 60 * 1000)); // 15 minute default timeout + if (err) { + return reject(err); + } + 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; + } + 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; + } + } + + 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); + } + } + } + resolve(convo); + }); + }); + }; + + /* ---------------------------------------------------------------- + * Botkit Studio Stats + * The features below this line pertain to communicating with Botkit Studio's + * stats feature. + * ---------------------------------------------------------------- */ + + + + function statsAPI(bot, options) { + 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); + } + 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; + 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) { + console.log('Error in Botkit Studio Stats:', err); + return reject(err); + } + try { + json = JSON.parse(body); + if (json.error) { + console.log('Error in Botkit Studio Stats:', json.error); + reject(json.error); + } else { + // console.log('** Stat recorded: ', options.form.type); + resolve(json); + } + } catch (e) { + console.log('Error in Botkit Studio Stats:', e); + return reject('Invalid 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 'fb': + x = md5(bot.botkit.config.access_token); + break; + + case 'twilioipm': + x = md5(bot.config.TWILIO_IPM_SERVICE_SID); + 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, + }); + }); + + 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, + }); + }); + + 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', + bot_type: bot.type, + }; + controller.trigger('stats:remote_command_end', message); + return statsAPI(bot, { + method: 'post', + form: data, + }); + + }); + + } + +}; diff --git a/lib/TwilioIPMBot.js b/lib/TwilioIPMBot.js index 52f2ca4c0..25c4c1ed4 100644 --- a/lib/TwilioIPMBot.js +++ b/lib/TwilioIPMBot.js @@ -17,6 +17,7 @@ function Twiliobot(configuration) { // spawn! twilio_botkit.defineBot(function(botkit, config) { var bot = { + type: 'twilioipm', botkit: botkit, config: config || {}, utterances: botkit.utterances, diff --git a/package.json b/package.json index eab32c777..0b2475ea2 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,22 @@ { "name": "botkit", - "version": "0.2.2", + "version": "0.4.0", "description": "Building blocks for Building Bots", "main": "lib/Botkit.js", "dependencies": { "async": "^2.0.0-rc.5", "back": "^1.0.1", "body-parser": "^1.14.2", + "botbuilder": "^3.2.3", "command-line-args": "^3.0.0", "express": "^4.13.3", "https-proxy-agent": "^1.0.0", "jfs": "^0.2.6", "localtunnel": "^1.8.1", + "md5": "^2.1.0", "mustache": "^2.2.1", + "promise": "^7.1.1", + "randomstring": "^1.1.5", "request": "^2.67.0", "twilio": "^2.9.1", "ware": "^1.3.0", diff --git a/readme-botframework.md b/readme-botframework.md new file mode 100644 index 000000000..66455535b --- /dev/null +++ b/readme-botframework.md @@ -0,0 +1,207 @@ +# 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 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" }); +``` diff --git a/readme-facebook.md b/readme-facebook.md index 9c8f6ea8b..f39157b53 100644 --- a/readme-facebook.md +++ b/readme-facebook.md @@ -15,6 +15,7 @@ Table of Contents * [Facebook-specific Events](#facebook-specific-events) * [Working with Facebook Webhooks](#working-with-facebook-messenger) * [Using Structured Messages and Postbacks](#using-structured-messages-and-postbacks) +* [Simulate typing](#simulate-typing) * [Running Botkit with an Express server](#use-botkit-for-facebook-messenger-with-an-express-web-server) ## Getting Started @@ -199,7 +200,7 @@ controller.on('facebook_postback', function(bot, message) { 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" } @@ -207,8 +208,22 @@ var reply_message = { 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!'); +``` + ## 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). - diff --git a/readme-studio.md b/readme-studio.md new file mode 100644 index 000000000..27f93f0d0 --- /dev/null +++ b/readme-studio.md @@ -0,0 +1,297 @@ +# 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. + +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](docs/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 a 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 at [https://github.com/howdyai/botkit-studio-starter](https://github.com/howdyai/botkit-studio-starter). 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: + +``` +// 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. + +``` +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: + +``` +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) + +``` +controller.studio.run(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: + +``` +// 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. + +``` +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. + +``` +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! + +``` +// 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! + +``` +// 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! + +``` +// 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(); + } + +}); +``` diff --git a/readme.md b/readme.md index a180b2398..89b61a8d6 100755 --- a/readme.md +++ b/readme.md @@ -4,7 +4,7 @@ [![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) -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 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. Support for new platforms is added regularly! 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. @@ -13,9 +13,42 @@ Botkit features a comprehensive set of tools to deal with popular messaging plat * [Slack](readme-slack.md) * [Facebook Messenger](readme-facebook.md) * [Twilio IP Messaging](readme-twilioipm.md) +* [Microsoft Bot Framework](readme-botframework.md) * Yours? [info@howdy.ai](mailto:info@howdy.ai) -## Installation +## Botkit Studio + +[Botkit Studio](https://studio.botkit.ai) is a hosted development environment for bots from the same team that built Botkit. +Based on feedback from the developer community, as well as experience running our flagship Botkit-powered bot, [Howdy](http://howdy.ai), +the tools in Botkit Studio allow bot designers and developers to manage many aspects of bot behavior without writing additional code. + +[Start building your bot with Botkit Studio](readme-studio.md) and you'll start from day one with extra tools and features that +help you create and manage a successful bot application. It is also possible to add Studio features to your existing Botkit application. [With a few lines of code](readme-studio.md#adding-studio-features-to-an-existing-bot), you can add access new features and APIs. + +Botkit Studio is built on top of Botkit, so everything that works with Botkit continues to just work. All of the available plugins and middleware are compatible! + +## Getting Started + +There are two ways to start a Botkit project: + +1) [Install the Botkit Studio Starter Kit](https://github.com/howdyai/botkit-studio-starter) and build on top of an already fully functioning bot +that comes pre-configured with popular middleware plug-ins and components. + +2) [Install Botkit directly from NPM or Github](#install-botkit-from-npm-or-github) and build a new app from scratch, or use one of the [included examples](#included-examples) as a starting point. + +After you've installed Botkit using one of these methods, the first thing you'll need to do is register your bot with a messaging platform, and get a few configuration options set. This will allow your bot to connect, send and receive messages. + +If you intend to create a bot that +lives in Slack, [follow these instructions for attaining a Bot Token](readme-slack.md#getting-started). + +If you intend to create a bot that lives in Facebook Messenger, [follow these instructions for configuring your Facebook page](readme-facebook.md#getting-started). + +If you intend to create a bot that lives inside a Twilio IP Messaging client, [follow these instructions for configuring your app](readme-twilioipm.md#getting-started). + +If you intend to create a bot that uses Microsoft Bot Framework to send and receive messages, [follow these instructions for configuring your app](readme-botframework.md#getting-started). + + +## Install Botkit from NPM or Github Botkit is available via NPM. @@ -41,19 +74,6 @@ npm install --production ``` -## Getting Started - -After you've installed Botkit, the first thing you'll need to do is register your bot with a messaging platform, and get a few configuration options set. This will allow your bot to connect, send and receive messages. - -The fastest way to get a bot online and get to work is to start from one of the [examples included in the repo](#included-examples). - -If you intend to create a bot that -lives in Slack, [follow these instructions for attaining a Bot Token](readme-slack.md#getting-started). - -If you intend to create a bot that lives in Facebook Messenger, [follow these instructions for configuring your Facebook page](readme-facebook.md#getting-started). - -If you intent to create a bot that lives inside a Twilio IP Messaging client, [follow these instructions for configuring your app](readme-twilioipm.md#getting-started). - ## 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. @@ -72,8 +92,10 @@ it is ready to be connected to a stream of incoming messages. Currently, Botkit * [Slack Slash Commands](http://api.slack.com/slash-commands) * [Facebook Messenger Webhooks](https://developers.facebook.com/docs/messenger-platform/implementation) * [Twilio IP Messaging](https://www.twilio.com/user/account/ip-messaging/getting-started) +* [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 Facebook](readme-facebook.md#getting-started), or [connecting your bot to Twilio](readme-twilioipm.md#getting-started). +Read more about [connecting your bot to Slack](readme-slack.md#connecting-your-bot-to-slack), [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) ## Included Examples @@ -85,6 +107,8 @@ These examples are included in the Botkit [Github repo](https://github.com/howdy [twilio_ipm_bot.js](https://github.com/howdyai/botkit/blob/master/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/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/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/team_outgoingwebhook.js) an example of a Botkit app that receives and responds to outgoing webhooks from a single team. @@ -137,6 +161,29 @@ controller.hears('hello',['direct_message','direct_mention','mention'],function( ``` +### 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: + +``` +var controller = Botkit.slackbot({ + stats_optout: true +}); +``` + + # Developing with Botkit Table of Contents @@ -422,8 +469,26 @@ Only the user who sent the original incoming message will be able to respond to `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. + ### Control Conversation Flow +#### conversation.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. + #### conversation.say() | Argument | Description |--- |--- @@ -543,6 +608,195 @@ controller.hears(['question me'], 'message_received', function(bot,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. + + +#### 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 | _Optional_ Object defining options for capturing the response +| thread_name | String defining the name of a thread + +This function works identically to `convo.ask()` except that it takes 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.ask(). + + +#### 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()` + +``` +// 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'); +``` + +#### 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. + +``` +// 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. + +``` +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.ask('Do you like cheese?', [ + { + pattern: 'yes', + callback: function(response, convo) { + convo.changeTopic('yes_thread'); + }, + }, + { + pattern: 'no', + callback: function(response, convo) { + convo.changeTopic('no_thread'); + }, + }, + { + default: true, + callback: function(response, convo) { + convo.changeTopic('bad_response'); + }, + } + ]); + + 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-the-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. + +``` +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}}` + ##### 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) @@ -609,8 +863,7 @@ 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`. +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) { diff --git a/slack_bot.js b/slack_bot.js index cb902e4fb..ee39d7f13 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({