├── index.js ├── .node-version ├── .gitattributes ├── .gitignore ├── package.json ├── README.md ├── LICENSE ├── lib ├── lgtm_in.js ├── slack_room_list.js └── slack_bot.js ├── etc ├── isrgrootx1.pem └── letsencryptauthorityx3.pem └── bin └── slack-lgtm-bot /index.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 6.4.0 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pem binary 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log* 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-lgtm-bot", 3 | "version": "0.1.0", 4 | "description": "The LGTM bot for Slack", 5 | "main": "index.js", 6 | "bin": "./bin/slack-lgtm-bot", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "dependencies": { 11 | "@slack/client": "^3.5.4", 12 | "co": "^4.6.0", 13 | "co-fs": "^1.2.0", 14 | "fancy-log": "^1.2.0", 15 | "got": "^6.3.0", 16 | "lodash.sample": "^4.2.1", 17 | "promise-retry": "^1.1.1", 18 | "rx": "^4.1.0", 19 | "thenify": "^3.2.0" 20 | }, 21 | "author": { 22 | "name": "Pine Mizune", 23 | "email": "pinemz@gmail.com", 24 | "url": "https://github.com/pine" 25 | }, 26 | "license": "MIT" 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED 2 | 3 | :warning: This repository is no longer maintained. 4 | 5 | --- 6 | 7 | slack-lgtm-bot 8 | -------------- 9 | 10 | ## Requirements 11 | 12 | - Node.js v4.5.0 ~ 13 | 14 | ## Getting Started 15 | 16 | ``` 17 | $ npm install -g git+https://github.com/pine/slack-lgtm-bot.git 18 | $ SLACK_API_TOKEN=XXX slack-lgtm-bot 19 | ``` 20 | 21 | ## Options 22 | You can set any options uses environment variables. 23 | 24 | - `SLACK_API_TOKEN` Slack API token 25 | - `SLACK_USERNAME` Slack username 26 | - Default: `'LGTM'` 27 | - `SLACK_ICON_URL` Slack icon URL 28 | - Default: `''` 29 | - `LGTM_IN_URL` 30 | - Default: `'http://lgtm.in/g'` 31 | - API endpoint of [lgtm.in](http://lgtm.in/) 32 | - If you have personal lgtm.in list, set to `'http://lgtm.in/g/username'` 33 | - `LGTM_IN_URLS` Slack API endpoint URLs (comma separated) 34 | - `SLACK_CHANNELS` Slack channels of bot enabled (comma separated) 35 | - Default: `''` (not limited) 36 | - Example: `'general,random'` 37 | 38 | ## License 39 | MIT 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Pine Mizune 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/lgtm_in.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const got = require('got') 4 | const promiseRetry = require('promise-retry') 5 | const log = require('fancy-log') 6 | const sample = require('lodash.sample') 7 | 8 | class LgtmIn { 9 | constructor({ lgtmInUrls, ca }) { 10 | this.lgtmInUrls = lgtmInUrls 11 | this.concurrency = 5 12 | this.cacheSize = 15 13 | this.imageUrls = [] 14 | this.ca = ca || [] 15 | this.preload() 16 | } 17 | 18 | next() { 19 | this.update() 20 | return this.imageUrls.shift() 21 | } 22 | 23 | preload() { 24 | log('Preloading ...') 25 | this.update() 26 | } 27 | 28 | update() { 29 | if (this.imageUrls.length > this.cacheSize) { 30 | return 31 | } 32 | 33 | log('Fetch queued:', this.concurrency) 34 | 35 | for (let i = 0; i < this.concurrency; ++i) { 36 | this.imageUrls.push(promiseRetry( 37 | retry => this.fetch().catch(retry), 38 | { retries: 30, randomize: true } 39 | )) 40 | } 41 | } 42 | 43 | endpoint() { 44 | return sample(this.lgtmInUrls) 45 | } 46 | 47 | fetch() { 48 | return got(this.endpoint(), { json: true, ca: this.ca }) 49 | .then(res => { 50 | const imageUrl = res.body.actualImageUrl 51 | 52 | if (typeof imageUrl !== 'string') { 53 | return Promise.reject('`actualImageUrl` is not found') 54 | } 55 | 56 | log('LGTM image resolved:', imageUrl) 57 | return imageUrl 58 | }) 59 | } 60 | } 61 | 62 | module.exports = LgtmIn 63 | -------------------------------------------------------------------------------- /lib/slack_room_list.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const co = require('co') 4 | const promiseRetry = require('promise-retry') 5 | const thenify = require('thenify') 6 | const log = require('fancy-log') 7 | const Rx = require('rx') 8 | 9 | class SlackRoomList { 10 | constructor(web) { 11 | this.web = web 12 | this.rooms = new Rx.BehaviorSubject(null) 13 | this.progress = false 14 | this.updatedAt = null 15 | } 16 | 17 | findById(id) { 18 | return this.rooms 19 | .filter(rooms => !!rooms) 20 | .first() 21 | .map(rooms => rooms.find(room => room.id === id)) 22 | .toPromise() 23 | } 24 | 25 | sync() { 26 | log('Syncing rooms ...') 27 | 28 | if (this.progress) { 29 | log('Already syncing rooms started') 30 | return Promise.resolve() 31 | } 32 | this.progress = true 33 | 34 | const _this = this 35 | return co(function* () { 36 | try { 37 | const rooms = yield promiseRetry(retry => _this.fetch().catch(retry)) 38 | 39 | log('Synced rooms') 40 | process.nextTick(_ => _this.rooms.onNext(rooms)) 41 | } catch (e) { } 42 | 43 | _this.progress = false 44 | }) 45 | } 46 | 47 | fetch() { 48 | const getChannels = thenify(this.web.channels.list.bind(this.web.channels)) 49 | const getGroups = thenify(this.web.groups.list.bind(this.web.groups)) 50 | 51 | return co(function* () { 52 | const [ { channels }, { groups } ] = yield [ getChannels(), getGroups() ] 53 | return channels.concat(groups) 54 | }) 55 | } 56 | } 57 | 58 | module.exports = SlackRoomList 59 | -------------------------------------------------------------------------------- /etc/isrgrootx1.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw 3 | TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh 4 | cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 5 | WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu 6 | ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY 7 | MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc 8 | h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ 9 | 0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U 10 | A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW 11 | T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH 12 | B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC 13 | B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv 14 | KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn 15 | OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn 16 | jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw 17 | qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI 18 | rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV 19 | HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq 20 | hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL 21 | ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ 22 | 3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK 23 | NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 24 | ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur 25 | TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC 26 | jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc 27 | oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq 28 | 4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA 29 | mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d 30 | emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= 31 | -----END CERTIFICATE----- 32 | -------------------------------------------------------------------------------- /etc/letsencryptauthorityx3.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFjTCCA3WgAwIBAgIRANOxciY0IzLc9AUoUSrsnGowDQYJKoZIhvcNAQELBQAw 3 | TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh 4 | cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTYxMDA2MTU0MzU1 5 | WhcNMjExMDA2MTU0MzU1WjBKMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg 6 | RW5jcnlwdDEjMCEGA1UEAxMaTGV0J3MgRW5jcnlwdCBBdXRob3JpdHkgWDMwggEi 7 | MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCc0wzwWuUuR7dyXTeDs2hjMOrX 8 | NSYZJeG9vjXxcJIvt7hLQQWrqZ41CFjssSrEaIcLo+N15Obzp2JxunmBYB/XkZqf 9 | 89B4Z3HIaQ6Vkc/+5pnpYDxIzH7KTXcSJJ1HG1rrueweNwAcnKx7pwXqzkrrvUHl 10 | Npi5y/1tPJZo3yMqQpAMhnRnyH+lmrhSYRQTP2XpgofL2/oOVvaGifOFP5eGr7Dc 11 | Gu9rDZUWfcQroGWymQQ2dYBrrErzG5BJeC+ilk8qICUpBMZ0wNAxzY8xOJUWuqgz 12 | uEPxsR/DMH+ieTETPS02+OP88jNquTkxxa/EjQ0dZBYzqvqEKbbUC8DYfcOTAgMB 13 | AAGjggFnMIIBYzAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADBU 14 | BgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEBATAwMC4GCCsGAQUFBwIB 15 | FiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQub3JnMB0GA1UdDgQWBBSo 16 | SmpjBH3duubRObemRWXv86jsoTAzBgNVHR8ELDAqMCigJqAkhiJodHRwOi8vY3Js 17 | LnJvb3QteDEubGV0c2VuY3J5cHQub3JnMHIGCCsGAQUFBwEBBGYwZDAwBggrBgEF 18 | BQcwAYYkaHR0cDovL29jc3Aucm9vdC14MS5sZXRzZW5jcnlwdC5vcmcvMDAGCCsG 19 | AQUFBzAChiRodHRwOi8vY2VydC5yb290LXgxLmxldHNlbmNyeXB0Lm9yZy8wHwYD 20 | VR0jBBgwFoAUebRZ5nu25eQBc4AIiMgaWPbpm24wDQYJKoZIhvcNAQELBQADggIB 21 | ABnPdSA0LTqmRf/Q1eaM2jLonG4bQdEnqOJQ8nCqxOeTRrToEKtwT++36gTSlBGx 22 | A/5dut82jJQ2jxN8RI8L9QFXrWi4xXnA2EqA10yjHiR6H9cj6MFiOnb5In1eWsRM 23 | UM2v3e9tNsCAgBukPHAg1lQh07rvFKm/Bz9BCjaxorALINUfZ9DD64j2igLIxle2 24 | DPxW8dI/F2loHMjXZjqG8RkqZUdoxtID5+90FgsGIfkMpqgRS05f4zPbCEHqCXl1 25 | eO5HyELTgcVlLXXQDgAWnRzut1hFJeczY1tjQQno6f6s+nMydLN26WuU4s3UYvOu 26 | OsUxRlJu7TSRHqDC3lSE5XggVkzdaPkuKGQbGpny+01/47hfXXNB7HntWNZ6N2Vw 27 | p7G6OfY+YQrZwIaQmhrIqJZuigsrbe3W+gdn5ykE9+Ky0VgVUsfxo52mwFYs1JKY 28 | 2PGDuWx8M6DlS6qQkvHaRUo0FMd8TsSlbF0/v965qGFKhSDeQoMpYnwcmQilRh/0 29 | ayLThlHLN81gSkJjVrPI0Y8xCVPB4twb1PFUd2fPM3sA1tJ83sZ5v8vgFv2yofKR 30 | PB0t6JzUA81mSqM3kxl5e+IZwhYAyO0OTg3/fs8HqGTNKd9BqoUwSRBzp06JMg5b 31 | rUCGwbCUDI0mxadJ3Bz4WxR6fyNpBK2yAinWEsikxqEt 32 | -----END CERTIFICATE----- 33 | -------------------------------------------------------------------------------- /bin/slack-lgtm-bot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const https = require('https') 4 | const path = require('path') 5 | const co = require('co') 6 | const fs = require('co-fs') 7 | const log = require('fancy-log') 8 | const SlackBot = require('../lib/slack_bot') 9 | 10 | const token = process.env.SLACK_API_TOKEN 11 | const botUser = { 12 | username: process.env.SLACK_USERNAME || 'LGTM', 13 | icon_url: process.env.SLACK_ICON_URL || '', 14 | } 15 | const lgtmInUrls = [] 16 | const channels = [] 17 | 18 | // ---------------------------------------------------------------------------- 19 | 20 | if (!token) { 21 | log.error('`SLACK_API_TOKEN` not found') 22 | process.exit(1) 23 | } 24 | 25 | // ---------------------------------------------------------------------------- 26 | 27 | if (process.env.LGTM_IN_URL) { 28 | lgtmInUrls.push(process.env.LGTM_IN_URL) 29 | } 30 | 31 | if (process.env.LGTM_IN_URLS) { 32 | const urls = process.env.LGTM_IN_URLS.split(',') 33 | for (let url of urls) { 34 | if (url) { 35 | lgtmInUrls.push(url) 36 | } 37 | } 38 | } 39 | 40 | if (lgtmInUrls.length === 0) { 41 | lgtmInUrls.push('http://lgtm.in/g') 42 | } 43 | 44 | log('LGTM.in URLs:', lgtmInUrls) 45 | 46 | // ---------------------------------------------------------------------------- 47 | 48 | if (process.env.SLACK_CHANNELS) { 49 | const values = process.env.SLACK_CHANNELS.split(',') 50 | for (let value of values) { 51 | if (value) { channels.push(value) } 52 | } 53 | } 54 | 55 | log('Slack channels:', channels.length > 0 ? channels : 'not limited') 56 | 57 | // ---------------------------------------------------------------------------- 58 | 59 | co(function* () { 60 | // see also. https://letsencrypt.org/certificates/ 61 | const certs = [ 'isrgrootx1.pem', 'letsencryptauthorityx3.pem' ] 62 | .map(f => path.join(__dirname, '..', 'etc', f)) 63 | .map(f => fs.readFile(f)) 64 | 65 | const ca = https.globalAgent.options.ca || [] 66 | ca.push(... yield certs) 67 | 68 | const bot = new SlackBot({ token, lgtmInUrls, botUser, channels, ca }) 69 | bot.listen() 70 | }) 71 | 72 | // vim: se et ts=2 sw=2 sts=0 ft=javascript : 73 | -------------------------------------------------------------------------------- /lib/slack_bot.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const co = require('co') 4 | const thenify = require('thenify') 5 | const log = require('fancy-log') 6 | 7 | const RtmClient = require('@slack/client').RtmClient 8 | const WebClient = require('@slack/client').WebClient 9 | const LgtmIn = require('./lgtm_in') 10 | const SlackRoomList = require('./slack_room_list') 11 | 12 | class SlackBot { 13 | constructor({ token, lgtmInUrls, botUser, channels, ca }) { 14 | this.startedAt = (new Date()).getTime() 15 | this.rtm = new RtmClient(token, { autoReconnect: true, logLevel: 'error' }) 16 | this.web = new WebClient(token) 17 | this.lgtmIn = new LgtmIn({ lgtmInUrls, ca }) 18 | this.rooms = new SlackRoomList(this.web) 19 | this.botUser = botUser 20 | this.channels = channels || [] 21 | } 22 | 23 | listen() { 24 | log('Listening ...') 25 | this.rtm.start() 26 | this.rtm.on('authenticated', this.onAuthenticated.bind(this)) 27 | this.rtm.on('message', this.onMessage.bind(this)) 28 | 29 | this.rooms.sync() 30 | setInterval(_ => this.rooms.sync(), 60 * 60 * 1000) // 1 hour 31 | } 32 | 33 | onAuthenticated(message) { 34 | const getTeamInfo = thenify(this.web.team.info.bind(this.web.team)) 35 | 36 | co(function* () { 37 | log('Connected') 38 | 39 | try { 40 | const teamInfo = yield getTeamInfo() 41 | delete teamInfo['team']['icon'] 42 | log('Team:', JSON.stringify(teamInfo)) 43 | } catch (e) { 44 | log.error(e) 45 | } 46 | }) 47 | } 48 | 49 | onMessage(message) { 50 | const postMessage = this.web.chat.postMessage.bind(this.web.chat) 51 | 52 | const _this = this 53 | co(function* () { 54 | try { 55 | const channel = message.channel 56 | const ts = parseFloat(message.ts) * 1000 57 | const pattern = /^LGTM$/i 58 | 59 | if (_this.startedAt > ts || !pattern.test(message.text)) { return } 60 | log('Recevied:', JSON.stringify(message)) 61 | 62 | const room = yield _this.rooms.findById(channel) 63 | const imageUrl = yield _this.lgtmIn.next() 64 | 65 | if (_this.channels.length > 0) { 66 | if (!_this.channels.includes(room.name)) { 67 | log('Not targetted:', room.name) 68 | return 69 | } 70 | } 71 | 72 | yield postMessage(channel, imageUrl, _this.botUser) 73 | 74 | log('Posted:', imageUrl) 75 | } catch (e) { 76 | log.error(e) 77 | } 78 | }) 79 | } 80 | } 81 | 82 | module.exports = SlackBot 83 | --------------------------------------------------------------------------------