├── .gitignore ├── Procfile ├── README.md ├── env.sample ├── heroku-sample.env ├── lib ├── pair_db.js ├── pair_db_memory.js ├── pair_db_mongo.js ├── pair_list.js └── pair_list_mongo.js ├── package.json └── web.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | .DS_Store 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node web.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## slack commands for communicating about pairing / coworking with your team 3 | 4 | So you can find teammates to pair on the company website or a bite to eat, for example: 5 | 6 | Slack-Pair example screenshot 7 | 8 | 9 | ### Usage 10 | 11 | Use "/pair" alone to list the status of all teammates: 12 | 13 | > `/pair` 14 | 15 | > Yes! Someone should come find me now. Let's pair: 16 | > 17 | > * Jeremia: "Want to work on design today! Open to other ideas."
18 | > * Giselle: "Would love to do learn some JS today, or teach design" 19 | > 20 | > OK. I'm working now but feel free to interrupt me: 21 | > 22 | > * Molly 23 | > * Tom 24 | > 25 | > Nope. Do Not Disturb: 26 | > 27 | > * Jason: travelling 28 | > * Maksim 29 | > * Peter: deadlines! 30 | 31 | Use "/pair [yes/ok/no]" to set your status 32 | 33 | > `/pair yes` 34 | 35 | > Yes! You want to pair. 36 | > Use "/pair yes [subject]" to specify the [subject] you want to pair on. 37 | 38 | or 39 | 40 | > `/pair ok` 41 | > 42 | > OK! You're working but are OK with occassional interruptions for brief pairing. 43 | 44 | or 45 | 46 | > `/pair no` 47 | > 48 | > Bummer! You're too busy for pairing. 49 | 50 | ### Setup & Run 51 | 52 | 1. get a copy of the source: `git clone https://github.com/techieshark/slack-pair.git && cd slack-pair` 53 | 2. you can start it by just running `npm start`, but first: 54 | 3. follow the [instructions for configuring the Slack integration](https://github.com/techieshark/slack-pair/issues/14). 55 | 4. If you want notifications sent to a channel (e.g. "Samantha says yes to pairing (kernel debugging)"), configure an incoming webhook (name=pair, description="pair with buddies", channel = whatever channel you want things sent to), then copy the channel & webhook url to your config file and uncomment the lines for SLACK_PAIR_CHANNEL and SLACK_WEBHOOK_URL. Make sure to `$ source your-slack-domain.env` after you've copied and edited the env.sample. 56 | 5. By default, `pair` will run using an in-memory data store, which works for testing purposes but as soon as the app restarts (which could be more than once a day on Heroku), the list of users wanting to pair will be wiped. To prevent that, set up a MongoDB database, update `MONGO_URL` in your environment (see `env.sample`) and switch `DB_PROVIDER` from `memory` to `mongo` (again, see `env.sample`). 57 | 58 | ### Deploying to Heroku 59 | 60 | 1. Follow the setup instructions above. You should have `pair` up and running on your development machine, and connected to Slack through an `ngrok`-provided URL. 61 | 2. Create a heroku app: `heroku create your-app`. 62 | 3. Copy `heroku-sample.env` to `your-app.heroku.env`. Put your environment settings in that file. 63 | 4. If you want to user MongoDB, create that addon: `heroku addons:create mongolab:sandbox`. You'll need to create a new user / password and update the MONGO_URL in your environment settings. If you don't do this, you'l just run in memory and the pair list will reset fairly often (ok for testing, a bummer for production). 64 | 5. Push your confit to heroku: `heroku config:push -e your-app.heroku.env`. 65 | 6. Deploy to Heroku: `git push heroku master` 66 | 7. Update Slack's URL setting in the Custom Integration you set up in `Setup & Run` above. Your URL should look like https://your-app.herokuapp.com. 67 | 8. Enjoy! 68 | 69 | 70 | ### Contributing 71 | 72 | [Pull requests](https://help.github.com/articles/using-pull-requests/) are welcome and encouraged! You'll need 73 | 74 | 1. A slack account and the [ability to add slash commands](http://YOURTEAMNAME.slack.com/services/new/slash-commands) 75 | 2. [ngrok](https://ngrok.com/) or some other method of exposing a local port through a public URL 76 | 77 | Once you pull down the project, simply run `npm install` to set up the dependencies. There is a required `PAIRBOT_URL` environment variable but you can `source env.sample` to set it. This is used so that the bot pings itself to keep the Heroku dynos up. 78 | 79 | Then you should be able to just `npm start` (or `node web.js`) and be off to the races. 80 | 81 | You'll be wanting a slack command integration and supply a publicly accessible URL along with a testing command. Slack uses these commands to trigger the integration. To test out your app you'll tell slack to `/ ok test all the things`. 82 | 83 | Of course, if you run into any problems you can always open an [issue](https://github.com/techieshark/slack-pair/issues). 84 | 85 | 86 | ### Credits 87 | 88 | This is a collaborative project. We welcome your contributitions (see above). Ping [@techieshark](https://twitter.com/techieshark) on twitter if you want to get involved. 89 | 90 | Code originally by [@jeremiak](https://github.com/jeremiak) & [@techieshark](https://github.com/techieshark). Other collaborators listed here: https://github.com/techieshark/slack-pair/graphs/contributors. 91 | 92 | Special thanks to Ainsley ([@ainsleywagon](https://github.com/ainsleywagon)) for designing such a cool pair icon (https://thenounproject.com/term/pair/19161/). 93 | 94 | --- 95 | 96 | **happy pairing :)** 97 | -------------------------------------------------------------------------------- /env.sample: -------------------------------------------------------------------------------- 1 | export PAIRBOT_URL=http://0.0.0.0:5000 2 | export SLACK_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXX 3 | #export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/TXXXXXXXX/BXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX 4 | #export SLACK_PAIR_CHANNEL=#pair 5 | export MONGO_URL="mongodb://localhost:27017/test" 6 | export DB_PROVIDER=memory 7 | #export DB_PROVIDER=mongo 8 | #export DB_PROVIDER=redis 9 | -------------------------------------------------------------------------------- /heroku-sample.env: -------------------------------------------------------------------------------- 1 | PAIRBOT_URL=http://your-app.herokuapp.com 2 | SLACK_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXX 3 | #SLACK_WEBHOOK_URL=https://hooks.slack.com/services/TXXXXXXXX/BXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX 4 | #SLACK_PAIR_CHANNEL=#pair 5 | MONGO_URL="mongodb://user:password@xyz123.mongolab.com:35965/heroku_xyz123" 6 | DB_PROVIDER=memory 7 | #DB_PROVIDER=mongo 8 | #DB_PROVIDER=redis 9 | -------------------------------------------------------------------------------- /lib/pair_db.js: -------------------------------------------------------------------------------- 1 | var PairDbMemory = require('./pair_db_memory.js'), 2 | PairDbMongo = require('./pair_db_mongo.js'); 3 | // PairDbRedis = require('./pair_db_redis.js'); 4 | 5 | function PairDb () { } 6 | 7 | // factory function: 8 | // Decides which PairDb to return based on env var DB_PROVIDER 9 | // 10 | // returns a PairDb with functions: 11 | // .connect(callback) -> connects to database, calls callback when done 12 | // .getPairList() -> fetches pairlist from database 13 | // 14 | PairDb.build = function () { 15 | 16 | var provider = process.env.DB_PROVIDER; 17 | if (!provider || provider === '') { 18 | console.error("ERROR: environment variable DB_PROVIDER must be set to `memory`, `mongo`, or `redis`"); 19 | process.exit(1); 20 | } 21 | 22 | if (provider === 'memory') { 23 | console.error("WARNING: While using in-memory storage, list won't save across app restarts."); 24 | Db = PairDbMemory; 25 | 26 | } else if (provider === 'mongo') { 27 | Db = PairDbMongo; 28 | 29 | } else if (provider === 'redis') { 30 | throw "Sorry, Redis isn't actually supported yet."; 31 | } 32 | 33 | try { 34 | return new Db(); 35 | } catch (e) { 36 | console.error("ERROR: " + e); 37 | process.exit(1); 38 | } 39 | 40 | }; 41 | 42 | 43 | module.exports = PairDb; 44 | 45 | -------------------------------------------------------------------------------- /lib/pair_db_memory.js: -------------------------------------------------------------------------------- 1 | var PairList = require('./pair_list.js'); 2 | 3 | function PairDbMemory() { 4 | this.pairList = new PairList(); 5 | } 6 | 7 | // .connect(callback) -> connects to database, calls callback when done 8 | PairDbMemory.prototype.connect = function (callback) { 9 | // does nothing, we're already connected to memory 10 | callback(null); 11 | }; 12 | 13 | // .getPairList() -> fetches pairlist from database 14 | PairDbMemory.prototype.getPairList = function () { 15 | return this.pairList; 16 | }; 17 | 18 | module.exports = PairDbMemory; 19 | -------------------------------------------------------------------------------- /lib/pair_db_mongo.js: -------------------------------------------------------------------------------- 1 | var MongoClient = require('mongodb').MongoClient; 2 | var PairListMongo = require('./pair_list_mongo.js'); 3 | 4 | 5 | function PairDbMongo () { 6 | this.dbUrl = process.env.MONGO_URL; 7 | if (!this.dbUrl || this.dbUrl === '') { 8 | throw "Missing MONGO_URL environment variable needed by Pair to run on Mongo DB."; 9 | } 10 | 11 | this.slack_token = process.env.SLACK_TOKEN; 12 | if (!this.slack_token || this.slack_token === '') { 13 | throw "Missing SLACK_TOKEN environment variable needed by Pair to run on Mongo DB."; 14 | } 15 | } 16 | 17 | 18 | PairDbMongo.prototype.getPairList = function () { 19 | return new PairListMongo(this.database, this.slack_token); 20 | }; 21 | 22 | 23 | PairDbMongo.prototype.connect = function(callback) { 24 | var that = this; 25 | MongoClient.connect(this.dbUrl, function(err, database) { 26 | that.database = database; 27 | callback(err); 28 | }); 29 | }; 30 | 31 | 32 | module.exports = PairDbMongo; -------------------------------------------------------------------------------- /lib/pair_list.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'), 2 | assert = require('assert'), 3 | debug = require('debug')('pair_list'); 4 | 5 | 6 | // Simple PairList using an array 7 | function PairList () { 8 | this._pairs = []; 9 | } 10 | 11 | // Fetches (currently no-op), then calls callback 12 | // callback(err, pairList) 13 | PairList.prototype.fetch = function (callback) { 14 | callback(null, this); 15 | }; 16 | 17 | function _update (users, username, status, comment) { 18 | var user = _.find(users, {'username': username}); 19 | if (user) { 20 | user.status = status; 21 | user.comment = comment; 22 | } 23 | else { 24 | user = {'username': username, 'status': status, 'comment': comment}; 25 | users.push(user); 26 | } 27 | debug("Updated pair list: "); 28 | debug(users); 29 | return user; 30 | } 31 | 32 | 33 | // update the pair list. Calls cb with the user & pairlist that has been updated. 34 | PairList.prototype.update = function (username, status, comment, callback) { 35 | assert(this._pairs instanceof Array); 36 | user = _update(this._pairs, username, status, comment); 37 | callback(null, {user: user, pairs: this._pairs}); 38 | }; 39 | 40 | 41 | function setof(status, users) { 42 | return _.filter(users, { 'status': status }).map(function(user) { 43 | return ">*" + user.username + "*: " + user.comment; 44 | }).join('\n'); 45 | } 46 | 47 | 48 | PairList.prototype.toString = function () { 49 | var status = '', yes, no, ok; 50 | 51 | yes = setof('yes', this._pairs); 52 | ok = setof('ok', this._pairs); 53 | no = setof('no', this._pairs); 54 | 55 | if (yes.length > 0) { 56 | status = '*Yes! Let\'s pair. Come find me now:*\n'; 57 | status += yes; 58 | } 59 | if (ok.length > 0 ) { 60 | status += '\n*Ok. I\'m working but you can interrupt:*\n'; 61 | status += ok; 62 | } 63 | if (no.length > 0 ) { 64 | status += '\n*Nope. Do Not Disturb:*\n'; 65 | status += no; 66 | } 67 | if (status === '') { 68 | status = 'No one up for pairing (yet!). Pair up, yo.\n'; 69 | } else { 70 | status += '\n Pair up, yo. (Go find \'em!) \n'; 71 | } 72 | return status; 73 | }; 74 | 75 | module.exports = PairList; 76 | 77 | -------------------------------------------------------------------------------- /lib/pair_list_mongo.js: -------------------------------------------------------------------------------- 1 | 2 | var PairList = require('./pair_list.js'), 3 | assert = require('assert'), 4 | debug = require('debug')('pair_list_mongo'); 5 | 6 | 7 | var pairCollection = 'pair'; 8 | 9 | // Create a pair list backed by a connected MongoDB database 10 | function PairListMongo (db, slack_token) { 11 | assert(db); 12 | assert(slack_token); 13 | this._db = db; 14 | this._token = slack_token; 15 | 16 | PairList.call(this); 17 | } 18 | 19 | PairListMongo.prototype = Object.create(PairList.prototype); 20 | PairListMongo.prototype.constructor = PairListMongo; 21 | 22 | 23 | // fetch list initially 24 | PairListMongo.prototype.fetch = function (callback) { 25 | var pairList = this; 26 | var cursor = this._db.collection(pairCollection).find({"slack_token": this._token}).limit(1); 27 | cursor.toArray(function(err, documents) { 28 | assert.equal(err, null); 29 | if (documents !== null) { 30 | assert(documents.length < 2); 31 | var list; 32 | if (documents.length === 1 && documents[0].users instanceof Array) { 33 | list = documents[0].users; 34 | debug("db found users list:"); 35 | debug(list); 36 | debug("docs:"); 37 | debug(documents); 38 | } else { 39 | list = []; 40 | } 41 | pairList._pairs = list; 42 | assert(pairList._pairs instanceof Array); 43 | 44 | callback(null, pairList); 45 | } else { 46 | callback("No userlist found."); 47 | } 48 | }); 49 | }; 50 | 51 | 52 | PairListMongo.prototype.update = function (username, status, comment, callback) { 53 | // call PairList update first, then save to database 54 | pairList = this; 55 | assert(this._pairs instanceof Array); 56 | Object.getPrototypeOf(PairListMongo.prototype).update.call(this, 57 | username, status, comment, 58 | function (err, data) { 59 | if (err) throw err; 60 | pairList._save(data, callback); 61 | }); 62 | }; 63 | 64 | 65 | PairListMongo.prototype._save = function (data, callback) { 66 | var list = data.pairs; 67 | var user = data.user; 68 | debug("saving data:"); 69 | debug(data); 70 | this._db.collection(pairCollection).updateOne( 71 | { "slack_token": this._token }, 72 | { // perhaps we could just update the one user, but for small lists this s/be ok 73 | "slack_token": this._token, 74 | "users" : list 75 | }, 76 | { upsert: true }, 77 | function (err, result) { 78 | assert.equal(err, null); 79 | debug("Updated MongoDB w/ slack pairs: "); 80 | debug(list); 81 | callback(null, data); 82 | } 83 | ); 84 | }; 85 | 86 | 87 | module.exports = PairListMongo; 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-pair", 3 | "version": "0.0.1", 4 | "description": "a co-working (or pair programming) availability tracker for Slack", 5 | "main": "web.js", 6 | "scripts": { 7 | "start": "node web.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/techieshark/slack-pair.git" 13 | }, 14 | "keywords": [ 15 | "slack", 16 | "cowork", 17 | "pairing", 18 | "bot" 19 | ], 20 | "author": "techieshark", 21 | "contributors": [ 22 | "jeremiak" 23 | ], 24 | "license": "GPL-3.0", 25 | "bugs": { 26 | "url": "https://github.com/techieshark/slack-pair/issues" 27 | }, 28 | "homepage": "https://github.com/techieshark/slack-pair", 29 | "dependencies": { 30 | "body-parser": "^1.4.3", 31 | "debug": "^2.2.0", 32 | "express": "^4.4.4", 33 | "lodash": "^2.4.1", 34 | "logfmt": "^1.1.2", 35 | "mongodb": "^2.0.52", 36 | "request": "^2.67.0" 37 | }, 38 | "devDependencies": {} 39 | } 40 | -------------------------------------------------------------------------------- /web.js: -------------------------------------------------------------------------------- 1 | // web.js 2 | var _ = require('lodash'); 3 | var express = require("express"); 4 | var bodyParser = require('body-parser'); 5 | var logfmt = require("logfmt"); 6 | var request = require('request'); 7 | var PairDb = require('./lib/pair_db.js'); 8 | var debug = require('debug')('pair'); 9 | 10 | var db; 11 | var app = express(); 12 | app.use(bodyParser.urlencoded({extended: true})); 13 | app.use(bodyParser.json()); 14 | app.use(logfmt.requestLogger()); 15 | 16 | var help = 'Usage:\t `/pair [yes|ok|no ]` or `/pair` alone to see who is free.'; 17 | 18 | function validToken(token) { 19 | if (token == process.env.SLACK_TOKEN) { 20 | debug('Slack token verified'); 21 | return true; 22 | } else{ 23 | debug('Slack token does not match stored token, if present.'); 24 | return false; 25 | } 26 | } 27 | 28 | function notifyChannel(text) { 29 | payload = { 30 | "channel": process.env.SLACK_PAIR_CHANNEL, 31 | "username": "pair", 32 | "text": text, 33 | "icon_url": "http://s8.postimg.org/kmlmmglid/noun_19161_cc.png" // thx @ainsleywagon, https://thenounproject.com/term/pair/19161/ 34 | }; 35 | 36 | request.post({ 37 | uri: process.env.SLACK_WEBHOOK_URL, 38 | body: JSON.stringify(payload), 39 | }, 40 | function (error, response, body) { 41 | if (!error && response.statusCode == 200) { 42 | debug(body); 43 | } else if (error) { 44 | console.error("Error posting to channel: " + error); 45 | } 46 | } 47 | ); 48 | } 49 | 50 | 51 | app.post('/', function(req, res) { 52 | var hasArgs = req.body.text.length > 0; 53 | var args = req.body.text.toLowerCase().split(' '), 54 | command = req.body.command, 55 | username = req.body.user_name, 56 | token = req.body.token, 57 | acceptable = ["yes", "ok", "no"]; 58 | var user, status, notification; 59 | var pairList; 60 | 61 | debug('args'); 62 | debug(args); 63 | 64 | if (!validToken(token)) { 65 | res.send('Invalid Slack token. Ensure that the correct Slack integration token is set as the SLACK_TOKEN env var.'); 66 | } else { 67 | if (hasArgs) { 68 | if (args[0] === 'help') { 69 | // send help 70 | res.send( 71 | { 72 | "response_type": "ephemeral", 73 | "text": help + "\n" + 74 | "Full documentation .", 75 | }); 76 | } else if (_.contains(acceptable, args[0])) { 77 | var comment = args.slice(1).join(' '); 78 | status = args[0]; 79 | 80 | // get pairList from DB 81 | pairList = db.getPairList(); 82 | pairList.fetch(function (err, pairList) { 83 | pairList.update(username, status, comment, function (err, data) { 84 | user = data.user; // users = data.users 85 | 86 | // People want confirmation that we got their status, so show it and everyone else's: 87 | notification = 'Your pairing status was set to: ' + status + '\n'; 88 | notification += pairList.toString(); 89 | res.send(notification); 90 | 91 | // notify pairing channel if environment vars are provided and status is yes/ok 92 | if (process.env.SLACK_WEBHOOK_URL && process.env.SLACK_PAIR_CHANNEL && 93 | (status === 'yes' || status === 'ok')) { 94 | notifyChannel(user.username + " says '" + user.status + "' to pairing" + (user.comment ? " (" + user.comment + ")" : "" ) + "! Go pair!"); 95 | } 96 | }); 97 | }); 98 | } else { 99 | res.send('Close but no cigar. What is this command, "' + args[0] + '", that you speak of?\n' + help); 100 | } 101 | } 102 | else { 103 | // get pairList from DB 104 | pairList = db.getPairList(); 105 | pairList.fetch(function (err, pairList) { 106 | if(err) throw err; 107 | status = pairList.toString(); 108 | debug(status); 109 | res.send(status); 110 | }); 111 | } 112 | } 113 | }); 114 | 115 | app.get('/keepalive', function (req, res) { 116 | debug('pong'); 117 | res.send(Date.now()+''); 118 | }); 119 | 120 | function keepalive() { 121 | request(process.env.PAIRBOT_URL + '/keepalive'); 122 | } 123 | 124 | db = PairDb.build(); 125 | db.connect(function (err) { 126 | 127 | if (err) throw err; 128 | 129 | // Start the application after the database connection is ready 130 | var port = Number(process.env.PORT || 5000); 131 | app.listen(port, function() { 132 | console.log("Listening on " + port); 133 | console.log("PAIR URL: " + process.env.PAIRBOT_URL); 134 | console.log("SLACK_TOKEN: " + process.env.SLACK_TOKEN); 135 | console.log("SLACK_WEBHOOK_URL: " + process.env.SLACK_WEBHOOK_URL); 136 | console.log("SLACK_PAIR_CHANNEL: " + process.env.SLACK_PAIR_CHANNEL); 137 | console.log("MONGO_URL: " + process.env.MONGO_URL); 138 | console.log("DB_PROVIDER: " + process.env.DB_PROVIDER); 139 | setInterval(keepalive, 60e3); 140 | }); 141 | 142 | }); 143 | 144 | --------------------------------------------------------------------------------