diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 5760be5..0000000 --- a/.editorconfig +++ /dev/null @@ -1,12 +0,0 @@ -# http://editorconfig.org -root = true - -[*] -indent_style = space -indent_size = 2 -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.md] -trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index e9049bb..1f43471 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ node_modules -.DS_Store* -.hubot_history +.config.env.json diff --git a/Procfile b/Procfile index 9cf394e..1da0cd6 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: bin/hubot -a slack +web: node index.js diff --git a/README.md b/README.md index 77b4180..c61b60d 100644 --- a/README.md +++ b/README.md @@ -1,214 +1,34 @@ -# butler +# Butler -butler is a chat bot built on the [Hubot][hubot] framework. It was -initially generated by [generator-hubot][generator-hubot], and configured to be -deployed on [Heroku][heroku] to get you up and running as quick as possible. +Our welcoming Slack bot. -This README is intended to help get you started. Definitely update and improve -to talk about your own instance, how to use and deploy, what functionality he -has, etc! +This bot will welcome new users to the Slack team by entering a dialog to determine their skillset and direct them to the proper resources. -[heroku]: http://www.heroku.com -[hubot]: http://hubot.github.com -[generator-hubot]: https://github.com/github/generator-hubot -### Running butler Locally -You can test your hubot by running the following, however some plugins will not -behave as expected unless the [environment variables](#configuration) they rely -upon have been set. +## Installation -You can start butler locally by running: - - % bin/hubot - -You'll see some start up output and a prompt: - - [Sat Feb 28 2015 12:38:27 GMT+0000 (GMT)] INFO Using default redis on localhost:6379 - butler> - -Then you can interact with butler by typing `butler help`. - - butler> butler help - butler animate me - The same thing as `image me`, except adds [snip] - butler help - Displays all of the help commands that butler knows about. - ... - -### Configuration - -A few scripts (including some installed by default) require environment -variables to be set as a simple form of configuration. - -Each script should have a commented header which contains a "Configuration" -section that explains which values it requires to be placed in which variable. -When you have lots of scripts installed this process can be quite labour -intensive. The following shell command can be used as a stop gap until an -easier way to do this has been implemented. - - grep -o 'hubot-[a-z0-9_-]\+' external-scripts.json | \ - xargs -n1 -I {} sh -c 'sed -n "/^# Configuration/,/^#$/ s/^/{} /p" \ - $(find node_modules/{}/ -name "*.coffee")' | \ - awk -F '#' '{ printf "%-25s %s\n", $1, $2 }' - -How to set environment variables will be specific to your operating system. -Rather than recreate the various methods and best practices in achieving this, -it's suggested that you search for a dedicated guide focused on your OS. - -### Scripting - -An example script is included at `scripts/example.coffee`, so check it out to -get started, along with the [Scripting Guide](scripting-docs). - -For many common tasks, there's a good chance someone has already one to do just -the thing. - -[scripting-docs]: https://github.com/github/hubot/blob/master/docs/scripting.md - -### external-scripts - -There will inevitably be functionality that everyone will want. Instead of -writing it yourself, you can use existing plugins. - -Hubot is able to load plugins from third-party `npm` packages. This is the -recommended way to add functionality to your hubot. You can get a list of -available hubot plugins on [npmjs.com](npmjs) or by using `npm search`: - - % npm search hubot-scripts panda - NAME DESCRIPTION AUTHOR DATE VERSION KEYWORDS - hubot-pandapanda a hubot script for panda responses =missu 2014-11-30 0.9.2 hubot hubot-scripts panda - ... - - -To use a package, check the package's documentation, but in general it is: - -1. Use `npm install --save` to add the package to `package.json` and install it -2. Add the package name to `external-scripts.json` as a double quoted string - -You can review `external-scripts.json` to see what is included by default. - -##### Advanced Usage - -It is also possible to define `external-scripts.json` as an object to -explicitly specify which scripts from a package should be included. The example -below, for example, will only activate two of the six available scripts inside -the `hubot-fun` plugin, but all four of those in `hubot-auto-deploy`. - -```json -{ - "hubot-fun": [ - "crazy", - "thanks" - ], - "hubot-auto-deploy": "*" -} +Install dependencies: +``` +npm install ``` -**Be aware that not all plugins support this usage and will typically fallback -to including all scripts.** - -[npmjs]: https://www.npmjs.com - -### hubot-scripts - -Before hubot plugin packages were adopted, most plugins were held in the -[hubot-scripts][hubot-scripts] package. Some of these plugins have yet to be -migrated to their own packages. They can still be used but the setup is a bit -different. - -To enable scripts from the hubot-scripts package, add the script name with -extension as a double quoted string to the `hubot-scripts.json` file in this -repo. - -[hubot-scripts]: https://github.com/github/hubot-scripts - -## Persistence - -If you are going to use the `hubot-redis-brain` package (strongly suggested), -you will need to add the Redis to Go addon on Heroku which requires a verified -account or you can create an account at [Redis to Go][redistogo] and manually -set the `REDISTOGO_URL` variable. - - % heroku config:add REDISTOGO_URL="..." - -If you don't need any persistence feel free to remove the `hubot-redis-brain` -from `external-scripts.json` and you don't need to worry about redis at all. - -[redistogo]: https://redistogo.com/ - -## Adapters - -Adapters are the interface to the service you want your hubot to run on, such -as Campfire or IRC. There are a number of third party adapters that the -community have contributed. Check [Hubot Adapters][hubot-adapters] for the -available ones. - -If you would like to run a non-Campfire or shell adapter you will need to add -the adapter package as a dependency to the `package.json` file in the -`dependencies` section. - -Once you've added the dependency with `npm install --save` to install it you -can then run hubot with the adapter. - - % bin/hubot -a - -Where `` is the name of your adapter without the `hubot-` prefix. - -[hubot-adapters]: https://github.com/github/hubot/blob/master/docs/adapters.md - -## Deployment - - % heroku create --stack cedar - % git push heroku master - -If your Heroku account has been verified you can run the following to enable -and add the Redis to Go addon to your app. - - % heroku addons:create redistogo:nano - -If you run into any problems, checkout Heroku's [docs][heroku-node-docs]. - -You'll need to edit the `Procfile` to set the name of your hubot. - -More detailed documentation can be found on the [deploying hubot onto -Heroku][deploy-heroku] wiki page. - -### Deploying to UNIX or Windows - -If you would like to deploy to either a UNIX operating system or Windows. -Please check out the [deploying hubot onto UNIX][deploy-unix] and [deploying -hubot onto Windows][deploy-windows] wiki pages. - -[heroku-node-docs]: http://devcenter.heroku.com/articles/node-js -[deploy-heroku]: https://github.com/github/hubot/blob/master/docs/deploying/heroku.md -[deploy-unix]: https://github.com/github/hubot/blob/master/docs/deploying/unix.md -[deploy-windows]: https://github.com/github/hubot/blob/master/docs/deploying/unix.md - -## Campfire Variables - -If you are using the Campfire adapter you will need to set some environment -variables. If not, refer to your adapter documentation for how to configure it, -links to the adapters can be found on [Hubot Adapters][hubot-adapters]. - -Create a separate Campfire user for your bot and get their token from the web -UI. - - % heroku config:add HUBOT_CAMPFIRE_TOKEN="..." +## Running +Start the application with `SLACK_TOKEN` as an environment variable +``` +SLACK_TOKEN=xxx node . +``` -Get the numeric IDs of the rooms you want the bot to join, comma delimited. If -you want the bot to connect to `https://mysubdomain.campfirenow.com/room/42` -and `https://mysubdomain.campfirenow.com/room/1024` then you'd add it like -this: +## How it works - % heroku config:add HUBOT_CAMPFIRE_ROOMS="42,1024" +The bot uses a basic finite state machine to enter a dialog with the user. -Add the subdomain hubot should connect to. If you web URL looks like -`http://mysubdomain.campfirenow.com` then you'd add it like this: +The configuration of the state machine is defind in [dialog.js](src/dialog.js) as an array of JSON objects. - % heroku config:add HUBOT_CAMPFIRE_ACCOUNT="mysubdomain" +[node-factory.js](src/node-factory.js) will construct all the objects into [nodes](src/node.js). -[hubot-adapters]: https://github.com/github/hubot/blob/master/docs/adapters.md +The [bot](src/bot.js) will dispatch messages to specific [conversations](src/conversation.js). A [conversation](src/conversation.js) tracks the current state of a slack conversation with a user. -## Restart the bot +As of current (800a4ffb8a7fa86d3ccad436b2caf5623ffa7928), the state machine looks as follows: -You may want to get comfortable with `heroku logs` and `heroku restart` if -you're having issues. +![fsm](https://cloud.githubusercontent.com/assets/656630/9482684/7fb0be3e-4b64-11e5-98db-9da4496c74b8.jpg) diff --git a/bin/hubot b/bin/hubot deleted file mode 100755 index 559372e..0000000 --- a/bin/hubot +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh - -set -e - -#npm install -export PATH="node_modules/.bin:node_modules/hubot/node_modules/.bin:$PATH" - -exec node_modules/.bin/hubot --name "butler" "$@" diff --git a/bin/hubot.cmd b/bin/hubot.cmd deleted file mode 100644 index b0e1cb1..0000000 --- a/bin/hubot.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -npm install && node_modules\.bin\hubot.cmd --name "butler" %* \ No newline at end of file diff --git a/external-scripts.json b/external-scripts.json deleted file mode 100644 index 5d9e44c..0000000 --- a/external-scripts.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - "hubot-diagnostics", - "hubot-help", - "hubot-heroku-keepalive", - "hubot-google-images", - "hubot-google-translate", - "hubot-maps", - "hubot-redis-brain", - "hubot-rules", - "hubot-shipit", - "hubot-calculator" -] diff --git a/hubot-scripts.json b/hubot-scripts.json deleted file mode 100644 index ea3a3e6..0000000 --- a/hubot-scripts.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - "applause.coffee", - "hangman.coffee" -] diff --git a/index.js b/index.js new file mode 100644 index 0000000..4a3c183 --- /dev/null +++ b/index.js @@ -0,0 +1,39 @@ +var Slack = require('slack-client'), + Redis = require('redis'), + Promise = require('bluebird'), + Bot = require('./src/bot'), + http = require('http'); + +(function () { + var token = process.env.SLACK_TOKEN, + slack, + redis, + bot; + + if (!token) { + throw new Error('Slack token is required. Please provide SLACK_TOKEN as an environment variable'); + } + + redis = Redis.createClient({detect_buffers: true}); + + // promisify lists + redis.lpushAsync = Promise.promisify(redis.lpush); + redis.lrangeAsync = Promise.promisify(redis.lrange); + redis.lremAsync = Promise.promisify(redis.lrem); + + // promisify hashes + redis.hmsetAsync = Promise.promisify(redis.hmset); + redis.hmgetAsync = Promise.promisify(redis.hmget); + redis.hgetallAsync = Promise.promisify(redis.hgetall); + + // promisify all + redis.getAsync = Promise.promisify(redis.get); + redis.setAsync = Promise.promisify(redis.set); + + slack = new Slack(token, true); + bot = new Bot(slack, redis); + + http.createServer(function (req, res) { + res.end('Talk to me through slack'); + }).listen(process.env.PORT || 5000); +}).call(this); diff --git a/package.json b/package.json index 9af81c0..e0b3a7c 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,19 @@ { - "name": "butler", - "version": "0.0.0", - "private": true, - "author": "Atticus White ", - "description": "A simple helpful robot for your Company", - "dependencies": { - "hubot": "^2.16.0", - "hubot-calculator": "^0.4.0", - "hubot-diagnostics": "0.0.1", - "hubot-google-images": "^0.2.1", - "hubot-google-translate": "^0.2.0", - "hubot-help": "^0.1.1", - "hubot-heroku-keepalive": "^1.0.0", - "hubot-maps": "0.0.2", - "hubot-pugme": "^0.1.0", - "hubot-redis-brain": "0.0.3", - "hubot-rules": "^0.1.1", - "hubot-scripts": "^2.16.2", - "hubot-shipit": "^0.2.0", - "hubot-slack": "slackhq/hubot-slack#master", - "lodash": "^3.10.1" + "name": "slackbot", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" }, - "engines": { - "node": "0.10.x" + "author": "Atticus White (http://atticuswhite.com/)", + "license": "MIT", + "dependencies": { + "bluebird": "^2.9.34", + "lodash": "^3.10.1", + "log4js": "^0.6.26", + "moment": "^2.10.6", + "redis": "^1.0.0", + "slack-client": "^1.4.1" } } diff --git a/scripts/introductions.coffee b/scripts/introductions.coffee deleted file mode 100644 index db938a6..0000000 --- a/scripts/introductions.coffee +++ /dev/null @@ -1,42 +0,0 @@ -# Description: -# This is a test -# -# Commands: -# Message hubot to learn about the group -# - -module.exports = (robot) -> - # Have to bypass the typical robot dispatcher as userChange is a private event - SAFE_TTL = 10000 - - # The message to greet new users - getWelcomeMessage = (channelID) -> - [ - 'Hey, welcome to our Slack group!', - 'My name is Butler, and I try to make it easier for everyone to find what they\'re looking for.', - 'You can ask me things like:', - '- Are there any projects that need <phrase>?', - 'To list projects from a channel you can say:', - '- butler list projects', - 'Or DM me directly:', - '- list projects', - '\n', - 'For now, you should introduce yourself in the <#' + channelID + '> channel and browse some of our projects!' - ].join('\n') - - - # Introduce the robot to new users - robot.adapter.client.on 'userChange', (user) -> - if user.id && !user.deleted && ! user.is_bot - setTimeout -> - introChannel = robot.adapter.client.getChannelByName('introduction') - robot.send {room: user.name}, getWelcomeMessage(introChannel.id) - , SAFE_TTL - - robot.respond /show welcome/i, (msg) -> - introChannel = robot.adapter.client.getChannelByName('introduction') - msg.send getWelcomeMessage(introChannel.id) - # - # robot.respond /(.*?)/g, (res) -> - # if res.message.user.name == res.message.user.room - # res.send 'I am responding' diff --git a/scripts/projects.coffee b/scripts/projects.coffee deleted file mode 100644 index f4df20e..0000000 --- a/scripts/projects.coffee +++ /dev/null @@ -1,78 +0,0 @@ -# Description -# Allows users to find any open projects by skillset -# -# Commands -# hubot do any projects need - Finds any projects that require a skillset of - -_ = require 'lodash' - -knownTech = [ - 'html', 'css', 'less', 'saas', 'javascript', 'js', 'front end', 'back end', 'nodejs', 'node js', - 'rails', 'ruby', 'obj-c', 'objective c', 'objective-c', 'java', - 'android', 'jekyll', 'grunt', 'gulp', 'wordpress', 'bootstrap', 'c#', 'net', - 'django', 'python', 'php' -] - -projectTypes = - 'website': ['html', 'css', 'javascript', 'js'], - 'web_app': ['html', 'css', 'javascript', 'js'], - 'mobile_app': ['ios', 'android', 'iphone', 'java', 'objc', 'objective c'] - -module.exports = (robot) -> - projectPattern = new RegExp('projects .*(?:need|want|have|looking for).* (' + (knownTech.join '|') + ')', 'i'); - - getProjects = (cb) -> - robot.http('http://googledoctoapi.forberniesanders.com/1zKQZGGdKvDudZKKyds33vZMPwxt7I8soKt9qZ0t1LhE/') - .header('User-Agent', 'Mozilla/5.0') - .get() (err, res, body) -> - if err - msg.send 'I was unable to look up the projects' - robot.emit 'error', err, res - return - - projects = JSON.parse(body); - projects = _.filter projects, (project) -> - project.slack_name && project.project_type && project.slack_channel && project.used_tech - .map (project) -> - project = - 'name': project.project, - 'channel': robot.adapter.client.getChannelByName(project.slack_channel.replace('#', '').trim()), - 'leaders': project.slack_name.split(','), - 'tech': project.used_tech.toLowerCase(), - 'description': project.description, - 'type': project.project_type, - 'type_slug': _.snakeCase project.project_type - .filter (project) -> - return !!project.channel - cb(projects) - - formatProjectMessage = (project) -> - [ - '*' + project.name + '*', - '(' + project.type + ')', - 'in <#' + project.channel.id + '>', - 'lead by ' + project.leaders.join ', ' - ].join ' ' - - - projectResponseHandler = (msg) -> - skill = msg.match[1].toLowerCase() - - getProjects (projects) -> - projects = projects.filter (project) -> - (project.type_slug of projectTypes && _.contains projectTypes[project.type_slug], skill) || _.contains project.tech, skill - - if projects.length > 0 - message = ['We have found ' + projects.length + ' projects:'] - message.push _.map projects, formatProjectMessage - msg.send _.flatten(message).join('\n') - - robot.respond /list projects/i, (msg) -> - msg.send 'I\'ll PM you a list of the projects!' - getProjects (projects) -> - messages = _.map projects, formatProjectMessage - messages.push('You can find all the projects at https://docs.google.com/spreadsheets/d/1zKQZGGdKvDudZKKyds33vZMPwxt7I8soKt9qZ0t1LhE'); - robot.send {room: msg.envelope.user.name}, messages.join '\n' - - robot.hear projectPattern, projectResponseHandler - robot.respond projectPattern, projectResponseHandler diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..600c707 --- /dev/null +++ b/src/bot.js @@ -0,0 +1,86 @@ +var Slack = require('slack-client'), + Conversation = require('./conversation'), + Interpretter = require('./interpretter'), + TaskCoordinator = require('./task-coordinator'), + Promise = require('bluebird'), + Redis = require('redis'), + logger = require('log4js').getLogger('bot'), + _ = require('lodash'); + +module.exports = (function () { + var CHANNEL_TYPE_DM = 'DM', + CHANNEL_TYPE_CHANNEL = 'Channel'; + + function Bot (slackClient, redisClient) { + this.service = slackClient; + this.redisClient = redisClient; + this.taskCoordinator = new TaskCoordinator(this) + this.conversations = {}; + + this.service.on('message', this.dispatch.bind(this)); + this.service.on('userChange', this.userJoined.bind(this)); + this.service.on('open', this.connected.bind(this)); + this.service.login(); + } + + Bot.prototype.dispatch = function (message) { + var messageObject, + user; + if (!message.user || !message.channel) { + return; + } + + user = this.service.getUserByID(message.user); + messageObject = this.service.getChannelGroupOrDMByID(message.channel); + + if (messageObject.getType() === CHANNEL_TYPE_DM) { + this.respondToDM(user, message); + } else if (messageObject.getType() === CHANNEL_TYPE_CHANNEL && + _.contains(message.text, '<@' + this.service.self.id + '>')) { + this.respondToMention(user, message, messageObject); + } + }; + + Bot.prototype.respondToDM = function (user, message) { + if (!(message.user in this.conversations)) { + logger.info('new user conversation', message.user, '(', user.name, ')'); + this.conversations[message.user] = new Conversation(this, message.channel); + } + this.conversations[message.user].push(message); + }; + + Bot.prototype.respondToMention = function (user, message, channel) { + if (Interpretter.isLookingForHelp(message.text)) { + this.taskCoordinator.requestHelp(user, message, channel); + } else if (Interpretter.isAskingForHelp(message.text)) { + this.taskCoordinator.provideHelp(user, message, channel); + } + logger.info(message.user, '(', user.name, ') pinged from', channel.getType(), 'with message', message.text); + }; + + Bot.prototype.connected = function () { + // leave all channels that the bot may have been added to + logger.info('connection successful'); + }; + + Bot.prototype.userJoined = function (event) { + var userId = event.id; + if (event.deleted || event.is_bot) { + // user account deactivation, ignore + return; + } + + this.service.openDM(userId, function (response) { + var channel; + if (!response.ok) { + return; + } + channel = response.channel; + this.conversations[userId] = new Conversation(this, channel.id); + this.conversations[userId].introduce(); + logger.info(userId, '(', event.name, ') joined - conversation initialized', channel.id); + }.bind(this)); + }; + + return Bot; +}).call(this); diff --git a/src/conversation.js b/src/conversation.js new file mode 100644 index 0000000..f140ea2 --- /dev/null +++ b/src/conversation.js @@ -0,0 +1,53 @@ +var NodeFactory = require('./node-factory'), + Filters = require('./filters'), + logger = require('log4js').getLogger('conversation'); + +module.exports = (function () { + /** + * A conversation object tracking the current dialog node with a given user + */ + function Conversation (delegate, channel) { + // the bot + this.delegate = delegate; + // the DM channel + this.channel = delegate.service.getDMByID(channel); + // the current dialog node + this.node = null; + } + + /** + * Receive a message from the user and interact with the current node + */ + Conversation.prototype.push = function (message) { + var transitionNode, + user = this.delegate.service.getUserByID(message.user), + response = 'Try again, I did not understand'; + + logger.info(message.user, '(', user.name, ') sent', message.text, 'in response to node', this.node ? this.node.state : 'initial node'); + if (this.node === null) { + // the converation has not yet started, grab the root (welcome) node + this.node = NodeFactory.getRootNode(); + // respond with the root node message + response = this.node.getValue(); + } else if (transition = this.node.interact(message.text)) { + // the message has transitioned to a new node in the conversation + this.node = transition; + // respond with the next node message + response = this.node.getValue(); + } + + // apply any escape rules to the message + // @link https://api.slack.com/docs/formatting + response = Filters.escapeMessage(response, this.delegate.service); + + // send the node's message to the user + this.channel.send(response); + }; + + Conversation.prototype.introduce = function () { + this.node = NodeFactory.getRootNode(); + this.channel.send(this.node.getValue()); + }; + + return Conversation; +}).call(this); diff --git a/src/dialog.js b/src/dialog.js new file mode 100644 index 0000000..8ba6f12 --- /dev/null +++ b/src/dialog.js @@ -0,0 +1,144 @@ +var states = [ + // 0 - Initial Greeting + { + message: [ + "Hey! :wave:\n", + "Welcome to our Slack group :simple_smile:\n", + "I'm here to help get you started and point you to the right resources and people.\n", + "So let's get right to it -- have you used Slack before?" + ], + type: Boolean, + options: [ + { + word: 'Yes', + state: 2, + regex: /[y]+(es|e|a)*$/i + }, { + word: 'No', + state: 1, + regex: /[n]/i + } + ] + }, + + // 1 - Newcomer + { + message: [ + "Not a problem! Slack is a great tool for teams. You can find a lot information on their website in how it works https://slack.com.\n", + "On the left sidebar is where you'll different channels and people. Most of our channels are made up of groups working on different projects.\n", + "Feel free to poke around and drop by different channels to see what people are working on.\n", + "In the meantime, I can help point you in the right direction. First, what are you interested in?" + ], + options: [ + { + word: 'Development', + state: 3, + regex: /[d]+(ev)+/i + }, { + word: 'Design', + state: 4, + regex: /[d]+(es)+/i + }, { + word: 'Managing', + state: 5, + regex: /(man)+/i + }, { + word: 'Other', + state: 6, + regex: /(other)+/i + } + ], + type: String + }, + + // 2 - Experienced + { + message: [ + "Great, you know your way around here then!\n", + "As you can guess, we have a handful of channels. Most of them are split up by project. Feel free to poke around and drop by different channels to see what people are working on.\n", + "In the meantime, I can help point you in the right direction. First, what are you interested in?" + ], + type: String, + options: [ + { + word: 'Development', + state: 3, + regex: /[d]+(ev)+/i + }, { + word: 'Design', + state: 4, + regex: /[d]+(es)+/i + }, { + word: 'Managing', + state: 5, + regex: /(man)+/i + }, { + word: 'Other', + state: 6, + regex: /(other)+/i + } + ] + }, + + // 3 - Developer + { + message: [ + "Awesome, we have plenty of projects for developers.", + "Here's a few that I can think of broken down by platform:\n", + "*Android, iOS, and other mobile platform development*\n", + "- #bernie-app - The bernie app\n", + "\n", + "*Web application development*\n", + "- #berniestrap - Bernie Sanders theme of the Twitter Bootstrap fork\n", + "- #bus-rsvp - Reserve seats in busses to travel to a Bernie event\n", + "- #carpool-app - Organize rides to travel to a Bernie event\n", + "- #elastic-search-es4bs - ElasticSearch API for all our Bernie apps\n", + "- #map-berniesanders - The map on https://map.berniesanders.com\n", + "- #sites-for-bernie - The http://forberniesanders.com project\n", + "\n\n", + "Those are just a few of the many projects we have going on. You will find new ideas brewing in #pitch-zone\n", + "If you'd like to learn more, feel free to reach out to some of our mods @jahaz, @atticusw, @schneidmaster, @rcas, @jonculver, or @validatorian!" + ], + type: String + }, + + // 4 - Designer + { + message: [ + "Awesome, we have plenty of projects for designers", + "Here's a few that I can think of broken down by platform:\n", + "*Android, iOS, and other mobile platform development*\n", + "- #bernie-app - The bernie app\n", + "\n", + "*Web application development*\n", + "- #berniestrap - Bernie Sanders theme of the Twitter Bootstrap fork\n", + "- #bus-rsvp - Reserve seats in busses to travel to a Bernie event\n", + "- #carpool-app - Organize rides to travel to a Bernie event\n", + "- #elastic-search-es4bs - ElasticSearch API for all our Bernie apps\n", + "- #map-berniesanders - The map on https://map.berniesanders.com\n", + "- #sites-for-bernie - The http://forberniesanders.com project\n", + "\n\n", + "Those are just a few of the many projects we have going on. You will find new ideas brewing in #pitch-zone\n", + "If you'd like to learn more, feel free to reach out to some of our mods @jahaz, @atticusw, @schneidmaster, @rcas, @jonculver, or @validatorian!" + ], + type: String + }, + + // 5 - Management + { + message: [ + "Awesome, you'll want to coordinate with @jahaz. Give him a shout!\n" + ], + type: String + }, + + // 6 - Other + { + message: [ + "Cool, give @jahaz a shout to see where you can come in!\n" + ], + type: String + } +]; + +module.exports = states; diff --git a/src/filters.js b/src/filters.js new file mode 100644 index 0000000..385f279 --- /dev/null +++ b/src/filters.js @@ -0,0 +1,90 @@ +var _ = require('lodash'), + logger = require('log4js').getLogger('filter'); + +module.exports = (function () { + var CHANNEL_REGEX = /(#[\w\-]+)/g, + USER_REGEX = /(@\w+)/g, + TOKEN = { + OPEN: '<', + CLOSE: '>', + CHANNEL: '#', + USER: '@', + SEPARATOR: '|' + }, + service = {}; + + service.escape = function (id, name, token) { + return [ + TOKEN.OPEN, + token, + id, + TOKEN.SEPARATOR, + name, + TOKEN.CLOSE + ].join(''); + }; + + service.escapeChannel = function (id, name) { + return service.escape(id, name, TOKEN.CHANNEL); + }; + + service.escapeUser = function (id, name) { + return service.escape(id, name, TOKEN.USER); + } + + service.escapeChannelByName = function (name, client) { + var channel = client.getChannelByName(name); + if (!channel || !channel.id) { + logger.warn('no channel found for filter', name); + return name; + } + return service.escapeChannel(channel.id, name); + }; + + service.escapeUserByName = function (name, client) { + var user = client.getUserByName(name); + if (!user || !user.id) { + logger.warn('no user found for filter', name); + return name; + } + return service.escapeUser(user.id, name); + }; + + service.escapeUserById = function (id, client) { + var user = client.getUserByID(id); + if (!user || !user.name) { + logger.warn('no user found for filter', id); + return id; + } + return service.escapeUser(id, user.name); + }; + + service.escapeChannelById = function (id, client) { + var channel = client.getChannelByID(id); + if (!channel || !channel.name) { + logger.warn('no channel found for filter', id); + return id; + } + return service.escapeChannel(id, channel.name); + }; + + service.escapeMessage = function (message, client) { + var messageBits = message.split(' '); + return _.map(messageBits, function (word) { + var match = null, + filtered = null; + + if (match = CHANNEL_REGEX.exec(word)) { + filtered = service.escapeChannelByName(match[1].substring(1, match[1].length), client); + } else if (match = USER_REGEX.exec(word)) { + filtered = service.escapeUserByName(match[1].substring(1, match[1].length), client); + } + if (filtered) { + word = word.replace(match[1], filtered); + } + return word; + }).join(' '); + }; + + return service; +}).call(this); diff --git a/src/interpretter.js b/src/interpretter.js new file mode 100644 index 0000000..e180177 --- /dev/null +++ b/src/interpretter.js @@ -0,0 +1,13 @@ +module.exports = (function () { + var service = {}; + + service.isLookingForHelp = function (message) { + return message.indexOf('i need help') > -1; + }; + + service.isAskingForHelp = function (message) { + return message.indexOf('anyone need help') > -1; + }; + + return service; +}).call(this); diff --git a/src/node-factory.js b/src/node-factory.js new file mode 100644 index 0000000..463f69e --- /dev/null +++ b/src/node-factory.js @@ -0,0 +1,14 @@ +var Node = require('./node'), + states = require('./dialog'), + _ = require('lodash'), + nodes; + +nodes = _.map(states, function (state, index) { + return new Node(index, state.message, state.options, state.type); +}); + +module.exports = { + getRootNode: function () { + return Node.get(0); + } +}; diff --git a/src/node.js b/src/node.js new file mode 100644 index 0000000..31d34d5 --- /dev/null +++ b/src/node.js @@ -0,0 +1,74 @@ +var _ = require('lodash'); + +module.exports = (function () { + function Node (state, message, options) { + // append the node to the static tree + Node.nodes.push(this); + + // the state value of the node + this.state = state; + // the node's message + this.message = Array.isArray(message) ? message.join(' ') : message; + // the options available to the node + this.options = options; + } + + // static collection of nodes + Node.nodes = []; + + // static node accessor + Node.get = function (state) { + return Node.nodes[state] || null; + }; + + /** + * Handle an interaction with the node. + * - Return the next state (node) based on the input message + * - Return the root node in the case of a rewinding + * - Return `null` if there is no transition + */ + Node.prototype.interact = function (message) { + var transitionNode; + if (message === "restart") { + return Node.get(0); + } + + // find the node to transition to based on the options + transitionNode = _.find(this.options, function (option) { + if (option.regex) { + // if the transition has a regex value, match it + return option.regex.test(message); + } else { + // otherwise check if the input matches the option value + return option.word.toLowerCase() === message.toLowerCase(); + } + }); + + if (transitionNode) { + // if we have a new node to transition to, return it + return Node.get(transitionNode.state); + } else { + // if no inputs are satisfied, there is no transition + return null; + } + }; + + /** + * Gets the formatted message value of the node. If there are options, they are formatted into a comma delimited list + */ + Node.prototype.getValue = function () { + var output = _.map(this.options, function (option) { + return option.word; + }); + if (output.length > 1) { + output[output.length - 1] = 'or ' + output[output.length - 1]; + } + return [ + this.message, + '\n', + output.join(output.length > 2 ? ', ' : ' ') + ].join(' '); + }; + + return Node; +}).call(this); diff --git a/src/task-coordinator.js b/src/task-coordinator.js new file mode 100644 index 0000000..78a91e3 --- /dev/null +++ b/src/task-coordinator.js @@ -0,0 +1,59 @@ +var _ = require('lodash'), + moment = require('moment'), + Filter = require('./filters'); + +module.exports = (function () { + function TaskCoordinator (delegate) { + this.delegate = delegate; + } + + TaskCoordinator.prototype.requestHelp = function (user, message, channel) { + var key = 'help:user:' + user.id, + promises = []; + + promises.push(this.delegate.redisClient.hmsetAsync(key, { + user: user.id, + channel: channel.id, + message: message.text, + date: moment().unix() + })); + + promises.push(this.delegate.redisClient.lremAsync('help', 0, key).finally(function () { + return this.delegate.redisClient.lpush('help', key); + }.bind(this))); + + Promise.all(promises).then(function () { + channel.send('I\'ll let someone know the next time they ask!'); + }).catch(function (error) { + channel.send('Sorry! having an issue right now processing help requests'); + console.log('error requesting help', error); + }); + }; + + TaskCoordinator.prototype.provideHelp = function (user, message, channel) { + this.delegate.redisClient.lrangeAsync('help', 0, 10).then(function (keys) { + var lookups = _.map(keys, function (key) { + return this.delegate.redisClient.hgetallAsync(key); + }.bind(this)); + return Promise.all(lookups); + }.bind(this)).then(function (helpRequests) { + var messages = _.map(helpRequests, function (request) { + return [ + Filter.escapeUserById(request.user, this.delegate.service), + 'needed help in', + Filter.escapeChannelById(request.channel, this.delegate.service), + moment.unix(request.date).from(moment()) + '.', + '\n', + '>', + request.message.replace('<@' + this.delegate.service.self.id + '>', '') + ].join(' '); + }.bind(this)); + channel.send(messages.join('\n')); + }.bind(this)).catch(function (error) { + console.log('error', error); + channel.send('I had some trouble looking up help requests'); + }) + }; + + return TaskCoordinator; +}).call(this);