├── .babelrc ├── .dockerignore ├── .eslintrc ├── .github └── workflows │ ├── docker.yml │ └── node.js.yml ├── .gitignore ├── .npmignore ├── .nycrc ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── lib ├── bot.js ├── cli.js ├── emoji.json ├── errors.js ├── formatting.js ├── helpers.js ├── index.js ├── logger.js └── validators.js ├── package-lock.json ├── 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-json-config.json ├── msg-formats-default.json ├── single-test-config.json ├── string-config.json ├── test-config-comments.json ├── test-config.json └── test-javascript-config.js ├── formatting.test.js └── stubs ├── discord-stub.js ├── irc-client-stub.js └── webhook-stub.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "12" 8 | } 9 | } 10 | ] 11 | ], 12 | "env": { 13 | "test": { 14 | "plugins": [ 15 | "istanbul" 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | 3 | # Coverage directory used by tools like istanbul 4 | coverage 5 | .nyc_output 6 | 7 | # Compiled binary addons (http://nodejs.org/api/addons.html) 8 | build/Release 9 | 10 | # Dependency directory 11 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 12 | node_modules 13 | 14 | # Environment variables and configuration 15 | .env 16 | .environment 17 | config.json 18 | 19 | # Build 20 | dist/ 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parser": "@babel/eslint-parser", 4 | "env": { 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "arrow-parens": 0, 10 | "global-require": 0, 11 | "comma-dangle": 0, 12 | "func-names": 0, 13 | "import/no-extraneous-dependencies": [2, { "devDependencies": true }], 14 | "import/prefer-default-export": 0, 15 | "import/no-dynamic-require": 0, 16 | "operator-linebreak": 0, 17 | "implicit-arrow-linebreak": 0 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build the project's Dockerfile and publish images to Docker Hub 2 | # For more information see: https://docs.github.com/en/actions/language-and-framework-guides/publishing-docker-images 3 | 4 | name: Publish Docker image 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | release: 10 | types: [ published ] 11 | 12 | jobs: 13 | push_to_registry: 14 | name: Publish to Docker Hub 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build and push to Docker Hub 20 | uses: docker/build-push-action@v1 21 | with: 22 | username: ${{ secrets.DOCKER_USERNAME }} 23 | password: ${{ secrets.DOCKER_PASSWORD }} 24 | repository: discordirc/discord-irc 25 | tag_with_ref: true 26 | tag_with_sha: true 27 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 15.x, 16.x, 18.x, 20.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | - run: npm run save-coverage 31 | - name: Report to Coveralls (parallel) 32 | uses: coverallsapp/github-action@master 33 | with: 34 | github-token: ${{ secrets.GITHUB_TOKEN }} 35 | flag-name: run-${{ matrix.node-version }} 36 | parallel: true 37 | 38 | finish: 39 | 40 | needs: test 41 | runs-on: ubuntu-latest 42 | 43 | steps: 44 | - name: Report to Coveralls 45 | uses: coverallsapp/github-action@master 46 | with: 47 | github-token: ${{ secrets.GITHUB_TOKEN }} 48 | parallel-finished: true 49 | 50 | -------------------------------------------------------------------------------- /.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 | .nyc_output 16 | 17 | # node-waf configuration 18 | .lock-wscript 19 | 20 | # Compiled binary addons (http://nodejs.org/api/addons.html) 21 | build/Release 22 | 23 | # Dependency directory 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Environment variables and configuration 28 | .env 29 | .environment 30 | config.json 31 | 32 | # Build 33 | dist/ 34 | -------------------------------------------------------------------------------- /.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 | .nyc_output 16 | 17 | # node-waf configuration 18 | .lock-wscript 19 | 20 | # Compiled binary addons (http://nodejs.org/api/addons.html) 21 | build/Release 22 | 23 | # Dependency directory 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Environment variables and configuration 28 | .env 29 | .environment 30 | config.json 31 | 32 | # Ignore everything except build: 33 | lib/ 34 | test/ 35 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@istanbuljs/nyc-config-babel" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | ## [2.9.0] - 2020-10-03 5 | This release comes with a (relatively large) change to use discord.js v12! 6 | There were a lot of breaking changes in the upstream library, including removal of support for Node <12, which prompted a 2.9.0-alpha release before this main version. 7 | Everything seemed to go fine, which is why you're now receiving this latest update! 8 | 9 | ### Added 10 | * IRC nick colors are now customizable in the config (thanks to [KWeaver87](https://github.com/reactiflux/discord-irc/pull/561)) 11 | 12 | ### Changed 13 | * Upgrade discord.js to 12.3.1 (the pre-release came with 12.2.0) - this changes a lot of how the bot works behind the scenes, and prepares us for upcoming Discord API changes - [#555](https://github.com/reactiflux/discord-irc/pull/555), [#564](https://github.com/reactiflux/discord-irc/pull/564) 14 | * Add support for Node 14 ([#549](https://github.com/reactiflux/discord-irc/pull/549)); drop support for Node 6, 8 and 10 ([#550](https://github.com/reactiflux/discord-irc/pull/550)) 15 | 16 | ### Fixed 17 | * A bunch of vulnerabilities listed in `npm audit` are now gone - [#544](https://github.com/reactiflux/discord-irc/pull/544), [#548](https://github.com/reactiflux/discord-irc/pull/548), [#551](https://github.com/reactiflux/discord-irc/pull/551), [#552](https://github.com/reactiflux/discord-irc/pull/552), [#553](https://github.com/reactiflux/discord-irc/pull/553), [#562](https://github.com/reactiflux/discord-irc/pull/562) 18 | * Log messages now typically take up one line again (fixing a relatively long-standing bug, at this point – oops!) - [#554](https://github.com/reactiflux/discord-irc/pull/554) 19 | * Mentioning the same user more than once in a single message is now fixed (thanks to [Qyriad](https://github.com/reactiflux/discord-irc/pull/541)) 20 | * Our Discord client now uses a retry limit of 3 (rather than 1) on internal server errors (hopefully fixing [#461](https://github.com/reactiflux/discord-irc/issues/461)) - [#565](https://github.com/reactiflux/discord-irc/pull/565) 21 | 22 | ## [2.8.1] - 2020-03-16 23 | ### Fixed 24 | * Large avatars failed to display when bridging through webhooks - (thanks to [Miosame](https://github.com/reactiflux/discord-irc/pull/511), follow up in [#530](https://github.com/reactiflux/discord-irc/pull/530)) 25 | * Update acorn to 7.1.1 - [#534](https://github.com/reactiflux/discord-irc/pull/534) 26 | * Remove code coverage instrumentation from `dist/` files - [#536](https://github.com/reactiflux/discord-irc/pull/536) 27 | 28 | ## [2.8.0] - 2019-12-14 29 | ### Added 30 | * `format.webhookAvatarURL`, to customize the unrecognized user webhook avatar (thanks to [Geo1088](https://github.com/reactiflux/discord-irc/pull/419)) 31 | * `parallelPingFix`, disabled by default, to prevent users of both Discord and IRC getting pings whenever their messages are mirrored to IRC (thanks to [qaisjp](https://github.com/reactiflux/discord-irc/pull/243) originally, follow up in [#502](https://github.com/reactiflux/discord-irc/pull/502) and [#520](https://github.com/reactiflux/discord-irc/pull/520)) 32 | * `ignoreUsers.discordIds`, to ignore specific Discord users through the bridge by ID instead of name (thanks to [nsavch](https://github.com/reactiflux/discord-irc/pull/508)) 33 | * A basic Docker image, found at [discordirc/discord-irc](https://hub.docker.com/r/discordirc/discord-irc)! This may not be often updated with the release tags we publish on GitHub, but it should contain a `latest` tag in addition to whichever Git hash is available on main (thanks to [gdude2002](https://github.com/reactiflux/discord-irc/pull/421), follow up in [#498](https://github.com/reactiflux/discord-irc/pull/498)) 34 | 35 | ### Changed 36 | * Add support for Node 13, drop testing for Node 6 - [#521](https://github.com/reactiflux/discord-irc/pull/521) 37 | 38 | ### Fixed 39 | * Upgrade various dependencies: babel, commander, coveralls, discord.js, eslint, mocha, simple-markdown, sinon, sinon-chai - [#488](https://github.com/reactiflux/discord-irc/pull/488), [#503](https://github.com/reactiflux/discord-irc/pull/503), [#522](https://github.com/reactiflux/discord-irc/pull/522), [#523](https://github.com/reactiflux/discord-irc/pull/523), [#524](https://github.com/reactiflux/discord-irc/pull/524/files), [#525](https://github.com/reactiflux/discord-irc/pull/525) 40 | 41 | ## [2.7.2] - 2019-08-08 42 | ### Fixed 43 | * Defer to discord permissions for allowing `@everyone` and `@here` in webhook messages - [#497](https://github.com/reactiflux/discord-irc/pull/497) 44 | * Support Node 10 and 12 - [#499](https://github.com/reactiflux/discord-irc/pull/499) 45 | * Upgrade dependencies 46 | * Tests: Fix lint config deprecation - [#500](https://github.com/reactiflux/discord-irc/pull/500) 47 | * Tests: Ensure all tests are run in dev environment - [#501](https://github.com/reactiflux/discord-irc/pull/501) 48 | 49 | ## [2.7.1] - 2019-06-15 50 | ### Changed 51 | * Upgraded dependencies. 52 | 53 | ## [2.7.0] - 2019-04-02 54 | ### Changed 55 | * Convert channel mentions to codified mentions (thanks to [Throne3d](https://github.com/reactiflux/discord-irc/pull/476)). 56 | * Match IRC style mentions at the beginning of message (thanks to [rdb](https://github.com/reactiflux/discord-irc/pull/470)). 57 | * Upgraded dependencies. 58 | 59 | ## [2.6.2] - 2018-09-19 60 | ### Changed 61 | * Upgraded dependencies. 62 | 63 | ## [2.6.1] - 2018-05-11 64 | ### Changed 65 | * Upgraded dependencies. 66 | 67 | ## [2.6.0] - 2018-03-22 68 | ### Added 69 | * Support for posting messages to Discord using webhooks (thanks to 70 | [Fiaxhs](https://github.com/reactiflux/discord-irc/pull/230)!). 71 | 72 | Webhooks lets you override nicknames and avatars, so messages coming from IRC 73 | can appear as regular Discord messages: 74 | 75 | ![discord-webhook](http://i.imgur.com/lNeJIUI.jpg) 76 | 77 | To enable webhooks, follow part 1 of [this 78 | guide](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) 79 | to create and retrieve a webhook URL for a specific channel, then enable it in 80 | discord-irc's config as follows: 81 | 82 | ```json 83 | "webhooks": { 84 | "#discord-channel": "https://discord.com/api/webhooks/id/token" 85 | } 86 | ``` 87 | 88 | ## [2.5.1] - 2018-01-18 89 | ### Fixed 90 | * Upgraded dependencies. 91 | 92 | ## [2.5.0] - 2017-10-27 93 | ### Added 94 | * Support multi-character command prefixes - [#301](https://github.com/reactiflux/discord-irc/pull/301) 95 | 96 | * Enable auto-renicking by default, so the bot tries to get the target nickname after it fails - [#302](https://github.com/reactiflux/discord-irc/pull/302) 97 | 98 | * Add the ability to ignore IRC/Discord users by nickname - [#322](https://github.com/reactiflux/discord-irc/pull/322) 99 | 100 | ### Fixed 101 | * Improve IRC → Discord mentions around non-word characters and nickname prefix matches - [#273](https://github.com/reactiflux/discord-irc/pull/273) 102 | 103 | * Default to UTF-8 encoding when bridging messages to prevent character corruption - [#315](https://github.com/reactiflux/discord-irc/pull/315) 104 | 105 | * Fix a crash when using the bot in a group DM - [#316](https://github.com/reactiflux/discord-irc/pull/316) 106 | 107 | * Use a `prepare` script for transpiling instead of `prepublish`, fixing `npm` installation direct from the GitHub repository - [#323](https://github.com/reactiflux/discord-irc/pull/323) 108 | 109 | * Update dependencies: 110 | 111 | - discord.js to 11.2.1 112 | - sinon to ^4.0.1 113 | - irc-upd to 0.8.0 - [#313](https://github.com/reactiflux/discord-irc/pull/313) 114 | - simple-markdown to ^0.3.1 115 | - coveralls to ^3.0.0 116 | - mocha to ^4.0.0 117 | - winston to 2.4.0 118 | 119 | ### Changed 120 | * Add a link to the IRC spec in the README - [#307](https://github.com/reactiflux/discord-irc/pull/307) 121 | 122 | * Drop testing for Node 7, add testing for Node 8 - [#329](https://github.com/reactiflux/discord-irc/pull/329) 123 | 124 | ## [2.4.2] - 2017-08-21 125 | ### Fixed 126 | * Tests: Use globbing instead of `find` so tests work on Windows - [#279](https://github.com/reactiflux/discord-irc/pull/279) 127 | 128 | ### Changed 129 | * Update dependency irc-upd to [0.7.0](https://github.com/Throne3d/node-irc/releases/tag/v0.7.0) - [#284](https://github.com/reactiflux/discord-irc/pull/284) 130 | 131 | * Tests: Use Discord objects to simplify code - [#272](https://github.com/reactiflux/discord-irc/pull/272) 132 | 133 | ## [2.4.1] - 2017-07-16 134 | ### Added 135 | * Falsy command preludes are no longer sent (previously would choose default prelude) - [#260](https://github.com/reactiflux/discord-irc/pull/260) 136 | 137 | ### Fixed 138 | * Update link to IRC library in README so it points to the new irc-upd library - [#264](https://github.com/reactiflux/discord-irc/pull/264) 139 | 140 | * Update dependency commander to 2.11.0 - [#262](https://github.com/reactiflux/discord-irc/pull/262) 141 | 142 | * Fix deprecation warning on `TextChannel#sendMessage` - [#267](https://github.com/reactiflux/discord-irc/pull/267) 143 | 144 | * Fix reconnection by updating dependency irc-upd to 0.6.2 - [#270](https://github.com/reactiflux/discord-irc/pull/270) 145 | 146 | ## [2.4.0] - 2017-07-01 147 | This project now uses [irc-upd](https://github.com/Throne3d/node-irc) as a dependency, instead of the old [irc](https://github.com/martynsmith/node-irc) package – this fork should be better maintained and will solve some bugs, detailed below. 148 | 149 | ### Added 150 | * Allow commandCharacters to work for messages sent to Discord - [#221](https://github.com/reactiflux/discord-irc/pull/221). 151 | 152 | * Send nick changes from IRC to Discord with `ircStatusNotices` - [#235](https://github.com/reactiflux/discord-irc/pull/235), [#241](https://github.com/reactiflux/discord-irc/pull/241). 153 | 154 | * Translate custom emoji references from IRC to Discord - [#256](https://github.com/reactiflux/discord-irc/pull/256). 155 | 156 | ### Fixed 157 | * Use `ircClient.nick` instead of `nickname` when checking if the `ircStatusNotices` event is for the bot, to prevent a potential crash - [#257](https://github.com/reactiflux/discord-irc/pull/257). 158 | 159 | * Use the updated `irc-upd` library instead of `irc`, causing IRC messages to now be split by byte instead of character (fixing [#199](https://github.com/reactiflux/discord-irc/issues/199)) and adding support for certain Unicode characters in nicknames (fixing [#200](https://github.com/reactiflux/discord-irc/issues/200)) - [#258](https://github.com/reactiflux/discord-irc/pull/258). 160 | 161 | * Update dependencies: 162 | 163 | - discord.js to 11.1.0 164 | - check-env to 1.3.0 165 | - chai to ^4.0.2 166 | - nyc to ^11.0.3 167 | - commander to 2.10.0 168 | - eslint to ^4.1.1 169 | 170 | ## [2.3.3] - 2017-04-29 171 | ### Fixed 172 | * Warn if a part/quit is received and no channelUsers is set - 173 | [#218](https://github.com/reactiflux/discord-irc/pull/218). 174 | 175 | ## [2.3.2] - 2017-04-27 176 | ### Fixed 177 | * Fix ircStatucNotices when channels are not lowercase - 178 | [#219](https://github.com/reactiflux/discord-irc/pull/219). 179 | 180 | ## [2.3.1] - 2017-04-05 181 | ### Fixed 182 | * Fix IRC quit messages sending to all channels by tracking users - [#214](https://github.com/reactiflux/discord-irc/pull/214#pullrequestreview-31156291). 183 | 184 | ## [2.3.0] - 2017-04-03 185 | A huge thank you to [@Throne3d](https://github.com/Throne3d), 186 | [@rahatarmanahmed](https://github.com/rahatarmanahmed), [@mraof](https://github.com/mraof) 187 | and [@Ratismal](https://github.com/Ratismal) for all the fixes and features 188 | in this release. 189 | 190 | ### Added 191 | * Bridge IRC join/part/quit messages to Discord 192 | (enable by setting ircStatusNotices to true) - 193 | [#207](https://github.com/reactiflux/discord-irc/pull/207). 194 | 195 | * Convert text styles between IRC and Discord 196 | [#205](https://github.com/reactiflux/discord-irc/pull/205). 197 | 198 | * Allow users to configure the patterns of messages on 199 | IRC and Discord using the format options object 200 | [#204](https://github.com/reactiflux/discord-irc/pull/204). 201 | 202 | * Add Discord channel ID matching to the channel mapping 203 | [#202](https://github.com/reactiflux/discord-irc/pull/202). 204 | 205 | ### Fixed 206 | * Parse role mentions appropriately, as with channel and user mentions 207 | [#203](https://github.com/reactiflux/discord-irc/pull/203). 208 | 209 | * Make the bot not crash when a channel mentioned by ID fails to exist 210 | [#201](https://github.com/reactiflux/discord-irc/pull/201). 211 | 212 | ### Changed 213 | * Convert username mentions even if nickname is set - 214 | [#208](https://github.com/reactiflux/discord-irc/pull/208). 215 | 216 | ## [2.2.1] - 2017-03-12 217 | ### Fixed 218 | * Reverts the changes in 2.2.0 due to incompatibilities with different clients. 219 | See https://github.com/reactiflux/discord-irc/issues/196 for more 220 | information. 221 | 222 | ## [2.2.0] - 2017-03-06 223 | ### Fixed 224 | * Added a zero width character between each letter of the IRC nicknames, to 225 | avoid unwanted highlights. Fixed by @Sanqui in 226 | [#193](https://github.com/reactiflux/discord-irc/pull/193). 227 | 228 | ## [2.1.6] - 2017-01-10 229 | ### Fixed 230 | * Upgraded discord.js. 231 | 232 | ## [2.1.6] - 2016-12-08 233 | ### Fixed 234 | * Listen to warn events from Discord. 235 | * Listen to debug events from Discord (only in development). 236 | * Log info events upon connection, instead of debug 237 | 238 | ## [2.1.5] - 2016-11-17 239 | ### Fixed 240 | * Upgraded node-irc to 0.5.1, fixing #129. 241 | 242 | ## [2.1.4] - 2016-11-14 243 | ### Fixed 244 | * Added support for highlighting users by their nicknames, 245 | thanks to @DarkSpyro003. 246 | * Upgraded to discord.js v10. 247 | 248 | ## [2.1.3] - 2016-11-02 249 | ### Fixed 250 | * Send text messages only to text channels (thanks @dustinlacewell). 251 | 252 | ## [2.1.2] - 2016-11-01 253 | ### Fixed 254 | * Use nickname, not username, in command prelude. 255 | Thanks to @williamjacksn. 256 | 257 | ## [2.1.1] - 2016-10-21 258 | ### Fixed 259 | * A bug where Discord attachment URLs weren't posted to IRC, thanks to @LordAlderaan for the report. 260 | 261 | ## [2.1.0] - 2016-10-09 262 | ### Added 263 | * Messages sent to IRC will now use the correct server nickname, 264 | instead of the user's global username (thanks to @DarkSpyro003). 265 | 266 | ## [2.0.2] - 2016-10-02 267 | ### Fixed 268 | - Display custom emojis correctly, thanks to @macdja38. 269 | 270 | ## [2.0.0] - 2016-09-25 271 | ### Fixed 272 | - Upgrade to version 9.3 of discord.js. 273 | This removes support for Node.js versions older than v6, 274 | as that's the oldest discord.js supports. 275 | 276 | ## [1.0.3] - 2016-09-09 277 | ### Fixed 278 | - Replace changes in 1.0.2 with the #indev-old version 279 | of discord.js. 280 | 281 | ## [1.0.2] - 2016-09-09 282 | ### Fixed 283 | - Discord's API now requires bot tokens 284 | to be prefixed with "Bot". This adds 285 | a hotfix that does exactly that. 286 | 287 | ## [1.0.1] - 2016-06-19 288 | ### Fixed 289 | - Upgraded dependencies. 290 | 291 | ## [1.0.0] - 2016-05-06 292 | ### Changed 293 | - Breaking: discord-irc now uses tokens for authentication, instead of 294 | email/password, thanks to @TheDoctorsLife. See the README for more instructions. 295 | 296 | ## [0.8.2] - 2016-04-21 297 | ### Fixed 298 | - Enable auto reconnect for IRC and Discord. 299 | 300 | ## [0.8.1] - 2016-04-21 301 | ### Fixed 302 | - Upgrade discord.js to 7.0.1. 303 | 304 | ## [0.8.0] - 2016-04-04 305 | Implemented by @rce: 306 | ### Added 307 | - Support for messages containing both attachments and text. 308 | 309 | ### Changed 310 | - Attachment URLs are now posted by themselves, instead of including a 311 | preliminary message explaining that it's an attachment. 312 | 313 | ## [0.7.0] - 2016-04-04 314 | ### Added 315 | - Added the config option `ircNickColor` to make it possible to 316 | disable nick colors for messages sent to IRC. 317 | 318 | ## [0.6.1] - 2016-04-04 319 | ### Fixed 320 | - Upgrade dependencies. 321 | 322 | ## [0.6.0] - 2016-02-24 323 | ### Added 324 | - Highlight Discord users when they're mentioned on IRC (thanks to @rce). 325 | 326 | ## [0.5.0] - 2016-02-08 327 | ### Added 328 | - Discord attachments will be linked to on IRC when 329 | they're posted (fixed by @rce). 330 | 331 | ## [0.4.3] - 2016-01-23 332 | ### Fixed 333 | - Upgraded dependencies. 334 | - istanbul -> nyc for coverage. 335 | 336 | ## [0.4.1] - 2015-12-22 337 | ### Changed 338 | - Comments are now stripped from JSON configs before they're parsed. 339 | - Upgraded dependencies. 340 | 341 | ## [0.4.0] - 2015-11-11 342 | ### Added 343 | - Colors to IRC nicks. 344 | 345 | ## [0.3.0] - 2015-10-28 346 | ### Changed 347 | - Rewrote everything to ES6. 348 | 349 | ## [0.2.0] - 2015-10-28 350 | ### Added 351 | - Support for channel and username highlights from Discord to IRC. 352 | This means that e.g. #general will no longer result in something like #512312. 353 | 354 | ### Added 355 | - Working tests for all functionality. 356 | 357 | ## [0.1.1] - 2015-10-27 358 | ### Changed 359 | - Made `discord.irc` a regular dependency, instead of a devDependency. 360 | 361 | ## [0.1.0] - 2015-10-13 362 | ### Added 363 | - Initial implementation. 364 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | ENV LIBRARY_PATH=/lib:/usr/lib 3 | 4 | RUN mkdir /bot 5 | COPY . /bot 6 | 7 | WORKDIR /bot 8 | 9 | RUN apk add --update tini && \ 10 | npm install && \ 11 | npm run build && \ 12 | mkdir /config 13 | 14 | ENTRYPOINT ["/sbin/tini", "--"] 15 | CMD ["npm", "start", "--", "--config", "/config/config.json"] 16 | -------------------------------------------------------------------------------- /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 | [![Coverage Status](https://coveralls.io/repos/github/reactiflux/discord-irc/badge.svg?branch=main)](https://coveralls.io/github/reactiflux/discord-irc?branch=main) 2 | 3 | > Connects [Discord](https://discord.com/) and [IRC](https://www.ietf.org/rfc/rfc1459.txt) channels by sending messages back and forth. 4 | 5 | ## Example 6 | ![discord-irc](http://i.imgur.com/oI6iCrf.gif) 7 | 8 | ## Installation and usage 9 | **Note**: discord-irc requires Node.js version 12 or newer, as it depends on [discord.js](https://github.com/hydrabolt/discord.js). 10 | Future versions may require newer Node.js versions, though we should support active releases. 11 | 12 | Before you can run discord-irc you need to create a configuration file by 13 | following the instructions [here](https://github.com/reactiflux/discord-irc#configuration). 14 | After you've done that you can replace `/path/to/config.json` in the commands 15 | below with the path to your newly created configuration file - or just `config.json` if it's 16 | in the same directory as the one you're starting the bot from. 17 | 18 | When you've done that you can install and start the bot either through npm: 19 | 20 | ```bash 21 | $ npm install -g discord-irc 22 | $ discord-irc --config /path/to/config.json 23 | ``` 24 | 25 | or by cloning the repository: 26 | 27 | ```bash 28 | In the repository folder: 29 | $ npm install 30 | $ npm run build 31 | $ npm start -- --config /path/to/config.json # Note the extra double dash 32 | ``` 33 | 34 | It can also be used as a module: 35 | ```js 36 | import discordIRC from 'discord-irc'; 37 | import config from './config.json'; 38 | discordIRC(config); 39 | ``` 40 | 41 | ## Docker 42 | As an alternative to running discord-irc directly on your machine, we provide a [Docker container image](https://hub.docker.com/r/discordirc/discord-irc). 43 | After creating a configuration file, you can fetch the image from Docker Hub and run it with the following command: 44 | 45 | ```bash 46 | docker run -v /path/to/config:/config/config.json discordirc/discord-irc 47 | ``` 48 | 49 | If you've checked out the repository already, you can build the Docker image locally and run that instead: 50 | 51 | ```bash 52 | docker build -t discord-irc . 53 | docker run -v /path/to/config:/config/config.json discord-irc 54 | ``` 55 | 56 | Note that the path to the config file on the host (`/path/to/config`) _must_ be a valid absolute path to a config file. 57 | Otherwise, you may get the error "illegal operation on a directory". 58 | 59 | ## Configuration 60 | First you need to create a Discord bot user, which you can do by following the instructions [here](https://github.com/reactiflux/discord-irc/wiki/Creating-a-discord-bot-&-getting-a-token). 61 | 62 | ### Example configuration 63 | ```js 64 | [ 65 | // Bot 1 (minimal configuration): 66 | { 67 | "nickname": "test2", 68 | "server": "irc.testbot.org", 69 | "discordToken": "botwantsin123", 70 | "channelMapping": { 71 | "#other-discord": "#new-irc-channel" 72 | } 73 | }, 74 | 75 | // Bot 2 (advanced options): 76 | { 77 | "nickname": "test", 78 | "server": "irc.bottest.org", 79 | "discordToken": "botwantsin123", 80 | "autoSendCommands": [ // Commands that will be sent on connect 81 | ["PRIVMSG", "NickServ", "IDENTIFY password"], 82 | ["MODE", "test", "+x"], 83 | ["AUTH", "test", "password"] 84 | ], 85 | "channelMapping": { // Maps each Discord-channel to an IRC-channel, used to direct messages to the correct place 86 | "#discord": "#irc channel-password", // Add channel keys after the channel name 87 | "1234567890": "#channel" // Use a discord channel ID instead of its name (so you can rename it or to disambiguate) 88 | }, 89 | "ircOptions": { // Optional node-irc options 90 | "floodProtection": false, // On by default 91 | "floodProtectionDelay": 1000, // 500 by default 92 | "port": "6697", // 6697 by default 93 | "secure": true, // enable SSL, false by default 94 | "sasl": true, // false by default 95 | "username": "test", // nodeirc by default 96 | "password": "p455w0rd" // empty by default 97 | }, 98 | "format": { // Optional custom formatting options 99 | // Patterns, represented by {$patternName}, are replaced when sending messages 100 | "commandPrelude": "Command sent by {$nickname}", // Message sent before a command 101 | "ircText": "<{$displayUsername}> {$text}", // When sending a message to IRC 102 | "urlAttachment": "<{$displayUsername}> {$attachmentURL}", // When sending a Discord attachment to IRC 103 | "discord": "**<{$author}>** {$withMentions}", // When sending a message to Discord 104 | // Other patterns that can be used: 105 | // {$discordChannel} (e.g. #general) 106 | // {$ircChannel} (e.g. #irc) 107 | "webhookAvatarURL": "https://robohash.org/{$nickname}" // Default avatar to use for webhook messages 108 | }, 109 | "ircNickColor": false, // Gives usernames a color in IRC for better readability (on by default) 110 | "ircNickColors": ['light_blue', 'dark_blue', 'light_red', 'dark_red', 'light_green', 'dark_green', 'magenta', 'light_magenta', 'orange', 'yellow', 'cyan', 'light_cyan'], // Which irc-upd colors to use 111 | "parallelPingFix": true, // Prevents users of both IRC and Discord from being mentioned in IRC when they speak in Discord (off by default) 112 | // Makes the bot hide the username prefix for messages that start 113 | // with one of these characters (commands): 114 | "commandCharacters": ["!", "."], 115 | "ircStatusNotices": true, // Enables notifications in Discord when people join/part in the relevant IRC channel 116 | "ignoreUsers": { 117 | "irc": ["irc_nick1", "irc_nick2"], // Ignore specified IRC nicks and do not send their messages to Discord. 118 | "discord": ["discord_nick1", "discord_nick2"], // Ignore specified Discord nicks and do not send their messages to IRC. 119 | "discordIds": ["198528216523210752"] // Ignore specified Discord ids and do not send their messages to IRC. 120 | }, 121 | // List of webhooks per channel 122 | "webhooks": { 123 | "#discord": "https://discord.com/api/webhooks/id/token" 124 | } 125 | } 126 | ] 127 | ``` 128 | 129 | The `ircOptions` object is passed directly to irc-upd ([available options](https://node-irc-upd.readthedocs.io/en/latest/API.html#irc.Client)). 130 | 131 | To retrieve a discord channel ID, write `\#channel` on the relevant server – it should produce something of the form `<#1234567890>`, which you can then use in the `channelMapping` config. 132 | 133 | ### Webhooks 134 | Webhooks lets you override nicknames and avatars, so messages coming from IRC 135 | can appear as regular Discord messages: 136 | 137 | ![discord-webhook](http://i.imgur.com/lNeJIUI.jpg) 138 | 139 | To enable webhooks, follow part 1 of [this 140 | guide](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) 141 | to create and retrieve a webhook URL for a specific channel, then enable it in 142 | discord-irc's config as follows: 143 | 144 | ```json 145 | "webhooks": { 146 | "#discord-channel": "https://discord.com/api/webhooks/id/token" 147 | } 148 | ``` 149 | 150 | ### Encodings 151 | If you encounter trouble with some characters being corrupted from some clients (particularly umlauted characters, such as `ä` or `ö`), try installing the optional dependencies `iconv` and `node-icu-charset-detector`. 152 | The bot will produce a warning when started if the IRC library is unable to convert between encodings. 153 | 154 | Further information can be found in [the installation section of irc-upd](https://github.com/Throne3d/node-irc#character-set-detection). 155 | 156 | ## Tests 157 | Run the tests with: 158 | ```bash 159 | $ npm test 160 | ``` 161 | 162 | ## Style Guide 163 | discord-irc follows the [Airbnb Style Guide](https://github.com/airbnb/javascript). 164 | [ESLint](http://eslint.org/) is used to make sure this is followed correctly, which can be run with: 165 | 166 | ```bash 167 | $ npm run lint 168 | ``` 169 | -------------------------------------------------------------------------------- /lib/bot.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import irc from 'irc-upd'; 3 | import discord from 'discord.js'; 4 | import logger from './logger'; 5 | import { ConfigurationError } from './errors'; 6 | import { validateChannelMapping } from './validators'; 7 | import { formatFromDiscordToIRC, formatFromIRCToDiscord } from './formatting'; 8 | 9 | // Usernames need to be between 2 and 32 characters for webhooks: 10 | const USERNAME_MIN_LENGTH = 2; 11 | const USERNAME_MAX_LENGTH = 32; 12 | 13 | const REQUIRED_FIELDS = ['server', 'nickname', 'channelMapping', 'discordToken']; 14 | const DEFAULT_NICK_COLORS = ['light_blue', 'dark_blue', 'light_red', 'dark_red', 'light_green', 15 | 'dark_green', 'magenta', 'light_magenta', 'orange', 'yellow', 'cyan', 'light_cyan']; 16 | const patternMatch = /{\$(.+?)}/g; 17 | 18 | /** 19 | * An IRC bot, works as a middleman for all communication 20 | * @param {object} options - server, nickname, channelMapping, outgoingToken, incomingURL 21 | */ 22 | class Bot { 23 | constructor(options) { 24 | REQUIRED_FIELDS.forEach((field) => { 25 | if (!options[field]) { 26 | throw new ConfigurationError(`Missing configuration field ${field}`); 27 | } 28 | }); 29 | 30 | validateChannelMapping(options.channelMapping); 31 | 32 | this.discord = new discord.Client({ 33 | autoReconnect: true, 34 | retryLimit: 3, 35 | }); 36 | 37 | this.server = options.server; 38 | this.nickname = options.nickname; 39 | this.ircOptions = options.ircOptions; 40 | this.discordToken = options.discordToken; 41 | this.commandCharacters = options.commandCharacters || []; 42 | this.ircNickColor = options.ircNickColor !== false; // default to true 43 | this.ircNickColors = options.ircNickColors || DEFAULT_NICK_COLORS; 44 | this.parallelPingFix = options.parallelPingFix === true; // default: false 45 | this.channels = _.values(options.channelMapping); 46 | this.ircStatusNotices = options.ircStatusNotices; 47 | this.announceSelfJoin = options.announceSelfJoin; 48 | this.webhookOptions = options.webhooks; 49 | 50 | // Nicks to ignore 51 | this.ignoreUsers = options.ignoreUsers || {}; 52 | this.ignoreUsers.irc = this.ignoreUsers.irc || []; 53 | this.ignoreUsers.discord = this.ignoreUsers.discord || []; 54 | this.ignoreUsers.discordIds = this.ignoreUsers.discordIds || []; 55 | 56 | // "{$keyName}" => "variableValue" 57 | // author/nickname: nickname of the user who sent the message 58 | // discordChannel: Discord channel (e.g. #general) 59 | // ircChannel: IRC channel (e.g. #irc) 60 | // text: the (appropriately formatted) message content 61 | this.format = options.format || {}; 62 | 63 | // "{$keyName}" => "variableValue" 64 | // displayUsername: nickname with wrapped colors 65 | // attachmentURL: the URL of the attachment (only applicable in formatURLAttachment) 66 | this.formatIRCText = this.format.ircText || '<{$displayUsername}> {$text}'; 67 | this.formatURLAttachment = this.format.urlAttachment || '<{$displayUsername}> {$attachmentURL}'; 68 | 69 | // "{$keyName}" => "variableValue" 70 | // side: "Discord" or "IRC" 71 | if ('commandPrelude' in this.format) { 72 | this.formatCommandPrelude = this.format.commandPrelude; 73 | } else { 74 | this.formatCommandPrelude = 'Command sent from {$side} by {$nickname}:'; 75 | } 76 | 77 | // "{$keyName}" => "variableValue" 78 | // withMentions: text with appropriate mentions reformatted 79 | this.formatDiscord = this.format.discord || '**<{$author}>** {$withMentions}'; 80 | 81 | // "{$keyName} => "variableValue" 82 | // nickname: nickame of IRC message sender 83 | this.formatWebhookAvatarURL = this.format.webhookAvatarURL; 84 | 85 | // Keep track of { channel => [list, of, usernames] } for ircStatusNotices 86 | this.channelUsers = {}; 87 | 88 | this.channelMapping = {}; 89 | this.webhooks = {}; 90 | 91 | // Remove channel passwords from the mapping and lowercase IRC channel names 92 | _.forOwn(options.channelMapping, (ircChan, discordChan) => { 93 | this.channelMapping[discordChan] = ircChan.split(' ')[0].toLowerCase(); 94 | }); 95 | 96 | this.invertedMapping = _.invert(this.channelMapping); 97 | this.autoSendCommands = options.autoSendCommands || []; 98 | } 99 | 100 | connect() { 101 | logger.debug('Connecting to IRC and Discord'); 102 | this.discord.login(this.discordToken); 103 | 104 | // Extract id and token from Webhook urls and connect. 105 | _.forOwn(this.webhookOptions, (url, channel) => { 106 | const [id, token] = url.split('/').slice(-2); 107 | const client = new discord.WebhookClient(id, token); 108 | this.webhooks[channel] = { 109 | id, 110 | client 111 | }; 112 | }); 113 | 114 | const ircOptions = { 115 | userName: this.nickname, 116 | realName: this.nickname, 117 | channels: this.channels, 118 | floodProtection: true, 119 | floodProtectionDelay: 500, 120 | retryCount: 10, 121 | autoRenick: true, 122 | // options specified in the configuration file override the above defaults 123 | ...this.ircOptions 124 | }; 125 | 126 | // default encoding to UTF-8 so messages to Discord aren't corrupted 127 | if (!Object.prototype.hasOwnProperty.call(ircOptions, 'encoding')) { 128 | if (irc.canConvertEncoding()) { 129 | ircOptions.encoding = 'utf-8'; 130 | } else { 131 | logger.warn('Cannot convert message encoding; you may encounter corrupted characters with non-English text.\n' + 132 | 'For information on how to fix this, please see: https://github.com/Throne3d/node-irc#character-set-detection'); 133 | } 134 | } 135 | 136 | this.ircClient = new irc.Client(this.server, this.nickname, ircOptions); 137 | this.attachListeners(); 138 | } 139 | 140 | disconnect() { 141 | this.ircClient.disconnect(); 142 | this.discord.destroy(); 143 | Object.values(this.webhooks).forEach(x => x.client.destroy()); 144 | } 145 | 146 | attachListeners() { 147 | this.discord.on('ready', () => { 148 | logger.info('Connected to Discord'); 149 | }); 150 | 151 | this.ircClient.on('registered', (message) => { 152 | logger.info('Connected to IRC'); 153 | logger.debug('Registered event: ', message); 154 | this.autoSendCommands.forEach((element) => { 155 | this.ircClient.send(...element); 156 | }); 157 | }); 158 | 159 | this.ircClient.on('error', (error) => { 160 | logger.error('Received error event from IRC', error); 161 | }); 162 | 163 | this.discord.on('error', (error) => { 164 | logger.error('Received error event from Discord', error); 165 | }); 166 | 167 | this.discord.on('warn', (warning) => { 168 | logger.warn('Received warn event from Discord', warning); 169 | }); 170 | 171 | this.discord.on('message', (message) => { 172 | // Ignore bot messages and people leaving/joining 173 | this.sendToIRC(message); 174 | }); 175 | 176 | this.ircClient.on('message', this.sendToDiscord.bind(this)); 177 | 178 | this.ircClient.on('notice', (author, to, text) => { 179 | this.sendToDiscord(author, to, `*${text}*`); 180 | }); 181 | 182 | this.ircClient.on('nick', (oldNick, newNick, channels) => { 183 | if (!this.ircStatusNotices) return; 184 | channels.forEach((channelName) => { 185 | const channel = channelName.toLowerCase(); 186 | if (this.channelUsers[channel]) { 187 | if (this.channelUsers[channel].has(oldNick)) { 188 | this.channelUsers[channel].delete(oldNick); 189 | this.channelUsers[channel].add(newNick); 190 | this.sendExactToDiscord(channel, `*${oldNick}* is now known as ${newNick}`); 191 | } 192 | } else { 193 | logger.warn(`No channelUsers found for ${channel} when ${oldNick} changed.`); 194 | } 195 | }); 196 | }); 197 | 198 | this.ircClient.on('join', (channelName, nick) => { 199 | logger.debug('Received join:', channelName, nick); 200 | if (!this.ircStatusNotices) return; 201 | if (nick === this.ircClient.nick && !this.announceSelfJoin) return; 202 | const channel = channelName.toLowerCase(); 203 | // self-join is announced before names (which includes own nick) 204 | // so don't add nick to channelUsers 205 | if (nick !== this.ircClient.nick) this.channelUsers[channel].add(nick); 206 | this.sendExactToDiscord(channel, `*${nick}* has joined the channel`); 207 | }); 208 | 209 | this.ircClient.on('part', (channelName, nick, reason) => { 210 | logger.debug('Received part:', channelName, nick, reason); 211 | if (!this.ircStatusNotices) return; 212 | const channel = channelName.toLowerCase(); 213 | // remove list of users when no longer in channel (as it will become out of date) 214 | if (nick === this.ircClient.nick) { 215 | logger.debug('Deleting channelUsers as bot parted:', channel); 216 | delete this.channelUsers[channel]; 217 | return; 218 | } 219 | if (this.channelUsers[channel]) { 220 | this.channelUsers[channel].delete(nick); 221 | } else { 222 | logger.warn(`No channelUsers found for ${channel} when ${nick} parted.`); 223 | } 224 | this.sendExactToDiscord(channel, `*${nick}* has left the channel (${reason})`); 225 | }); 226 | 227 | this.ircClient.on('quit', (nick, reason, channels) => { 228 | logger.debug('Received quit:', nick, channels); 229 | if (!this.ircStatusNotices || nick === this.ircClient.nick) return; 230 | channels.forEach((channelName) => { 231 | const channel = channelName.toLowerCase(); 232 | if (!this.channelUsers[channel]) { 233 | logger.warn(`No channelUsers found for ${channel} when ${nick} quit, ignoring.`); 234 | return; 235 | } 236 | if (!this.channelUsers[channel].delete(nick)) return; 237 | this.sendExactToDiscord(channel, `*${nick}* has quit (${reason})`); 238 | }); 239 | }); 240 | 241 | this.ircClient.on('names', (channelName, nicks) => { 242 | logger.debug('Received names:', channelName, nicks); 243 | if (!this.ircStatusNotices) return; 244 | const channel = channelName.toLowerCase(); 245 | this.channelUsers[channel] = new Set(Object.keys(nicks)); 246 | }); 247 | 248 | this.ircClient.on('action', (author, to, text) => { 249 | this.sendToDiscord(author, to, `_${text}_`); 250 | }); 251 | 252 | this.ircClient.on('invite', (channel, from) => { 253 | logger.debug('Received invite:', channel, from); 254 | if (!this.invertedMapping[channel]) { 255 | logger.debug('Channel not found in config, not joining:', channel); 256 | } else { 257 | this.ircClient.join(channel); 258 | logger.debug('Joining channel:', channel); 259 | } 260 | }); 261 | 262 | if (logger.level === 'debug') { 263 | this.discord.on('debug', (message) => { 264 | logger.debug('Received debug event from Discord', message); 265 | }); 266 | } 267 | } 268 | 269 | static getDiscordNicknameOnServer(user, guild) { 270 | if (guild) { 271 | const userDetails = guild.members.cache.get(user.id); 272 | if (userDetails) { 273 | return userDetails.nickname || user.username; 274 | } 275 | } 276 | return user.username; 277 | } 278 | 279 | parseText(message) { 280 | const text = message.mentions.users.reduce((content, mention) => { 281 | const displayName = Bot.getDiscordNicknameOnServer(mention, message.guild); 282 | const userMentionRegex = RegExp(`<@(&|!)?${mention.id}>`, 'g'); 283 | return content.replace(userMentionRegex, `@${displayName}`); 284 | }, message.content); 285 | 286 | return text 287 | .replace(/\n|\r\n|\r/g, ' ') 288 | .replace(/<#(\d+)>/g, (match, channelId) => { 289 | const channel = this.discord.channels.cache.get(channelId); 290 | if (channel) return `#${channel.name}`; 291 | return '#deleted-channel'; 292 | }) 293 | .replace(/<@&(\d+)>/g, (match, roleId) => { 294 | const role = message.guild.roles.cache.get(roleId); 295 | if (role) return `@${role.name}`; 296 | return '@deleted-role'; 297 | }) 298 | .replace(//g, (match, emoteName) => emoteName); 299 | } 300 | 301 | isCommandMessage(message) { 302 | return this.commandCharacters.some(prefix => message.startsWith(prefix)); 303 | } 304 | 305 | ignoredIrcUser(user) { 306 | return this.ignoreUsers.irc.some(i => i.toLowerCase() === user.toLowerCase()); 307 | } 308 | 309 | ignoredDiscordUser(discordUser) { 310 | const ignoredName = this.ignoreUsers.discord.some( 311 | i => i.toLowerCase() === discordUser.username.toLowerCase() 312 | ); 313 | const ignoredId = this.ignoreUsers.discordIds.some(i => i === discordUser.id); 314 | return ignoredName || ignoredId; 315 | } 316 | 317 | static substitutePattern(message, patternMapping) { 318 | return message.replace(patternMatch, (match, varName) => patternMapping[varName] || match); 319 | } 320 | 321 | sendToIRC(message) { 322 | const { author } = message; 323 | // Ignore messages sent by the bot itself: 324 | if (author.id === this.discord.user.id || 325 | Object.keys(this.webhooks).some(channel => this.webhooks[channel].id === author.id) 326 | ) return; 327 | 328 | // Do not send to IRC if this user is on the ignore list. 329 | if (this.ignoredDiscordUser(author)) { 330 | return; 331 | } 332 | 333 | const channelName = `#${message.channel.name}`; 334 | const ircChannel = this.channelMapping[message.channel.id] || 335 | this.channelMapping[channelName]; 336 | 337 | logger.debug('Channel Mapping', channelName, this.channelMapping[channelName]); 338 | if (ircChannel) { 339 | const fromGuild = message.guild; 340 | const nickname = Bot.getDiscordNicknameOnServer(author, fromGuild); 341 | let text = this.parseText(message); 342 | let displayUsername = nickname; 343 | 344 | if (this.parallelPingFix) { 345 | // Prevent users of both IRC and Discord from 346 | // being mentioned in IRC when they talk in Discord. 347 | displayUsername = `${displayUsername.slice(0, 1)}\u200B${displayUsername.slice(1)}`; 348 | } 349 | 350 | if (this.ircNickColor) { 351 | const colorIndex = (nickname.charCodeAt(0) + nickname.length) % this.ircNickColors.length; 352 | displayUsername = irc.colors.wrap(this.ircNickColors[colorIndex], displayUsername); 353 | } 354 | 355 | const patternMap = { 356 | author: nickname, 357 | nickname, 358 | displayUsername, 359 | text, 360 | discordChannel: channelName, 361 | ircChannel 362 | }; 363 | 364 | if (this.isCommandMessage(text)) { 365 | patternMap.side = 'Discord'; 366 | logger.debug('Sending command message to IRC', ircChannel, text); 367 | // if (prelude) this.ircClient.say(ircChannel, prelude); 368 | if (this.formatCommandPrelude) { 369 | const prelude = Bot.substitutePattern(this.formatCommandPrelude, patternMap); 370 | this.ircClient.say(ircChannel, prelude); 371 | } 372 | this.ircClient.say(ircChannel, text); 373 | } else { 374 | if (text !== '') { 375 | // Convert formatting 376 | text = formatFromDiscordToIRC(text); 377 | patternMap.text = text; 378 | 379 | text = Bot.substitutePattern(this.formatIRCText, patternMap); 380 | logger.debug('Sending message to IRC', ircChannel, text); 381 | this.ircClient.say(ircChannel, text); 382 | } 383 | 384 | if (message.attachments && message.attachments.size) { 385 | message.attachments.forEach((a) => { 386 | patternMap.attachmentURL = a.url; 387 | const urlMessage = Bot.substitutePattern(this.formatURLAttachment, patternMap); 388 | 389 | logger.debug('Sending attachment URL to IRC', ircChannel, urlMessage); 390 | this.ircClient.say(ircChannel, urlMessage); 391 | }); 392 | } 393 | } 394 | } 395 | } 396 | 397 | findDiscordChannel(ircChannel) { 398 | const discordChannelName = this.invertedMapping[ircChannel.toLowerCase()]; 399 | if (discordChannelName) { 400 | // #channel -> channel before retrieving and select only text channels: 401 | let discordChannel = null; 402 | 403 | if (this.discord.channels.cache.has(discordChannelName)) { 404 | discordChannel = this.discord.channels.cache.get(discordChannelName); 405 | } else if (discordChannelName.startsWith('#')) { 406 | discordChannel = this.discord.channels.cache 407 | .filter(c => c.type === 'text') 408 | .find(c => c.name === discordChannelName.slice(1)); 409 | } 410 | 411 | if (!discordChannel) { 412 | logger.info( 413 | 'Tried to send a message to a channel the bot isn\'t in: ', 414 | discordChannelName 415 | ); 416 | return null; 417 | } 418 | return discordChannel; 419 | } 420 | return null; 421 | } 422 | 423 | findWebhook(ircChannel) { 424 | const discordChannelName = this.invertedMapping[ircChannel.toLowerCase()]; 425 | return discordChannelName && this.webhooks[discordChannelName]; 426 | } 427 | 428 | getDiscordAvatar(nick, channel) { 429 | const guildMembers = this.findDiscordChannel(channel).guild.members.cache; 430 | const findByNicknameOrUsername = caseSensitive => 431 | (member) => { 432 | if (caseSensitive) { 433 | return member.user.username === nick || member.nickname === nick; 434 | } 435 | const nickLowerCase = nick.toLowerCase(); 436 | return member.user.username.toLowerCase() === nickLowerCase 437 | || (member.nickname && member.nickname.toLowerCase() === nickLowerCase); 438 | }; 439 | 440 | // Try to find exact matching case 441 | let users = guildMembers.filter(findByNicknameOrUsername(true)); 442 | 443 | // Now let's search case insensitive. 444 | if (users.size === 0) { 445 | users = guildMembers.filter(findByNicknameOrUsername(false)); 446 | } 447 | 448 | // No matching user or more than one => default avatar 449 | if (users && users.size === 1) { 450 | const url = users.first().user.avatarURL({ size: 128, format: 'png' }); 451 | if (url) return url; 452 | } 453 | 454 | // If there isn't a URL format, don't send an avatar at all 455 | if (this.formatWebhookAvatarURL) { 456 | return Bot.substitutePattern(this.formatWebhookAvatarURL, { nickname: nick }); 457 | } 458 | return null; 459 | } 460 | 461 | // compare two strings case-insensitively 462 | // for discord mention matching 463 | static caseComp(str1, str2) { 464 | return str1.toUpperCase() === str2.toUpperCase(); 465 | } 466 | 467 | // check if the first string starts with the second case-insensitively 468 | // for discord mention matching 469 | static caseStartsWith(str1, str2) { 470 | return str1.toUpperCase().startsWith(str2.toUpperCase()); 471 | } 472 | 473 | sendToDiscord(author, channel, text) { 474 | const discordChannel = this.findDiscordChannel(channel); 475 | if (!discordChannel) return; 476 | 477 | // Do not send to Discord if this user is on the ignore list. 478 | if (this.ignoredIrcUser(author)) { 479 | return; 480 | } 481 | 482 | // Convert text formatting (bold, italics, underscore) 483 | const withFormat = formatFromIRCToDiscord(text); 484 | 485 | const patternMap = { 486 | author, 487 | nickname: author, 488 | displayUsername: author, 489 | text: withFormat, 490 | discordChannel: `#${discordChannel.name}`, 491 | ircChannel: channel 492 | }; 493 | 494 | if (this.isCommandMessage(text)) { 495 | patternMap.side = 'IRC'; 496 | logger.debug('Sending command message to Discord', `#${discordChannel.name}`, text); 497 | if (this.formatCommandPrelude) { 498 | const prelude = Bot.substitutePattern(this.formatCommandPrelude, patternMap); 499 | discordChannel.send(prelude); 500 | } 501 | discordChannel.send(text); 502 | return; 503 | } 504 | 505 | const { guild } = discordChannel; 506 | const withMentions = withFormat.replace(/@([^\s#]+)#(\d+)/g, (match, username, discriminator) => { 507 | // @username#1234 => mention 508 | // skips usernames including spaces for ease (they cannot include hashes) 509 | // checks case insensitively as Discord does 510 | const user = guild.members.cache.find(x => 511 | Bot.caseComp(x.user.username, username) 512 | && x.user.discriminator === discriminator); 513 | if (user) return user; 514 | 515 | return match; 516 | }).replace(/^([^@\s:,]+)[:,]|@([^\s]+)/g, (match, startRef, atRef) => { 517 | const reference = startRef || atRef; 518 | 519 | // this preliminary stuff is ultimately unnecessary 520 | // but might save time over later more complicated calculations 521 | // @nickname => mention, case insensitively 522 | const nickUser = guild.members.cache.find(x => 523 | x.nickname && Bot.caseComp(x.nickname, reference)); 524 | if (nickUser) return nickUser; 525 | 526 | // @username => mention, case insensitively 527 | const user = guild.members.cache.find(x => Bot.caseComp(x.user.username, reference)); 528 | if (user) return user; 529 | 530 | // @role => mention, case insensitively 531 | const role = guild.roles.cache.find(x => x.mentionable && Bot.caseComp(x.name, reference)); 532 | if (role) return role; 533 | 534 | // No match found checking the whole word. Check for partial matches now instead. 535 | // @nameextra => [mention]extra, case insensitively, as Discord does 536 | // uses the longest match, and if there are two, whichever is a match by case 537 | let matchLength = 0; 538 | let bestMatch = null; 539 | let caseMatched = false; 540 | 541 | // check if a partial match is found in reference and if so update the match values 542 | const checkMatch = function (matchString, matchValue) { 543 | // if the matchString is longer than the current best and is a match 544 | // or if it's the same length but it matches by case unlike the current match 545 | // set the best match to this matchString and matchValue 546 | if ((matchString.length > matchLength && Bot.caseStartsWith(reference, matchString)) 547 | || (matchString.length === matchLength && !caseMatched 548 | && reference.startsWith(matchString))) { 549 | matchLength = matchString.length; 550 | bestMatch = matchValue; 551 | caseMatched = reference.startsWith(matchString); 552 | } 553 | }; 554 | 555 | // check users by username and nickname 556 | guild.members.cache.forEach((member) => { 557 | checkMatch(member.user.username, member); 558 | if (bestMatch === member || !member.nickname) return; 559 | checkMatch(member.nickname, member); 560 | }); 561 | // check mentionable roles by visible name 562 | guild.roles.cache.forEach((member) => { 563 | if (!member.mentionable) return; 564 | checkMatch(member.name, member); 565 | }); 566 | 567 | // if a partial match was found, return the match and the unmatched trailing characters 568 | if (bestMatch) return bestMatch.toString() + reference.substring(matchLength); 569 | 570 | return match; 571 | }).replace(/:(\w+):/g, (match, ident) => { 572 | // :emoji: => mention, case sensitively 573 | const emoji = guild.emojis.cache.find(x => x.name === ident && x.requiresColons); 574 | if (emoji) return emoji; 575 | 576 | return match; 577 | }).replace(/#([^\s#@'!?,.]+)/g, (match, channelName) => { 578 | // channel names can't contain spaces, #, @, ', !, ?, , or . 579 | // (based on brief testing. they also can't contain some other symbols, 580 | // but these seem likely to be common around channel references) 581 | 582 | // discord matches channel names case insensitively 583 | const chan = guild.channels.cache.find(x => Bot.caseComp(x.name, channelName)); 584 | return chan || match; 585 | }); 586 | 587 | // Webhooks first 588 | const webhook = this.findWebhook(channel); 589 | if (webhook) { 590 | logger.debug('Sending message to Discord via webhook', withMentions, channel, '->', `#${discordChannel.name}`); 591 | const permissions = discordChannel.permissionsFor(this.discord.user); 592 | let canPingEveryone = false; 593 | if (permissions) { 594 | canPingEveryone = permissions.has(discord.Permissions.FLAGS.MENTION_EVERYONE); 595 | } 596 | const avatarURL = this.getDiscordAvatar(author, channel); 597 | const username = _.padEnd(author.substring(0, USERNAME_MAX_LENGTH), USERNAME_MIN_LENGTH, '_'); 598 | webhook.client.send(withMentions, { 599 | username, 600 | avatarURL, 601 | disableMentions: canPingEveryone ? 'none' : 'everyone', 602 | }).catch(logger.error); 603 | return; 604 | } 605 | 606 | patternMap.withMentions = withMentions; 607 | 608 | // Add bold formatting: 609 | // Use custom formatting from config / default formatting with bold author 610 | const withAuthor = Bot.substitutePattern(this.formatDiscord, patternMap); 611 | logger.debug('Sending message to Discord', withAuthor, channel, '->', `#${discordChannel.name}`); 612 | discordChannel.send(withAuthor); 613 | } 614 | 615 | /* Sends a message to Discord exactly as it appears */ 616 | sendExactToDiscord(channel, text) { 617 | const discordChannel = this.findDiscordChannel(channel); 618 | if (!discordChannel) return; 619 | 620 | logger.debug('Sending special message to Discord', text, channel, '->', `#${discordChannel.name}`); 621 | discordChannel.send(text); 622 | } 623 | } 624 | 625 | export default Bot; 626 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs'; 4 | import program from 'commander'; 5 | import path from 'path'; 6 | import stripJsonComments from 'strip-json-comments'; 7 | import { endsWith } from 'lodash'; 8 | import * as helpers from './helpers'; 9 | import { ConfigurationError } from './errors'; 10 | import { version } from '../package.json'; 11 | 12 | function readJSONConfig(filePath) { 13 | const configFile = fs.readFileSync(filePath, { encoding: 'utf8' }); 14 | try { 15 | return JSON.parse(stripJsonComments(configFile)); 16 | } catch (err) { 17 | if (err instanceof SyntaxError) { 18 | throw new ConfigurationError('The configuration file contains invalid JSON'); 19 | } else { 20 | throw err; 21 | } 22 | } 23 | } 24 | 25 | function run() { 26 | program 27 | .version(version) 28 | .option( 29 | '-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 | const opts = program.opts(); 35 | 36 | // If no config option is given, try to use the env variable: 37 | if (opts.config) process.env.CONFIG_FILE = opts.config; 38 | if (!process.env.CONFIG_FILE) throw new Error('Missing environment variable CONFIG_FILE'); 39 | 40 | const completePath = path.resolve(process.cwd(), process.env.CONFIG_FILE); 41 | const config = endsWith(process.env.CONFIG_FILE, '.js') ? 42 | require(completePath) : readJSONConfig(completePath); 43 | helpers.createBots(config); 44 | } 45 | 46 | export default run; 47 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | export class ConfigurationError extends Error { 2 | constructor(message) { 3 | super(message); 4 | this.name = 'ConfigurationError'; 5 | this.message = message || 'Invalid configuration file given'; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/formatting.js: -------------------------------------------------------------------------------- 1 | import ircFormatting from 'irc-formatting'; 2 | import SimpleMarkdown from 'simple-markdown'; 3 | import colors from 'irc-colors'; 4 | 5 | function mdNodeToIRC(node) { 6 | let { content } = node; 7 | if (Array.isArray(content)) content = content.map(mdNodeToIRC).join(''); 8 | switch (node.type) { 9 | case 'em': 10 | return colors.italic(content); 11 | case 'strong': 12 | return colors.bold(content); 13 | case 'u': 14 | return colors.underline(content); 15 | default: 16 | return content; 17 | } 18 | } 19 | 20 | export function formatFromDiscordToIRC(text) { 21 | const markdownAST = SimpleMarkdown.defaultInlineParse(text); 22 | return markdownAST.map(mdNodeToIRC).join(''); 23 | } 24 | 25 | export function formatFromIRCToDiscord(text) { 26 | const blocks = ircFormatting.parse(text).map(block => ({ 27 | // Consider reverse as italic, some IRC clients use that 28 | ...block, 29 | italic: block.italic || block.reverse 30 | })); 31 | let mdText = ''; 32 | 33 | for (let i = 0; i <= blocks.length; i += 1) { 34 | // Default to unstyled blocks when index out of range 35 | const block = blocks[i] || {}; 36 | const prevBlock = blocks[i - 1] || {}; 37 | 38 | // Add start markers when style turns from false to true 39 | if (!prevBlock.italic && block.italic) mdText += '*'; 40 | if (!prevBlock.bold && block.bold) mdText += '**'; 41 | if (!prevBlock.underline && block.underline) mdText += '__'; 42 | 43 | // Add end markers when style turns from true to false 44 | // (and apply in reverse order to maintain nesting) 45 | if (prevBlock.underline && !block.underline) mdText += '__'; 46 | if (prevBlock.bold && !block.bold) mdText += '**'; 47 | if (prevBlock.italic && !block.italic) mdText += '*'; 48 | 49 | mdText += block.text || ''; 50 | } 51 | 52 | return mdText; 53 | } 54 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { createBots } from './helpers'; 4 | 5 | /* istanbul ignore next */ 6 | if (!module.parent) { 7 | require('./cli').default(); 8 | } 9 | 10 | export default createBots; 11 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | import winston, { format } from 'winston'; 2 | import { inspect } from 'util'; 3 | 4 | function simpleInspect(value) { 5 | if (typeof value === 'string') return value; 6 | return inspect(value, { depth: null }); 7 | } 8 | 9 | function formatter(info) { 10 | const splat = info[Symbol.for('splat')] || []; 11 | const stringifiedRest = splat.length > 0 ? ` ${splat.map(simpleInspect).join(' ')}` : ''; 12 | 13 | const padding = (info.padding && info.padding[info.level]) || ''; 14 | return `${info.timestamp} ${info.level}:${padding} ${info.message}${stringifiedRest}`; 15 | } 16 | 17 | const logger = winston.createLogger({ 18 | transports: [new winston.transports.Console()], 19 | level: process.env.NODE_ENV === 'development' ? 'debug' : 'info', 20 | format: format.combine( 21 | format.colorize(), 22 | format.timestamp(), 23 | format.printf(formatter), 24 | ) 25 | }); 26 | 27 | export default logger; 28 | -------------------------------------------------------------------------------- /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": "discord-irc", 3 | "version": "2.9.0", 4 | "description": "Connects IRC and Discord channels by sending messages back and forth.", 5 | "keywords": [ 6 | "discord", 7 | "irc", 8 | "gateway", 9 | "bot", 10 | "discord-irc", 11 | "reactiflux" 12 | ], 13 | "engines": { 14 | "node": ">=12.0.0" 15 | }, 16 | "main": "dist/index.js", 17 | "bin": "dist/index.js", 18 | "repository": { 19 | "type": "git", 20 | "url": "git@github.com:reactiflux/discord-irc.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/reactiflux/discord-irc/issues" 24 | }, 25 | "scripts": { 26 | "start": "node dist/index.js", 27 | "build": "babel lib --out-dir dist", 28 | "prepare": "npm run build", 29 | "lint": "eslint . --ignore-path .gitignore", 30 | "coverage": "cross-env NODE_ENV=test nyc --require @babel/register mocha -- 'test/*.test.js'", 31 | "save-coverage": "nyc report --reporter=lcov", 32 | "test": "npm run lint && npm run coverage" 33 | }, 34 | "author": { 35 | "name": "Reactiflux" 36 | }, 37 | "license": "MIT", 38 | "dependencies": { 39 | "commander": "^7.2.0", 40 | "discord.js": "^12.5.3", 41 | "irc-colors": "1.5.0", 42 | "irc-formatting": "1.0.0-rc3", 43 | "irc-upd": "0.11.0", 44 | "lodash": "^4.17.21", 45 | "simple-markdown": "^0.7.3", 46 | "strip-json-comments": "^3.1.1", 47 | "winston": "^3.3.3" 48 | }, 49 | "devDependencies": { 50 | "@babel/cli": "^7.13.16", 51 | "@babel/core": "^7.13.16", 52 | "@babel/eslint-parser": "^7.13.14", 53 | "@babel/preset-env": "^7.13.15", 54 | "@babel/register": "^7.13.16", 55 | "@istanbuljs/nyc-config-babel": "^3.0.0", 56 | "babel-plugin-istanbul": "^6.0.0", 57 | "chai": "^4.3.4", 58 | "coveralls": "^3.1.0", 59 | "cross-env": "^7.0.3", 60 | "eslint": "^7.25.0", 61 | "eslint-config-airbnb-base": "^14.2.1", 62 | "eslint-plugin-import": "^2.22.1", 63 | "mocha": "^8.3.2", 64 | "nyc": "^15.1.0", 65 | "sinon": "^10.0.0", 66 | "sinon-chai": "^3.6.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/bot-events.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions, prefer-arrow-callback */ 2 | import chai from 'chai'; 3 | import sinonChai from 'sinon-chai'; 4 | import sinon from 'sinon'; 5 | import irc from 'irc-upd'; 6 | import discord from 'discord.js'; 7 | import Bot from '../lib/bot'; 8 | import logger from '../lib/logger'; 9 | import createDiscordStub from './stubs/discord-stub'; 10 | import createWebhookStub from './stubs/webhook-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('Bot Events', function () { 18 | const sandbox = sinon.createSandbox({ 19 | useFakeTimers: false, 20 | useFakeServer: false 21 | }); 22 | 23 | const createBot = (optConfig = null) => { 24 | const useConfig = optConfig || config; 25 | const bot = new Bot(useConfig); 26 | bot.sendToIRC = sandbox.stub(); 27 | bot.sendToDiscord = sandbox.stub(); 28 | bot.sendExactToDiscord = sandbox.stub(); 29 | return bot; 30 | }; 31 | 32 | beforeEach(function () { 33 | this.infoSpy = sandbox.stub(logger, 'info'); 34 | this.debugSpy = sandbox.stub(logger, 'debug'); 35 | this.warnSpy = sandbox.stub(logger, 'warn'); 36 | this.errorSpy = sandbox.stub(logger, 'error'); 37 | this.sendStub = sandbox.stub(); 38 | irc.Client = ClientStub; 39 | discord.Client = createDiscordStub(this.sendStub); 40 | discord.WebhookClient = createWebhookStub(this.sendStub); 41 | ClientStub.prototype.send = sandbox.stub(); 42 | ClientStub.prototype.join = sandbox.stub(); 43 | this.bot = createBot(); 44 | this.bot.connect(); 45 | }); 46 | 47 | afterEach(function () { 48 | this.bot.disconnect(); 49 | sandbox.restore(); 50 | }); 51 | 52 | it('should log on discord ready event', function () { 53 | this.bot.discord.emit('ready'); 54 | this.infoSpy.should.have.been.calledWithExactly('Connected to Discord'); 55 | }); 56 | 57 | it('should log on irc registered event', function () { 58 | const message = 'registered'; 59 | this.bot.ircClient.emit('registered', message); 60 | this.infoSpy.should.have.been.calledWithExactly('Connected to IRC'); 61 | this.debugSpy.should.have.been.calledWithExactly('Registered event: ', message); 62 | }); 63 | 64 | it('should try to send autoSendCommands on registered IRC event', function () { 65 | this.bot.ircClient.emit('registered'); 66 | ClientStub.prototype.send.should.have.been.calledTwice; 67 | ClientStub.prototype.send.getCall(0) 68 | .args.should.deep.equal(config.autoSendCommands[0]); 69 | ClientStub.prototype.send.getCall(1) 70 | .args.should.deep.equal(config.autoSendCommands[1]); 71 | }); 72 | 73 | it('should error log on error events', function () { 74 | const discordError = new Error('discord'); 75 | const ircError = new Error('irc'); 76 | this.bot.discord.emit('error', discordError); 77 | this.bot.ircClient.emit('error', ircError); 78 | this.errorSpy.getCall(0).args[0].should.equal('Received error event from Discord'); 79 | this.errorSpy.getCall(0).args[1].should.equal(discordError); 80 | this.errorSpy.getCall(1).args[0].should.equal('Received error event from IRC'); 81 | this.errorSpy.getCall(1).args[1].should.equal(ircError); 82 | }); 83 | 84 | it('should warn log on warn events from discord', function () { 85 | const discordError = new Error('discord'); 86 | this.bot.discord.emit('warn', discordError); 87 | const [message, error] = this.warnSpy.firstCall.args; 88 | message.should.equal('Received warn event from Discord'); 89 | error.should.equal(discordError); 90 | }); 91 | 92 | it('should send messages to irc if correct', function () { 93 | const message = { 94 | type: 'message' 95 | }; 96 | 97 | this.bot.discord.emit('message', message); 98 | this.bot.sendToIRC.should.have.been.calledWithExactly(message); 99 | }); 100 | 101 | it('should send messages to discord', function () { 102 | const channel = '#channel'; 103 | const author = 'user'; 104 | const text = 'hi'; 105 | this.bot.ircClient.emit('message', author, channel, text); 106 | this.bot.sendToDiscord.should.have.been.calledWithExactly(author, channel, text); 107 | }); 108 | 109 | it('should send notices to discord', function () { 110 | const channel = '#channel'; 111 | const author = 'user'; 112 | const text = 'hi'; 113 | const formattedText = `*${text}*`; 114 | this.bot.ircClient.emit('notice', author, channel, text); 115 | this.bot.sendToDiscord.should.have.been.calledWithExactly(author, channel, formattedText); 116 | }); 117 | 118 | it('should not send name change event to discord', function () { 119 | const channel = '#channel'; 120 | const oldnick = 'user1'; 121 | const newnick = 'user2'; 122 | this.bot.ircClient.emit('nick', oldnick, newnick, [channel]); 123 | this.bot.sendExactToDiscord.should.not.have.been.called; 124 | }); 125 | 126 | it('should send name change event to discord', function () { 127 | const channel1 = '#channel1'; 128 | const channel2 = '#channel2'; 129 | const channel3 = '#channel3'; 130 | const oldNick = 'user1'; 131 | const newNick = 'user2'; 132 | const user3 = 'user3'; 133 | const bot = createBot({ ...config, ircStatusNotices: true }); 134 | const staticChannel = new Set([bot.nickname, user3]); 135 | bot.connect(); 136 | bot.ircClient.emit('names', channel1, { [bot.nickname]: '', [oldNick]: '' }); 137 | bot.ircClient.emit('names', channel2, { [bot.nickname]: '', [user3]: '' }); 138 | const channelNicksPre = new Set([bot.nickname, oldNick]); 139 | bot.channelUsers.should.deep.equal({ '#channel1': channelNicksPre, '#channel2': staticChannel }); 140 | const formattedText = `*${oldNick}* is now known as ${newNick}`; 141 | const channelNicksAfter = new Set([bot.nickname, newNick]); 142 | bot.ircClient.emit('nick', oldNick, newNick, [channel1, channel2, channel3]); 143 | bot.sendExactToDiscord.should.have.been.calledWithExactly(channel1, formattedText); 144 | bot.channelUsers.should.deep.equal({ '#channel1': channelNicksAfter, '#channel2': staticChannel }); 145 | }); 146 | 147 | it('should send actions to discord', function () { 148 | const channel = '#channel'; 149 | const author = 'user'; 150 | const text = 'hi'; 151 | const formattedText = '_hi_'; 152 | const message = {}; 153 | this.bot.ircClient.emit('action', author, channel, text, message); 154 | this.bot.sendToDiscord.should.have.been.calledWithExactly(author, channel, formattedText); 155 | }); 156 | 157 | it('should keep track of users through names event when irc status notices enabled', function () { 158 | const bot = createBot({ ...config, ircStatusNotices: true }); 159 | bot.connect(); 160 | bot.channelUsers.should.be.an('object'); 161 | const channel = '#channel'; 162 | // nick => '' means the user is not a special user 163 | const nicks = { 164 | [bot.nickname]: '', user: '', user2: '@', user3: '+' 165 | }; 166 | bot.ircClient.emit('names', channel, nicks); 167 | const channelNicks = new Set([bot.nickname, 'user', 'user2', 'user3']); 168 | bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); 169 | }); 170 | 171 | it('should lowercase the channelUsers mapping', function () { 172 | const bot = createBot({ ...config, ircStatusNotices: true }); 173 | bot.connect(); 174 | const channel = '#channelName'; 175 | const nicks = { [bot.nickname]: '' }; 176 | bot.ircClient.emit('names', channel, nicks); 177 | const channelNicks = new Set([bot.nickname]); 178 | bot.channelUsers.should.deep.equal({ '#channelname': channelNicks }); 179 | }); 180 | 181 | it('should send join messages to discord when config enabled', function () { 182 | const bot = createBot({ ...config, ircStatusNotices: true }); 183 | bot.connect(); 184 | const channel = '#channel'; 185 | bot.ircClient.emit('names', channel, { [bot.nickname]: '' }); 186 | const nick = 'user'; 187 | const text = `*${nick}* has joined the channel`; 188 | bot.ircClient.emit('join', channel, nick); 189 | bot.sendExactToDiscord.should.have.been.calledWithExactly(channel, text); 190 | const channelNicks = new Set([bot.nickname, nick]); 191 | bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); 192 | }); 193 | 194 | it('should not announce itself joining by default', function () { 195 | const bot = createBot({ ...config, ircStatusNotices: true }); 196 | bot.connect(); 197 | const channel = '#channel'; 198 | bot.ircClient.emit('names', channel, { [bot.nickname]: '' }); 199 | const nick = bot.nickname; 200 | bot.ircClient.emit('join', channel, nick); 201 | bot.sendExactToDiscord.should.not.have.been.called; 202 | const channelNicks = new Set([bot.nickname]); 203 | bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); 204 | }); 205 | 206 | it('should announce the bot itself when config enabled', function () { 207 | // self-join is announced before names (which includes own nick) 208 | // hence don't trigger a names and don't expect anything of bot.channelUsers 209 | const bot = createBot({ ...config, ircStatusNotices: true, announceSelfJoin: true }); 210 | bot.connect(); 211 | const channel = '#channel'; 212 | const nick = this.bot.nickname; 213 | const text = `*${nick}* has joined the channel`; 214 | bot.ircClient.emit('join', channel, nick); 215 | bot.sendExactToDiscord.should.have.been.calledWithExactly(channel, text); 216 | }); 217 | 218 | it('should send part messages to discord when config enabled', function () { 219 | const bot = createBot({ ...config, ircStatusNotices: true }); 220 | bot.connect(); 221 | const channel = '#channel'; 222 | const nick = 'user'; 223 | bot.ircClient.emit('names', channel, { [bot.nickname]: '', [nick]: '' }); 224 | const originalNicks = new Set([bot.nickname, nick]); 225 | bot.channelUsers.should.deep.equal({ '#channel': originalNicks }); 226 | const reason = 'Leaving'; 227 | const text = `*${nick}* has left the channel (${reason})`; 228 | bot.ircClient.emit('part', channel, nick, reason); 229 | bot.sendExactToDiscord.should.have.been.calledWithExactly(channel, text); 230 | // it should remove the nickname from the channelUsers list 231 | const channelNicks = new Set([bot.nickname]); 232 | bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); 233 | }); 234 | 235 | it('should not announce itself leaving a channel', function () { 236 | const bot = createBot({ ...config, ircStatusNotices: true }); 237 | bot.connect(); 238 | const channel = '#channel'; 239 | bot.ircClient.emit('names', channel, { [bot.nickname]: '', user: '' }); 240 | const originalNicks = new Set([bot.nickname, 'user']); 241 | bot.channelUsers.should.deep.equal({ '#channel': originalNicks }); 242 | const reason = 'Leaving'; 243 | bot.ircClient.emit('part', channel, bot.nickname, reason); 244 | bot.sendExactToDiscord.should.not.have.been.called; 245 | // it should remove the nickname from the channelUsers list 246 | bot.channelUsers.should.deep.equal({}); 247 | }); 248 | 249 | it('should only send quit messages to discord for channels the user is tracked in', function () { 250 | const bot = createBot({ ...config, ircStatusNotices: true }); 251 | bot.connect(); 252 | const channel1 = '#channel1'; 253 | const channel2 = '#channel2'; 254 | const channel3 = '#channel3'; 255 | const nick = 'user'; 256 | bot.ircClient.emit('names', channel1, { [bot.nickname]: '', [nick]: '' }); 257 | bot.ircClient.emit('names', channel2, { [bot.nickname]: '' }); 258 | bot.ircClient.emit('names', channel3, { [bot.nickname]: '', [nick]: '' }); 259 | const reason = 'Quit: Leaving'; 260 | const text = `*${nick}* has quit (${reason})`; 261 | // send quit message for all channels on server, as the node-irc library does 262 | bot.ircClient.emit('quit', nick, reason, [channel1, channel2, channel3]); 263 | bot.sendExactToDiscord.should.have.been.calledTwice; 264 | bot.sendExactToDiscord.getCall(0).args.should.deep.equal([channel1, text]); 265 | bot.sendExactToDiscord.getCall(1).args.should.deep.equal([channel3, text]); 266 | }); 267 | 268 | it('should not crash with join/part/quit messages and weird channel casing', function () { 269 | const bot = createBot({ ...config, ircStatusNotices: true }); 270 | bot.connect(); 271 | 272 | function wrap() { 273 | const nick = 'user'; 274 | const reason = 'Leaving'; 275 | bot.ircClient.emit('names', '#Channel', { [bot.nickname]: '' }); 276 | bot.ircClient.emit('join', '#cHannel', nick); 277 | bot.ircClient.emit('part', '#chAnnel', nick, reason); 278 | bot.ircClient.emit('join', '#chaNnel', nick); 279 | bot.ircClient.emit('quit', nick, reason, ['#chanNel']); 280 | } 281 | (wrap).should.not.throw(); 282 | }); 283 | 284 | it('should be possible to disable join/part/quit messages', function () { 285 | const bot = createBot({ ...config, ircStatusNotices: false }); 286 | bot.connect(); 287 | const channel = '#channel'; 288 | const nick = 'user'; 289 | const reason = 'Leaving'; 290 | 291 | bot.ircClient.emit('names', channel, { [bot.nickname]: '' }); 292 | bot.ircClient.emit('join', channel, nick); 293 | bot.ircClient.emit('part', channel, nick, reason); 294 | bot.ircClient.emit('join', channel, nick); 295 | bot.ircClient.emit('quit', nick, reason, [channel]); 296 | bot.sendExactToDiscord.should.not.have.been.called; 297 | }); 298 | 299 | it('should warn if it receives a part/quit before a names event', function () { 300 | const bot = createBot({ ...config, ircStatusNotices: true }); 301 | bot.connect(); 302 | const channel = '#channel'; 303 | const reason = 'Leaving'; 304 | 305 | bot.ircClient.emit('part', channel, 'user1', reason); 306 | bot.ircClient.emit('quit', 'user2', reason, [channel]); 307 | this.warnSpy.should.have.been.calledTwice; 308 | this.warnSpy.getCall(0).args.should.deep.equal([`No channelUsers found for ${channel} when user1 parted.`]); 309 | this.warnSpy.getCall(1).args.should.deep.equal([`No channelUsers found for ${channel} when user2 quit, ignoring.`]); 310 | }); 311 | 312 | it('should not crash if it uses a different name from config', function () { 313 | // this can happen when a user with the same name is already connected 314 | const bot = createBot({ ...config, nickname: 'testbot' }); 315 | bot.connect(); 316 | const newName = 'testbot1'; 317 | bot.ircClient.nick = newName; 318 | function wrap() { 319 | bot.ircClient.emit('join', '#channel', newName); 320 | } 321 | (wrap).should.not.throw; 322 | }); 323 | 324 | it('should not listen to discord debug messages in production', function () { 325 | logger.level = 'info'; 326 | const bot = createBot(); 327 | bot.connect(); 328 | const listeners = bot.discord.listeners('debug'); 329 | listeners.length.should.equal(0); 330 | }); 331 | 332 | it('should listen to discord debug messages in development', function () { 333 | logger.level = 'debug'; 334 | const bot = createBot(); 335 | bot.connect(); 336 | const listeners = bot.discord.listeners('debug'); 337 | listeners.length.should.equal(1); 338 | }); 339 | 340 | it('should join channels when invited', function () { 341 | const channel = '#irc'; 342 | const author = 'user'; 343 | this.bot.ircClient.emit('invite', channel, author); 344 | const firstCall = this.debugSpy.getCall(1); 345 | firstCall.args[0].should.equal('Received invite:'); 346 | firstCall.args[1].should.equal(channel); 347 | firstCall.args[2].should.equal(author); 348 | 349 | ClientStub.prototype.join.should.have.been.calledWith(channel); 350 | const secondCall = this.debugSpy.getCall(2); 351 | secondCall.args[0].should.equal('Joining channel:'); 352 | secondCall.args[1].should.equal(channel); 353 | }); 354 | 355 | it('should not join channels that aren\'t in the channel mapping', function () { 356 | const channel = '#wrong'; 357 | const author = 'user'; 358 | this.bot.ircClient.emit('invite', channel, author); 359 | const firstCall = this.debugSpy.getCall(1); 360 | firstCall.args[0].should.equal('Received invite:'); 361 | firstCall.args[1].should.equal(channel); 362 | firstCall.args[2].should.equal(author); 363 | 364 | ClientStub.prototype.join.should.not.have.been.called; 365 | const secondCall = this.debugSpy.getCall(2); 366 | secondCall.args[0].should.equal('Channel not found in config, not joining:'); 367 | secondCall.args[1].should.equal(channel); 368 | }); 369 | }); 370 | -------------------------------------------------------------------------------- /test/bot.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions, prefer-arrow-callback */ 2 | import chai from 'chai'; 3 | import sinon from 'sinon'; 4 | import sinonChai from 'sinon-chai'; 5 | import irc from 'irc-upd'; 6 | import discord from 'discord.js'; 7 | import logger from '../lib/logger'; 8 | import Bot from '../lib/bot'; 9 | import createDiscordStub from './stubs/discord-stub'; 10 | import ClientStub from './stubs/irc-client-stub'; 11 | import createWebhookStub from './stubs/webhook-stub'; 12 | import config from './fixtures/single-test-config.json'; 13 | import configMsgFormatDefault from './fixtures/msg-formats-default.json'; 14 | 15 | const { expect } = chai; 16 | chai.should(); 17 | chai.use(sinonChai); 18 | 19 | describe('Bot', function () { 20 | const sandbox = sinon.createSandbox({ 21 | useFakeTimers: false, 22 | useFakeServer: false 23 | }); 24 | 25 | beforeEach(function () { 26 | this.infoSpy = sandbox.stub(logger, 'info'); 27 | this.debugSpy = sandbox.stub(logger, 'debug'); 28 | this.errorSpy = sandbox.stub(logger, 'error'); 29 | this.sendStub = sandbox.stub(); 30 | 31 | irc.Client = ClientStub; 32 | discord.Client = createDiscordStub(this.sendStub); 33 | 34 | ClientStub.prototype.say = sandbox.stub(); 35 | ClientStub.prototype.send = sandbox.stub(); 36 | ClientStub.prototype.join = sandbox.stub(); 37 | this.sendWebhookMessageStub = sandbox.stub(); 38 | discord.WebhookClient = createWebhookStub(this.sendWebhookMessageStub); 39 | 40 | this.setCustomBot = conf => { 41 | this.bot = new Bot(conf); 42 | this.guild = this.bot.discord.guilds.cache.first(); 43 | this.bot.connect(); 44 | }; 45 | 46 | this.setCustomBot(config); 47 | 48 | // modified variants of https://github.com/discordjs/discord.js/blob/stable/src/client/ClientDataManager.js 49 | // (for easier stubbing) 50 | this.addUser = function (user, member = null) { 51 | const userObj = new discord.User(this.bot.discord, user); 52 | // also set guild members 53 | const guildMember = { ...(member || user), user: userObj }; 54 | guildMember.nick = guildMember.nickname; // nick => nickname in Discord API 55 | const memberObj = new discord.GuildMember(this.bot.discord, guildMember, this.guild); 56 | this.guild.members.cache.set(userObj.id, memberObj); 57 | this.bot.discord.users.cache.set(userObj.id, userObj); 58 | return memberObj; 59 | }; 60 | 61 | this.addRole = function (role) { 62 | const roleObj = new discord.Role(this.bot.discord, role, this.guild); 63 | this.guild.roles.cache.set(roleObj.id, roleObj); 64 | return roleObj; 65 | }; 66 | 67 | this.addEmoji = function (emoji) { 68 | const emojiObj = new discord.GuildEmoji(this.bot.discord, emoji, this.guild); 69 | this.guild.emojis.cache.set(emojiObj.id, emojiObj); 70 | return emojiObj; 71 | }; 72 | }); 73 | 74 | afterEach(function () { 75 | sandbox.restore(); 76 | }); 77 | 78 | const createAttachments = (url) => { 79 | const attachments = new discord.Collection(); 80 | attachments.set(1, { url }); 81 | return attachments; 82 | }; 83 | 84 | it('should invert the channel mapping', function () { 85 | this.bot.invertedMapping['#irc'].should.equal('#discord'); 86 | }); 87 | 88 | it('should send correctly formatted messages to discord', function () { 89 | const username = 'testuser'; 90 | const text = 'test message'; 91 | const formatted = `**<${username}>** ${text}`; 92 | this.bot.sendToDiscord(username, '#irc', text); 93 | this.sendStub.should.have.been.calledWith(formatted); 94 | }); 95 | 96 | it('should lowercase channel names before sending to discord', function () { 97 | const username = 'testuser'; 98 | const text = 'test message'; 99 | const formatted = `**<${username}>** ${text}`; 100 | this.bot.sendToDiscord(username, '#IRC', text); 101 | this.sendStub.should.have.been.calledWith(formatted); 102 | }); 103 | 104 | it( 105 | 'should not send messages to discord if the channel isn\'t in the channel mapping', 106 | function () { 107 | this.bot.sendToDiscord('user', '#no-irc', 'message'); 108 | this.sendStub.should.not.have.been.called; 109 | } 110 | ); 111 | 112 | it( 113 | 'should not send messages to discord if it isn\'t in the channel', 114 | function () { 115 | this.bot.sendToDiscord('user', '#otherirc', 'message'); 116 | this.sendStub.should.not.have.been.called; 117 | } 118 | ); 119 | 120 | it('should send to a discord channel ID appropriately', function () { 121 | const username = 'testuser'; 122 | const text = 'test message'; 123 | const formatted = `**<${username}>** ${text}`; 124 | this.bot.sendToDiscord(username, '#channelforid', text); 125 | this.sendStub.should.have.been.calledWith(formatted); 126 | }); 127 | 128 | it( 129 | 'should not send special messages to discord if the channel isn\'t in the channel mapping', 130 | function () { 131 | this.bot.sendExactToDiscord('#no-irc', 'message'); 132 | this.sendStub.should.not.have.been.called; 133 | } 134 | ); 135 | 136 | it( 137 | 'should not send special messages to discord if it isn\'t in the channel', 138 | function () { 139 | this.bot.sendExactToDiscord('#otherirc', 'message'); 140 | this.sendStub.should.not.have.been.called; 141 | } 142 | ); 143 | 144 | it( 145 | 'should send special messages to discord', 146 | function () { 147 | this.bot.sendExactToDiscord('#irc', 'message'); 148 | this.sendStub.should.have.been.calledWith('message'); 149 | this.debugSpy.should.have.been.calledWith('Sending special message to Discord', 'message', '#irc', '->', '#discord'); 150 | } 151 | ); 152 | 153 | it('should not color irc messages if the option is disabled', function () { 154 | const text = 'testmessage'; 155 | const newConfig = { ...config, ircNickColor: false }; 156 | this.setCustomBot(newConfig); 157 | const message = { 158 | content: text, 159 | mentions: { users: [] }, 160 | channel: { 161 | name: 'discord' 162 | }, 163 | author: { 164 | username: 'otherauthor', 165 | id: 'not bot id' 166 | }, 167 | guild: this.guild 168 | }; 169 | 170 | this.bot.sendToIRC(message); 171 | const expected = `<${message.author.username}> ${text}`; 172 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 173 | }); 174 | 175 | it('should only use message color defined in config', function () { 176 | const text = 'testmessage'; 177 | const newConfig = { ...config, ircNickColors: ['orange'] }; 178 | this.setCustomBot(newConfig); 179 | const message = { 180 | content: text, 181 | mentions: { users: [] }, 182 | channel: { 183 | name: 'discord' 184 | }, 185 | author: { 186 | username: 'otherauthor', 187 | id: 'not bot id' 188 | }, 189 | guild: this.guild 190 | }; 191 | 192 | this.bot.sendToIRC(message); 193 | const expected = `<\u000307${message.author.username}\u000f> ${text}`; 194 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 195 | }); 196 | 197 | it('should send correct messages to irc', function () { 198 | const text = 'testmessage'; 199 | const message = { 200 | content: text, 201 | mentions: { users: [] }, 202 | channel: { 203 | name: 'discord' 204 | }, 205 | author: { 206 | username: 'otherauthor', 207 | id: 'not bot id' 208 | }, 209 | guild: this.guild 210 | }; 211 | 212 | this.bot.sendToIRC(message); 213 | // Wrap in colors: 214 | const expected = `<\u000304${message.author.username}\u000f> ${text}`; 215 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 216 | }); 217 | 218 | it('should send to IRC channel mapped by discord channel ID if available', function () { 219 | const text = 'test message'; 220 | const message = { 221 | content: text, 222 | mentions: { users: [] }, 223 | channel: { 224 | id: 1234, 225 | name: 'namenotinmapping' 226 | }, 227 | author: { 228 | username: 'test', 229 | id: 'not bot id' 230 | }, 231 | guild: this.guild 232 | }; 233 | 234 | // Wrap it in colors: 235 | const expected = `<\u000312${message.author.username}\u000f> test message`; 236 | this.bot.sendToIRC(message); 237 | ClientStub.prototype.say 238 | .should.have.been.calledWith('#channelforid', expected); 239 | }); 240 | 241 | it('should send to IRC channel mapped by discord channel name if ID not available', function () { 242 | const text = 'test message'; 243 | const message = { 244 | content: text, 245 | mentions: { users: [] }, 246 | channel: { 247 | id: 1235, 248 | name: 'discord' 249 | }, 250 | author: { 251 | username: 'test', 252 | id: 'not bot id' 253 | }, 254 | guild: this.guild 255 | }; 256 | 257 | // Wrap it in colors: 258 | const expected = `<\u000312${message.author.username}\u000f> test message`; 259 | this.bot.sendToIRC(message); 260 | ClientStub.prototype.say 261 | .should.have.been.calledWith('#irc', expected); 262 | }); 263 | 264 | it('should send attachment URL to IRC', function () { 265 | const attachmentUrl = 'https://image/url.jpg'; 266 | const message = { 267 | content: '', 268 | mentions: { users: [] }, 269 | attachments: createAttachments(attachmentUrl), 270 | channel: { 271 | name: 'discord' 272 | }, 273 | author: { 274 | username: 'otherauthor', 275 | id: 'not bot id' 276 | }, 277 | guild: this.guild 278 | }; 279 | 280 | this.bot.sendToIRC(message); 281 | const expected = `<\u000304${message.author.username}\u000f> ${attachmentUrl}`; 282 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 283 | }); 284 | 285 | it('should send text message and attachment URL to IRC if both exist', function () { 286 | const text = 'Look at this cute cat picture!'; 287 | const attachmentUrl = 'https://image/url.jpg'; 288 | const message = { 289 | content: text, 290 | attachments: createAttachments(attachmentUrl), 291 | mentions: { users: [] }, 292 | channel: { 293 | name: 'discord' 294 | }, 295 | author: { 296 | username: 'otherauthor', 297 | id: 'not bot id' 298 | }, 299 | guild: this.guild 300 | }; 301 | 302 | this.bot.sendToIRC(message); 303 | 304 | ClientStub.prototype.say.should.have.been.calledWith( 305 | '#irc', 306 | `<\u000304${message.author.username}\u000f> ${text}` 307 | ); 308 | 309 | const expected = `<\u000304${message.author.username}\u000f> ${attachmentUrl}`; 310 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 311 | }); 312 | 313 | it('should not send an empty text message with an attachment to IRC', function () { 314 | const message = { 315 | content: '', 316 | attachments: createAttachments('https://image/url.jpg'), 317 | mentions: { users: [] }, 318 | channel: { 319 | name: 'discord' 320 | }, 321 | author: { 322 | username: 'otherauthor', 323 | id: 'not bot id' 324 | }, 325 | guild: this.guild 326 | }; 327 | 328 | this.bot.sendToIRC(message); 329 | 330 | ClientStub.prototype.say.should.have.been.calledOnce; 331 | }); 332 | 333 | it('should not send its own messages to irc', function () { 334 | const message = { 335 | author: { 336 | username: 'bot', 337 | id: this.bot.discord.user.id 338 | }, 339 | guild: this.guild 340 | }; 341 | 342 | this.bot.sendToIRC(message); 343 | ClientStub.prototype.say.should.not.have.been.called; 344 | }); 345 | 346 | it( 347 | 'should not send messages to irc if the channel isn\'t in the channel mapping', 348 | function () { 349 | const message = { 350 | channel: { 351 | name: 'wrongdiscord' 352 | }, 353 | author: { 354 | username: 'otherauthor', 355 | id: 'not bot id' 356 | }, 357 | guild: this.guild 358 | }; 359 | 360 | this.bot.sendToIRC(message); 361 | ClientStub.prototype.say.should.not.have.been.called; 362 | } 363 | ); 364 | 365 | it('should break mentions when parallelPingFix is enabled', function () { 366 | const newConfig = { ...config, parallelPingFix: true }; 367 | this.setCustomBot(newConfig); 368 | 369 | const text = 'testmessage'; 370 | const username = 'otherauthor'; 371 | const brokenNickname = 'o\u200Btherauthor'; 372 | const message = { 373 | content: text, 374 | mentions: { users: [] }, 375 | channel: { 376 | name: 'discord' 377 | }, 378 | author: { 379 | username, 380 | id: 'not bot id' 381 | }, 382 | guild: this.guild 383 | }; 384 | 385 | this.bot.sendToIRC(message); 386 | // Wrap in colors: 387 | const expected = `<\u000304${brokenNickname}\u000f> ${text}`; 388 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 389 | }); 390 | 391 | it('should parse text from discord when sending messages', function () { 392 | const text = '<#1234>'; 393 | const message = { 394 | content: text, 395 | mentions: { users: [] }, 396 | channel: { 397 | name: 'discord' 398 | }, 399 | author: { 400 | username: 'test', 401 | id: 'not bot id' 402 | }, 403 | guild: this.guild 404 | }; 405 | 406 | // Wrap it in colors: 407 | const expected = `<\u000312${message.author.username}\u000f> #${message.channel.name}`; 408 | this.bot.sendToIRC(message); 409 | ClientStub.prototype.say 410 | .should.have.been.calledWith('#irc', expected); 411 | }); 412 | 413 | it('should use #deleted-channel when referenced channel fails to exist', function () { 414 | const text = '<#1235>'; 415 | const message = { 416 | content: text, 417 | mentions: { users: [] }, 418 | channel: { 419 | name: 'discord' 420 | }, 421 | author: { 422 | username: 'test', 423 | id: 'not bot id' 424 | }, 425 | guild: this.guild 426 | }; 427 | 428 | // Discord displays "#deleted-channel" if channel doesn't exist (e.g. <#1235>) 429 | // Wrap it in colors: 430 | const expected = `<\u000312${message.author.username}\u000f> #deleted-channel`; 431 | this.bot.sendToIRC(message); 432 | ClientStub.prototype.say 433 | .should.have.been.calledWith('#irc', expected); 434 | }); 435 | 436 | it('should convert user mentions from discord', function () { 437 | const message = { 438 | mentions: { 439 | users: [{ 440 | id: 123, 441 | username: 'testuser' 442 | }], 443 | }, 444 | content: '<@123> hi', 445 | guild: this.guild 446 | }; 447 | 448 | this.bot.parseText(message).should.equal('@testuser hi'); 449 | }); 450 | 451 | it('should convert user nickname mentions from discord', function () { 452 | const message = { 453 | mentions: { 454 | users: [{ 455 | id: 123, 456 | username: 'testuser' 457 | }], 458 | }, 459 | content: '<@!123> hi', 460 | guild: this.guild 461 | }; 462 | 463 | this.bot.parseText(message).should.equal('@testuser hi'); 464 | }); 465 | 466 | it('should convert twitch emotes from discord', function () { 467 | const message = { 468 | mentions: { users: [] }, 469 | content: '<:SCGWat:230473833046343680>' 470 | }; 471 | 472 | this.bot.parseText(message).should.equal(':SCGWat:'); 473 | }); 474 | 475 | it('should convert animated emoji from discord', function () { 476 | const message = { 477 | mentions: { users: [] }, 478 | content: '' 479 | }; 480 | 481 | this.bot.parseText(message).should.equal(':in_love:'); 482 | }); 483 | 484 | it('should convert user at-mentions from IRC', function () { 485 | const testUser = this.addUser({ username: 'testuser', id: '123' }); 486 | 487 | const username = 'ircuser'; 488 | const text = 'Hello, @testuser!'; 489 | const expected = `**<${username}>** Hello, <@${testUser.id}>!`; 490 | 491 | this.bot.sendToDiscord(username, '#irc', text); 492 | this.sendStub.should.have.been.calledWith(expected); 493 | }); 494 | 495 | it('should convert user colon-initial mentions from IRC', function () { 496 | const testUser = this.addUser({ username: 'testuser', id: '123' }); 497 | 498 | const username = 'ircuser'; 499 | const text = 'testuser: hello!'; 500 | const expected = `**<${username}>** <@${testUser.id}> hello!`; 501 | 502 | this.bot.sendToDiscord(username, '#irc', text); 503 | this.sendStub.should.have.been.calledWith(expected); 504 | }); 505 | 506 | it('should convert user comma-initial mentions from IRC', function () { 507 | const testUser = this.addUser({ username: 'testuser', id: '123' }); 508 | 509 | const username = 'ircuser'; 510 | const text = 'testuser, hello!'; 511 | const expected = `**<${username}>** <@${testUser.id}> hello!`; 512 | 513 | this.bot.sendToDiscord(username, '#irc', text); 514 | this.sendStub.should.have.been.calledWith(expected); 515 | }); 516 | 517 | it('should not convert user initial mentions from IRC mid-message', function () { 518 | this.addUser({ username: 'testuser', id: '123' }); 519 | 520 | const username = 'ircuser'; 521 | const text = 'Hi there testuser, how goes?'; 522 | const expected = `**<${username}>** Hi there testuser, how goes?`; 523 | 524 | this.bot.sendToDiscord(username, '#irc', text); 525 | this.sendStub.should.have.been.calledWith(expected); 526 | }); 527 | 528 | it('should not convert user at-mentions from IRC if such user does not exist', function () { 529 | const username = 'ircuser'; 530 | const text = 'See you there @5pm'; 531 | const expected = `**<${username}>** See you there @5pm`; 532 | 533 | this.bot.sendToDiscord(username, '#irc', text); 534 | this.sendStub.should.have.been.calledWith(expected); 535 | }); 536 | 537 | it('should not convert user initial mentions from IRC if such user does not exist', function () { 538 | const username = 'ircuser'; 539 | const text = 'Agreed, see you then.'; 540 | const expected = `**<${username}>** Agreed, see you then.`; 541 | 542 | this.bot.sendToDiscord(username, '#irc', text); 543 | this.sendStub.should.have.been.calledWith(expected); 544 | }); 545 | 546 | it('should convert multiple user mentions from IRC', function () { 547 | const testUser = this.addUser({ username: 'testuser', id: '123' }); 548 | const anotherUser = this.addUser({ username: 'anotheruser', id: '124' }); 549 | 550 | const username = 'ircuser'; 551 | const text = 'Hello, @testuser and @anotheruser, was our meeting scheduled @5pm?'; 552 | const expected = `**<${username}>** Hello, <@${testUser.id}> and <@${anotherUser.id}>,` + 553 | ' was our meeting scheduled @5pm?'; 554 | 555 | this.bot.sendToDiscord(username, '#irc', text); 556 | this.sendStub.should.have.been.calledWith(expected); 557 | }); 558 | 559 | it('should convert emoji mentions from IRC', function () { 560 | this.addEmoji({ id: '987', name: 'testemoji', require_colons: true }); 561 | 562 | const username = 'ircuser'; 563 | const text = 'Here is a broken :emojitest:, a working :testemoji: and another :emoji: that won\'t parse'; 564 | const expected = `**<${username}>** Here is a broken :emojitest:, a working <:testemoji:987> and another :emoji: that won't parse`; 565 | this.bot.sendToDiscord(username, '#irc', text); 566 | this.sendStub.should.have.been.calledWith(expected); 567 | }); 568 | 569 | it('should convert channel mentions from IRC', function () { 570 | this.guild.addTextChannel({ id: '1235', name: 'testchannel' }); 571 | this.guild.addTextChannel({ id: '1236', name: 'channel-compliqué' }); 572 | const otherGuild = this.bot.discord.createGuildStub({ id: '2' }); 573 | otherGuild.addTextChannel({ id: '1237', name: 'foreignchannel' }); 574 | 575 | const username = 'ircuser'; 576 | const text = "Here is a broken #channelname, a working #testchannel, #channel-compliqué, an irregular case #TestChannel and another guild's #foreignchannel"; 577 | const expected = `**<${username}>** Here is a broken #channelname, a working <#1235>, <#1236>, an irregular case <#1235> and another guild's #foreignchannel`; 578 | this.bot.sendToDiscord(username, '#irc', text); 579 | this.sendStub.should.have.been.calledWith(expected); 580 | }); 581 | 582 | it('should convert newlines from discord', function () { 583 | const message = { 584 | mentions: { users: [] }, 585 | content: 'hi\nhi\r\nhi\r' 586 | }; 587 | 588 | this.bot.parseText(message).should.equal('hi hi hi '); 589 | }); 590 | 591 | it('should hide usernames for commands to IRC', function () { 592 | const text = '!test command'; 593 | const message = { 594 | content: text, 595 | mentions: { users: [] }, 596 | channel: { 597 | name: 'discord' 598 | }, 599 | author: { 600 | username: 'test', 601 | id: 'not bot id' 602 | }, 603 | guild: this.guild 604 | }; 605 | 606 | this.bot.sendToIRC(message); 607 | ClientStub.prototype.say.getCall(0).args.should.deep.equal([ 608 | '#irc', 'Command sent from Discord by test:' 609 | ]); 610 | ClientStub.prototype.say.getCall(1).args.should.deep.equal(['#irc', text]); 611 | }); 612 | 613 | it('should support multi-character command prefixes', function () { 614 | this.setCustomBot({ ...config, commandCharacters: ['@@'] }); 615 | const text = '@@test command'; 616 | const message = { 617 | content: text, 618 | mentions: { users: [] }, 619 | channel: { 620 | name: 'discord' 621 | }, 622 | author: { 623 | username: 'test', 624 | id: 'not bot id' 625 | }, 626 | guild: this.guild 627 | }; 628 | 629 | this.bot.sendToIRC(message); 630 | ClientStub.prototype.say.getCall(0).args.should.deep.equal([ 631 | '#irc', 'Command sent from Discord by test:' 632 | ]); 633 | ClientStub.prototype.say.getCall(1).args.should.deep.equal(['#irc', text]); 634 | }); 635 | 636 | it('should hide usernames for commands to Discord', function () { 637 | const username = 'ircuser'; 638 | const text = '!command'; 639 | 640 | this.bot.sendToDiscord(username, '#irc', text); 641 | this.sendStub.getCall(0).args.should.deep.equal(['Command sent from IRC by ircuser:']); 642 | this.sendStub.getCall(1).args.should.deep.equal([text]); 643 | }); 644 | 645 | it('should use nickname instead of username when available', function () { 646 | const text = 'testmessage'; 647 | const newConfig = { ...config, ircNickColor: false }; 648 | this.setCustomBot(newConfig); 649 | const id = 'not bot id'; 650 | const nickname = 'discord-nickname'; 651 | this.guild.members.cache.set(id, { nickname }); 652 | const message = { 653 | content: text, 654 | mentions: { users: [] }, 655 | channel: { 656 | name: 'discord' 657 | }, 658 | author: { 659 | username: 'otherauthor', 660 | id 661 | }, 662 | guild: this.guild 663 | }; 664 | 665 | this.bot.sendToIRC(message); 666 | const expected = `<${nickname}> ${text}`; 667 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 668 | }); 669 | 670 | it('should convert user nickname mentions from IRC', function () { 671 | const testUser = this.addUser({ username: 'testuser', id: '123', nickname: 'somenickname' }); 672 | 673 | const username = 'ircuser'; 674 | const text = 'Hello, @somenickname!'; 675 | const expected = `**<${username}>** Hello, ${testUser}!`; 676 | 677 | this.bot.sendToDiscord(username, '#irc', text); 678 | this.sendStub.should.have.been.calledWith(expected); 679 | }); 680 | 681 | it('should convert username mentions from IRC even if nickname differs', function () { 682 | const testUser = this.addUser({ username: 'testuser', id: '123', nickname: 'somenickname' }); 683 | 684 | const username = 'ircuser'; 685 | const text = 'Hello, @testuser!'; 686 | const expected = `**<${username}>** Hello, ${testUser}!`; 687 | 688 | this.bot.sendToDiscord(username, '#irc', text); 689 | this.sendStub.should.have.been.calledWith(expected); 690 | }); 691 | 692 | it('should convert username-discriminator mentions from IRC properly', function () { 693 | const user1 = this.addUser({ username: 'user', id: '123', discriminator: '9876' }); 694 | const user2 = this.addUser({ 695 | username: 'user', 696 | id: '124', 697 | discriminator: '5555', 698 | nickname: 'secondUser' 699 | }); 700 | 701 | const username = 'ircuser'; 702 | const text = 'hello @user#9876 and @user#5555 and @fakeuser#1234'; 703 | const expected = `**<${username}>** hello ${user1} and ${user2} and @fakeuser#1234`; 704 | 705 | this.bot.sendToDiscord(username, '#irc', text); 706 | this.sendStub.should.have.been.calledWith(expected); 707 | }); 708 | 709 | it('should convert role mentions from discord', function () { 710 | this.addRole({ name: 'example-role', id: '12345' }); 711 | const text = '<@&12345>'; 712 | const message = { 713 | content: text, 714 | mentions: { users: [] }, 715 | channel: { 716 | name: 'discord' 717 | }, 718 | author: { 719 | username: 'test', 720 | id: 'not bot id' 721 | }, 722 | guild: this.guild 723 | }; 724 | 725 | this.bot.parseText(message).should.equal('@example-role'); 726 | }); 727 | 728 | it('should use @deleted-role when referenced role fails to exist', function () { 729 | this.addRole({ name: 'example-role', id: '12345' }); 730 | 731 | const text = '<@&12346>'; 732 | const message = { 733 | content: text, 734 | mentions: { users: [] }, 735 | channel: { 736 | name: 'discord' 737 | }, 738 | author: { 739 | username: 'test', 740 | id: 'not bot id' 741 | }, 742 | guild: this.guild 743 | }; 744 | 745 | // Discord displays "@deleted-role" if role doesn't exist (e.g. <@&12346>) 746 | this.bot.parseText(message).should.equal('@deleted-role'); 747 | }); 748 | 749 | it('should convert role mentions from IRC if role mentionable', function () { 750 | const testRole = this.addRole({ name: 'example-role', id: '12345', mentionable: true }); 751 | 752 | const username = 'ircuser'; 753 | const text = 'Hello, @example-role!'; 754 | const expected = `**<${username}>** Hello, <@&${testRole.id}>!`; 755 | 756 | this.bot.sendToDiscord(username, '#irc', text); 757 | this.sendStub.should.have.been.calledWith(expected); 758 | }); 759 | 760 | it('should not convert role mentions from IRC if role not mentionable', function () { 761 | this.addRole({ name: 'example-role', id: '12345', mentionable: false }); 762 | 763 | const username = 'ircuser'; 764 | const text = 'Hello, @example-role!'; 765 | const expected = `**<${username}>** Hello, @example-role!`; 766 | 767 | this.bot.sendToDiscord(username, '#irc', text); 768 | this.sendStub.should.have.been.calledWith(expected); 769 | }); 770 | 771 | it('should convert overlapping mentions from IRC properly and case-insensitively', function () { 772 | const user = this.addUser({ username: 'user', id: '111' }); 773 | const nickUser = this.addUser({ username: 'user2', id: '112', nickname: 'userTest' }); 774 | const nickUserCase = this.addUser({ username: 'user3', id: '113', nickname: 'userTEST' }); 775 | const role = this.addRole({ name: 'userTestRole', id: '12345', mentionable: true }); 776 | 777 | const username = 'ircuser'; 778 | const text = 'hello @User, @user, @userTest, @userTEST, @userTestRole and @usertestrole'; 779 | const expected = `**<${username}>** hello ${user}, ${user}, ${nickUser}, ${nickUserCase}, ${role} and ${role}`; 780 | 781 | this.bot.sendToDiscord(username, '#irc', text); 782 | this.sendStub.should.have.been.calledWith(expected); 783 | }); 784 | 785 | it('should convert partial matches from IRC properly', function () { 786 | const user = this.addUser({ username: 'user', id: '111' }); 787 | const longUser = this.addUser({ username: 'user-punc', id: '112' }); 788 | const nickUser = this.addUser({ username: 'user2', id: '113', nickname: 'nick' }); 789 | const nickUserCase = this.addUser({ username: 'user3', id: '114', nickname: 'NiCK' }); 790 | const role = this.addRole({ name: 'role', id: '12345', mentionable: true }); 791 | 792 | const username = 'ircuser'; 793 | const text = '@user-ific @usermore, @user\'s friend @user-punc, @nicks and @NiCKs @roles'; 794 | const expected = `**<${username}>** ${user}-ific ${user}more, ${user}'s friend ${longUser}, ${nickUser}s and ${nickUserCase}s ${role}s`; 795 | 796 | this.bot.sendToDiscord(username, '#irc', text); 797 | this.sendStub.should.have.been.calledWith(expected); 798 | }); 799 | 800 | it('should successfully send messages with default config', function () { 801 | this.setCustomBot(configMsgFormatDefault); 802 | 803 | this.bot.sendToDiscord('testuser', '#irc', 'test message'); 804 | this.sendStub.should.have.been.calledOnce; 805 | const message = { 806 | content: 'test message', 807 | mentions: { users: [] }, 808 | channel: { 809 | name: 'discord' 810 | }, 811 | author: { 812 | username: 'otherauthor', 813 | id: 'not bot id' 814 | }, 815 | guild: this.guild 816 | }; 817 | 818 | this.bot.sendToIRC(message); 819 | this.sendStub.should.have.been.calledOnce; 820 | }); 821 | 822 | it('should not replace unmatched patterns', function () { 823 | const format = { discord: '{$unmatchedPattern} stays intact: {$author} {$text}' }; 824 | this.setCustomBot({ ...configMsgFormatDefault, format }); 825 | 826 | const username = 'testuser'; 827 | const msg = 'test message'; 828 | const expected = `{$unmatchedPattern} stays intact: ${username} ${msg}`; 829 | this.bot.sendToDiscord(username, '#irc', msg); 830 | this.sendStub.should.have.been.calledWith(expected); 831 | }); 832 | 833 | it('should respect custom formatting for Discord', function () { 834 | const format = { discord: '<{$author}> {$ircChannel} => {$discordChannel}: {$text}' }; 835 | this.setCustomBot({ ...configMsgFormatDefault, format }); 836 | 837 | const username = 'test'; 838 | const msg = 'test @user <#1234>'; 839 | const expected = ` #irc => #discord: ${msg}`; 840 | this.bot.sendToDiscord(username, '#irc', msg); 841 | this.sendStub.should.have.been.calledWith(expected); 842 | }); 843 | 844 | it('should successfully send messages with default config', function () { 845 | this.setCustomBot(configMsgFormatDefault); 846 | 847 | this.bot.sendToDiscord('testuser', '#irc', 'test message'); 848 | this.sendStub.should.have.been.calledOnce; 849 | const message = { 850 | content: 'test message', 851 | mentions: { users: [] }, 852 | channel: { 853 | name: 'discord' 854 | }, 855 | author: { 856 | username: 'otherauthor', 857 | id: 'not bot id' 858 | }, 859 | guild: this.guild 860 | }; 861 | 862 | this.bot.sendToIRC(message); 863 | this.sendStub.should.have.been.calledOnce; 864 | }); 865 | 866 | it('should not replace unmatched patterns', function () { 867 | const format = { discord: '{$unmatchedPattern} stays intact: {$author} {$text}' }; 868 | this.setCustomBot({ ...configMsgFormatDefault, format }); 869 | 870 | const username = 'testuser'; 871 | const msg = 'test message'; 872 | const expected = `{$unmatchedPattern} stays intact: ${username} ${msg}`; 873 | this.bot.sendToDiscord(username, '#irc', msg); 874 | this.sendStub.should.have.been.calledWith(expected); 875 | }); 876 | 877 | it('should respect custom formatting for regular Discord output', function () { 878 | const format = { discord: '<{$author}> {$ircChannel} => {$discordChannel}: {$text}' }; 879 | this.setCustomBot({ ...configMsgFormatDefault, format }); 880 | 881 | const username = 'test'; 882 | const msg = 'test @user <#1234>'; 883 | const expected = ` #irc => #discord: ${msg}`; 884 | this.bot.sendToDiscord(username, '#irc', msg); 885 | this.sendStub.should.have.been.calledWith(expected); 886 | }); 887 | 888 | it('should respect custom formatting for commands in Discord output', function () { 889 | const format = { commandPrelude: '{$nickname} from {$ircChannel} sent command to {$discordChannel}:' }; 890 | this.setCustomBot({ ...configMsgFormatDefault, format }); 891 | 892 | const username = 'test'; 893 | const msg = '!testcmd'; 894 | const expected = 'test from #irc sent command to #discord:'; 895 | this.bot.sendToDiscord(username, '#irc', msg); 896 | this.sendStub.getCall(0).args.should.deep.equal([expected]); 897 | this.sendStub.getCall(1).args.should.deep.equal([msg]); 898 | }); 899 | 900 | it('should respect custom formatting for regular IRC output', function () { 901 | const format = { ircText: '<{$nickname}> {$discordChannel} => {$ircChannel}: {$text}' }; 902 | this.setCustomBot({ ...configMsgFormatDefault, format }); 903 | const message = { 904 | content: 'test message', 905 | mentions: { users: [] }, 906 | channel: { 907 | name: 'discord' 908 | }, 909 | author: { 910 | username: 'testauthor', 911 | id: 'not bot id' 912 | }, 913 | guild: this.guild 914 | }; 915 | const expected = ' #discord => #irc: test message'; 916 | 917 | this.bot.sendToIRC(message); 918 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 919 | }); 920 | 921 | it('should respect custom formatting for commands in IRC output', function () { 922 | const format = { commandPrelude: '{$nickname} from {$discordChannel} sent command to {$ircChannel}:' }; 923 | this.setCustomBot({ ...configMsgFormatDefault, format }); 924 | 925 | const text = '!testcmd'; 926 | const message = { 927 | content: text, 928 | mentions: { users: [] }, 929 | channel: { 930 | name: 'discord' 931 | }, 932 | author: { 933 | username: 'testauthor', 934 | id: 'not bot id' 935 | }, 936 | guild: this.guild 937 | }; 938 | const expected = 'testauthor from #discord sent command to #irc:'; 939 | 940 | this.bot.sendToIRC(message); 941 | ClientStub.prototype.say.getCall(0).args.should.deep.equal(['#irc', expected]); 942 | ClientStub.prototype.say.getCall(1).args.should.deep.equal(['#irc', text]); 943 | }); 944 | 945 | it('should respect custom formatting for attachment URLs in IRC output', function () { 946 | const format = { urlAttachment: '<{$nickname}> {$discordChannel} => {$ircChannel}, attachment: {$attachmentURL}' }; 947 | this.setCustomBot({ ...configMsgFormatDefault, format }); 948 | 949 | const attachmentUrl = 'https://image/url.jpg'; 950 | const message = { 951 | content: '', 952 | mentions: { users: [] }, 953 | attachments: createAttachments(attachmentUrl), 954 | channel: { 955 | name: 'discord' 956 | }, 957 | author: { 958 | username: 'otherauthor', 959 | id: 'not bot id' 960 | }, 961 | guild: this.guild 962 | }; 963 | 964 | this.bot.sendToIRC(message); 965 | const expected = ` #discord => #irc, attachment: ${attachmentUrl}`; 966 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 967 | }); 968 | 969 | it('should not bother with command prelude if falsy', function () { 970 | const format = { commandPrelude: null }; 971 | this.setCustomBot({ ...configMsgFormatDefault, format }); 972 | 973 | const text = '!testcmd'; 974 | const message = { 975 | content: text, 976 | mentions: { users: [] }, 977 | channel: { 978 | name: 'discord' 979 | }, 980 | author: { 981 | username: 'testauthor', 982 | id: 'not bot id' 983 | }, 984 | guild: this.guild 985 | }; 986 | 987 | this.bot.sendToIRC(message); 988 | ClientStub.prototype.say.should.have.been.calledOnce; 989 | ClientStub.prototype.say.getCall(0).args.should.deep.equal(['#irc', text]); 990 | 991 | const username = 'test'; 992 | const msg = '!testcmd'; 993 | this.bot.sendToDiscord(username, '#irc', msg); 994 | this.sendStub.should.have.been.calledOnce; 995 | this.sendStub.getCall(0).args.should.deep.equal([msg]); 996 | }); 997 | 998 | it('should create webhooks clients for each webhook url in the config', function () { 999 | this.bot.webhooks.should.have.property('#withwebhook'); 1000 | }); 1001 | 1002 | it('should extract id and token from webhook urls', function () { 1003 | this.bot.webhooks['#withwebhook'].id.should.equal('id'); 1004 | }); 1005 | 1006 | it('should find the matching webhook when it exists', function () { 1007 | this.bot.findWebhook('#ircwebhook').should.not.equal(null); 1008 | }); 1009 | 1010 | context('with enabled Discord webhook', function () { 1011 | this.beforeEach(function () { 1012 | const newConfig = { ...config, webhooks: { '#discord': 'https://discord.com/api/webhooks/id/token' } }; 1013 | this.setCustomBot(newConfig); 1014 | }); 1015 | 1016 | it('should prefer webhooks to send a message', function () { 1017 | this.bot.sendToDiscord('nick', '#irc', 'text'); 1018 | this.sendWebhookMessageStub.should.have.been.called; 1019 | }); 1020 | 1021 | it('pads too short usernames', function () { 1022 | const text = 'message'; 1023 | this.bot.sendToDiscord('n', '#irc', text); 1024 | this.sendWebhookMessageStub.should.have.been.calledWith(text, { 1025 | username: 'n_', 1026 | avatarURL: null, 1027 | disableMentions: 'everyone', 1028 | }); 1029 | }); 1030 | 1031 | it('slices too long usernames', function () { 1032 | const text = 'message'; 1033 | this.bot.sendToDiscord('1234567890123456789012345678901234567890', '#irc', text); 1034 | this.sendWebhookMessageStub.should.have.been.calledWith(text, { 1035 | username: '12345678901234567890123456789012', 1036 | avatarURL: null, 1037 | disableMentions: 'everyone', 1038 | }); 1039 | }); 1040 | 1041 | it('does not ping everyone if user lacks permission', function () { 1042 | const text = 'message'; 1043 | const permission = discord.Permissions.FLAGS.VIEW_CHANNEL 1044 | + discord.Permissions.FLAGS.SEND_MESSAGES; 1045 | this.bot.discord.channels.cache.get('1234').setPermissionStub( 1046 | this.bot.discord.user, 1047 | new discord.Permissions(permission), 1048 | ); 1049 | this.bot.sendToDiscord('nick', '#irc', text); 1050 | this.sendWebhookMessageStub.should.have.been.calledWith(text, { 1051 | username: 'nick', 1052 | avatarURL: null, 1053 | disableMentions: 'everyone', 1054 | }); 1055 | }); 1056 | 1057 | it('sends @everyone messages if the bot has permission to do so', function () { 1058 | const text = 'message'; 1059 | const permission = discord.Permissions.FLAGS.VIEW_CHANNEL 1060 | + discord.Permissions.FLAGS.SEND_MESSAGES 1061 | + discord.Permissions.FLAGS.MENTION_EVERYONE; 1062 | this.bot.discord.channels.cache.get('1234').setPermissionStub( 1063 | this.bot.discord.user, 1064 | new discord.Permissions(permission), 1065 | ); 1066 | this.bot.sendToDiscord('nick', '#irc', text); 1067 | this.sendWebhookMessageStub.should.have.been.calledWith(text, { 1068 | username: 'nick', 1069 | avatarURL: null, 1070 | disableMentions: 'none', 1071 | }); 1072 | }); 1073 | 1074 | const setupUser = base => { 1075 | const userObj = { id: 123, username: 'Nick', avatar: 'avatarURL' }; 1076 | const memberObj = { nickname: 'Different' }; 1077 | base.addUser(userObj, memberObj); 1078 | }; 1079 | 1080 | const setupCommonPair = base => { 1081 | const userObj1 = { id: 124, username: 'common', avatar: 'avatarURL' }; 1082 | const userObj2 = { id: 125, username: 'diffUser', avatar: 'avatarURL' }; 1083 | const memberObj1 = { nickname: 'diffNick' }; 1084 | const memberObj2 = { nickname: 'common' }; 1085 | base.addUser(userObj1, memberObj1); 1086 | base.addUser(userObj2, memberObj2); 1087 | }; 1088 | 1089 | context('when matching avatars', function () { 1090 | this.beforeEach(function () { 1091 | setupUser(this); 1092 | }); 1093 | 1094 | it('should match a user\'s username', function () { 1095 | this.bot.getDiscordAvatar('Nick', '#irc').should.equal('/avatars/123/avatarURL.png?size=128'); 1096 | }); 1097 | 1098 | it('should match a user\'s username case insensitively', function () { 1099 | this.bot.getDiscordAvatar('nick', '#irc').should.equal('/avatars/123/avatarURL.png?size=128'); 1100 | }); 1101 | 1102 | it('should match a user\'s nickname', function () { 1103 | this.bot.getDiscordAvatar('Different', '#irc').should.equal('/avatars/123/avatarURL.png?size=128'); 1104 | }); 1105 | 1106 | it('should match a user\'s nickname case insensitively', function () { 1107 | this.bot.getDiscordAvatar('different', '#irc').should.equal('/avatars/123/avatarURL.png?size=128'); 1108 | }); 1109 | 1110 | it('should only return matching users\' avatars', function () { 1111 | expect(this.bot.getDiscordAvatar('other', '#irc')).to.equal(null); 1112 | }); 1113 | 1114 | it('should return no avatar when there are multiple matches', function () { 1115 | setupCommonPair(this); 1116 | this.bot.getDiscordAvatar('diffUser', '#irc').should.not.equal(null); 1117 | this.bot.getDiscordAvatar('diffNick', '#irc').should.not.equal(null); 1118 | expect(this.bot.getDiscordAvatar('common', '#irc')).to.equal(null); 1119 | }); 1120 | 1121 | it('should handle users without nicknames', function () { 1122 | const userObj = { id: 124, username: 'nickless', avatar: 'nickless-avatar' }; 1123 | const memberObj = {}; 1124 | this.addUser(userObj, memberObj); 1125 | this.bot.getDiscordAvatar('nickless', '#irc').should.equal('/avatars/124/nickless-avatar.png?size=128'); 1126 | }); 1127 | 1128 | it('should handle users without avatars', function () { 1129 | const userObj = { id: 124, username: 'avatarless' }; 1130 | const memberObj = {}; 1131 | this.addUser(userObj, memberObj); 1132 | expect(this.bot.getDiscordAvatar('avatarless', '#irc')).to.equal(null); 1133 | }); 1134 | }); 1135 | 1136 | context('when matching avatars with fallback URL', function () { 1137 | this.beforeEach(function () { 1138 | const newConfig = { ...config, webhooks: { '#discord': 'https://discord.com/api/webhooks/id/token' }, format: { webhookAvatarURL: 'avatarFrom/{$nickname}' } }; 1139 | this.setCustomBot(newConfig); 1140 | 1141 | setupUser(this); 1142 | }); 1143 | 1144 | it('should use a matching user\'s avatar', function () { 1145 | this.bot.getDiscordAvatar('Nick', '#irc').should.equal('/avatars/123/avatarURL.png?size=128'); 1146 | this.bot.getDiscordAvatar('nick', '#irc').should.equal('/avatars/123/avatarURL.png?size=128'); 1147 | this.bot.getDiscordAvatar('Different', '#irc').should.equal('/avatars/123/avatarURL.png?size=128'); 1148 | this.bot.getDiscordAvatar('different', '#irc').should.equal('/avatars/123/avatarURL.png?size=128'); 1149 | }); 1150 | 1151 | it('should use fallback without matching user', function () { 1152 | this.bot.getDiscordAvatar('other', '#irc').should.equal('avatarFrom/other'); 1153 | }); 1154 | 1155 | it('should use fallback when there are multiple matches', function () { 1156 | setupCommonPair(this); 1157 | this.bot.getDiscordAvatar('diffUser', '#irc').should.equal('/avatars/125/avatarURL.png?size=128'); 1158 | this.bot.getDiscordAvatar('diffNick', '#irc').should.equal('/avatars/124/avatarURL.png?size=128'); 1159 | this.bot.getDiscordAvatar('common', '#irc').should.equal('avatarFrom/common'); 1160 | }); 1161 | 1162 | it('should use fallback for users without avatars', function () { 1163 | const userObj = { id: 124, username: 'avatarless' }; 1164 | const memberObj = {}; 1165 | this.addUser(userObj, memberObj); 1166 | this.bot.getDiscordAvatar('avatarless', '#irc').should.equal('avatarFrom/avatarless'); 1167 | }); 1168 | }); 1169 | }); 1170 | 1171 | it( 1172 | 'should not send messages to Discord if IRC user is ignored', 1173 | function () { 1174 | this.bot.sendToDiscord('irc_ignored_user', '#irc', 'message'); 1175 | this.sendStub.should.not.have.been.called; 1176 | } 1177 | ); 1178 | 1179 | it( 1180 | 'should not send messages to IRC if Discord user is ignored', 1181 | function () { 1182 | const message = { 1183 | content: 'text', 1184 | mentions: { users: [] }, 1185 | channel: { 1186 | name: 'discord' 1187 | }, 1188 | author: { 1189 | username: 'discord_ignored_user', 1190 | id: 'some id' 1191 | }, 1192 | guild: this.guild 1193 | }; 1194 | 1195 | this.bot.sendToIRC(message); 1196 | ClientStub.prototype.say.should.not.have.been.called; 1197 | } 1198 | ); 1199 | 1200 | it( 1201 | 'should not send messages to IRC if Discord user is ignored by id', 1202 | function () { 1203 | const message = { 1204 | content: 'text', 1205 | mentions: { users: [] }, 1206 | channel: { 1207 | name: 'discord' 1208 | }, 1209 | author: { 1210 | username: 'vasya_pupkin', 1211 | id: '4499' 1212 | }, 1213 | guild: this.guild 1214 | }; 1215 | 1216 | this.bot.sendToIRC(message); 1217 | ClientStub.prototype.say.should.not.have.been.called; 1218 | } 1219 | ); 1220 | }); 1221 | -------------------------------------------------------------------------------- /test/channel-mapping.test.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import irc from 'irc-upd'; 3 | import discord from 'discord.js'; 4 | import Bot from '../lib/bot'; 5 | import config from './fixtures/single-test-config.json'; 6 | import caseConfig from './fixtures/case-sensitivity-config.json'; 7 | import DiscordStub from './stubs/discord-stub'; 8 | import ClientStub from './stubs/irc-client-stub'; 9 | import { validateChannelMapping } from '../lib/validators'; 10 | 11 | chai.should(); 12 | 13 | describe('Channel Mapping', () => { 14 | before(() => { 15 | irc.Client = ClientStub; 16 | discord.Client = DiscordStub; 17 | }); 18 | 19 | it('should fail when not given proper JSON', () => { 20 | const wrongMapping = 'not json'; 21 | function wrap() { 22 | validateChannelMapping(wrongMapping); 23 | } 24 | 25 | (wrap).should.throw('Invalid channel mapping given'); 26 | }); 27 | 28 | it('should not fail if given a proper channel list as JSON', () => { 29 | const correctMapping = { '#channel': '#otherchannel' }; 30 | function wrap() { 31 | validateChannelMapping(correctMapping); 32 | } 33 | 34 | (wrap).should.not.throw(); 35 | }); 36 | 37 | it('should clear channel keys from the mapping', () => { 38 | const bot = new Bot(config); 39 | bot.channelMapping['#discord'].should.equal('#irc'); 40 | bot.invertedMapping['#irc'].should.equal('#discord'); 41 | bot.channels.should.contain('#irc channelKey'); 42 | }); 43 | 44 | it('should lowercase IRC channel names', () => { 45 | const bot = new Bot(caseConfig); 46 | bot.channelMapping['#discord'].should.equal('#irc'); 47 | bot.channelMapping['#otherDiscord'].should.equal('#otherirc'); 48 | }); 49 | 50 | it('should work with ID maps', () => { 51 | const bot = new Bot(config); 52 | bot.channelMapping['1234'].should.equal('#channelforid'); 53 | bot.invertedMapping['#channelforid'].should.equal('1234'); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/cli.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions, prefer-arrow-callback */ 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.createSandbox({ 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-json-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 no-unused-expressions, prefer-arrow-callback */ 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.createSandbox({ 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 | function wrap() { 44 | createBots(badConfig); 45 | } 46 | 47 | (wrap).should.throw('Missing configuration field nickname'); 48 | }); 49 | 50 | it('should throw if a configuration file is neither an object or an array', function () { 51 | function wrap() { 52 | createBots(stringConfig); 53 | } 54 | 55 | (wrap).should.throw('Invalid configuration file given'); 56 | }); 57 | 58 | it('should be possible to run it through require(\'discord-irc\')', function () { 59 | const bots = index(singleTestConfig); 60 | bots.length.should.equal(1); 61 | this.connectStub.should.have.been.called; 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /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 | "discordToken": "hei", 5 | "channelMapping": { 6 | "#discord": "#irc" 7 | } 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /test/fixtures/case-sensitivity-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "nickname": "Reactiflux", 3 | "server": "irc.freenode.net", 4 | "discordToken": "discord@test.com", 5 | "commandCharacters": ["!", "."], 6 | "autoSendCommands": [ 7 | ["MODE", "test", "+x"], 8 | ["AUTH", "test", "password"] 9 | ], 10 | "channelMapping": { 11 | "#discord": "#irc chAnNelKey", 12 | "#otherDiscord": "#otherirc" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/fixtures/invalid-json-config.json: -------------------------------------------------------------------------------- 1 | { invalid json } 2 | -------------------------------------------------------------------------------- /test/fixtures/msg-formats-default.json: -------------------------------------------------------------------------------- 1 | { 2 | "nickname": "Reactiflux", 3 | "server": "irc.freenode.net", 4 | "discordToken": "whatapassword", 5 | "commandCharacters": ["!", "."], 6 | "ircOptions": { 7 | "encoding": "utf-8" 8 | }, 9 | "channelMapping": { 10 | "#discord": "#irc" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/fixtures/single-test-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "nickname": "Reactiflux", 3 | "server": "irc.freenode.net", 4 | "discordToken": "whatapassword", 5 | "commandCharacters": ["!", "."], 6 | "ircOptions": { 7 | "encoding": "utf-8" 8 | }, 9 | "autoSendCommands": [ 10 | ["MODE", "test", "+x"], 11 | ["AUTH", "test", "password"] 12 | ], 13 | "channelMapping": { 14 | "#discord": "#irc channelKey", 15 | "#notinchannel": "#otherIRC", 16 | "1234": "#channelforid", 17 | "#withwebhook": "#ircwebhook" 18 | }, 19 | "webhooks": { 20 | "#withwebhook": "https://discord.com/api/webhooks/id/token" 21 | }, 22 | "ignoreUsers": { 23 | "irc": ["irc_ignored_user"], 24 | "discord": ["discord_ignored_user"], 25 | "discordIds": ["4499"] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/string-config.json: -------------------------------------------------------------------------------- 1 | "test" 2 | -------------------------------------------------------------------------------- /test/fixtures/test-config-comments.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "nickname": "test", 4 | "server": "irc.freenode.net", 5 | "discordToken": /* comment */ "whatapassword", 6 | "ircOptions": { 7 | "encoding": "utf-8" 8 | }, 9 | "channelMapping": { 10 | "#discord": "#irc" // Comment 11 | } 12 | }, 13 | // Comment 14 | { 15 | "nickname": "test2", 16 | "server": "irc.freenode.net", 17 | "discordToken": "whatapassword", 18 | "ircOptions": { 19 | "encoding": "utf-8" 20 | }, 21 | "channelMapping": { 22 | "#discord": "#irc" 23 | } 24 | } 25 | ] 26 | -------------------------------------------------------------------------------- /test/fixtures/test-config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "nickname": "test", 4 | "server": "irc.freenode.net", 5 | "discordToken": "whatapassword", 6 | "ircOptions": { 7 | "encoding": "utf-8" 8 | }, 9 | "channelMapping": { 10 | "#discord": "#irc" 11 | } 12 | }, 13 | { 14 | "nickname": "test2", 15 | "server": "irc.freenode.net", 16 | "discordToken": "whatapassword", 17 | "ircOptions": { 18 | "encoding": "utf-8" 19 | }, 20 | "channelMapping": { 21 | "#discord": "#irc" 22 | } 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /test/fixtures/test-javascript-config.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | nickname: 'test', 4 | server: 'irc.freenode.net', 5 | discordToken: 'whatapassword', 6 | ircOptions: { 7 | encoding: 'utf-8' 8 | }, 9 | channelMapping: { 10 | '#discord': '#irc' 11 | } 12 | }, 13 | { 14 | nickname: 'test2', 15 | server: 'irc.freenode.net', 16 | discordToken: 'whatapassword', 17 | ircOptions: { 18 | encoding: 'utf-8' 19 | }, 20 | channelMapping: { 21 | '#discord': '#irc' 22 | } 23 | } 24 | ]; 25 | -------------------------------------------------------------------------------- /test/formatting.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-arrow-callback */ 2 | 3 | import chai from 'chai'; 4 | import { formatFromDiscordToIRC, formatFromIRCToDiscord } from '../lib/formatting'; 5 | 6 | chai.should(); 7 | 8 | describe('Formatting', () => { 9 | describe('Discord to IRC', () => { 10 | it('should convert bold markdown', () => { 11 | formatFromDiscordToIRC('**text**').should.equal('\x02text\x02'); 12 | }); 13 | 14 | it('should convert italic markdown', () => { 15 | formatFromDiscordToIRC('*text*').should.equal('\x1dtext\x1d'); 16 | formatFromDiscordToIRC('_text_').should.equal('\x1dtext\x1d'); 17 | }); 18 | 19 | it('should convert underline markdown', () => { 20 | formatFromDiscordToIRC('__text__').should.equal('\x1ftext\x1f'); 21 | }); 22 | 23 | it('should ignore strikethrough markdown', () => { 24 | formatFromDiscordToIRC('~~text~~').should.equal('text'); 25 | }); 26 | 27 | it('should convert nested markdown', () => { 28 | formatFromDiscordToIRC('**bold *italics***') 29 | .should.equal('\x02bold \x1ditalics\x1d\x02'); 30 | }); 31 | }); 32 | 33 | describe('IRC to Discord', () => { 34 | it('should convert bold IRC format', () => { 35 | formatFromIRCToDiscord('\x02text\x02').should.equal('**text**'); 36 | }); 37 | 38 | it('should convert reverse IRC format', () => { 39 | formatFromIRCToDiscord('\x16text\x16').should.equal('*text*'); 40 | }); 41 | 42 | it('should convert italic IRC format', () => { 43 | formatFromIRCToDiscord('\x1dtext\x1d').should.equal('*text*'); 44 | }); 45 | 46 | it('should convert underline IRC format', () => { 47 | formatFromIRCToDiscord('\x1ftext\x1f').should.equal('__text__'); 48 | }); 49 | 50 | it('should ignore color IRC format', () => { 51 | formatFromIRCToDiscord('\x0306,08text\x03').should.equal('text'); 52 | }); 53 | 54 | it('should convert nested IRC format', () => { 55 | formatFromIRCToDiscord('\x02bold \x16italics\x16\x02') 56 | .should.equal('**bold *italics***'); 57 | }); 58 | 59 | it('should convert nested IRC format', () => { 60 | formatFromIRCToDiscord('\x02bold \x1funderline\x1f\x02') 61 | .should.equal('**bold __underline__**'); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/stubs/discord-stub.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | import events from 'events'; 3 | import sinon from 'sinon'; 4 | import discord from 'discord.js'; 5 | 6 | export default function createDiscordStub(sendStub) { 7 | return class DiscordStub extends events.EventEmitter { 8 | constructor() { 9 | super(); 10 | this.user = { 11 | id: 'testid' 12 | }; 13 | this.channels = new discord.ChannelManager(this, []); 14 | this.options = { 15 | http: { 16 | cdn: '' 17 | } 18 | }; 19 | 20 | this.users = new discord.UserManager(this, []); 21 | this.guilds = new discord.GuildManager(this, []); 22 | const guild = this.createGuildStub(); 23 | this.guilds.cache.set(guild.id, guild); 24 | 25 | this.rest = this.createRestStub(); 26 | } 27 | 28 | destroy() {} 29 | 30 | addTextChannel(guild, textChannel) { 31 | const textChannelData = { 32 | type: discord.Constants.ChannelTypes.TEXT, 33 | ...textChannel 34 | }; 35 | const textChannelObj = new discord.TextChannel(guild, textChannelData); 36 | textChannelObj.send = sendStub; 37 | const permissions = new discord.Collection(); 38 | textChannelObj.setPermissionStub = (user, perms) => permissions.set(user, perms); 39 | textChannelObj.permissionsFor = user => permissions.get(user); 40 | this.channels.cache.set(textChannelObj.id, textChannelObj); 41 | return textChannelObj; 42 | } 43 | 44 | createGuildStub(guildData = {}) { 45 | const guild = { 46 | id: '1', 47 | client: this, 48 | addTextChannel: (textChannel) => { 49 | const textChannelObj = this.addTextChannel(guild, textChannel); 50 | textChannelObj.guild.channels.cache.set(textChannelObj.id, textChannelObj); 51 | return textChannelObj; 52 | } 53 | }; 54 | guild.roles = new discord.RoleManager(guild, []); 55 | guild.members = new discord.GuildMemberManager(guild, []); 56 | guild.emojis = new discord.GuildEmojiManager(guild, []); 57 | guild.channels = new discord.GuildChannelManager(guild, []); 58 | Object.assign(guild, guildData); 59 | this.guilds.cache.set(guild.id, guild); 60 | 61 | if (guild.id === '1') { 62 | guild.addTextChannel({ 63 | name: 'discord', 64 | id: '1234', 65 | }); 66 | } 67 | 68 | return guild; 69 | } 70 | 71 | createRestStub() { 72 | return { 73 | cdn: discord.Constants.Endpoints.CDN(''), 74 | }; 75 | } 76 | 77 | login() { 78 | return sinon.stub(); 79 | } 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /test/stubs/irc-client-stub.js: -------------------------------------------------------------------------------- 1 | import events from 'events'; 2 | 3 | /* eslint-disable class-methods-use-this */ 4 | class ClientStub extends events.EventEmitter { 5 | constructor(...args) { 6 | super(); 7 | this.nick = args[1]; // eslint-disable-line prefer-destructuring 8 | } 9 | 10 | disconnect() {} 11 | } 12 | 13 | export default ClientStub; 14 | -------------------------------------------------------------------------------- /test/stubs/webhook-stub.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | export default function createWebhookStub(sendWebhookMessage) { 3 | return class WebhookStub { 4 | constructor(id, token) { 5 | this.id = id; 6 | this.token = token; 7 | } 8 | 9 | send(...args) { 10 | sendWebhookMessage(...args); 11 | return new Promise(() => {}); 12 | } 13 | 14 | destroy() {} 15 | }; 16 | } 17 | --------------------------------------------------------------------------------