├── .gitignore ├── .jshintrc ├── Procfile ├── ReadMe.md ├── config.json ├── package.json ├── test └── tests.js ├── trello-slack.js └── wercker.yml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | last.id 3 | config.json.bak -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true 3 | ,"esnext": false 4 | ,"globals": [ "require", "module", "console" ] 5 | ,"quotmark": "single" 6 | ,"undef": true 7 | ,"unused": true 8 | ,"laxcomma": true 9 | ,"bitwise": true 10 | ,"eqeqeq": true 11 | } 12 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: node trello-slack.js -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # node-trello-slack 2 | 3 | [![wercker status](https://app.wercker.com/status/c5b1d402d7139b17ed6b34ce74a29b3e/s/master "wercker status")](https://app.wercker.com/project/bykey/c5b1d402d7139b17ed6b34ce74a29b3e) 4 |
[![Dependency Status](https://david-dm.org/atuttle/node-trello-slack.svg?style=flat)](https://david-dm.org/atuttle/node-trello-slack) 5 | 6 | The built-in integration for Trello provided by Slack/SlackHQ isn't enough. It's limited to one board! 7 | 8 | This tool will check the trello api once a minute for updates and push them into your desired channels. You can configure any number of boards. 9 | 10 | ### Install 11 | 12 | npm install --save node-trello-slack 13 | 14 | ### Usage 15 | 16 | Create an executable script with the following code: 17 | 18 | ```js 19 | #!/usr/bin/env node 20 | 21 | var Bot = require('node-trello-slack') 22 | ,bot = new Bot({ 23 | pollFrequency: 1000*60*3 //every 3 minutes 24 | ,start: true 25 | ,trello: { 26 | boards: ['Nz5nyqZg','...'] 27 | ,key: 'trello-key-here' 28 | ,token: 'trello-token-here' 29 | ,events: ['createCard','commentCard','addAttachmentToCard','updateCard','updateCheckItemStateOnCard'] 30 | } 31 | ,slack: { 32 | domain: 'slack-domain-here' 33 | ,token: 'slack-webhook-token-here' 34 | ,channel: '#general' 35 | } 36 | }); 37 | ``` 38 | 39 | You may completely omit `trello.events`, which indicates that you want all (recognized) events announced. All recognized events are listed in the example above. Alternately, include some subset of the list shown above. (Case sensitive) 40 | 41 | ### Trello-board-specific channels 42 | 43 | If you want to send alerts from each board to a different Slack channel, pass object hashes instead of string board id's in the `trello.boards` array, as you see here: 44 | 45 | ```js 46 | ,trello: { 47 | boards: [ 48 | { 49 | id: 'Nz5nyqZg' 50 | ,channel: '#general' 51 | } 52 | ,{ 53 | id: '...' 54 | ,channel: '#devops' 55 | } 56 | ] 57 | ,key: 'trello-key-here' 58 | ,token: 'trello-token-here' 59 | } 60 | ``` 61 | 62 | ## Getting your Trello and Slack credentials 63 | 64 | You'll need a **Trello key and token.** [Get your key here](https://trello.com/1/appKey/generate): it's the one in the box near the top labeled "key." Once you have that key, substitute it into the following url for and open it up in a browser tab: 65 | 66 | https://trello.com/1/connect?name=node-trello-slack&response_type=token&expiration=never&key= 67 | 68 | You'll also need your webhook **token and domain** for Slack. The domain is just the part of the url before `.slack.com`. To get your token, go to the following url (substituting your domain for ``) and add the webhook integration (if it's not already enabled). The token will be listed in the left sidebar. 69 | 70 | https://.slack.com/services/new/incoming-webhook 71 | 72 | Fill all four of these values into your bot config, and tweak the other options 73 | 74 | ## Running... 75 | 76 | Once you've configured access to your Trello and Slack accounts, the last thing to know is how this tool knows what events it's already seen. There are two options: File system, and Redis. 77 | 78 | ### ...locally (or where the file system is writeable) 79 | 80 | The simplest is using the file system. Just create a file named `last.id` in the root folder of the project and put the number `0` into it. As long as the filesystem is writeable the file will be updated when new board actions have been seen. 81 | 82 | ### ...on Heroku (or where the file system is not writeable) 83 | 84 | I run this tool on Heroku (a single free dyno works great!) but they dont allow you to write to the filesystem. Instead, I use Redis. Add the free **Redis To Go** addon and everything should just work out of the box. 85 | 86 | heroku create 87 | heroku addons:add redistogo 88 | git push heroku master 89 | heroku ps:scale worker=1 && heroku logs -t 90 | 91 | This will push whatever you've got committed in your local git repo up to a new heroku app connected to a free RedisToGo instance, running on a free Heroku worker dyno, and tail the Heroku log file -- just in case there are errors. If no errors appear after a minute or two, just hit ctrl+c to exit the log tail and go about your business. 92 | 93 | Enjoy! 94 | 95 | # The MIT License (MIT) 96 | 97 | > Copyright (c) 2014 Adam Tuttle 98 | 99 | > Permission is hereby granted, free of charge, to any person obtaining a copy 100 | of this software and associated documentation files (the "Software"), to deal 101 | in the Software without restriction, including without limitation the rights 102 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 103 | copies of the Software, and to permit persons to whom the Software is 104 | furnished to do so, subject to the following conditions: 105 | 106 | > The above copyright notice and this permission notice shall be included in 107 | all copies or substantial portions of the Software. 108 | 109 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 110 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 111 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 112 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 113 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 114 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 115 | THE SOFTWARE. 116 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "trello": { 3 | "auth": { 4 | "key":"your-trello-key-here" 5 | ,"token":"your-trello-token-here" 6 | } 7 | ,"boards": [ 8 | { 9 | "name":"Board display name here" 10 | ,"id":"id from trello url here" 11 | ,"lists": ["*"] 12 | ,"slack_channel": "#ops" 13 | } 14 | ,{ 15 | "name":"Board display name here" 16 | ,"id":"id from trello url here" 17 | ,"lists": ["*"] 18 | ,"slack_channel": "#ops" 19 | } 20 | ] 21 | } 22 | ,"slack": { 23 | "domain":"your-slack-domain-here" 24 | ,"token": "your-slack-token-here" 25 | ,"default_channel": "#ops" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-trello-slack", 3 | "version": "0.5.0", 4 | "description": "Monitors a collection of Trello boards and pushes their activity into SlackHQ", 5 | "main": "trello-slack.js", 6 | "scripts": { 7 | "test": "mocha test/*.js" 8 | }, 9 | "keywords": [ 10 | "trello", 11 | "slack", 12 | "slackhq", 13 | "webhook", 14 | "integration" 15 | ], 16 | "author": "Adam Tuttle", 17 | "license": "MIT", 18 | "repository": { 19 | "type": "git", 20 | "url": "git://github.com/atuttle/node-trello-slack.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/atuttle/node-trello-slack/issues" 24 | }, 25 | "homepage": "https://github.com/atuttle/node-trello-slack", 26 | "dependencies": { 27 | "node-slack": "^0.0.5", 28 | "redis": "^0.12.1", 29 | "url": "^0.10.1", 30 | "trello-events": "^0.1.6" 31 | }, 32 | "devDependencies": { 33 | "mocha": "^1.18.2", 34 | "chai": "^1.9.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/tests.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai'); 2 | var Bot = require('../trello-slack'); 3 | 4 | chai.should(); 5 | 6 | describe("trello-slack", function(){ 7 | 8 | describe("configuration", function(){ 9 | 10 | it("should accept objects for trello boards", function(done){ 11 | 12 | var bot = new Bot({ 13 | pollFrequency: 1000*60*3 //every 3 minutes 14 | ,start: false 15 | ,trello: { 16 | boards: [{id:'Nz5nyqZg',channel:'#general'}] 17 | ,key: 'trello-key-here' 18 | ,token: 'trello-token-here' 19 | } 20 | ,slack: { 21 | domain: 'slack-domain-here' 22 | ,token: 'slack-webhook-token-here' 23 | ,channel: '#general' 24 | } 25 | }); 26 | 27 | var config = bot.getConfig(); 28 | config.trello.boardChannels.should.have.property('Nz5nyqZg') 29 | config.trello.boardChannels['Nz5nyqZg'].should.equal('#general'); 30 | 31 | done(); 32 | 33 | }); 34 | 35 | it("should accept strings for trello boards", function(done){ 36 | 37 | var bot = new Bot({ 38 | pollFrequency: 1000*60*3 //every 3 minutes 39 | ,start: false 40 | ,trello: { 41 | boards: ['Nz5nyqZg'] 42 | ,key: 'trello-key-here' 43 | ,token: 'trello-token-here' 44 | } 45 | ,slack: { 46 | domain: 'slack-domain-here' 47 | ,token: 'slack-webhook-token-here' 48 | ,channel: '#devops' 49 | } 50 | }); 51 | 52 | var config = bot.getConfig(); 53 | config.trello.boardChannels.should.have.property('Nz5nyqZg') 54 | config.trello.boardChannels['Nz5nyqZg'].should.equal('#devops'); 55 | 56 | done(); 57 | 58 | }); 59 | 60 | }); 61 | 62 | }); 63 | -------------------------------------------------------------------------------- /trello-slack.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs') 4 | ,Trello = require('trello-events') 5 | ,Slack = require('node-slack'); 6 | 7 | var cfg, trello, slack, redis, handlers; 8 | var mechanism = 'file'; 9 | 10 | module.exports = function(config){ 11 | this.config = cfg = config; 12 | 13 | cfg.trello.boardChannels = {}; 14 | for (var i = 0; i < cfg.trello.boards.length; i++){ 15 | switch(typeof cfg.trello.boards[i]){ 16 | case 'string': 17 | cfg.trello.boardChannels[cfg.trello.boards[i]] = cfg.slack.channel; 18 | break; 19 | case 'object': 20 | cfg.trello.boardChannels[cfg.trello.boards[i].id] = cfg.trello.boards[i].channel; 21 | cfg.trello.boards[i] = cfg.trello.boards[i].id; 22 | break; 23 | default: 24 | throw 'Unexpected boards array member type (' + typeof cfg.trello.boards[i] + ')'; 25 | } 26 | } 27 | 28 | var allEvents = false; 29 | if (!cfg.trello.hasOwnProperty('events')){ 30 | allEvents = true; 31 | } 32 | 33 | function wantsEvent( evt ){ 34 | if (!cfg.trello.hasOwnProperty('events')){ 35 | return false; 36 | } 37 | return cfg.trello.events.indexOf( evt ) > -1; 38 | } 39 | 40 | bootstrap(function(prev){ 41 | cfg.minId = prev; 42 | slack = new Slack(cfg.slack.domain, cfg.slack.token); 43 | trello = new Trello(cfg); 44 | 45 | trello 46 | .on('maxId', writePrevId) 47 | .on('trelloError', function(err){ 48 | console.error(err); 49 | process.exit(1); 50 | }); 51 | 52 | if ( wantsEvent('createCard') || allEvents ){ 53 | trello.on('createCard', handlers.createCard); 54 | } 55 | if ( wantsEvent('commentCard') || allEvents ){ 56 | trello.on('commentCard', handlers.commentCard); 57 | } 58 | if ( wantsEvent('addAttachmentToCard') || allEvents ){ 59 | trello.on('addAttachmentToCard', handlers.addAttachmentToCard); 60 | } 61 | if ( wantsEvent('updateCard') || allEvents ){ 62 | trello.on('updateCard', handlers.updateCard); 63 | } 64 | if ( wantsEvent('updateCheckItemStateOnCard') || allEvents ){ 65 | trello.on('updateCheckItemStateOnCard', handlers.updateCheckItemStateOnCard); 66 | } 67 | }); 68 | }; 69 | 70 | module.exports.prototype.getConfig = function(){ 71 | return this.config; 72 | }; 73 | 74 | /* 75 | handles the choice between redis and local files 76 | */ 77 | function bootstrap(callback){ 78 | //if we can find a file named "last.id" then use that to store the activity timeline bookmark 79 | if (fs.existsSync('./last.id')){ 80 | callback( fs.readFileSync('./last.id').toString() ); 81 | }else{ 82 | //redis! 83 | mechanism = 'redis'; 84 | if (process.env.REDISTOGO_URL) { 85 | var rtg = require('url').parse(process.env.REDISTOGO_URL); 86 | redis = require('redis').createClient(rtg.port, rtg.hostname); 87 | redis.auth(rtg.auth.split(':')[1]); 88 | } else { 89 | redis = require('redis').createClient(); 90 | } 91 | redis.get('prevId', function(err, reply){ 92 | if (err){ 93 | console.error(err); 94 | process.exit(1); 95 | } 96 | if (reply === null){ reply = 0; } 97 | return callback(reply); 98 | }); 99 | } 100 | } 101 | 102 | handlers = { 103 | createCard: function(event, boardId){ 104 | var card_name = event.data.card.name 105 | ,card_id = event.data.card.id 106 | ,card_id_short = event.data.card.idShort 107 | ,card_url = 'https://trello.com/card/' + card_id + '/' + boardId + '/' + card_id_short 108 | ,author = event.memberCreator.fullName 109 | ,board_url = 'https://trello.com/b/' + boardId 110 | ,board_name = event.data.board.name 111 | ,msg = ':boom: ' + author + ' created card <' + card_url + '|' + sanitize(card_name) + '> on board <' + board_url + '|' + board_name + '>'; 112 | notify(cfg.trello.boardChannels[boardId], msg); 113 | } 114 | ,commentCard: function(event, boardId){ 115 | var card_id_short = event.data.card.idShort 116 | ,card_id = event.data.card.id 117 | ,card_url = 'https://trello.com/card/' + card_id + '/' + boardId + '/' + card_id_short 118 | ,card_name = event.data.card.name 119 | ,author = event.memberCreator.fullName 120 | ,msg = ':speech_balloon: ' + author + ' commented on card <' + card_url + '|' + sanitize(card_name) + '>: ' + trunc(event.data.text); 121 | notify(cfg.trello.boardChannels[boardId], msg); 122 | } 123 | ,addAttachmentToCard: function(event, boardId){ 124 | var card_id_short = event.data.card.idShort 125 | ,card_id = event.data.card.id 126 | ,card_url = 'https://trello.com/card/' + card_id + '/' + boardId + '/' + card_id_short 127 | ,card_name = event.data.card.name 128 | ,author = event.memberCreator.fullName 129 | ,aurl = event.data.attachment.url; 130 | var msg = ':paperclip: ' + author + ' added an attachment to card <' + card_url + '|' + sanitize(card_name) + '>: ' + '<' + aurl + '|' + sanitize(event.data.attachment.name) + '>'; 131 | notify(cfg.trello.boardChannels[boardId], msg); 132 | } 133 | ,updateCard: function(event, boardId){ 134 | if (event.data.old.hasOwnProperty('idList') && event.data.card.hasOwnProperty('idList')){ 135 | //moving between lists 136 | var oldId = event.data.old.idList 137 | ,newId = event.data.card.idList 138 | ,nameO,nameN 139 | ,card_id_short = event.data.card.idShort 140 | ,card_id = event.data.card.id 141 | ,card_url = 'https://trello.com/card/' + card_id + '/' + boardId + '/' + card_id_short 142 | ,card_name = event.data.card.name 143 | ,author = event.memberCreator.fullName; 144 | trello.api.get('/1/list/' + oldId, function(err, resp){ 145 | if (err) throw err; 146 | nameO = resp.name; 147 | trello.api.get('/1/list/' + newId, function(err, resp){ 148 | if (err) throw err; 149 | nameN = resp.name; 150 | var msg = ':arrow_heading_up:' + author + ' moved card <' + card_url + '|' + sanitize(card_name) + '> from list ' + nameO + ' to list ' + nameN; 151 | notify(cfg.trello.boardChannels[boardId], msg); 152 | }); 153 | }); 154 | } 155 | } 156 | ,updateCheckItemStateOnCard: function(event, boardId){ 157 | var card_id_short = event.data.card.idShort 158 | ,card_id = event.data.card.id 159 | ,card_url = 'https://trello.com/card/' + card_id + '/' + boardId + '/' + card_id_short 160 | ,card_name = event.data.card.name 161 | ,author = event.memberCreator.fullName; 162 | if (event.data.checkItem.state === 'complete'){ 163 | var msg = ':ballot_box_with_check: ' + author + ' completed "' + event.data.checkItem.name + '" in card <' + card_url + '|' + sanitize(card_name) + '>.'; 164 | notify(cfg.trello.boardChannels[boardId], msg); 165 | } 166 | } 167 | }; 168 | 169 | function notify(room, msg, sender){ 170 | sender = sender || 'Trello'; 171 | slack.send({ 172 | text: msg 173 | ,channel: room 174 | ,username: sender 175 | ,icon_url: 'http://i.imgur.com/HJLfIU6.png' 176 | }, function(err){ 177 | if (err){ 178 | console.error('ERROR:\n', err); 179 | throw err; 180 | } 181 | }); 182 | } 183 | function sanitize(msg){ 184 | return msg.replace(/([><])/g, function(match, patt){ 185 | return ( 186 | patt === '>' ? '>' : 187 | patt === '<' ? '<' : '' 188 | ); 189 | }); 190 | } 191 | function trunc(s){ 192 | s = s || ''; 193 | if (s.length >= 200) 194 | return s.slice(0,199) + ' [...]'; 195 | return s; 196 | } 197 | function writePrevId(valu){ 198 | if (mechanism === 'file'){ 199 | fs.writeFileSync('./last.id', valu); 200 | }else{ 201 | redis.set('prevId', valu, function(err){ 202 | if (err){ 203 | console.error('Error setting new value to redis\n-----------------------------'); 204 | console.error(err); 205 | process.exit(1); 206 | } 207 | }); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /wercker.yml: -------------------------------------------------------------------------------- 1 | box: wercker/nodejs 2 | # Build definition 3 | build: 4 | # The steps that will be executed on build 5 | steps: 6 | - npm-install 7 | - script: 8 | name: make tests run without redis 9 | code: | 10 | echo 0 > last.id 11 | - npm-test 12 | 13 | # A custom script step, name value is used in the UI 14 | # and the code value contains the command that get executed 15 | - script: 16 | name: echo nodejs information 17 | code: | 18 | echo "node version $(node -v) running" 19 | echo "npm version $(npm -v) running" 20 | --------------------------------------------------------------------------------