├── .babelrc ├── .eslintrc ├── .frigg.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets └── emoji.json ├── lib ├── bot.js ├── cli.js ├── errors.js ├── helpers.js ├── index.js └── validators.js ├── package.json └── test ├── bot-events.test.js ├── bot.test.js ├── channel-mapping.test.js ├── cli.test.js ├── create-bots.test.js ├── errors.test.js ├── fixtures ├── bad-config.json ├── case-sensitivity-config.json ├── invalid-config.json ├── single-test-config.json ├── string-config.json ├── test-config-comments.json ├── test-config.json └── test-javascript-config.js ├── join-part.test.js ├── stubs ├── channel-stub.js ├── data-store-stub.js ├── irc-client-stub.js └── slack-stub.js └── username-decorator.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"], 3 | "plugins": ["add-module-exports"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "global-require": 0, 10 | "comma-dangle": 0, 11 | "func-names": 0, 12 | "import/no-extraneous-dependencies": [2, { "devDependencies": true }], 13 | "import/prefer-default-export": 0, 14 | "import/no-dynamic-require": 0 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | after_success: 3 | - npm run report 4 | node_js: 5 | - '4' 6 | - '6' 7 | - '7' 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | ## [3.11.2] - 2019-06-15 5 | ### Fixed 6 | - UPgrade dependencies. 7 | 8 | ## [3.11.1] - 2018-02-14 9 | ### Fixed 10 | - Replaced node-irc with @Throne3d's fork, [irc-upd](https://github.com/Throne3d/node-irc). 11 | 12 | ## [3.11.0] - 2017-05-12 13 | ### Added 14 | - `muteUsers` config option for muting users on Slack and IRC - 15 | [#174](https://github.com/ekmartin/slack-irc/pull/174). 16 | 17 | ## [3.10.1] - 2017-04-30 18 | ### Changed 19 | - Upgrade @slack/client to 3.9.0. 20 | 21 | ## [3.10.0] - 2017-04-30 22 | ### Added 23 | - Report file uploads to IRC - [#193](https://github.com/ekmartin/slack-irc/pull/193). 24 | 25 | ## [3.9.0] - 2016-11-07 26 | ### Added 27 | - Config option `slackUsernameFormat`, which allows for 28 | customization of the username that will be used when posting 29 | to Slack (thanks to @laughinghan!). 30 | 31 | ### Changed 32 | - Default Slack username changed to `$username (IRC)` 33 | (see the `(IRC)` suffix). This can be changed to the 34 | old default by setting the new `slackUsernameFormat`, 35 | to `'$username'`. 36 | 37 | ## [3.8.7] - 2016-10-04 38 | ### Fixed 39 | - Make sure the CLI works with babel-plugin-add- 40 | module-exports 41 | 42 | ## [3.8.6] - 2016-09-27 43 | ### Fixed 44 | - Added babel-plugin-add-module-exports so that slack-irc 45 | can be required without needing `.default` after. 46 | 47 | ## [3.8.5] - 2016-09-27 48 | ### Fixed 49 | - Correctly handle entity escaping in messages, fixed by @jlaunonen. 50 | 51 | ## [3.8.4] - 2016-09-26 52 | ### Fixed 53 | - Make sure multiple links works for readable representation as well. 54 | 55 | ## [3.8.3] - 2016-09-25 56 | ### Fixed 57 | - A bug where multiple links would be parsed wrongly, see 58 | https://github.com/ekmartin/slack-irc/issues/160 59 | - Upgraded linter. 60 | 61 | ## [3.8.2] - 2016-09-05 62 | ### Fixed 63 | - Upgraded dependencies. 64 | ESLint has dropped support for older Node.js versions, 65 | which means you'll require at least Node.js 4.0 to develop 66 | on slack-irc. It'll still be possible to run the application 67 | with older Node.js versions. 68 | - Removed unused `emoji.json` file. 69 | 70 | ## [3.8.1] - 2016-05-21 71 | ### Fixed 72 | - Exit the application if the maximum retry count for IRC is reached. 73 | 74 | ## [3.8.0] - 2016-04-30 75 | ### Added 76 | - The configuration option `avatarUrl`, which lets you decide 77 | how IRC users' messages should be presented on Slack. 78 | This can be set to `false` to disable them altogether, or 79 | a custom URL to change the avatar. 80 | 81 | Example: `'https://robohash.org/$username.png'`, where 82 | $username will be replaced with the IRC author's username. 83 | 84 | ### Fixed 85 | - Upgraded dependencies. 86 | 87 | ## [3.7.8] - 2016-04-06 88 | ### Fixed 89 | - Set node-irc's retryCount to 10, so that the bot attempts to reconnect 90 | to IRC upon disconnects. 91 | - Upgraded dependencies, including an upgrade of node-slack-client from 92 | version 1 to 2. 93 | 94 | ## [3.7.7] - 2016-03-09 95 | ### Fixed 96 | - Upgraded dependencies. 97 | - Pin ESLint to 2.2.0 so it works with babel-eslint 6. 98 | 99 | ## [3.7.6] - 2016-03-03 100 | ### Fixed 101 | - Upgraded dependencies. 102 | - Update ESLint config to use preset eslint-config-webkom. 103 | 104 | ## [3.7.5] - 2016-01-26 105 | ### Fixed 106 | - Make sure Don doesn't get highlighted for messages containing "don't", fixed by @Ibuprofen. 107 | 108 | ## [3.7.4] - 2016-01-21 109 | ### Fixed 110 | - Fix a bug where the bot-in-channel check would fail for private groups. 111 | 112 | ## [3.7.3] - 2016-01-12 113 | ### Fixed 114 | - Don't crash when trying to send a message to a Slack channel the bot 115 | isn't a member of. 116 | 117 | ## [3.7.2] - 2016-01-12 118 | ### Changed 119 | - Remove babel-polyfill, use functions available in Node 0.10 and above instead. 120 | 121 | ## [3.7.1] - 2016-01-10 122 | ### Changed 123 | - Added babel-polyfill, fixes #70. 124 | - Updated dependencies. 125 | 126 | ## [3.7.0] - 2015-12-21 127 | ### Added 128 | - Valid usernames are now highlighted with an @ before messages are posted to Slack, thanks to @grahamb. 129 | - `muteSlackbot` option that stops Slackbot messages from being forwarded to IRC, also courtesy of @grahamb. 130 | - `ircStatusNotices` option that makes slack-irc send status updates to Slack whenever an IRC user 131 | joins/parts/quits. See README.md for example. 132 | 133 | ### Changed 134 | - Upgraded dependencies. 135 | - Comments are now stripped from JSON configs before they're parsed. 136 | - Configurations with invalid JSON now throws a ConfigurationError. 137 | 138 | ## [3.6.2] - 2015-12-01 139 | ### Changed 140 | - Upgraded dependencies. 141 | 142 | ## [3.6.1] - 2015-11-18 143 | ### Changed 144 | - Refactor to use ES2015+ with Babel. 145 | - Refactor tests. 146 | 147 | ## [3.6.0] - 2015-09-14 148 | ### Added 149 | - Support for actions from IRC to Slack and vice versa (/me messages). 150 | - Support for sending notices from IRC to Slack (/notice #channel message). 151 | 152 | ## [3.5.2] - 2015-06-26 153 | ### Fixed 154 | - Remove old unused dependencies. 155 | 156 | ## [3.5.1] - 2015-06-26 157 | ### Fixed 158 | - A bug introduced in 3.5.0 where Slack messages sent to IRC wouldn't get parsed. 159 | Adds a test to confirm correct behavior. 160 | 161 | ## [3.5.0] - 2015-06-22 162 | ### Added 163 | - `commandCharacters` option - makes the bot hide the username prefix for 164 | messages that start with one of the provided characters when posting to IRC. 165 | A `Command sent from Slack by username:` message will be posted to the IRC 166 | channel before the command is submitted. 167 | 168 | ## [3.4.0] - 2015-05-22 169 | ### Added 170 | - Made it possible to require slack-irc as a node module. 171 | 172 | ## [3.3.2] - 2015-05-17 173 | ### Fixed 174 | - Upgrade dependencies. 175 | 176 | ## [3.3.1] - 2015-05-17 177 | ### Fixed 178 | - Make IRC channel names case insensitive in the channel mapping. 179 | Relevant issue: [#31](https://github.com/ekmartin/slack-irc/issues/31) 180 | 181 | ## [3.3.0] - 2015-04-17 182 | ### Added 183 | - Conversion of emojis to text smileys from Slack to IRC, by [andebor](https://github.com/andebor). 184 | Relevant issue: [#10](https://github.com/ekmartin/slack-irc/issues/10) 185 | 186 | ## [3.2.1] - 2015-04-07 187 | ### Fixed 188 | - Convert newlines sent from Slack to spaces to prevent the bot from sending multiple messages. 189 | 190 | ## [3.2.0] - 2015-04-03 191 | ### Added 192 | - Support for passing [node-irc](http://node-irc.readthedocs.org/en/latest/API.html#irc.Client) 193 | options directly by adding an `ircOptions` object to the config. Also sets `floodProtection` on 194 | by default, with a delay of 500 ms. 195 | 196 | ## [3.1.0] - 2015-03-27 197 | ### Added 198 | - Make the bot able to join password protected IRC channels. Example: 199 | 200 | ```json 201 | "channelMapping": { 202 | "#slack": "#irc channel-password", 203 | } 204 | ``` 205 | 206 | ## [3.0.0] - 2015-03-24 207 | ### Changed 208 | Move from using outgoing/incoming integrations to Slack's 209 | [bot users](https://api.slack.com/bot-users). See 210 | [README.md](https://github.com/ekmartin/slack-irc/blob/master/README.md) 211 | for a new example configuration. This mainly means slack-irc won't need 212 | to listen on a port anymore, as it uses websockets to receive the messages 213 | from Slack's [RTM API](https://api.slack.com/rtm). 214 | 215 | To change from version 2 to 3, do the following: 216 | - Create a new Slack bot user (under integrations) 217 | - Add its token to your slack-irc config, and remove 218 | the `outgoingToken` and `incomingURL` config options. 219 | 220 | ### Added 221 | - Message formatting, follows Slack's [rules](https://api.slack.com/docs/formatting). 222 | 223 | ## [2.0.1]- 2015-03-03 224 | ### Added 225 | - MIT License 226 | 227 | ## [2.0.0] - 2015-02-22 228 | ### Changed 229 | - Post URL changed from `/send` to `/`. 230 | 231 | ## [1.1.0] - 2015-02-12 232 | ### Added 233 | - Icons from [Adorable Avatars](http://avatars.adorable.io/). 234 | - Command-line interface 235 | 236 | ### Changed 237 | - Status code from 202 to 200. 238 | 239 | ## [1.0.0] - 2015-02-09 240 | ### Added 241 | - Support for running multiple bots (on different Slacks) 242 | 243 | ### Changed 244 | - New configuration format, example 245 | [here](https://github.com/ekmartin/slack-irc/blob/44f6079b5da597cd091e8a3582e34617824e619e/README.md#configuration). 246 | 247 | ## [0.2.0] - 2015-02-06 248 | ### Added 249 | - Add support for channel mapping. 250 | 251 | ### Changed 252 | - Use winston for logging. 253 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Martin Ek mail@ekmartin.com 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 | -------------------------------------------------------------------------------- /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://coveralls.io/repos/github/ekmartin/slack-irc/badge.svg?branch=master)](https://coveralls.io/github/ekmartin/slack-irc?branch=master) 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 | *Note*: [node-irc](https://github.com/martynsmith/node-irc/) 10 | uses icu-charset-detector as an optional dependency, 11 | which might fail to install depending on how you've installed Node.js. 12 | slack-irc works fine anyhow though, so no need to worry. 13 | 14 | Installing with npm: 15 | ```bash 16 | $ npm install -g slack-irc 17 | $ slack-irc --config /path/to/config.json 18 | ``` 19 | 20 | or by cloning the repository: 21 | 22 | ```bash 23 | $ git clone https://github.com/ekmartin/slack-irc.git && cd slack-irc 24 | $ npm install 25 | $ npm run build 26 | $ npm start -- --config /path/to/config.json # Note the extra -- here 27 | ``` 28 | 29 | It can also be used as a node module: 30 | ```js 31 | var slackIRC = require('slack-irc'); 32 | var config = require('./config.json'); 33 | slackIRC(config); 34 | ``` 35 | 36 | ## Configuration 37 | 38 | slack-irc uses Slack's [bot users](https://api.slack.com/bot-users). 39 | This means you'll have to set up a bot user as a Slack integration, and invite it 40 | to the Slack channels you want it to listen in on. This can be done using Slack's `/invite ` 41 | command. This has to be done manually as there's no way to do it through the Slack bot user API at 42 | the moment. 43 | 44 | slack-irc requires a JSON-configuration file, whose path can be given either through 45 | the CLI-option `--config` or the environment variable `CONFIG_FILE`. The configuration 46 | file needs to be an object or an array, depending on the number of IRC bots you want to run. 47 | 48 | This allows you to use one instance of slack-irc for multiple Slack teams if wanted, even 49 | if the IRC channels are on different networks. 50 | 51 | To set the log level to debug, export the environment variable `NODE_ENV` as `development`. 52 | 53 | slack-irc also supports invite-only IRC channels, and will join any channels it's invited to 54 | as long as they're present in the channel mapping. 55 | 56 | ### Example configuration 57 | Valid JSON cannot contain comments, so remember to remove them first! 58 | ```js 59 | [ 60 | // Bot 1 (minimal configuration): 61 | { 62 | "nickname": "test2", 63 | "server": "irc.testbot.org", 64 | "token": "slacktoken2", 65 | "channelMapping": { 66 | "#other-slack": "#new-irc-channel" 67 | } 68 | }, 69 | 70 | // Bot 2 (advanced options): 71 | { 72 | "nickname": "test", 73 | "server": "irc.bottest.org", 74 | "token": "slacktoken", // Your bot user's token 75 | "avatarUrl": "https://robohash.org/$username.png?size=48x48", // Set to false to disable Slack avatars 76 | "slackUsernameFormat": "<$username>", // defaults to "$username (IRC)"; "$username" overides so there's no suffix or prefix at all 77 | "ircUsernameFormat": "<$username> ", // defaults to "<$username>"; "$username" overides so there's no suffix or prefix at all 78 | "autoSendCommands": [ // Commands that will be sent on connect 79 | ["PRIVMSG", "NickServ", "IDENTIFY password"], 80 | ["MODE", "test", "+x"], 81 | ["AUTH", "test", "password"] 82 | ], 83 | "channelMapping": { // Maps each Slack-channel to an IRC-channel, used to direct messages to the correct place 84 | "#slack": "#irc channel-password", // Add channel keys after the channel name 85 | "privategroup": "#other-channel" // No hash in front of private groups 86 | }, 87 | "ircOptions": { // Optional node-irc options 88 | "floodProtection": false, // On by default 89 | "floodProtectionDelay": 1000 // 500 by default 90 | }, 91 | // Makes the bot hide the username prefix for messages that start 92 | // with one of these characters (commands): 93 | "commandCharacters": ["!", "."], 94 | // Prevent messages posted by Slackbot (e.g. Slackbot responses) 95 | // from being posted into the IRC channel: 96 | "muteSlackbot": true, // Off by default 97 | // Sends messages to Slack whenever a user joins/leaves an IRC channel: 98 | "ircStatusNotices": { 99 | "join": false, // Don't send messages about joins 100 | "leave": true 101 | }, 102 | // Prevent messages posted by users on Slack/IRC from being forwarded: 103 | "muteUsers": { 104 | "irc": ["irc-user"], 105 | "slack": ["slack-user"] 106 | } 107 | } 108 | ] 109 | ``` 110 | 111 | `ircOptions` is passed directly to node-irc ([available options](http://node-irc.readthedocs.org/en/latest/API.html#irc.Client)). 112 | 113 | ## Personal IRC Client 114 | slack-irc strengths mainly lie in many-to-many communication from Slack to IRC (and vice versa), 115 | and is thus not very suitable as a makeshift IRC client for one user. If that's 116 | what you need, check out 117 | [aeirola/slack-irc-client](https://github.com/aeirola/slack-irc-client), 118 | which adds an array of features to solve this problem as smoothly as possible. 119 | 120 | ## Development 121 | To be able to use the latest ES2015+ features, slack-irc uses [Babel](https://babeljs.io). 122 | 123 | Build the source with: 124 | ```bash 125 | $ npm run build 126 | ``` 127 | 128 | ### Tests 129 | Run the tests with: 130 | ```bash 131 | $ npm test 132 | ``` 133 | 134 | ### Style Guide 135 | slack-irc uses a slightly modified version of the 136 | [Airbnb Style Guide](https://github.com/airbnb/javascript/tree/master/es5). 137 | [ESLint](http://eslint.org/) is used to make sure this is followed correctly, which can be run with: 138 | 139 | ```bash 140 | $ npm run lint 141 | ``` 142 | 143 | The deviations from the Airbnb Style Guide can be seen in the [.eslintrc](.eslintrc) file. 144 | 145 | ## Docker 146 | A third-party Docker container can be found [here](https://github.com/caktux/slackbridge/). 147 | -------------------------------------------------------------------------------- /assets/emoji.json: -------------------------------------------------------------------------------- 1 | { 2 | "smile": ":)", 3 | "simple_smile": ":)", 4 | "slightly_smiling_face": ":)", 5 | "smiley": ":-)", 6 | "grin": ":D", 7 | "wink": ";)", 8 | "smirk": ";)", 9 | "blush": ":$", 10 | "stuck_out_tongue": ":P", 11 | "stuck_out_tongue_winking_eye": ";P", 12 | "stuck_out_tongue_closed_eyes": "xP", 13 | "disappointed": ":(", 14 | "astonished": ":O", 15 | "open_mouth": ":O", 16 | "heart": "<3", 17 | "broken_heart": ":(", 20 | "cry": ":,(", 21 | "frowning": ":(", 22 | "imp": "]:(", 23 | "innocent": "o:)", 24 | "joy": ":,)", 25 | "kissing": ":*", 26 | "laughing": "x)", 27 | "neutral_face": ":|", 28 | "no_mouth": ":-", 29 | "rage": ":@", 30 | "smiling_imp": "]:)", 31 | "sob": ":,'(", 32 | "sunglasses": "8)", 33 | "sweat": ",:(", 34 | "sweat_smile": ",:)", 35 | "unamused": ":$" 36 | } 37 | -------------------------------------------------------------------------------- /lib/bot.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import irc from 'irc-upd'; 3 | import logger from 'winston'; 4 | import { MemoryDataStore, RtmClient, WebClient } 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', 'file_share']; 11 | const REQUIRED_FIELDS = ['server', 'nickname', '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 | const web = new WebClient(options.token); 28 | const rtm = new RtmClient(options.token, { dataStore: new MemoryDataStore() }); 29 | this.slack = { web, rtm }; 30 | 31 | this.server = options.server; 32 | this.nickname = options.nickname; 33 | this.ircOptions = options.ircOptions; 34 | this.ircStatusNotices = options.ircStatusNotices || {}; 35 | this.commandCharacters = options.commandCharacters || []; 36 | this.channels = _.values(options.channelMapping); 37 | this.muteSlackbot = options.muteSlackbot || false; 38 | this.muteUsers = { 39 | slack: [], 40 | irc: [], 41 | ...options.muteUsers 42 | }; 43 | 44 | const defaultUrl = 'http://api.adorable.io/avatars/48/$username.png'; 45 | // Disable if it's set to false, override default with custom if available: 46 | this.avatarUrl = options.avatarUrl !== false && (options.avatarUrl || defaultUrl); 47 | this.slackUsernameFormat = options.slackUsernameFormat || '$username (IRC)'; 48 | this.ircUsernameFormat = options.ircUsernameFormat == null ? '<$username> ' : options.ircUsernameFormat; 49 | this.channelMapping = {}; 50 | 51 | // Remove channel passwords from the mapping and lowercase IRC channel names 52 | _.forOwn(options.channelMapping, (ircChan, slackChan) => { 53 | this.channelMapping[slackChan] = ircChan.split(' ')[0].toLowerCase(); 54 | }, this); 55 | 56 | this.invertedMapping = _.invert(this.channelMapping); 57 | this.autoSendCommands = options.autoSendCommands || []; 58 | } 59 | 60 | connect() { 61 | logger.debug('Connecting to IRC and Slack'); 62 | this.slack.rtm.start(); 63 | 64 | const ircOptions = { 65 | userName: this.nickname, 66 | realName: this.nickname, 67 | channels: this.channels, 68 | floodProtection: true, 69 | floodProtectionDelay: 500, 70 | retryCount: 10, 71 | ...this.ircOptions 72 | }; 73 | 74 | this.ircClient = new irc.Client(this.server, this.nickname, ircOptions); 75 | this.attachListeners(); 76 | } 77 | 78 | attachListeners() { 79 | this.slack.rtm.on('open', () => { 80 | logger.debug('Connected to Slack'); 81 | }); 82 | 83 | this.ircClient.on('registered', (message) => { 84 | logger.debug('Registered event: ', message); 85 | this.autoSendCommands.forEach((element) => { 86 | this.ircClient.send(...element); 87 | }); 88 | }); 89 | 90 | this.ircClient.on('error', (error) => { 91 | logger.error('Received error event from IRC', error); 92 | }); 93 | 94 | this.ircClient.on('abort', () => { 95 | logger.error('Maximum IRC retry count reached, exiting.'); 96 | process.exit(1); 97 | }); 98 | 99 | this.slack.rtm.on('error', (error) => { 100 | logger.error('Received error event from Slack', error); 101 | }); 102 | 103 | this.slack.rtm.on('message', (message) => { 104 | // Ignore bot messages and people leaving/joining 105 | if (message.type === 'message' && 106 | (!message.subtype || ALLOWED_SUBTYPES.indexOf(message.subtype) > -1)) { 107 | this.sendToIRC(message); 108 | } 109 | }); 110 | 111 | this.ircClient.on('message', this.sendToSlack.bind(this)); 112 | 113 | this.ircClient.on('notice', (author, to, text) => { 114 | const formattedText = `*${text}*`; 115 | this.sendToSlack(author, to, formattedText); 116 | }); 117 | 118 | this.ircClient.on('action', (author, to, text) => { 119 | const formattedText = `_${text}_`; 120 | this.sendToSlack(author, to, formattedText); 121 | }); 122 | 123 | this.ircClient.on('invite', (channel, from) => { 124 | logger.debug('Received invite:', channel, from); 125 | if (!this.invertedMapping[channel]) { 126 | logger.debug('Channel not found in config, not joining:', channel); 127 | } else { 128 | this.ircClient.join(channel); 129 | logger.debug('Joining channel:', channel); 130 | } 131 | }); 132 | 133 | if (this.ircStatusNotices.join) { 134 | this.ircClient.on('join', (channel, nick) => { 135 | if (nick !== this.nickname) { 136 | this.sendToSlack(this.nickname, channel, `*${nick}* has joined the IRC channel`); 137 | } 138 | }); 139 | } 140 | 141 | if (this.ircStatusNotices.leave) { 142 | this.ircClient.on('part', (channel, nick) => { 143 | this.sendToSlack(this.nickname, channel, `*${nick}* has left the IRC channel`); 144 | }); 145 | 146 | this.ircClient.on('quit', (nick, reason, channels) => { 147 | channels.forEach((channel) => { 148 | this.sendToSlack(this.nickname, channel, `*${nick}* has quit the IRC channel`); 149 | }); 150 | }); 151 | } 152 | } 153 | 154 | parseText(text) { 155 | const { dataStore } = this.slack.rtm; 156 | return text 157 | .replace(/\n|\r\n|\r/g, ' ') 158 | .replace(//g, '@channel') 159 | .replace(//g, '@group') 160 | .replace(//g, '@everyone') 161 | .replace(/<#(C\w+)\|?(\w+)?>/g, (match, channelId, readable) => { 162 | const { name } = dataStore.getChannelById(channelId); 163 | return readable || `#${name}`; 164 | }) 165 | .replace(/<@(U\w+)\|?(\w+)?>/g, (match, userId, readable) => { 166 | const { name } = dataStore.getUserById(userId); 167 | return readable || `@${name}`; 168 | }) 169 | .replace(/<(?!!)([^|]+?)>/g, (match, link) => link) 170 | .replace(//g, (match, command, label) => 171 | `<${label || command}>` 172 | ) 173 | .replace(/:(\w+):/g, (match, emoji) => { 174 | if (emoji in emojis) { 175 | return emojis[emoji]; 176 | } 177 | 178 | return match; 179 | }) 180 | .replace(/<.+?\|(.+?)>/g, (match, readable) => readable) 181 | .replace(/</g, '<') 182 | .replace(/>/g, '>') 183 | .replace(/&/g, '&'); 184 | } 185 | 186 | isCommandMessage(message) { 187 | return this.commandCharacters.indexOf(message[0]) !== -1; 188 | } 189 | 190 | sendToIRC(message) { 191 | const { dataStore } = this.slack.rtm; 192 | const channel = dataStore.getChannelGroupOrDMById(message.channel); 193 | if (!channel) { 194 | logger.info('Received message from a channel the bot isn\'t in:', 195 | message.channel); 196 | return; 197 | } 198 | 199 | if (this.muteSlackbot && message.user === 'USLACKBOT') { 200 | logger.debug(`Muted message from Slackbot: "${message.text}"`); 201 | return; 202 | } 203 | 204 | const user = dataStore.getUserById(message.user); 205 | const username = this.ircUsernameFormat.replace(/\$username/g, user.name); 206 | 207 | if (this.muteUsers.slack.indexOf(user.name) !== -1) { 208 | logger.debug(`Muted message from Slack ${user.name}: ${message.text}`); 209 | return; 210 | } 211 | 212 | const channelName = channel.is_channel ? `#${channel.name}` : channel.name; 213 | const ircChannel = this.channelMapping[channelName]; 214 | 215 | logger.debug('Channel Mapping', channelName, this.channelMapping[channelName]); 216 | if (ircChannel) { 217 | let text = this.parseText(message.text); 218 | 219 | if (this.isCommandMessage(text)) { 220 | const prelude = `Command sent from Slack by ${user.name}:`; 221 | this.ircClient.say(ircChannel, prelude); 222 | } else if (!message.subtype) { 223 | text = `${username}${text}`; 224 | } else if (message.subtype === 'file_share') { 225 | text = `${username}File uploaded ${message.file.permalink} / ${message.file.permalink_public}`; 226 | if (message.file.initial_comment) { 227 | text += ` - ${message.file.initial_comment.comment}`; 228 | } 229 | } else if (message.subtype === 'me_message') { 230 | text = `Action: ${user.name} ${text}`; 231 | } 232 | logger.debug('Sending message to IRC', channelName, text); 233 | this.ircClient.say(ircChannel, text); 234 | } 235 | } 236 | 237 | sendToSlack(author, channel, text) { 238 | const slackChannelName = this.invertedMapping[channel.toLowerCase()]; 239 | if (slackChannelName) { 240 | const { dataStore } = this.slack.rtm; 241 | const name = slackChannelName.replace(/^#/, ''); 242 | const slackChannel = dataStore.getChannelOrGroupByName(name); 243 | 244 | // If it's a private group and the bot isn't in it, we won't find anything here. 245 | // If it's a channel however, we need to check is_member. 246 | if (!slackChannel || (!slackChannel.is_member && !slackChannel.is_group)) { 247 | logger.info('Tried to send a message to a channel the bot isn\'t in: ', 248 | slackChannelName); 249 | return; 250 | } 251 | 252 | if (this.muteUsers.irc.indexOf(author) !== -1) { 253 | logger.debug(`Muted message from IRC ${author}: ${text}`); 254 | return; 255 | } 256 | 257 | const currentChannelUsernames = slackChannel.members.map(member => 258 | dataStore.getUserById(member).name 259 | ); 260 | 261 | const mappedText = currentChannelUsernames.reduce((current, username) => 262 | highlightUsername(username, current) 263 | , text); 264 | 265 | let iconUrl; 266 | if (author !== this.nickname && this.avatarUrl) { 267 | iconUrl = this.avatarUrl.replace(/\$username/g, author); 268 | } 269 | 270 | const options = { 271 | username: this.slackUsernameFormat.replace(/\$username/g, author), 272 | parse: 'full', 273 | icon_url: iconUrl 274 | }; 275 | 276 | logger.debug('Sending message to Slack', mappedText, channel, '->', slackChannelName); 277 | this.slack.web.chat.postMessage(slackChannel.id, mappedText, options); 278 | } 279 | } 280 | } 281 | 282 | export default Bot; 283 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import fs from 'fs'; 3 | import program from 'commander'; 4 | import path from 'path'; 5 | import checkEnv from 'check-env'; 6 | import stripJsonComments from 'strip-json-comments'; 7 | import * as helpers from './helpers'; 8 | import { ConfigurationError } from './errors'; 9 | import { version } from '../package.json'; 10 | 11 | function readJSONConfig(filePath) { 12 | const configFile = fs.readFileSync(filePath, { encoding: 'utf8' }); 13 | try { 14 | return JSON.parse(stripJsonComments(configFile)); 15 | } catch (err) { 16 | if (err instanceof SyntaxError) { 17 | throw new ConfigurationError('The configuration file contains invalid JSON'); 18 | } else { 19 | throw err; 20 | } 21 | } 22 | } 23 | 24 | function run() { 25 | program 26 | .version(version) 27 | .option('-c, --config ', 28 | 'Sets the path to the config file, otherwise read from the env variable CONFIG_FILE.' 29 | ) 30 | .parse(process.argv); 31 | 32 | // If no config option is given, try to use the env variable: 33 | if (!program.config) checkEnv(['CONFIG_FILE']); 34 | else process.env.CONFIG_FILE = program.config; 35 | 36 | const completePath = path.resolve(process.cwd(), process.env.CONFIG_FILE); 37 | const config = _.endsWith(process.env.CONFIG_FILE, '.js') ? 38 | require(completePath) : readJSONConfig(completePath); 39 | helpers.createBots(config); 40 | } 41 | 42 | export default run; 43 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | const userRegExp = new RegExp(`^${user}[,.:!?]?$`); 37 | 38 | return words.map((word) => { 39 | // if the user is already prefixed by @, don't replace 40 | if (word.indexOf(`@${user}`) === 0) { 41 | return word; 42 | } 43 | 44 | // username match (with some chars) 45 | if (userRegExp.test(word)) { 46 | return `@${word}`; 47 | } 48 | 49 | return word; 50 | }).join(' '); 51 | } 52 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable import/newline-after-import */ 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 cli = require('./cli'); 14 | cli(); 15 | } 16 | 17 | export default createBots; 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-irc", 3 | "version": "3.11.3", 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 | "engines": { 19 | "node": ">=4" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/ekmartin/slack-irc/issues" 23 | }, 24 | "scripts": { 25 | "start": "node dist/index.js", 26 | "build": "babel lib --out-dir dist", 27 | "prepublish": "npm run build", 28 | "lint": "eslint . --ignore-path .gitignore", 29 | "mocha": "mocha --compilers js:babel-core/register $(find test -name '*.test.js')", 30 | "mocha:watch": "npm run mocha -- --watch --reporter min", 31 | "coverage": "nyc --require babel-core/register _mocha -- $(find test -name '*.test.js')", 32 | "report": "nyc report --reporter=text-lcov | coveralls", 33 | "test": "npm run lint && npm run coverage" 34 | }, 35 | "author": { 36 | "name": "Martin Ek " 37 | }, 38 | "license": "MIT", 39 | "dependencies": { 40 | "@slack/client": "3.13.0", 41 | "check-env": "1.3.0", 42 | "commander": "2.20.0", 43 | "irc-upd": "0.10.0", 44 | "lodash": "^4.17.11", 45 | "strip-json-comments": "3.0.1", 46 | "winston": "3.2.1" 47 | }, 48 | "devDependencies": { 49 | "babel-cli": "^6.9.0", 50 | "babel-core": "^6.26.3", 51 | "babel-eslint": "^10.0.1", 52 | "babel-plugin-add-module-exports": "^1.0.2", 53 | "babel-preset-es2015": "^6.9.0", 54 | "babel-preset-stage-0": "^6.5.0", 55 | "chai": "^4.2.0", 56 | "coveralls": "^3.0.0", 57 | "eslint": "^5.16.0", 58 | "eslint-config-airbnb-base": "^11.0.0", 59 | "eslint-plugin-import": "^2.17.3", 60 | "mocha": "^6.1.4", 61 | "nyc": "^14.1.1", 62 | "sinon": "^1.17.5", 63 | "sinon-chai": "^2.8.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/bot-events.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-arrow-callback, no-unused-expressions */ 2 | import chai from 'chai'; 3 | import sinonChai from 'sinon-chai'; 4 | import sinon from 'sinon'; 5 | import irc from 'irc-upd'; 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.slack.rtm.start = sandbox.stub(); 35 | this.bot.connect(); 36 | }); 37 | 38 | afterEach(function () { 39 | sandbox.restore(); 40 | ChannelStub.prototype.postMessage.reset(); 41 | }); 42 | 43 | it('should log on slack open event', function () { 44 | this.bot.slack.rtm.emit('open'); 45 | this.debugStub.should.have.been.calledWithExactly('Connected to Slack'); 46 | }); 47 | 48 | it('should try to send autoSendCommands on registered IRC event', function () { 49 | this.bot.ircClient.emit('registered'); 50 | ClientStub.prototype.send.should.have.been.calledTwice; 51 | ClientStub.prototype.send.getCall(0).args.should.deep.equal(config.autoSendCommands[0]); 52 | ClientStub.prototype.send.getCall(1).args.should.deep.equal(config.autoSendCommands[1]); 53 | }); 54 | 55 | it('should error log on error events', function () { 56 | const slackError = new Error('slack'); 57 | const ircError = new Error('irc'); 58 | this.bot.slack.rtm.emit('error', slackError); 59 | this.bot.ircClient.emit('error', ircError); 60 | this.errorStub.getCall(0).args[0].should.equal('Received error event from Slack'); 61 | this.errorStub.getCall(0).args[1].should.equal(slackError); 62 | this.errorStub.getCall(1).args[0].should.equal('Received error event from IRC'); 63 | this.errorStub.getCall(1).args[1].should.equal(ircError); 64 | }); 65 | 66 | it('should crash on irc abort events', function () { 67 | sandbox.stub(process, 'exit'); 68 | this.bot.ircClient.emit('abort', 10); 69 | process.exit.should.have.been.calledWith(1); 70 | }); 71 | 72 | it('should send messages to irc if correct', function () { 73 | const message = { 74 | type: 'message' 75 | }; 76 | this.bot.slack.rtm.emit('message', message); 77 | this.bot.sendToIRC.should.have.been.calledWithExactly(message); 78 | }); 79 | 80 | it('should send files to irc if correct', function () { 81 | const message = { 82 | type: 'message', 83 | subtype: 'file_share', 84 | file: { 85 | permalink: 'test', 86 | permalink_public: 'test' 87 | } 88 | }; 89 | this.bot.slack.rtm.emit('message', message); 90 | this.bot.sendToIRC.should.have.been.calledWithExactly(message); 91 | }); 92 | 93 | it('should not send messages to irc if the type isn\'t message', function () { 94 | const message = { 95 | type: 'notmessage' 96 | }; 97 | this.bot.slack.rtm.emit('message', message); 98 | this.bot.sendToIRC.should.have.not.have.been.called; 99 | }); 100 | 101 | it('should not send messages to irc if it has an invalid subtype', function () { 102 | const message = { 103 | type: 'message', 104 | subtype: 'bot_message' 105 | }; 106 | this.bot.slack.rtm.emit('message', message); 107 | this.bot.sendToIRC.should.have.not.have.been.called; 108 | }); 109 | 110 | it('should send messages to slack', function () { 111 | const channel = '#channel'; 112 | const author = 'user'; 113 | const text = 'hi'; 114 | this.bot.ircClient.emit('message', author, channel, text); 115 | this.bot.sendToSlack.should.have.been.calledWithExactly(author, channel, text); 116 | }); 117 | 118 | it('should send notices to slack', function () { 119 | const channel = '#channel'; 120 | const author = 'user'; 121 | const text = 'hi'; 122 | const formattedText = `*${text}*`; 123 | this.bot.ircClient.emit('notice', author, channel, text); 124 | this.bot.sendToSlack.should.have.been.calledWithExactly(author, channel, formattedText); 125 | }); 126 | 127 | it('should send actions to slack', function () { 128 | const channel = '#channel'; 129 | const author = 'user'; 130 | const text = 'hi'; 131 | const formattedText = '_hi_'; 132 | const message = {}; 133 | this.bot.ircClient.emit('action', author, channel, text, message); 134 | this.bot.sendToSlack.should.have.been.calledWithExactly(author, channel, formattedText); 135 | }); 136 | 137 | it('should join channels when invited', function () { 138 | const channel = '#irc'; 139 | const author = 'user'; 140 | this.debugStub.reset(); 141 | this.bot.ircClient.emit('invite', channel, author); 142 | const firstCall = this.debugStub.getCall(0); 143 | firstCall.args[0].should.equal('Received invite:'); 144 | firstCall.args[1].should.equal(channel); 145 | firstCall.args[2].should.equal(author); 146 | 147 | ClientStub.prototype.join.should.have.been.calledWith(channel); 148 | const secondCall = this.debugStub.getCall(1); 149 | secondCall.args[0].should.equal('Joining channel:'); 150 | secondCall.args[1].should.equal(channel); 151 | }); 152 | 153 | it('should not join channels that aren\'t in the channel mapping', function () { 154 | const channel = '#wrong'; 155 | const author = 'user'; 156 | this.debugStub.reset(); 157 | this.bot.ircClient.emit('invite', channel, author); 158 | const firstCall = this.debugStub.getCall(0); 159 | firstCall.args[0].should.equal('Received invite:'); 160 | firstCall.args[1].should.equal(channel); 161 | firstCall.args[2].should.equal(author); 162 | 163 | ClientStub.prototype.join.should.not.have.been.called; 164 | const secondCall = this.debugStub.getCall(1); 165 | secondCall.args[0].should.equal('Channel not found in config, not joining:'); 166 | secondCall.args[1].should.equal(channel); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /test/bot.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-arrow-callback, no-unused-expressions */ 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-upd'; 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 | const createBot = (cfg = config) => { 23 | const bot = new Bot(cfg); 24 | bot.slack = new SlackStub(); 25 | bot.slack.rtm.start = sandbox.stub(); 26 | bot.slack.web.chat.postMessage = sandbox.stub(); 27 | bot.connect(); 28 | return bot; 29 | }; 30 | 31 | beforeEach(function () { 32 | sandbox.stub(logger, 'info'); 33 | sandbox.stub(logger, 'debug'); 34 | sandbox.stub(logger, 'error'); 35 | sandbox.stub(irc, 'Client', ClientStub); 36 | ClientStub.prototype.say = sandbox.stub(); 37 | ClientStub.prototype.send = sandbox.stub(); 38 | ClientStub.prototype.join = sandbox.stub(); 39 | this.bot = createBot(); 40 | }); 41 | 42 | afterEach(function () { 43 | sandbox.restore(); 44 | }); 45 | 46 | it('should invert the channel mapping', function () { 47 | this.bot.invertedMapping['#irc'].should.equal('#slack'); 48 | }); 49 | 50 | it('should send correct message objects to slack', function () { 51 | const text = 'testmessage'; 52 | const message = { 53 | username: 'testuser (IRC)', 54 | parse: 'full', 55 | icon_url: 'http://api.adorable.io/avatars/48/testuser.png' 56 | }; 57 | 58 | this.bot.sendToSlack('testuser', '#irc', text); 59 | this.bot.slack.web.chat.postMessage.should.have.been.calledWith(1, text, message); 60 | }); 61 | 62 | it('should send messages to slack groups if the bot is in the channel', function () { 63 | this.bot.slack.rtm.dataStore.getChannelOrGroupByName = () => { 64 | const channel = new ChannelStub(); 65 | delete channel.is_member; 66 | channel.is_group = true; 67 | return channel; 68 | }; 69 | 70 | const text = 'testmessage'; 71 | const message = { 72 | username: 'testuser (IRC)', 73 | parse: 'full', 74 | icon_url: 'http://api.adorable.io/avatars/48/testuser.png' 75 | }; 76 | 77 | this.bot.sendToSlack('testuser', '#irc', text); 78 | this.bot.slack.web.chat.postMessage.should.have.been.calledWith(1, text, message); 79 | }); 80 | 81 | it('should not include an avatar for the bot\'s own messages', function () { 82 | const text = 'testmessage'; 83 | const message = { 84 | username: `${config.nickname} (IRC)`, 85 | parse: 'full', 86 | icon_url: undefined 87 | }; 88 | 89 | this.bot.sendToSlack(config.nickname, '#irc', text); 90 | this.bot.slack.web.chat.postMessage.should.have.been.calledWith(1, text, message); 91 | }); 92 | 93 | it('should not include an avatar if avatarUrl is set to false', function () { 94 | const noAvatarConfig = { 95 | ...config, 96 | avatarUrl: false 97 | }; 98 | const bot = createBot(noAvatarConfig); 99 | const text = 'testmessage'; 100 | const message = { 101 | username: 'testuser (IRC)', 102 | parse: 'full', 103 | icon_url: undefined 104 | }; 105 | 106 | bot.sendToSlack('testuser', '#irc', text); 107 | bot.slack.web.chat.postMessage.should.have.been.calledWith(1, text, message); 108 | }); 109 | 110 | it('should use a custom icon url if given', function () { 111 | const avatarUrl = 'https://cat.com'; 112 | const customAvatarConfig = { 113 | ...config, 114 | avatarUrl 115 | }; 116 | const bot = createBot(customAvatarConfig); 117 | const text = 'testmessage'; 118 | const message = { 119 | username: 'testuser (IRC)', 120 | parse: 'full', 121 | icon_url: avatarUrl 122 | }; 123 | 124 | bot.sendToSlack('testuser', '#irc', text); 125 | bot.slack.web.chat.postMessage.should.have.been.calledWith(1, text, message); 126 | }); 127 | 128 | it('should replace $username in the given avatarUrl', function () { 129 | const avatarUrl = 'https://robohash.org/$username.png'; 130 | const customAvatarConfig = { 131 | ...config, 132 | avatarUrl 133 | }; 134 | const bot = createBot(customAvatarConfig); 135 | const text = 'testmessage'; 136 | const message = { 137 | username: 'testuser (IRC)', 138 | parse: 'full', 139 | icon_url: 'https://robohash.org/testuser.png' 140 | }; 141 | 142 | bot.sendToSlack('testuser', '#irc', text); 143 | bot.slack.web.chat.postMessage.should.have.been.calledWith(1, text, message); 144 | }); 145 | 146 | it('should lowercase channel names before sending to slack', function () { 147 | const text = 'testmessage'; 148 | const message = { 149 | username: 'testuser (IRC)', 150 | parse: 'full', 151 | icon_url: 'http://api.adorable.io/avatars/48/testuser.png' 152 | }; 153 | 154 | this.bot.sendToSlack('testuser', '#IRC', text); 155 | this.bot.slack.web.chat.postMessage.should.have.been.calledWith(1, text, message); 156 | }); 157 | 158 | it('should not send messages to slack if the channel isn\'t in the channel mapping', function () { 159 | this.bot.sendToSlack('user', '#wrongchan', 'message'); 160 | this.bot.slack.web.chat.postMessage.should.not.have.been.called; 161 | }); 162 | 163 | it('should not send messages to slack if the bot isn\'t in the channel', function () { 164 | this.bot.slack.rtm.dataStore.getChannelOrGroupByName = () => null; 165 | this.bot.sendToSlack('user', '#irc', 'message'); 166 | this.bot.slack.web.chat.postMessage.should.not.have.been.called; 167 | }); 168 | 169 | it('should not send messages to slack if the channel\'s is_member is false', function () { 170 | this.bot.slack.rtm.dataStore.getChannelOrGroupByName = () => { 171 | const channel = new ChannelStub(); 172 | channel.is_member = false; 173 | return channel; 174 | }; 175 | 176 | this.bot.sendToSlack('user', '#irc', 'message'); 177 | this.bot.slack.web.chat.postMessage.should.not.have.been.called; 178 | }); 179 | 180 | it('should replace a bare username if the user is in-channel', function () { 181 | const message = { 182 | username: 'testuser (IRC)', 183 | parse: 'full', 184 | icon_url: 'http://api.adorable.io/avatars/48/testuser.png' 185 | }; 186 | 187 | const before = 'testuser should be replaced in the message'; 188 | const after = '@testuser should be replaced in the message'; 189 | this.bot.sendToSlack('testuser', '#IRC', before); 190 | this.bot.slack.web.chat.postMessage.should.have.been.calledWith(1, after, message); 191 | }); 192 | 193 | it('should allow disabling Slack username suffix', function () { 194 | const customSlackUsernameFormatConfig = { 195 | ...config, 196 | slackUsernameFormat: '$username' 197 | }; 198 | const bot = createBot(customSlackUsernameFormatConfig); 199 | const text = 'textmessage'; 200 | const message = { 201 | username: 'testuser', 202 | parse: 'full', 203 | icon_url: 'http://api.adorable.io/avatars/48/testuser.png' 204 | }; 205 | 206 | bot.sendToSlack('testuser', '#irc', text); 207 | bot.slack.web.chat.postMessage.should.have.been.calledWith(1, text, message); 208 | }); 209 | 210 | it('should replace $username in custom Slack username format if given', function () { 211 | const customSlackUsernameFormatConfig = { 212 | ...config, 213 | slackUsernameFormat: 'prefix $username suffix' 214 | }; 215 | const bot = createBot(customSlackUsernameFormatConfig); 216 | const text = 'textmessage'; 217 | const message = { 218 | username: 'prefix testuser suffix', 219 | parse: 'full', 220 | icon_url: 'http://api.adorable.io/avatars/48/testuser.png' 221 | }; 222 | 223 | bot.sendToSlack('testuser', '#irc', text); 224 | bot.slack.web.chat.postMessage.should.have.been.calledWith(1, text, message); 225 | }); 226 | 227 | it('should send correct messages to irc', function () { 228 | const text = 'testmessage'; 229 | const message = { 230 | text, 231 | channel: 'slack' 232 | }; 233 | 234 | this.bot.sendToIRC(message); 235 | const ircText = ` ${text}`; 236 | ClientStub.prototype.say.should.have.been.calledWith('#irc', ircText); 237 | }); 238 | 239 | it('should allow custom user formats for irc', function () { 240 | const customIRCUsernameFormatConfig = { 241 | ...config, 242 | ircUsernameFormat: '$username: ' 243 | }; 244 | const bot = createBot(customIRCUsernameFormatConfig); 245 | const text = 'testmessage'; 246 | const message = { 247 | text, 248 | channel: 'slack' 249 | }; 250 | 251 | bot.sendToIRC(message); 252 | const ircText = `testuser: ${text}`; 253 | ClientStub.prototype.say.should.have.been.calledWith('#irc', ircText); 254 | }); 255 | 256 | it('should allow to remove user name in formats for irc', function () { 257 | const customIRCUsernameFormatConfig = { 258 | ...config, 259 | ircUsernameFormat: '' 260 | }; 261 | const bot = createBot(customIRCUsernameFormatConfig); 262 | const text = 'testmessage'; 263 | const message = { 264 | text, 265 | channel: 'slack' 266 | }; 267 | 268 | bot.sendToIRC(message); 269 | const ircText = `${text}`; 270 | ClientStub.prototype.say.should.have.been.calledWith('#irc', ircText); 271 | }); 272 | 273 | it('should send /me messages to irc', function () { 274 | const text = 'testmessage'; 275 | const message = { 276 | text, 277 | channel: 'slack', 278 | subtype: 'me_message' 279 | }; 280 | 281 | this.bot.sendToIRC(message); 282 | const ircText = `Action: testuser ${text}`; 283 | ClientStub.prototype.say.should.have.been.calledWith('#irc', ircText); 284 | }); 285 | 286 | it('should send files to irc', function () { 287 | const link1 = 'test1'; 288 | const link2 = 'test2'; 289 | const text = 'testcomment'; 290 | const message = { 291 | text: '', 292 | channel: 'slack', 293 | subtype: 'file_share', 294 | file: { 295 | permalink: link1, 296 | permalink_public: link2, 297 | initial_comment: { 298 | comment: text 299 | } 300 | } 301 | }; 302 | 303 | this.bot.sendToIRC(message); 304 | const ircText = ` File uploaded ${link1} / ${link2} - ${text}`; 305 | ClientStub.prototype.say.should.have.been.calledWith('#irc', ircText); 306 | }); 307 | 308 | it('should not send messages to irc if the channel isn\'t in the channel mapping', function () { 309 | this.bot.slack.rtm.dataStore.getChannelGroupOrDMById = () => null; 310 | const message = { 311 | channel: 'wrongchannel' 312 | }; 313 | 314 | this.bot.sendToIRC(message); 315 | ClientStub.prototype.say.should.not.have.been.called; 316 | }); 317 | 318 | it('should send messages from slackbot if slackbot muting is off', function () { 319 | const text = 'A message from Slackbot'; 320 | const message = { 321 | text, 322 | user: 'USLACKBOT' 323 | }; 324 | 325 | this.bot.sendToIRC(message); 326 | const ircText = ` ${text}`; 327 | ClientStub.prototype.say.should.have.been.calledWith('#irc', ircText); 328 | }); 329 | 330 | it('should not send messages from slackbot to irc if slackbot muting is on', function () { 331 | this.bot.muteSlackbot = true; 332 | const message = { 333 | user: 'USLACKBOT', 334 | getBody() { 335 | return 'A message from Slackbot'; 336 | } 337 | }; 338 | this.bot.sendToIRC(message); 339 | ClientStub.prototype.say.should.not.have.been.called; 340 | }); 341 | 342 | it('should parse text from slack when sending messages', function () { 343 | const text = '<@USOMEID> <@USOMEID|readable>'; 344 | const message = { 345 | text, 346 | channel: 'slack' 347 | }; 348 | 349 | this.bot.sendToIRC(message); 350 | ClientStub.prototype.say.should.have.been.calledWith('#irc', ' @testuser readable'); 351 | }); 352 | 353 | it('should parse text from slack', function () { 354 | this.bot.parseText('hi\nhi\r\nhi\r').should.equal('hi hi hi '); 355 | this.bot.parseText('>><<').should.equal('>><<'); 356 | this.bot.parseText(' ') 357 | .should.equal('@channel @group @everyone'); 358 | this.bot.parseText('<#CSOMEID> <#CSOMEID|readable>') 359 | .should.equal('#slack readable'); 360 | this.bot.parseText('<@USOMEID> <@USOMEID|readable>') 361 | .should.equal('@testuser readable'); 362 | this.bot.parseText('').should.equal('https://example.com'); 363 | this.bot.parseText(' ') 364 | .should.equal('https://example.com https://ap.no'); 365 | this.bot.parseText(' ') 366 | .should.equal('example.com ap.no'); 367 | this.bot.parseText(' ') 368 | .should.equal(' '); 369 | }); 370 | 371 | it('should handle entity-encoded messages from slack', function () { 372 | this.bot.parseText('&lt;&gt;').should.equal('<>'); 373 | this.bot.parseText('<@UNONEID>').should.equal('<@UNONEID>'); 374 | this.bot.parseText('<#CNONEID>').should.equal('<#CNONEID>'); 375 | this.bot.parseText('<!channel>').should.equal(''); 376 | this.bot.parseText('<>').should.equal(''); 377 | this.bot.parseText('java.util.List<java.lang.String>') 378 | .should.equal('java.util.List'); 379 | }); 380 | 381 | it('should parse emojis correctly', function () { 382 | this.bot.parseText(':smile:').should.equal(':)'); 383 | this.bot.parseText(':train:').should.equal(':train:'); 384 | }); 385 | 386 | it('should hide usernames for commands', function () { 387 | const text = '!test command'; 388 | const message = { 389 | text, 390 | channel: 'slack' 391 | }; 392 | 393 | this.bot.sendToIRC(message); 394 | ClientStub.prototype.say.getCall(0).args.should.deep.equal([ 395 | '#irc', 'Command sent from Slack by testuser:' 396 | ]); 397 | ClientStub.prototype.say.getCall(1).args.should.deep.equal(['#irc', text]); 398 | }); 399 | 400 | it('should not forward messages from users in slack mute list', function () { 401 | this.bot.muteUsers.slack = ['testuser']; 402 | const text = 'testmessage'; 403 | const message = { 404 | text, 405 | channel: 'slack' 406 | }; 407 | 408 | this.bot.sendToIRC(message); 409 | ClientStub.prototype.say.should.not.have.been.called; 410 | }); 411 | 412 | it('should not forward messages from users in irc mute list', function () { 413 | this.bot.muteUsers.irc = ['testuser']; 414 | const text = 'testmessage'; 415 | 416 | this.bot.sendToSlack('testuser', '#irc', text); 417 | this.bot.slack.web.chat.postMessage.should.not.have.been.called; 418 | }); 419 | }); 420 | -------------------------------------------------------------------------------- /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/cli.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-arrow-callback, no-unused-expressions */ 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/create-bots.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-arrow-callback, no-unused-expressions */ 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/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/bad-config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "server": "irc.bottest.org", 4 | "token": "hei", 5 | "channelMapping": { 6 | "#slack": "#irc" 7 | } 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /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/invalid-config.json: -------------------------------------------------------------------------------- 1 | { invalid json } 2 | -------------------------------------------------------------------------------- /test/fixtures/single-test-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "nickname": "test", 3 | "server": "irc.bottest.org", 4 | "token": "testtoken", 5 | "commandCharacters": ["!", "."], 6 | "autoSendCommands": [ 7 | ["MODE", "test", "+x"], 8 | ["AUTH", "test", "password"] 9 | ], 10 | "channelMapping": { 11 | "#slack": "#irc channelKey" 12 | }, 13 | "ircStatusNotices": { 14 | "join": true, 15 | "leave": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/fixtures/string-config.json: -------------------------------------------------------------------------------- 1 | "test" 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/join-part.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-arrow-callback, no-unused-expressions */ 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-upd'; 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 | this.bot.slack.rtm.start = sandbox.stub(); 36 | }); 37 | 38 | afterEach(function () { 39 | sandbox.restore(); 40 | ChannelStub.prototype.postMessage.reset(); 41 | }); 42 | 43 | it('should send joins to slack if enabled', function () { 44 | this.bot.connect(); 45 | const channel = '#channel'; 46 | const nick = 'nick'; 47 | const message = {}; 48 | const expected = `*${nick}* has joined the IRC channel`; 49 | this.bot.ircClient.emit('join', channel, nick, message); 50 | this.bot.sendToSlack.should.have.been.calledWithExactly(config.nickname, channel, expected); 51 | }); 52 | 53 | it('should not send joins to slack if disabled', function () { 54 | this.bot.ircStatusNotices.join = false; 55 | this.bot.connect(); 56 | const channel = '#channel'; 57 | const nick = 'nick'; 58 | const message = {}; 59 | this.bot.ircClient.emit('join', channel, nick, message); 60 | this.bot.sendToSlack.should.not.have.been.called; 61 | }); 62 | 63 | it('should send parts to slack if enabled', function () { 64 | this.bot.connect(); 65 | const channel = '#channel'; 66 | const nick = 'nick'; 67 | const message = {}; 68 | const expected = `*${nick}* has left the IRC channel`; 69 | this.bot.ircClient.emit('part', channel, nick, message); 70 | this.bot.sendToSlack.should.have.been.calledWithExactly(config.nickname, channel, expected); 71 | }); 72 | 73 | it('should not send parts to slack if disabled', function () { 74 | this.bot.ircStatusNotices.leave = false; 75 | this.bot.connect(); 76 | const channel = '#channel'; 77 | const nick = 'nick'; 78 | const message = {}; 79 | this.bot.ircClient.emit('part', channel, nick, message); 80 | this.bot.sendToSlack.should.not.have.been.called; 81 | }); 82 | 83 | it('should send quits to slack if enabled', function () { 84 | this.bot.connect(); 85 | const channels = ['#channel1', '#channel2']; 86 | const nick = 'nick'; 87 | const message = {}; 88 | this.bot.ircClient.emit('quit', nick, 'reason', channels, message); 89 | channels.forEach((channel) => { 90 | const expected = `*${nick}* has quit the IRC channel`; 91 | this.bot.sendToSlack.should.have.been.calledWithExactly(config.nickname, channel, expected); 92 | }); 93 | }); 94 | 95 | it('should not send quits to slack if disabled', function () { 96 | this.bot.ircStatusNotices.leave = false; 97 | this.bot.connect(); 98 | const channels = ['#channel1', '#channel2']; 99 | const nick = 'nick'; 100 | const message = {}; 101 | this.bot.ircClient.emit('quit', nick, 'reason', channels, message); 102 | this.bot.sendToSlack.should.not.have.been.called; 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /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.id = 1; 8 | this.name = 'slack'; 9 | this.is_channel = true; 10 | this.is_member = true; 11 | this.members = ['testuser']; 12 | } 13 | } 14 | 15 | ChannelStub.prototype.postMessage = sinon.stub(); 16 | 17 | export default ChannelStub; 18 | -------------------------------------------------------------------------------- /test/stubs/data-store-stub.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | import { EventEmitter } from 'events'; 3 | import ChannelStub from './channel-stub'; 4 | 5 | class DataStoreStub extends EventEmitter { 6 | getUserById() { 7 | return { 8 | name: 'testuser' 9 | }; 10 | } 11 | } 12 | 13 | function getChannelStub() { 14 | return new ChannelStub(); 15 | } 16 | 17 | DataStoreStub.prototype.getChannelById = getChannelStub; 18 | DataStoreStub.prototype.getChannelOrGroupByName = getChannelStub; 19 | DataStoreStub.prototype.getChannelGroupOrDMById = getChannelStub; 20 | 21 | export default DataStoreStub; 22 | -------------------------------------------------------------------------------- /test/stubs/irc-client-stub.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | class ClientStub extends EventEmitter {} 4 | 5 | export default ClientStub; 6 | -------------------------------------------------------------------------------- /test/stubs/slack-stub.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import DataStoreStub from './data-store-stub'; 3 | 4 | export default function createSlackStub() { 5 | const rtm = new EventEmitter(); 6 | rtm.dataStore = new DataStoreStub(); 7 | const web = { 8 | chat: {} 9 | }; 10 | 11 | return { rtm, web }; 12 | } 13 | -------------------------------------------------------------------------------- /test/username-decorator.test.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import { highlightUsername } from '../lib/helpers'; 3 | 4 | chai.should(); 5 | 6 | describe('Bare Slack Username Replacement', () => { 7 | ['', ',', '.', ':', '!', '?'].forEach((c) => { 8 | it(`should replace \`username${c}\` with \`@username${c}\``, () => { 9 | const message = `hey username${c} check this out`; 10 | const expected = `hey @username${c} check this out`; 11 | highlightUsername('username', message).should.equal(expected); 12 | }); 13 | }); 14 | 15 | it('should not replace `username\'` with `@username\'`', () => { 16 | const message = 'username\' go check this out'; 17 | highlightUsername('username', message).should.equal(message); 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 | --------------------------------------------------------------------------------