├── test ├── fixtures │ ├── string-config.json │ ├── invalid-config.json │ ├── bad-config.json │ ├── case-sensitivity-config.json │ ├── single-test-config.json │ ├── test-config.json │ ├── test-javascript-config.js │ └── test-config-comments.json ├── stubs │ ├── irc-client-stub.js │ ├── channel-stub.js │ └── slack-stub.js ├── errors.test.js ├── channel-mapping.test.js ├── username-decorator.test.js ├── create-bots.test.js ├── cli.test.js ├── join-part.test.js ├── bot-events.test.js └── bot.test.js ├── .babelrc ├── .travis.yml ├── .frigg.yml ├── lib ├── errors.js ├── index.js ├── validators.js ├── emoji.json ├── helpers.js ├── cli.js └── bot.js ├── .eslintrc ├── .npmignore ├── .gitignore ├── assets └── emoji.json ├── LICENSE ├── package.json ├── CHANGELOG.md └── README.md /test/fixtures/string-config.json: -------------------------------------------------------------------------------- 1 | "test" 2 | -------------------------------------------------------------------------------- /test/fixtures/invalid-config.json: -------------------------------------------------------------------------------- 1 | { invalid json } 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '0.10' 4 | - '0.12' 5 | - '4' 6 | - '5' 7 | -------------------------------------------------------------------------------- /test/stubs/irc-client-stub.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | class ClientStub extends EventEmitter {} 4 | 5 | export default ClientStub; 6 | -------------------------------------------------------------------------------- /.frigg.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - npm install 3 | - npm run lint 4 | - npm run coverage 5 | 6 | coverage: 7 | path: coverage/cobertura-coverage.xml 8 | parser: cobertura 9 | -------------------------------------------------------------------------------- /test/fixtures/bad-config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "server": "irc.bottest.org", 4 | "token": "hei", 5 | "channelMapping": { 6 | "#slack": "#irc" 7 | } 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | export class ConfigurationError extends Error { 2 | constructor(message = 'Invalid configuration file given') { 3 | super(message); 4 | this.name = 'ConfigurationError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/case-sensitivity-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "nickname": "test", 3 | "server": "irc.bottest.org", 4 | "token": "testtoken", 5 | "autoSendCommands": [ 6 | ["MODE", "test", "+x"], 7 | ["AUTH", "test", "password"] 8 | ], 9 | "channelMapping": { 10 | "#slack": "#iRc channelKey", 11 | "#OtherSlack": "#OtherIRC" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/single-test-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "nickname": "test", 3 | "server": "irc.bottest.org", 4 | "token": "testtoken", 5 | "autoSendCommands": [ 6 | ["MODE", "test", "+x"], 7 | ["AUTH", "test", "password"] 8 | ], 9 | "channelMapping": { 10 | "#slack": "#irc channelKey" 11 | }, 12 | "ircStatusNotices": { 13 | "join": true, 14 | "leave": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/errors.test.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import { ConfigurationError } from '../lib/errors'; 3 | 4 | chai.should(); 5 | 6 | describe('Errors', () => { 7 | it('should have a configuration error', () => { 8 | const error = new ConfigurationError(); 9 | error.message.should.equal('Invalid configuration file given'); 10 | error.should.be.an.instanceof(Error); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /test/fixtures/test-config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "nickname": "test", 4 | "server": "irc.bottest.org", 5 | "token": "testtoken", 6 | "channelMapping": { 7 | "#slack": "#irc" 8 | } 9 | }, 10 | { 11 | "nickname": "test2", 12 | "server": "irc.bottest.org", 13 | "token": "testtoken", 14 | "channelMapping": { 15 | "#slack": "#irc" 16 | } 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import logger from 'winston'; 4 | import { createBots } from './helpers'; 5 | 6 | /* istanbul ignore next */ 7 | if (process.env.NODE_ENV === 'development') { 8 | logger.level = 'debug'; 9 | } 10 | 11 | /* istanbul ignore next */ 12 | if (!module.parent) { 13 | const { default: cli } = require('./cli'); 14 | cli(); 15 | } 16 | 17 | export default createBots; 18 | -------------------------------------------------------------------------------- /test/fixtures/test-javascript-config.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | nickname: 'test', 4 | server: 'irc.bottest.org', 5 | token: 'testtoken', 6 | channelMapping: { 7 | '#slack': '#irc' 8 | } 9 | }, 10 | { 11 | nickname: 'test2', 12 | server: 'irc.bottest.org', 13 | token: 'testtoken', 14 | channelMapping: { 15 | '#slack': '#irc' 16 | } 17 | } 18 | ]; 19 | -------------------------------------------------------------------------------- /test/stubs/channel-stub.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import sinon from 'sinon'; 3 | 4 | class ChannelStub extends EventEmitter { 5 | constructor() { 6 | super(); 7 | this.name = 'slack'; 8 | this.is_channel = true; 9 | this.is_member = true; 10 | this.members = ['testuser']; 11 | } 12 | } 13 | 14 | ChannelStub.prototype.postMessage = sinon.stub(); 15 | 16 | export default ChannelStub; 17 | -------------------------------------------------------------------------------- /lib/validators.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { ConfigurationError } from './errors'; 3 | 4 | /** 5 | * Validates a given channel mapping, throwing an error if it's invalid 6 | * @param {Object} mapping 7 | * @return {Object} 8 | */ 9 | export function validateChannelMapping(mapping) { 10 | if (!_.isObject(mapping)) { 11 | throw new ConfigurationError('Invalid channel mapping given'); 12 | } 13 | 14 | return mapping; 15 | } 16 | -------------------------------------------------------------------------------- /test/fixtures/test-config-comments.json: -------------------------------------------------------------------------------- 1 | [ 2 | // This is a comment 3 | { 4 | "nickname": "test", // comment 5 | "server": /* comment */ "irc.bottest.org", 6 | "token": "testtoken", 7 | "channelMapping": { 8 | "#slack": "#irc" 9 | } 10 | }, 11 | { 12 | // comment 13 | "nickname": "test2", 14 | "server": "irc.bottest.org", 15 | "token": "testtoken", 16 | "channelMapping": { 17 | "#slack": "#irc" 18 | } 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb/base", 3 | "rules": { 4 | "func-names": 0, 5 | "vars-on-top": 0, 6 | "id-length": 0, 7 | "comma-dangle": [2, "never"], 8 | "object-curly-spacing": [2, "always"], 9 | "prefer-spread": 2, 10 | "constructor-super": 2, 11 | "arrow-spacing": [2, { "before": true, "after": true }], 12 | "space-before-function-paren": 0 13 | }, 14 | "ecmaFeatures": { 15 | "experimentalObjectRestSpread": true 16 | }, 17 | "env": { 18 | "mocha": true, 19 | "node": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/stubs/slack-stub.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import ChannelStub from './channel-stub'; 3 | 4 | class SlackStub extends EventEmitter { 5 | getUserByID() { 6 | return { 7 | name: 'testuser' 8 | }; 9 | } 10 | } 11 | 12 | function getChannelStub() { 13 | return new ChannelStub(); 14 | } 15 | 16 | SlackStub.prototype.getChannelByID = getChannelStub; 17 | SlackStub.prototype.getChannelGroupOrDMByName = getChannelStub; 18 | SlackStub.prototype.getChannelGroupOrDMByID = getChannelStub; 19 | 20 | export default SlackStub; 21 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # node-waf configuration 17 | .lock-wscript 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 24 | node_modules 25 | 26 | # Environment variables and configuration 27 | .env 28 | .environment 29 | config.json 30 | 31 | # Ignore everything except build: 32 | lib/ 33 | test/ 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # node-waf configuration 17 | .lock-wscript 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 24 | node_modules 25 | 26 | # Environment variables and configuration 27 | .env 28 | .environment 29 | /*.json 30 | 31 | dist/ 32 | .nyc_output/ 33 | 34 | # IntelliJ 35 | /.idea 36 | *.iml 37 | -------------------------------------------------------------------------------- /lib/emoji.json: -------------------------------------------------------------------------------- 1 | { 2 | "smile": ":)", 3 | "simple_smile": ":)", 4 | "smiley": ":-)", 5 | "grin": ":D", 6 | "wink": ";)", 7 | "smirk": ";)", 8 | "blush": ":$", 9 | "stuck_out_tongue": ":P", 10 | "stuck_out_tongue_winking_eye": ";P", 11 | "stuck_out_tongue_closed_eyes": "xP", 12 | "disappointed": ":(", 13 | "astonished": ":O", 14 | "open_mouth": ":O", 15 | "heart": "<3", 16 | "broken_heart": ":(", 19 | "cry": ":,(", 20 | "frowning": ":(", 21 | "imp": "]:(", 22 | "innocent": "o:)", 23 | "joy": ":,)", 24 | "kissing": ":*", 25 | "laughing": "x)", 26 | "neutral_face": ":|", 27 | "no_mouth": ":-", 28 | "rage": ":@", 29 | "smiling_imp": "]:)", 30 | "sob": ":,'(", 31 | "sunglasses": "8)", 32 | "sweat": ",:(", 33 | "sweat_smile": ",:)", 34 | "unamused": ":$" 35 | } 36 | -------------------------------------------------------------------------------- /assets/emoji.json: -------------------------------------------------------------------------------- 1 | { 2 | "smile": ":)", 3 | "simple_smile": ":)", 4 | "smiley": ":-)", 5 | "grin": ":D", 6 | "wink": ";)", 7 | "smirk": ";)", 8 | "blush": ":$", 9 | "stuck_out_tongue": ":P", 10 | "stuck_out_tongue_winking_eye": ";P", 11 | "stuck_out_tongue_closed_eyes": "xP", 12 | "disappointed": ":(", 13 | "astonished": ":O", 14 | "open_mouth": ":O", 15 | "heart": "<3", 16 | "broken_heart": ":(", 19 | "cry": ":,(", 20 | "frowning": ":(", 21 | "imp": "]:(", 22 | "innocent": "o:)", 23 | "joy": ":,)", 24 | "kissing": ":*", 25 | "laughing": "x)", 26 | "neutral_face": ":|", 27 | "no_mouth": ":-", 28 | "rage": ":@", 29 | "smiling_imp": "]:)", 30 | "sob": ":,'(", 31 | "sunglasses": "8)", 32 | "sweat": ",:(", 33 | "sweat_smile": ",:)", 34 | "unamused": ":$" 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Martin Ek mail@ekmartin.no 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/helpers.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Bot from './bot'; 3 | import { ConfigurationError } from './errors'; 4 | 5 | /** 6 | * Reads from the provided config file and returns an array of bots 7 | * @return {object[]} 8 | */ 9 | export function createBots(configFile) { 10 | const bots = []; 11 | 12 | // The config file can be both an array and an object 13 | if (Array.isArray(configFile)) { 14 | configFile.forEach(config => { 15 | const bot = new Bot(config); 16 | bot.connect(); 17 | bots.push(bot); 18 | }); 19 | } else if (_.isObject(configFile)) { 20 | const bot = new Bot(configFile); 21 | bot.connect(); 22 | bots.push(bot); 23 | } else { 24 | throw new ConfigurationError(); 25 | } 26 | 27 | return bots; 28 | } 29 | 30 | /** 31 | * Returns occurances of a current channel member's name with `@${name}` 32 | * @return {string} 33 | */ 34 | export function highlightUsername(user, text) { 35 | const words = text.split(' '); 36 | return words.map(word => { 37 | // if the user is already prefixed by @, don't replace 38 | if (word.indexOf(`@${user}`) === 0) { 39 | return word; 40 | } 41 | 42 | const regexp = new RegExp(`^${user}\\b`); 43 | return word.replace(regexp, `@${user}`); 44 | }).join(' '); 45 | } 46 | -------------------------------------------------------------------------------- /test/channel-mapping.test.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import Bot from '../lib/bot'; 3 | import config from './fixtures/single-test-config.json'; 4 | import caseConfig from './fixtures/case-sensitivity-config.json'; 5 | import { validateChannelMapping } from '../lib/validators'; 6 | 7 | chai.should(); 8 | 9 | describe('Channel Mapping', () => { 10 | it('should fail when not given proper JSON', () => { 11 | const wrongMapping = 'not json'; 12 | const wrap = () => validateChannelMapping(wrongMapping); 13 | (wrap).should.throw('Invalid channel mapping given'); 14 | }); 15 | 16 | it('should not fail if given a proper channel list as JSON', () => { 17 | const correctMapping = { '#channel': '#otherchannel' }; 18 | const wrap = () => validateChannelMapping(correctMapping); 19 | (wrap).should.not.throw(); 20 | }); 21 | 22 | it('should clear channel keys from the mapping', () => { 23 | const bot = new Bot(config); 24 | bot.channelMapping['#slack'].should.equal('#irc'); 25 | bot.invertedMapping['#irc'].should.equal('#slack'); 26 | bot.channels[0].should.equal('#irc channelKey'); 27 | }); 28 | 29 | it('should lowercase IRC channel names', () => { 30 | const bot = new Bot(caseConfig); 31 | bot.channelMapping['#slack'].should.equal('#irc'); 32 | bot.channelMapping['#OtherSlack'].should.equal('#otherirc'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/username-decorator.test.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import { highlightUsername } from '../lib/helpers'; 3 | chai.should(); 4 | 5 | describe('Bare Slack Username Replacement', () => { 6 | it('should replace `username` with `@username`', () => { 7 | const message = 'hey username, check this out'; 8 | const expected = 'hey @username, check this out'; 9 | const result = highlightUsername('username', message); 10 | result.should.equal(expected); 11 | }); 12 | 13 | it('should replace when followed by a character', () => { 14 | const message = 'username: go check this out'; 15 | const expected = '@username: go check this out'; 16 | const result = highlightUsername('username', message); 17 | result.should.equal(expected); 18 | }); 19 | 20 | it('should not replace `username` in a url with a protocol', () => { 21 | const message = 'the repo is https://github.com/username/foo'; 22 | highlightUsername('username', message).should.equal(message); 23 | }); 24 | 25 | it('should not replace `username` in a url without a protocol', () => { 26 | const message = 'the repo is github.com/username/foo'; 27 | highlightUsername('username', message).should.equal(message); 28 | }); 29 | 30 | it('should not replace a @-prefixed username', () => { 31 | const message = 'hey @username, check this out'; 32 | highlightUsername('username', message).should.equal(message); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import _ from 'lodash'; 4 | import fs from 'fs'; 5 | import program from 'commander'; 6 | import path from 'path'; 7 | import checkEnv from 'check-env'; 8 | import stripJsonComments from 'strip-json-comments'; 9 | import * as helpers from './helpers'; 10 | import { ConfigurationError } from './errors'; 11 | import { version } from '../package.json'; 12 | 13 | function readJSONConfig(filePath) { 14 | const configFile = fs.readFileSync(filePath, { encoding: 'utf8' }); 15 | try { 16 | return JSON.parse(stripJsonComments(configFile)); 17 | } catch (err) { 18 | if (err instanceof SyntaxError) { 19 | throw new ConfigurationError('The configuration file contains invalid JSON'); 20 | } else { 21 | throw err; 22 | } 23 | } 24 | } 25 | 26 | function run() { 27 | program 28 | .version(version) 29 | .option('-c, --config ', 30 | 'Sets the path to the config file, otherwise read from the env variable CONFIG_FILE.' 31 | ) 32 | .parse(process.argv); 33 | 34 | // If no config option is given, try to use the env variable: 35 | if (!program.config) checkEnv(['CONFIG_FILE']); 36 | else process.env.CONFIG_FILE = program.config; 37 | 38 | const completePath = path.resolve(process.cwd(), process.env.CONFIG_FILE); 39 | const config = _.endsWith(process.env.CONFIG_FILE, '.js') ? 40 | require(completePath) : readJSONConfig(completePath); 41 | helpers.createBots(config); 42 | } 43 | 44 | export default run; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-irc", 3 | "version": "3.7.4", 4 | "description": "Connects IRC and Slack channels by sending messages back and forth.", 5 | "keywords": [ 6 | "slack", 7 | "irc", 8 | "gateway", 9 | "bot", 10 | "slack-irc" 11 | ], 12 | "main": "dist/index.js", 13 | "bin": "dist/index.js", 14 | "repository": { 15 | "type": "git", 16 | "url": "git@github.com:ekmartin/slack-irc.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/ekmartin/slack-irc/issues" 20 | }, 21 | "scripts": { 22 | "start": "node dist/index.js", 23 | "build": "babel lib --out-dir dist", 24 | "prepublish": "npm run build", 25 | "lint": "eslint . --ignore-path .gitignore", 26 | "coverage": "nyc --require babel-core/register _mocha -- $(find test -name '*.test.js') && nyc report --reporter=cobertura", 27 | "test": "npm run lint && npm run coverage" 28 | }, 29 | "author": { 30 | "name": "Martin Ek " 31 | }, 32 | "license": "MIT", 33 | "dependencies": { 34 | "check-env": "~1.2.0", 35 | "commander": "~2.9.0", 36 | "irc": "~0.4.0", 37 | "lodash": "~3.10.1", 38 | "slack-client": "~1.5.0", 39 | "strip-json-comments": "~2.0.0", 40 | "winston": "~2.1.1" 41 | }, 42 | "devDependencies": { 43 | "babel-cli": "~6.4.0", 44 | "babel-core": "~6.4.0", 45 | "babel-eslint": "~5.0.0-beta4", 46 | "babel-preset-es2015": "~6.3.13", 47 | "babel-preset-stage-0": "~6.3.13", 48 | "chai": "~3.4.1", 49 | "eslint": "~1.10.2", 50 | "eslint-config-airbnb": "~3.1.0", 51 | "istanbul": "~0.4.1", 52 | "mocha": "~2.3.4", 53 | "nyc": "~5.3.0", 54 | "sinon": "~1.17.2", 55 | "sinon-chai": "~2.8.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/create-bots.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0 */ 2 | import chai from 'chai'; 3 | import sinon from 'sinon'; 4 | import sinonChai from 'sinon-chai'; 5 | import Bot from '../lib/bot'; 6 | import index from '../lib/index'; 7 | import testConfig from './fixtures/test-config.json'; 8 | import singleTestConfig from './fixtures/single-test-config.json'; 9 | import badConfig from './fixtures/bad-config.json'; 10 | import stringConfig from './fixtures/string-config.json'; 11 | import { createBots } from '../lib/helpers'; 12 | 13 | chai.should(); 14 | chai.use(sinonChai); 15 | 16 | describe('Create Bots', function() { 17 | const sandbox = sinon.sandbox.create({ 18 | useFakeTimers: false, 19 | useFakeServer: false 20 | }); 21 | 22 | beforeEach(function() { 23 | this.connectStub = sandbox.stub(Bot.prototype, 'connect'); 24 | }); 25 | 26 | afterEach(function() { 27 | sandbox.restore(); 28 | }); 29 | 30 | it('should work when given an array of configs', function() { 31 | const bots = createBots(testConfig); 32 | bots.length.should.equal(2); 33 | this.connectStub.should.have.been.called; 34 | }); 35 | 36 | it('should work when given an object as a config file', function() { 37 | const bots = createBots(singleTestConfig); 38 | bots.length.should.equal(1); 39 | this.connectStub.should.have.been.called; 40 | }); 41 | 42 | it('should throw a configuration error if any fields are missing', function() { 43 | const wrap = () => createBots(badConfig); 44 | (wrap).should.throw('Missing configuration field nickname'); 45 | }); 46 | 47 | it('should throw if a configuration file is neither an object or an array', function() { 48 | const wrap = () => createBots(stringConfig); 49 | (wrap).should.throw('Invalid configuration file given'); 50 | }); 51 | 52 | it('should be possible to run it through require(\'slack-irc\')', function() { 53 | const bots = index(singleTestConfig); 54 | bots.length.should.equal(1); 55 | this.connectStub.should.have.been.called; 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/cli.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0 */ 2 | import chai from 'chai'; 3 | import sinon from 'sinon'; 4 | import sinonChai from 'sinon-chai'; 5 | import cli from '../lib/cli'; 6 | import * as helpers from '../lib/helpers'; 7 | import testConfig from './fixtures/test-config.json'; 8 | import singleTestConfig from './fixtures/single-test-config.json'; 9 | 10 | chai.should(); 11 | chai.use(sinonChai); 12 | 13 | describe('CLI', function() { 14 | const sandbox = sinon.sandbox.create({ 15 | useFakeTimers: false, 16 | useFakeServer: false 17 | }); 18 | 19 | beforeEach(function() { 20 | this.createBotsStub = sandbox.stub(helpers, 'createBots'); 21 | }); 22 | 23 | afterEach(function() { 24 | sandbox.restore(); 25 | }); 26 | 27 | it('should be possible to give the config as an env var', function() { 28 | process.env.CONFIG_FILE = process.cwd() + '/test/fixtures/test-config.json'; 29 | process.argv = ['node', 'index.js']; 30 | cli(); 31 | this.createBotsStub.should.have.been.calledWith(testConfig); 32 | }); 33 | 34 | it('should strip comments from JSON config', function() { 35 | process.env.CONFIG_FILE = process.cwd() + '/test/fixtures/test-config-comments.json'; 36 | process.argv = ['node', 'index.js']; 37 | cli(); 38 | this.createBotsStub.should.have.been.calledWith(testConfig); 39 | }); 40 | 41 | it('should support JS configs', function() { 42 | process.env.CONFIG_FILE = process.cwd() + '/test/fixtures/test-javascript-config.js'; 43 | process.argv = ['node', 'index.js']; 44 | cli(); 45 | this.createBotsStub.should.have.been.calledWith(testConfig); 46 | }); 47 | 48 | it('should throw a ConfigurationError for invalid JSON', function() { 49 | process.env.CONFIG_FILE = process.cwd() + '/test/fixtures/invalid-config.json'; 50 | process.argv = ['node', 'index.js']; 51 | const wrap = () => cli(); 52 | (wrap).should.throw('The configuration file contains invalid JSON'); 53 | }); 54 | 55 | it('should be possible to give the config as an option', function() { 56 | delete process.env.CONFIG_FILE; 57 | process.argv = [ 58 | 'node', 59 | 'index.js', 60 | '--config', 61 | `${process.cwd()}/test/fixtures/single-test-config.json` 62 | ]; 63 | 64 | cli(); 65 | this.createBotsStub.should.have.been.calledWith(singleTestConfig); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/join-part.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0 */ 2 | import _ from 'lodash'; 3 | import chai from 'chai'; 4 | import sinonChai from 'sinon-chai'; 5 | import sinon from 'sinon'; 6 | import irc from 'irc'; 7 | import logger from 'winston'; 8 | import Bot from '../lib/bot'; 9 | import SlackStub from './stubs/slack-stub'; 10 | import ChannelStub from './stubs/channel-stub'; 11 | import ClientStub from './stubs/irc-client-stub'; 12 | import config from './fixtures/single-test-config.json'; 13 | 14 | chai.should(); 15 | chai.use(sinonChai); 16 | 17 | describe('Join/Part/Quit Notices', function() { 18 | const sandbox = sinon.sandbox.create({ 19 | useFakeTimers: false, 20 | useFakeServer: false 21 | }); 22 | 23 | beforeEach(function() { 24 | this.infoStub = sandbox.stub(logger, 'info'); 25 | this.debugStub = sandbox.stub(logger, 'debug'); 26 | this.errorStub = sandbox.stub(logger, 'error'); 27 | sandbox.stub(irc, 'Client', ClientStub); 28 | SlackStub.prototype.login = sandbox.stub(); 29 | ClientStub.prototype.send = sandbox.stub(); 30 | ClientStub.prototype.join = sandbox.stub(); 31 | this.bot = new Bot(_.cloneDeep(config)); 32 | this.bot.sendToIRC = sandbox.stub(); 33 | this.bot.sendToSlack = sandbox.stub(); 34 | this.bot.slack = new SlackStub(); 35 | }); 36 | 37 | afterEach(function() { 38 | sandbox.restore(); 39 | ChannelStub.prototype.postMessage.reset(); 40 | }); 41 | 42 | it('should send joins to slack if enabled', function() { 43 | this.bot.connect(); 44 | const channel = '#channel'; 45 | const nick = 'nick'; 46 | const message = {}; 47 | const expected = `*${nick}* has joined the IRC channel`; 48 | this.bot.ircClient.emit('join', channel, nick, message); 49 | this.bot.sendToSlack.should.have.been.calledWithExactly(config.nickname, channel, expected); 50 | }); 51 | 52 | it('should not send joins to slack if disabled', function() { 53 | this.bot.ircStatusNotices.join = false; 54 | this.bot.connect(); 55 | const channel = '#channel'; 56 | const nick = 'nick'; 57 | const message = {}; 58 | this.bot.ircClient.emit('join', channel, nick, message); 59 | this.bot.sendToSlack.should.not.have.been.called; 60 | }); 61 | 62 | it('should send parts to slack if enabled', function() { 63 | this.bot.connect(); 64 | const channel = '#channel'; 65 | const nick = 'nick'; 66 | const message = {}; 67 | const expected = `*${nick}* has left the IRC channel`; 68 | this.bot.ircClient.emit('part', channel, nick, message); 69 | this.bot.sendToSlack.should.have.been.calledWithExactly(config.nickname, channel, expected); 70 | }); 71 | 72 | it('should not send parts to slack if disabled', function() { 73 | this.bot.ircStatusNotices.leave = false; 74 | this.bot.connect(); 75 | const channel = '#channel'; 76 | const nick = 'nick'; 77 | const message = {}; 78 | this.bot.ircClient.emit('part', channel, nick, message); 79 | this.bot.sendToSlack.should.not.have.been.called; 80 | }); 81 | 82 | it('should send quits to slack if enabled', function() { 83 | this.bot.connect(); 84 | const channels = ['#channel1', '#channel2']; 85 | const nick = 'nick'; 86 | const message = {}; 87 | this.bot.ircClient.emit('quit', nick, 'reason', channels, message); 88 | channels.forEach(channel => { 89 | const expected = `*${nick}* has quit the IRC channel`; 90 | this.bot.sendToSlack.should.have.been.calledWithExactly(config.nickname, channel, expected); 91 | }); 92 | }); 93 | 94 | it('should not send quits to slack if disabled', function() { 95 | this.bot.ircStatusNotices.leave = false; 96 | this.bot.connect(); 97 | const channels = ['#channel1', '#channel2']; 98 | const nick = 'nick'; 99 | const message = {}; 100 | this.bot.ircClient.emit('quit', nick, 'reason', channels, message); 101 | this.bot.sendToSlack.should.not.have.been.called; 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | ## [3.7.4] - 2016-01-21 5 | ### Fixed 6 | - Fix a bug where the bot-in-channel check would fail for private groups. 7 | 8 | ## [3.7.3] - 2016-01-12 9 | ### Fixed 10 | - Don't crash when trying to send a message to a Slack channel the bot 11 | isn't a member of. 12 | 13 | ## [3.7.2] - 2016-01-12 14 | ### Changed 15 | - Remove babel-polyfill, use functions available in Node 0.10 and above instead. 16 | 17 | ## [3.7.1] - 2016-01-10 18 | ### Changed 19 | - Added babel-polyfill, fixes #70. 20 | - Updated dependencies. 21 | 22 | ## [3.7.0] - 2015-12-21 23 | ### Added 24 | - Valid usernames are now highlighted with an @ before messages are posted to Slack, thanks to @grahamb. 25 | - `muteSlackbot` option that stops Slackbot messages from being forwarded to IRC, also courtesy of @grahamb. 26 | - `ircStatusNotices` option that makes slack-irc send status updates to Slack whenever an IRC user 27 | joins/parts/quits. See README.md for example. 28 | 29 | ### Changed 30 | - Upgraded dependencies. 31 | - Comments are now stripped from JSON configs before they're parsed. 32 | - Configurations with invalid JSON now throws a ConfigurationError. 33 | 34 | ## [3.6.2] - 2015-12-01 35 | ### Changed 36 | - Upgraded dependencies. 37 | 38 | ## [3.6.1] - 2015-11-18 39 | ### Changed 40 | - Refactor to use ES2015+ with Babel. 41 | - Refactor tests. 42 | 43 | ## [3.6.0] - 2015-09-14 44 | ### Added 45 | - Support for actions from IRC to Slack and vice versa (/me messages). 46 | - Support for sending notices from IRC to Slack (/notice #channel message). 47 | 48 | ## [3.5.2] - 2015-06-26 49 | ### Fixed 50 | - Remove old unused dependencies. 51 | 52 | ## [3.5.1] - 2015-06-26 53 | ### Fixed 54 | - A bug introduced in 3.5.0 where Slack messages sent to IRC wouldn't get parsed. 55 | Adds a test to confirm correct behavior. 56 | 57 | ## [3.5.0] - 2015-06-22 58 | ### Added 59 | - `commandCharacters` option - makes the bot hide the username prefix for 60 | messages that start with one of the provided characters when posting to IRC. 61 | A `Command sent from Slack by username:` message will be posted to the IRC 62 | channel before the command is submitted. 63 | 64 | ## [3.4.0] - 2015-05-22 65 | ### Added 66 | - Made it possible to require slack-irc as a node module. 67 | 68 | ## [3.3.2] - 2015-05-17 69 | ### Fixed 70 | - Upgrade dependencies. 71 | 72 | ## [3.3.1] - 2015-05-17 73 | ### Fixed 74 | - Make IRC channel names case insensitive in the channel mapping. 75 | Relevant issue: [#31](https://github.com/ekmartin/slack-irc/issues/31) 76 | 77 | ## [3.3.0] - 2015-04-17 78 | ### Added 79 | - Conversion of emojis to text smileys from Slack to IRC, by [andebor](https://github.com/andebor). 80 | Relevant issue: [#10](https://github.com/ekmartin/slack-irc/issues/10) 81 | 82 | ## [3.2.1] - 2015-04-07 83 | ### Fixed 84 | - Convert newlines sent from Slack to spaces to prevent the bot from sending multiple messages. 85 | 86 | ## [3.2.0] - 2015-04-03 87 | ### Added 88 | - Support for passing [node-irc](http://node-irc.readthedocs.org/en/latest/API.html#irc.Client) 89 | options directly by adding an `ircOptions` object to the config. Also sets `floodProtection` on 90 | by default, with a delay of 500 ms. 91 | 92 | ## [3.1.0] - 2015-03-27 93 | ### Added 94 | - Make the bot able to join password protected IRC channels. Example: 95 | 96 | ```json 97 | "channelMapping": { 98 | "#slack": "#irc channel-password", 99 | } 100 | ``` 101 | 102 | ## [3.0.0] - 2015-03-24 103 | ### Changed 104 | Move from using outgoing/incoming integrations to Slack's 105 | [bot users](https://api.slack.com/bot-users). See 106 | [README.md](https://github.com/ekmartin/slack-irc/blob/master/README.md) 107 | for a new example configuration. This mainly means slack-irc won't need 108 | to listen on a port anymore, as it uses websockets to receive the messages 109 | from Slack's [RTM API](https://api.slack.com/rtm). 110 | 111 | To change from version 2 to 3, do the following: 112 | - Create a new Slack bot user (under integrations) 113 | - Add its token to your slack-irc config, and remove 114 | the `outgoingToken` and `incomingURL` config options. 115 | 116 | ### Added 117 | - Message formatting, follows Slack's [rules](https://api.slack.com/docs/formatting). 118 | 119 | ## [2.0.1]- 2015-03-03 120 | ### Added 121 | - MIT License 122 | 123 | ## [2.0.0] - 2015-02-22 124 | ### Changed 125 | - Post URL changed from `/send` to `/`. 126 | 127 | ## [1.1.0] - 2015-02-12 128 | ### Added 129 | - Icons from [Adorable Avatars](http://avatars.adorable.io/). 130 | - Command-line interface 131 | 132 | ### Changed 133 | - Status code from 202 to 200. 134 | 135 | ## [1.0.0] - 2015-02-09 136 | ### Added 137 | - Support for running multiple bots (on different Slacks) 138 | 139 | ### Changed 140 | - New configuration format, example 141 | [here](https://github.com/ekmartin/slack-irc/blob/44f6079b5da597cd091e8a3582e34617824e619e/README.md#configuration). 142 | 143 | ## [0.2.0] - 2015-02-06 144 | ### Added 145 | - Add support for channel mapping. 146 | 147 | ### Changed 148 | - Use winston for logging. 149 | -------------------------------------------------------------------------------- /test/bot-events.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0 */ 2 | import chai from 'chai'; 3 | import sinonChai from 'sinon-chai'; 4 | import sinon from 'sinon'; 5 | import irc from 'irc'; 6 | import logger from 'winston'; 7 | import Bot from '../lib/bot'; 8 | import SlackStub from './stubs/slack-stub'; 9 | import ChannelStub from './stubs/channel-stub'; 10 | import ClientStub from './stubs/irc-client-stub'; 11 | import config from './fixtures/single-test-config.json'; 12 | 13 | chai.should(); 14 | chai.use(sinonChai); 15 | 16 | describe('Bot Events', function() { 17 | const sandbox = sinon.sandbox.create({ 18 | useFakeTimers: false, 19 | useFakeServer: false 20 | }); 21 | 22 | beforeEach(function() { 23 | this.infoStub = sandbox.stub(logger, 'info'); 24 | this.debugStub = sandbox.stub(logger, 'debug'); 25 | this.errorStub = sandbox.stub(logger, 'error'); 26 | sandbox.stub(irc, 'Client', ClientStub); 27 | SlackStub.prototype.login = sandbox.stub(); 28 | ClientStub.prototype.send = sandbox.stub(); 29 | ClientStub.prototype.join = sandbox.stub(); 30 | this.bot = new Bot(config); 31 | this.bot.sendToIRC = sandbox.stub(); 32 | this.bot.sendToSlack = sandbox.stub(); 33 | this.bot.slack = new SlackStub(); 34 | this.bot.connect(); 35 | }); 36 | 37 | afterEach(function() { 38 | sandbox.restore(); 39 | ChannelStub.prototype.postMessage.reset(); 40 | }); 41 | 42 | it('should log on slack open event', function() { 43 | this.bot.slack.emit('open'); 44 | this.debugStub.should.have.been.calledWithExactly('Connected to Slack'); 45 | }); 46 | 47 | it('should try to send autoSendCommands on registered IRC event', function() { 48 | this.bot.ircClient.emit('registered'); 49 | ClientStub.prototype.send.should.have.been.calledTwice; 50 | ClientStub.prototype.send.getCall(0).args.should.deep.equal(config.autoSendCommands[0]); 51 | ClientStub.prototype.send.getCall(1).args.should.deep.equal(config.autoSendCommands[1]); 52 | }); 53 | 54 | it('should error log on error events', function() { 55 | const slackError = new Error('slack'); 56 | const ircError = new Error('irc'); 57 | this.bot.slack.emit('error', slackError); 58 | this.bot.ircClient.emit('error', ircError); 59 | this.errorStub.getCall(0).args[0].should.equal('Received error event from Slack'); 60 | this.errorStub.getCall(0).args[1].should.equal(slackError); 61 | this.errorStub.getCall(1).args[0].should.equal('Received error event from IRC'); 62 | this.errorStub.getCall(1).args[1].should.equal(ircError); 63 | }); 64 | 65 | it('should send messages to irc if correct', function() { 66 | const message = { 67 | type: 'message' 68 | }; 69 | this.bot.slack.emit('message', message); 70 | this.bot.sendToIRC.should.have.been.calledWithExactly(message); 71 | }); 72 | 73 | it('should not send messages to irc if the type isn\'t message', function() { 74 | const message = { 75 | type: 'notmessage' 76 | }; 77 | this.bot.slack.emit('message', message); 78 | this.bot.sendToIRC.should.have.not.have.been.called; 79 | }); 80 | 81 | it('should not send messages to irc if it has an invalid subtype', function() { 82 | const message = { 83 | type: 'message', 84 | subtype: 'bot_message' 85 | }; 86 | this.bot.slack.emit('message', message); 87 | this.bot.sendToIRC.should.have.not.have.been.called; 88 | }); 89 | 90 | it('should send messages to slack', function() { 91 | const channel = '#channel'; 92 | const author = 'user'; 93 | const text = 'hi'; 94 | this.bot.ircClient.emit('message', author, channel, text); 95 | this.bot.sendToSlack.should.have.been.calledWithExactly(author, channel, text); 96 | }); 97 | 98 | it('should send notices to slack', function() { 99 | const channel = '#channel'; 100 | const author = 'user'; 101 | const text = 'hi'; 102 | const formattedText = `*${text}*`; 103 | this.bot.ircClient.emit('notice', author, channel, text); 104 | this.bot.sendToSlack.should.have.been.calledWithExactly(author, channel, formattedText); 105 | }); 106 | 107 | it('should send actions to slack', function() { 108 | const channel = '#channel'; 109 | const author = 'user'; 110 | const text = 'hi'; 111 | const formattedText = '_hi_'; 112 | const message = {}; 113 | this.bot.ircClient.emit('action', author, channel, text, message); 114 | this.bot.sendToSlack.should.have.been.calledWithExactly(author, channel, formattedText); 115 | }); 116 | 117 | it('should join channels when invited', function() { 118 | const channel = '#irc'; 119 | const author = 'user'; 120 | this.debugStub.reset(); 121 | this.bot.ircClient.emit('invite', channel, author); 122 | const firstCall = this.debugStub.getCall(0); 123 | firstCall.args[0].should.equal('Received invite:'); 124 | firstCall.args[1].should.equal(channel); 125 | firstCall.args[2].should.equal(author); 126 | 127 | ClientStub.prototype.join.should.have.been.calledWith(channel); 128 | const secondCall = this.debugStub.getCall(1); 129 | secondCall.args[0].should.equal('Joining channel:'); 130 | secondCall.args[1].should.equal(channel); 131 | }); 132 | 133 | it('should not join channels that aren\'t in the channel mapping', function() { 134 | const channel = '#wrong'; 135 | const author = 'user'; 136 | this.debugStub.reset(); 137 | this.bot.ircClient.emit('invite', channel, author); 138 | const firstCall = this.debugStub.getCall(0); 139 | firstCall.args[0].should.equal('Received invite:'); 140 | firstCall.args[1].should.equal(channel); 141 | firstCall.args[2].should.equal(author); 142 | 143 | ClientStub.prototype.join.should.not.have.been.called; 144 | const secondCall = this.debugStub.getCall(1); 145 | secondCall.args[0].should.equal('Channel not found in config, not joining:'); 146 | secondCall.args[1].should.equal(channel); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slack-irc [![Join the chat at https://gitter.im/ekmartin/slack-irc](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/ekmartin/slack-irc?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Build Status](https://travis-ci.org/ekmartin/slack-irc.svg?branch=travis)](https://travis-ci.org/ekmartin/slack-irc) [![Coverage status](https://ci.frigg.io/badges/coverage/ekmartin/slack-irc/)](https://ci.frigg.io/ekmartin/slack-irc/last/) 2 | 3 | > Connects Slack and IRC channels by sending messages back and forth. Read more [here](https://ekmartin.com/2015/slack-irc/). 4 | 5 | ## Demo 6 | ![Slack IRC](http://i.imgur.com/58H6HgO.gif) 7 | 8 | ## Installation and usage 9 | Either by installing through npm: 10 | ```bash 11 | $ npm install -g slack-irc 12 | $ slack-irc --config /path/to/config.json 13 | ``` 14 | 15 | or by cloning the repository: 16 | 17 | ```bash 18 | $ git clone https://github.com/ekmartin/slack-irc.git && cd slack-irc 19 | $ npm install 20 | $ npm run build 21 | $ npm start -- --config /path/to/config.json # Note the extra -- here 22 | ``` 23 | 24 | It can also be used as a node module: 25 | ```js 26 | var slackIRC = require('slack-irc'); 27 | var config = require('./config.json'); 28 | slackIRC(config); 29 | ``` 30 | 31 | ## Configuration 32 | 33 | slack-irc uses Slack's [bot users](https://api.slack.com/bot-users). 34 | This means you'll have to set up a bot user as a Slack integration, and invite it 35 | to the Slack channels you want it to listen in on. This can be done using Slack's `/invite ` 36 | command. This has to be done manually as there's no way to do it through the Slack bot user API at 37 | the moment. 38 | 39 | slack-irc requires a JSON-configuration file, whose path can be given either through 40 | the CLI-option `--config` or the environment variable `CONFIG_FILE`. The configuration 41 | file needs to be an object or an array, depending on the number of IRC bots you want to run. 42 | 43 | This allows you to use one instance of slack-irc for multiple Slack teams if wanted, even 44 | if the IRC channels are on different networks. 45 | 46 | To set the log level to debug, export the environment variable `NODE_ENV` as `development`. 47 | 48 | slack-irc also supports invite-only IRC channels, and will join any channels it's invited to 49 | as long as they're present in the channel mapping. 50 | 51 | ### Example configuration 52 | Valid JSON cannot contain comments, so remember to remove them first! 53 | ```js 54 | [ 55 | // Bot 1 (minimal configuration): 56 | { 57 | "nickname": "test2", 58 | "server": "irc.testbot.org", 59 | "slackUser": "yourSlackUsername", 60 | "token": "slacktoken2", 61 | "channelMapping": { 62 | "#other-slack": "#new-irc-channel" 63 | } 64 | }, 65 | 66 | // Bot 2 (advanced options): 67 | { 68 | "nickname": "test", 69 | "server": "irc.bottest.org", 70 | "slackUser": "yourSlackUsername", 71 | "token": "slacktoken", // Your bot user's token 72 | "autoSendCommands": [ // Commands that will be sent on connect 73 | ["PRIVMSG", "NickServ", "IDENTIFY password"], 74 | ["MODE", "test", "+x"], 75 | ["AUTH", "test", "password"] 76 | ], 77 | "channelMapping": { // Maps each Slack-channel to an IRC-channel, used to direct messages to the correct place 78 | "#slack": "#irc channel-password", // Add channel keys after the channel name 79 | "privategroup": "#other-channel" // No hash in front of private groups 80 | }, 81 | "ircOptions": { // Optional node-irc options 82 | "floodProtection": false, // On by default 83 | "floodProtectionDelay": 1000 // 500 by default 84 | }, 85 | // Sends messages to Slack whenever a user joins/leaves an IRC channel: 86 | "ircStatusNotices": { // These are all disabled by default 87 | "join": false, // Don't send messages about joins 88 | "leave": true, 89 | "changeNick": true, 90 | "modes": true 91 | }, 92 | // How long the last DM recipient should be remembered, default 10 minutes 93 | "rememberRecipientsFor": 3600000 // 1 hour 94 | } 95 | ] 96 | ``` 97 | 98 | `ircOptions` is passed directly to node-irc ([available options](http://node-irc.readthedocs.org/en/latest/API.html#irc.Client)). 99 | 100 | ## Development 101 | To be able to use the latest ES2015+ features, slack-irc uses [Babel](https://babeljs.io). 102 | 103 | Build the source with: 104 | ```bash 105 | $ npm run build 106 | ``` 107 | 108 | ### Tests 109 | Run the tests with: 110 | ```bash 111 | $ npm test 112 | ``` 113 | 114 | ### Style Guide 115 | slack-irc uses a slightly modified version of the 116 | [Airbnb Style Guide](https://github.com/airbnb/javascript/tree/master/es5). 117 | [ESLint](http://eslint.org/) is used to make sure this is followed correctly, which can be run with: 118 | 119 | ```bash 120 | $ npm run lint 121 | ``` 122 | 123 | The deviations from the Airbnb Style Guide can be seen in the [.eslintrc](.eslintrc) file. 124 | 125 | ## Docker 126 | A third-party Docker container can be found [here](https://github.com/caktux/slackbridge/). 127 | 128 | ## License 129 | 130 | (The MIT License) 131 | 132 | Copyright (c) 2015 Martin Ek 133 | 134 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 135 | 136 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 137 | 138 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 139 | -------------------------------------------------------------------------------- /test/bot.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0 */ 2 | import chai from 'chai'; 3 | import sinon from 'sinon'; 4 | import logger from 'winston'; 5 | import sinonChai from 'sinon-chai'; 6 | import irc from 'irc'; 7 | import Bot from '../lib/bot'; 8 | import SlackStub from './stubs/slack-stub'; 9 | import ChannelStub from './stubs/channel-stub'; 10 | import ClientStub from './stubs/irc-client-stub'; 11 | import config from './fixtures/single-test-config.json'; 12 | 13 | chai.should(); 14 | chai.use(sinonChai); 15 | 16 | describe('Bot', function() { 17 | const sandbox = sinon.sandbox.create({ 18 | useFakeTimers: false, 19 | useFakeServer: false 20 | }); 21 | 22 | beforeEach(function() { 23 | sandbox.stub(logger, 'info'); 24 | sandbox.stub(logger, 'debug'); 25 | sandbox.stub(logger, 'error'); 26 | sandbox.stub(irc, 'Client', ClientStub); 27 | ClientStub.prototype.say = sandbox.stub(); 28 | ClientStub.prototype.send = sandbox.stub(); 29 | ClientStub.prototype.join = sandbox.stub(); 30 | SlackStub.prototype.login = sandbox.stub(); 31 | this.bot = new Bot(config); 32 | this.bot.slack = new SlackStub(); 33 | this.bot.connect(); 34 | }); 35 | 36 | afterEach(function() { 37 | sandbox.restore(); 38 | ChannelStub.prototype.postMessage.reset(); 39 | }); 40 | 41 | it('should invert the channel mapping', function() { 42 | this.bot.invertedMapping['#irc'].should.equal('#slack'); 43 | }); 44 | 45 | it('should send correct message objects to slack', function() { 46 | const message = { 47 | text: 'testmessage', 48 | username: 'testuser', 49 | parse: 'full', 50 | icon_url: 'http://api.adorable.io/avatars/48/testuser.png' 51 | }; 52 | 53 | this.bot.sendToSlack(message.username, '#irc', message.text); 54 | ChannelStub.prototype.postMessage.should.have.been.calledWith(message); 55 | }); 56 | 57 | it('should send messages to slack groups if the bot is in the channel', function() { 58 | this.bot.slack.getChannelGroupOrDMByName = () => { 59 | const channel = new ChannelStub(); 60 | delete channel.is_member; 61 | channel.is_group = true; 62 | return channel; 63 | }; 64 | 65 | const message = { 66 | text: 'testmessage', 67 | username: 'testuser', 68 | parse: 'full', 69 | icon_url: 'http://api.adorable.io/avatars/48/testuser.png' 70 | }; 71 | 72 | this.bot.sendToSlack(message.username, '#irc', message.text); 73 | ChannelStub.prototype.postMessage.should.have.been.calledWith(message); 74 | }); 75 | 76 | it('should not include an avatar for the bot\'s own messages', 77 | function() { 78 | const message = { 79 | text: 'testmessage', 80 | username: config.nickname, 81 | parse: 'full', 82 | icon_url: undefined 83 | }; 84 | 85 | this.bot.sendToSlack(message.username, '#irc', message.text); 86 | ChannelStub.prototype.postMessage.should.have.been.calledWith(message); 87 | }); 88 | 89 | it('should lowercase channel names before sending to slack', function() { 90 | const message = { 91 | text: 'testmessage', 92 | username: 'testuser', 93 | parse: 'full', 94 | icon_url: 'http://api.adorable.io/avatars/48/testuser.png' 95 | }; 96 | 97 | this.bot.sendToSlack(message.username, '#IRC', message.text); 98 | ChannelStub.prototype.postMessage.should.have.been.calledWith(message); 99 | }); 100 | 101 | it('should not send messages to slack if the channel isn\'t in the channel mapping', 102 | function() { 103 | this.bot.sendToSlack('user', '#wrongchan', 'message'); 104 | ChannelStub.prototype.postMessage.should.not.have.been.called; 105 | }); 106 | 107 | it('should not send messages to slack if the bot isn\'t in the channel', function() { 108 | this.bot.slack.getChannelGroupOrDMByName = () => null; 109 | this.bot.sendToSlack('user', '#irc', 'message'); 110 | ChannelStub.prototype.postMessage.should.not.have.been.called; 111 | }); 112 | 113 | it('should not send messages to slack if the channel\'s is_member is false', function() { 114 | this.bot.slack.getChannelGroupOrDMByName = () => { 115 | const channel = new ChannelStub(); 116 | channel.is_member = false; 117 | return channel; 118 | }; 119 | 120 | this.bot.sendToSlack('user', '#irc', 'message'); 121 | ChannelStub.prototype.postMessage.should.not.have.been.called; 122 | }); 123 | 124 | it('should replace a bare username if the user is in-channel', function() { 125 | const message = { 126 | text: 'testuser should be replaced in the message', 127 | username: 'testuser', 128 | parse: 'full', 129 | icon_url: 'http://api.adorable.io/avatars/48/testuser.png' 130 | }; 131 | 132 | const expected = { 133 | ...message, 134 | text: '@testuser should be replaced in the message' 135 | }; 136 | 137 | this.bot.sendToSlack(message.username, '#IRC', message.text); 138 | ChannelStub.prototype.postMessage.should.have.been.calledWith(expected); 139 | }); 140 | 141 | it('should send correct messages to irc', function() { 142 | const text = 'testmessage'; 143 | const message = { 144 | channel: 'slack', 145 | getBody() { 146 | return text; 147 | } 148 | }; 149 | 150 | this.bot.sendToIRC(message); 151 | const ircText = ` ${text}`; 152 | ClientStub.prototype.say.should.have.been.calledWith('#irc', ircText); 153 | }); 154 | 155 | it('should send /me messages to irc', function() { 156 | const text = 'testmessage'; 157 | const message = { 158 | channel: 'slack', 159 | subtype: 'me_message', 160 | getBody() { 161 | return text; 162 | } 163 | }; 164 | 165 | this.bot.sendToIRC(message); 166 | const ircText = `Action: testuser ${text}`; 167 | ClientStub.prototype.say.should.have.been.calledWith('#irc', ircText); 168 | }); 169 | 170 | it('should not send messages to irc if the channel isn\'t in the channel mapping', 171 | function() { 172 | this.bot.slack.getChannelGroupOrDMByID = () => null; 173 | const message = { 174 | channel: 'wrongchannel' 175 | }; 176 | 177 | this.bot.sendToIRC(message); 178 | ClientStub.prototype.say.should.not.have.been.called; 179 | }); 180 | 181 | it('should send messages from slackbot if slackbot muting is off', 182 | function() { 183 | const text = 'A message from Slackbot'; 184 | const message = { 185 | user: 'USLACKBOT', 186 | getBody() { 187 | return text; 188 | } 189 | }; 190 | 191 | this.bot.sendToIRC(message); 192 | const ircText = ` ${text}`; 193 | ClientStub.prototype.say.should.have.been.calledWith('#irc', ircText); 194 | }); 195 | 196 | it('should parse text from slack when sending messages', function() { 197 | const text = '<@USOMEID> <@USOMEID|readable>'; 198 | const message = { 199 | channel: 'slack', 200 | getBody() { 201 | return text; 202 | } 203 | }; 204 | 205 | this.bot.sendToIRC(message); 206 | ClientStub.prototype.say.should.have.been.calledWith('#irc', ' @testuser readable'); 207 | }); 208 | 209 | it('should parse text from slack', function() { 210 | this.bot.parseText('hi\nhi\r\nhi\r').should.equal('hi hi hi '); 211 | this.bot.parseText('>><<').should.equal('>><<'); 212 | this.bot.parseText(' ') 213 | .should.equal('@channel @group @everyone'); 214 | this.bot.parseText('<#CSOMEID> <#CSOMEID|readable>') 215 | .should.equal('#slack readable'); 216 | this.bot.parseText('<@USOMEID> <@USOMEID|readable>') 217 | .should.equal('@testuser readable'); 218 | this.bot.parseText('').should.equal('https://example.com'); 219 | this.bot.parseText(' ') 220 | .should.equal(' '); 221 | }); 222 | 223 | it('should parse emojis correctly', function() { 224 | this.bot.parseText(':smile:').should.equal(':)'); 225 | this.bot.parseText(':train:').should.equal(':train:'); 226 | }); 227 | 228 | it('should hide usernames for commands', function() { 229 | const text = '!test command'; 230 | const message = { 231 | channel: 'slack', 232 | getBody() { 233 | return text; 234 | } 235 | }; 236 | 237 | this.bot.sendToIRC(message); 238 | ClientStub.prototype.say.getCall(0).args.should.deep.equal([ 239 | '#irc', 'Command sent from Slack by testuser:' 240 | ]); 241 | ClientStub.prototype.say.getCall(1).args.should.deep.equal(['#irc', text]); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /lib/bot.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import irc from 'irc'; 3 | import logger from 'winston'; 4 | import Slack from 'slack-client'; 5 | import { ConfigurationError } from './errors'; 6 | import emojis from '../assets/emoji.json'; 7 | import { validateChannelMapping } from './validators'; 8 | import { highlightUsername } from './helpers'; 9 | 10 | const ALLOWED_SUBTYPES = ['me_message']; 11 | const REQUIRED_FIELDS = ['server', 'nickname', 'slackUser', 'channelMapping', 'token']; 12 | 13 | /** 14 | * An IRC bot, works as a middleman for all communication 15 | * @param {object} options 16 | */ 17 | class Bot { 18 | constructor(options) { 19 | REQUIRED_FIELDS.forEach(field => { 20 | if (!options[field]) { 21 | throw new ConfigurationError('Missing configuration field ' + field); 22 | } 23 | }); 24 | 25 | validateChannelMapping(options.channelMapping); 26 | 27 | this.slack = new Slack(options.token); 28 | 29 | this.server = options.server; 30 | this.nickname = options.nickname; 31 | this.slackUser = options.slackUser; 32 | this.ircOptions = options.ircOptions; 33 | this.ircStatusNotices = options.ircStatusNotices || {}; 34 | this.channels = _.values(options.channelMapping); 35 | 36 | this.rememberRecipientsFor = +(options.rememberRecipientsFor || 1000 * 60 * 10); 37 | this.lastPM = { 38 | user: '', 39 | timestamp: 0 40 | }; 41 | 42 | this.channelMapping = {}; 43 | 44 | // Remove channel passwords from the mapping and lowercase IRC channel names 45 | _.forOwn(options.channelMapping, (ircChan, slackChan) => { 46 | this.channelMapping[slackChan] = ircChan.split(' ')[0].toLowerCase(); 47 | }, this); 48 | 49 | this.invertedMapping = _.invert(this.channelMapping); 50 | this.autoSendCommands = options.autoSendCommands || []; 51 | } 52 | 53 | connect() { 54 | logger.debug('Connecting to IRC and Slack'); 55 | this.slack.login(); 56 | 57 | const ircOptions = { 58 | userName: this.nickname, 59 | realName: this.nickname, 60 | channels: this.channels, 61 | floodProtection: true, 62 | floodProtectionDelay: 500, 63 | ...this.ircOptions 64 | }; 65 | 66 | this.ircClient = new irc.Client(this.server, this.nickname, ircOptions); 67 | this.attachListeners(); 68 | } 69 | 70 | attachListeners() { 71 | this.slack.on('open', () => { 72 | logger.debug('Connected to Slack'); 73 | }); 74 | 75 | this.ircClient.on('registered', message => { 76 | logger.debug('Registered event: ', message); 77 | this.autoSendCommands.forEach(element => { 78 | this.ircClient.send(...element); 79 | }); 80 | }); 81 | 82 | this.ircClient.on('error', error => { 83 | logger.error('Received error event from IRC', error); 84 | this.sendToSlack(false, this.nickname, `_${error.command}_ : _${error.args.join(' ')}_`); 85 | }); 86 | 87 | this.slack.on('error', error => { 88 | logger.error('Received error event from Slack', error); 89 | }); 90 | 91 | this.slack.on('presenceChange', (user, presence) => { 92 | if (user.name === this.slackUser) { 93 | if (presence === 'active') { 94 | this.ircClient.send('AWAY'); 95 | } else { 96 | this.ircClient.send('AWAY', ' '); 97 | } 98 | } 99 | }); 100 | 101 | this.slack.on('message', message => { 102 | // Ignore everything except the desired Slack user 103 | const user = this.slack.getUserByID(message.user); 104 | if (user && user.name === this.slackUser && message.type === 'message' && 105 | (!message.subtype || ALLOWED_SUBTYPES.indexOf(message.subtype) > -1)) { 106 | this.sendToIRC(message); 107 | } 108 | }); 109 | 110 | this.ircClient.on('message', this.sendToSlack.bind(this)); 111 | 112 | this.ircClient.on('notice', (author, to, text) => { 113 | const formattedText = '*' + text + '*'; 114 | this.sendToSlack(author, to, formattedText); 115 | }); 116 | 117 | this.ircClient.on('action', (author, to, text) => { 118 | const formattedText = '_' + text + '_'; 119 | this.sendToSlack(author, to, formattedText); 120 | }); 121 | 122 | this.ircClient.on('topic', (channel, topic, nick) => { 123 | this.sendToSlack(false, channel, `*${nick}* has changed the topic to: *${topic}*`); 124 | }); 125 | 126 | this.ircClient.on('kick', (channel, nick, by, reason) => { 127 | this.sendToSlack(false, channel, `*${by}* has kicked *${nick}* (_${reason}_)`); 128 | }); 129 | 130 | this.ircClient.on('kill', (nick, reason, channels) => { 131 | channels.forEach(channel => { 132 | this.sendToSlack(false, channel, `*${nick}* has been killed (_${reason}_)`); 133 | }); 134 | }); 135 | 136 | this.ircClient.on('invite', (channel, from) => { 137 | logger.debug('Received invite:', channel, from); 138 | if (!this.invertedMapping[channel]) { 139 | logger.debug('Channel not found in config, not joining:', channel); 140 | } else { 141 | this.ircClient.join(channel); 142 | logger.debug('Joining channel:', channel); 143 | } 144 | }); 145 | 146 | this.ircClient.on('whois', (info) => { 147 | const date = new Date(Date.now() - info.idle * 1000); 148 | this.sendToSlack(false, this.nickname, [ 149 | `WHOIS for *${info.nick}*`, 150 | `(_${info.user}@${info.host}_): _${info.realname}_`, 151 | `_${info.server}_ :_${info.serverinfo}_`, 152 | info.account && `${info.accountinfo} _${info.account}_`, 153 | info.away && `is away (_${info.away}_)`, 154 | info.idle && `idle since _${date.toDateString()}, ${date.toLocaleTimeString()}_` 155 | ].filter(s => s).join('\r\n')); 156 | }); 157 | 158 | if (this.ircStatusNotices.join) { 159 | this.ircClient.on('join', (channel, nick) => { 160 | if (nick !== this.nickname) { 161 | this.sendToSlack(false, channel, `*${nick}* has joined`); 162 | } 163 | }); 164 | } 165 | 166 | if (this.ircStatusNotices.leave) { 167 | this.ircClient.on('part', (channel, nick, reason) => { 168 | this.sendToSlack(false, channel, `*${nick}* has left (_${reason}_)`); 169 | }); 170 | 171 | this.ircClient.on('quit', (nick, reason, channels) => { 172 | channels.forEach(channel => { 173 | this.sendToSlack(false, channel, `*${nick}* has quit (_${reason}_)`); 174 | }); 175 | }); 176 | } 177 | 178 | if (this.ircStatusNotices.changeNick) { 179 | this.ircClient.on('nick', (oldNick, newNick, channels) => { 180 | channels.forEach(channel => { 181 | this.sendToSlack(false, channel, `*${oldNick}* is now known as *${newNick}*`); 182 | }); 183 | }); 184 | } 185 | 186 | if (this.ircStatusNotices.modes) { 187 | this.ircClient.on('+mode', (channel, by, mode, arg) => { 188 | this.sendToSlack(false, channel, `*${by}* sets mode *+${mode}* on _${arg || channel}_`); 189 | }); 190 | this.ircClient.on('-mode', (channel, by, mode, arg) => { 191 | this.sendToSlack(false, channel, `*${by}* sets mode *-${mode}* on _${arg || channel}_`); 192 | }); 193 | } 194 | } 195 | 196 | parseText(text) { 197 | return text 198 | .replace(/\n|\r\n|\r/g, ' ') 199 | .replace(/&/g, '&') 200 | .replace(/</g, '<') 201 | .replace(/>/g, '>') 202 | .replace(//g, '@channel') 203 | .replace(//g, '@group') 204 | .replace(//g, '@everyone') 205 | .replace(/<#(C\w+)\|?(\w+)?>/g, (match, channelId, readable) => { 206 | const { name } = this.slack.getChannelByID(channelId); 207 | return readable || `#${name}`; 208 | }) 209 | .replace(/<@(U\w+)\|?(\w+)?>/g, (match, userId, readable) => { 210 | const { name } = this.slack.getUserByID(userId); 211 | return readable || `@${name}`; 212 | }) 213 | .replace(/<(?!!)(\S+)>/g, (match, link) => link) 214 | .replace(//g, (match, command, label) => 215 | `<${label || command}>` 216 | ) 217 | .replace(/\:(\w+)\:/g, (match, emoji) => { 218 | if (emoji in emojis) { 219 | return emojis[emoji]; 220 | } 221 | 222 | return match; 223 | }); 224 | } 225 | 226 | sendDMToIRC(text, channel) { 227 | const pmMatch = (/^(\S+):\s+(.+)/i).exec(text); 228 | 229 | if (pmMatch) { 230 | const [, user, msg] = pmMatch; 231 | logger.debug('Sending /msg to IRC user', user, msg); 232 | this.ircClient.send('PRIVMSG', user, msg); 233 | this.lastPM.user = user; 234 | this.lastPM.timestamp = Date.now(); 235 | return; 236 | } 237 | 238 | const lastMessage = Date.now() - this.lastPM.timestamp; 239 | if (lastMessage < this.rememberRecipientsFor) { 240 | const user = this.lastPM.user; 241 | logger.debug('Sending /msg to last messaged IRC user', user, text); 242 | this.ircClient.send('PRIVMSG', user, text); 243 | this.lastPM.user = user; 244 | this.lastPM.timestamp = Date.now(); 245 | return; 246 | } 247 | 248 | if (this.lastPM.user) { 249 | logger.debug('Not sending message', text, 'since user', this.lastPM.user, 'was messaged', 250 | lastMessage, 'ago, which is more than', this.rememberRecipientsFor); 251 | channel.send(`_it's been too long since your last message, please specify the user_`); 252 | return; 253 | } 254 | 255 | logger.debug('Not sending message', text, 'since no users have been messaged'); 256 | channel.send(`_you haven't messaged anyone yet, please specify the user_`); 257 | } 258 | 259 | sendToIRC(message) { 260 | const channel = this.slack.getChannelGroupOrDMByID(message.channel); 261 | if (!channel) { 262 | logger.info('Received message from a channel the bot isn\'t in:', 263 | message.channel); 264 | return; 265 | } 266 | 267 | let text = this.parseText(message.getBody()); 268 | const cmdMatch = (/^%[A-Z]{3,}\s\S/).test(text); 269 | 270 | if (cmdMatch) { 271 | text = text.replace(/^%/, ''); 272 | logger.debug('Sending raw command to IRC', text); 273 | this.ircClient.send(...text.split(' ')); 274 | channel.send(`_sent raw command_`); 275 | return; 276 | } 277 | 278 | if (channel.is_im) { 279 | this.sendDMToIRC(text, channel); 280 | return; 281 | } 282 | 283 | const channelName = channel.is_channel ? `#${channel.name}` : channel.name; 284 | const ircChannel = this.channelMapping[channelName]; 285 | 286 | logger.debug('Channel Mapping', channelName, this.channelMapping[channelName]); 287 | if (ircChannel) { 288 | if (message.subtype === 'me_message') { 289 | text = `\x01ACTION ${text}\x01`; 290 | } 291 | logger.debug('Sending message to IRC', channelName, text); 292 | this.ircClient.say(ircChannel, text); 293 | } 294 | } 295 | 296 | sendToSlack(author, channel, text) { 297 | const slackChannelName = channel === this.nickname ? 298 | this.slackUser : 299 | this.invertedMapping[channel.toLowerCase()]; 300 | if (slackChannelName) { 301 | const slackChannel = this.slack.getChannelGroupOrDMByName(slackChannelName); 302 | 303 | // If it's a private group and the bot isn't in it, we won't find anything here. 304 | // If it's a channel however, we need to check is_member. 305 | if (!slackChannel || 306 | (!slackChannel.is_member && !slackChannel.is_group && !slackChannel.is_im)) { 307 | logger.info('Tried to send a message to a channel the bot isn\'t in:', slackChannelName); 308 | return; 309 | } 310 | 311 | const currentChannelUsernames = (slackChannel.members || []).map(member => 312 | this.slack.getUserByID(member).name 313 | ); 314 | 315 | const mappedText = currentChannelUsernames.reduce((current, username) => 316 | highlightUsername(username, current) 317 | , text); 318 | 319 | logger.debug('Sending message to Slack', mappedText, channel, '->', slackChannelName); 320 | if (author) { 321 | slackChannel.postMessage({ 322 | text: mappedText, 323 | username: author, 324 | parse: 'full', 325 | icon_url: `http://api.adorable.io/avatars/48/${author}.png` 326 | }); 327 | } else { 328 | slackChannel.send(mappedText); 329 | } 330 | } 331 | } 332 | } 333 | 334 | export default Bot; 335 | --------------------------------------------------------------------------------