├── .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 | [](https://app.wercker.com/project/bykey/c5b1d402d7139b17ed6b34ce74a29b3e)
4 |
[](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 |
--------------------------------------------------------------------------------