├── test ├── fixtures │ ├── string-config.json │ ├── invalid-json-config.json │ ├── bad-config.json │ ├── msg-formats-default.json │ ├── case-sensitivity-config.json │ ├── test-config.json │ ├── test-javascript-config.js │ ├── test-config-comments.json │ └── single-test-config.json ├── stubs │ ├── irc-client-stub.js │ ├── webhook-stub.js │ └── discord-stub.js ├── errors.test.js ├── channel-mapping.test.js ├── create-bots.test.js ├── formatting.test.js ├── cli.test.js ├── bot-events.test.js └── bot.test.js ├── .babelrc ├── .travis.yml ├── lib ├── index.js ├── errors.js ├── validators.js ├── helpers.js ├── emoji.json ├── logger.js ├── cli.js ├── formatting.js └── bot.js ├── .eslintrc ├── .gitignore ├── .npmignore ├── LICENSE ├── package.json ├── README.md └── CHANGELOG.md /test/fixtures/string-config.json: -------------------------------------------------------------------------------- 1 | "test" 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["node6", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid-json-config.json: -------------------------------------------------------------------------------- 1 | { invalid json } 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | after_success: 3 | - npm run report 4 | node_js: 5 | - '6' 6 | - '8' 7 | -------------------------------------------------------------------------------- /test/fixtures/bad-config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "server": "irc.bottest.org", 4 | "discordToken": "hei", 5 | "channelMapping": { 6 | "#discord": "#irc" 7 | } 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /test/stubs/irc-client-stub.js: -------------------------------------------------------------------------------- 1 | import events from 'events'; 2 | 3 | class ClientStub extends events.EventEmitter { 4 | constructor(...args) { 5 | super(); 6 | this.nick = args[1]; // eslint-disable-line prefer-destructuring 7 | } 8 | } 9 | 10 | export default ClientStub; 11 | -------------------------------------------------------------------------------- /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/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 | sendMessage() { 10 | sendWebhookMessage(); 11 | return new Promise(() => {}); 12 | } 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "global-require": 0, 10 | "comma-dangle": 0, 11 | "func-names": 0, 12 | "import/no-extraneous-dependencies": [2, { "devDependencies": true }], 13 | "import/prefer-default-export": 0, 14 | "import/no-dynamic-require": 0 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/validators.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { ConfigurationError } from './errors'; 3 | 4 | /** 5 | * Validates a given channel mapping, throwing an error if it's invalid 6 | * @param {Object} mapping 7 | * @return {Object} 8 | */ 9 | export function validateChannelMapping(mapping) { 10 | if (!_.isObject(mapping)) { 11 | throw new ConfigurationError('Invalid channel mapping given'); 12 | } 13 | 14 | return mapping; 15 | } 16 | -------------------------------------------------------------------------------- /test/fixtures/test-config.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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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://discordapp.com/api/webhooks/id/token" 21 | }, 22 | "ignoreUsers": { 23 | "irc": ["irc_ignored_user"], 24 | "discord": ["discord_ignored_user"] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /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/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/logger.js: -------------------------------------------------------------------------------- 1 | import winston, { format } from 'winston'; 2 | import { inspect } from 'util'; 3 | 4 | function formatter(info) { 5 | const stringifiedRest = inspect( 6 | Object.assign({}, info, { 7 | level: undefined, 8 | message: undefined, 9 | splat: undefined 10 | }), 11 | { depth: null } 12 | ); 13 | 14 | const padding = (info.padding && info.padding[info.level]) || ''; 15 | if (stringifiedRest !== '{}') { 16 | return `${info.timestamp} ${info.level}:${padding} ${info.message} ${stringifiedRest}`; 17 | } 18 | 19 | return `${info.timestamp} ${info.level}:${padding} ${info.message}`; 20 | } 21 | 22 | const logger = winston.createLogger({ 23 | transports: [new winston.transports.Console()], 24 | level: process.env.NODE_ENV === 'development' ? 'debug' : 'info', 25 | format: format.combine( 26 | format.colorize(), 27 | format.timestamp(), 28 | format.printf(formatter) 29 | ) 30 | }); 31 | 32 | export default logger; 33 | -------------------------------------------------------------------------------- /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, guild, discordUsers) { 7 | return class DiscordStub extends events.EventEmitter { 8 | constructor() { 9 | super(); 10 | this.user = { 11 | id: 'testid' 12 | }; 13 | this.channels = this.guildChannels(); 14 | this.options = { 15 | http: { 16 | cdn: '' 17 | } 18 | }; 19 | 20 | this.users = discordUsers; 21 | } 22 | 23 | guildChannels() { 24 | const channels = new discord.Collection(); 25 | channels.set('1234', { 26 | name: 'discord', 27 | id: '1234', 28 | type: 'text', 29 | send: sendStub, 30 | guild 31 | }); 32 | return channels; 33 | } 34 | 35 | login() { 36 | return sinon.stub(); 37 | } 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 checkEnv from 'check-env'; 7 | import stripJsonComments from 'strip-json-comments'; 8 | import { endsWith } from 'lodash'; 9 | import * as helpers from './helpers'; 10 | import { ConfigurationError } from './errors'; 11 | import { version } from '../package.json'; 12 | 13 | function readJSONConfig(filePath) { 14 | const configFile = fs.readFileSync(filePath, { encoding: 'utf8' }); 15 | try { 16 | return JSON.parse(stripJsonComments(configFile)); 17 | } catch (err) { 18 | if (err instanceof SyntaxError) { 19 | throw new ConfigurationError('The configuration file contains invalid JSON'); 20 | } else { 21 | throw err; 22 | } 23 | } 24 | } 25 | 26 | function run() { 27 | program 28 | .version(version) 29 | .option( 30 | '-c, --config ', 31 | 'Sets the path to the config file, otherwise read from the env variable CONFIG_FILE.' 32 | ) 33 | .parse(process.argv); 34 | 35 | // If no config option is given, try to use the env variable: 36 | if (!program.config) checkEnv(['CONFIG_FILE']); 37 | else process.env.CONFIG_FILE = program.config; 38 | 39 | const completePath = path.resolve(process.cwd(), process.env.CONFIG_FILE); 40 | const config = endsWith(process.env.CONFIG_FILE, '.js') ? 41 | require(completePath) : readJSONConfig(completePath); 42 | helpers.createBots(config); 43 | } 44 | 45 | export default run; 46 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-irc", 3 | "version": "2.6.2", 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": ">=6.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": "nyc --require babel-core/register _mocha -- test/*.test.js", 31 | "report": "nyc report --reporter=text-lcov | coveralls", 32 | "test": "npm run lint && npm run coverage" 33 | }, 34 | "author": { 35 | "name": "Reactiflux" 36 | }, 37 | "license": "MIT", 38 | "dependencies": { 39 | "check-env": "1.3.0", 40 | "commander": "2.18.0", 41 | "discord.js": "11.4.2", 42 | "irc-colors": "1.4.3", 43 | "irc-formatting": "1.0.0-rc3", 44 | "irc-upd": "0.10.0", 45 | "lodash": "^4.17.4", 46 | "simple-markdown": "0.4.2", 47 | "strip-json-comments": "2.0.1", 48 | "winston": "3.1.0" 49 | }, 50 | "devDependencies": { 51 | "babel-cli": "^6.6.5", 52 | "babel-core": "^6.7.4", 53 | "babel-eslint": "^8.0.1", 54 | "babel-preset-node6": "^11.0.0", 55 | "babel-preset-stage-0": "^6.5.0", 56 | "chai": "^4.0.2", 57 | "coveralls": "^3.0.0", 58 | "eslint": "^4.8.0", 59 | "eslint-config-airbnb-base": "^12.0.1", 60 | "eslint-plugin-import": "^2.7.0", 61 | "mocha": "^5.0.0", 62 | "nyc": "^13.0.1", 63 | "sinon": "^4.0.1", 64 | "sinon-chai": "^2.8.0" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /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.sandbox.create({ 18 | useFakeTimers: false, 19 | useFakeServer: false 20 | }); 21 | 22 | beforeEach(function () { 23 | this.connectStub = sandbox.stub(Bot.prototype, 'connect'); 24 | }); 25 | 26 | afterEach(function () { 27 | sandbox.restore(); 28 | }); 29 | 30 | it('should work when given an array of configs', function () { 31 | const bots = createBots(testConfig); 32 | bots.length.should.equal(2); 33 | this.connectStub.should.have.been.called; 34 | }); 35 | 36 | it('should work when given an object as a config file', function () { 37 | const bots = createBots(singleTestConfig); 38 | bots.length.should.equal(1); 39 | this.connectStub.should.have.been.called; 40 | }); 41 | 42 | it('should throw a configuration error if any fields are missing', function () { 43 | 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/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/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.sandbox.create({ 15 | useFakeTimers: false, 16 | useFakeServer: false 17 | }); 18 | 19 | beforeEach(function () { 20 | this.createBotsStub = sandbox.stub(helpers, 'createBots'); 21 | }); 22 | 23 | afterEach(function () { 24 | sandbox.restore(); 25 | }); 26 | 27 | it('should be possible to give the config as an env var', function () { 28 | process.env.CONFIG_FILE = `${process.cwd()}/test/fixtures/test-config.json`; 29 | process.argv = ['node', 'index.js']; 30 | cli(); 31 | this.createBotsStub.should.have.been.calledWith(testConfig); 32 | }); 33 | 34 | it('should strip comments from JSON config', function () { 35 | process.env.CONFIG_FILE = `${process.cwd()}/test/fixtures/test-config-comments.json`; 36 | process.argv = ['node', 'index.js']; 37 | cli(); 38 | this.createBotsStub.should.have.been.calledWith(testConfig); 39 | }); 40 | 41 | it('should support JS configs', function () { 42 | process.env.CONFIG_FILE = `${process.cwd()}/test/fixtures/test-javascript-config.js`; 43 | process.argv = ['node', 'index.js']; 44 | cli(); 45 | this.createBotsStub.should.have.been.calledWith(testConfig); 46 | }); 47 | 48 | it('should throw a ConfigurationError for invalid JSON', function () { 49 | process.env.CONFIG_FILE = `${process.cwd()}/test/fixtures/invalid-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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discord-irc [![Build Status](https://travis-ci.org/reactiflux/discord-irc.svg?branch=master)](https://travis-ci.org/reactiflux/discord-irc) [![Coverage Status](https://coveralls.io/repos/github/reactiflux/discord-irc/badge.svg?branch=master)](https://coveralls.io/github/reactiflux/discord-irc?branch=master) 2 | 3 | > Connects [Discord](https://discordapp.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 6 or newer, as it depends on [discord.js](https://github.com/hydrabolt/discord.js). 10 | 11 | Before you can run discord-irc you need to create a configuration file by 12 | following the instructions [here](https://github.com/reactiflux/discord-irc#configuration). 13 | After you've done that you can replace `/path/to/config.json` in the commands 14 | below with the path to your newly created configuration file - or just `config.json` if it's 15 | in the same directory as the one you're starting the bot from. 16 | 17 | When you've done that you can install and start the bot either through npm: 18 | 19 | ```bash 20 | $ npm install -g discord-irc 21 | $ discord-irc --config /path/to/config.json 22 | ``` 23 | 24 | or by cloning the repository: 25 | 26 | ```bash 27 | In the repository folder: 28 | $ npm install 29 | $ npm run build 30 | $ npm start -- --config /path/to/config.json # Note the extra double dash 31 | ``` 32 | 33 | It can also be used as a module: 34 | ```js 35 | import discordIRC from 'discord-irc'; 36 | import config from './config.json'; 37 | discordIRC(config); 38 | ``` 39 | 40 | ## Configuration 41 | 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). 42 | 43 | ### Example configuration 44 | ```js 45 | [ 46 | // Bot 1 (minimal configuration): 47 | { 48 | "nickname": "test2", 49 | "server": "irc.testbot.org", 50 | "discordToken": "botwantsin123", 51 | "channelMapping": { 52 | "#other-discord": "#new-irc-channel" 53 | } 54 | }, 55 | 56 | // Bot 2 (advanced options): 57 | { 58 | "nickname": "test", 59 | "server": "irc.bottest.org", 60 | "discordToken": "botwantsin123", 61 | "autoSendCommands": [ // Commands that will be sent on connect 62 | ["PRIVMSG", "NickServ", "IDENTIFY password"], 63 | ["MODE", "test", "+x"], 64 | ["AUTH", "test", "password"] 65 | ], 66 | "channelMapping": { // Maps each Discord-channel to an IRC-channel, used to direct messages to the correct place 67 | "#discord": "#irc channel-password", // Add channel keys after the channel name 68 | "1234567890": "#channel" // Use a discord channel ID instead of its name (so you can rename it or to disambiguate) 69 | }, 70 | "ircOptions": { // Optional node-irc options 71 | "floodProtection": false, // On by default 72 | "floodProtectionDelay": 1000, // 500 by default 73 | "port": "6697", // 6697 by default 74 | "secure": true, // enable SSL, false by default 75 | "sasl": true, // false by default 76 | "username": "test", // nodeirc by default 77 | "password": "p455w0rd" // empty by default 78 | }, 79 | "format": { // Optional custom formatting options 80 | // Patterns, represented by {$patternName}, are replaced when sending messages 81 | "commandPrelude": "Command sent by {$nickname}", // Message sent before a command 82 | "ircText": "<{$displayUsername}> {$text}", // When sending a message to IRC 83 | "urlAttachment": "<{$displayUsername}> {$attachmentURL}", // When sending a Discord attachment to IRC 84 | "discord": "**<{$author}>** {$withMentions}" // When sending a message to Discord 85 | // Other patterns that can be used: 86 | // {$discordChannel} (e.g. #general) 87 | // {$ircChannel} (e.g. #irc) 88 | }, 89 | "ircNickColor": false, // Gives usernames a color in IRC for better readability (on by default) 90 | // Makes the bot hide the username prefix for messages that start 91 | // with one of these characters (commands): 92 | "commandCharacters": ["!", "."], 93 | "ircStatusNotices": true, // Enables notifications in Discord when people join/part in the relevant IRC channel 94 | "ignoreUsers": { 95 | "irc": ["irc_nick1", "irc_nick2"], // Ignore specified IRC nicks and do not send their messages to Discord. 96 | "discord": ["discord_nick1", "discord_nick2"] // Ignore specified Discord nicks and do not send their messages to IRC. 97 | }, 98 | // List of webhooks per channel 99 | "webhooks": { 100 | "#discord": "https://discordapp.com/api/webhooks/id/token" 101 | } 102 | } 103 | ] 104 | ``` 105 | 106 | The `ircOptions` object is passed directly to irc-upd ([available options](https://node-irc-upd.readthedocs.io/en/latest/API.html#irc.Client)). 107 | 108 | 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. 109 | 110 | ### Webhooks 111 | Webhooks lets you override nicknames and avatars, so messages coming from IRC 112 | can appear as regular Discord messages: 113 | 114 | ![discord-webhook](http://i.imgur.com/lNeJIUI.jpg) 115 | 116 | To enable webhooks, follow part 1 of [this 117 | guide](https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks) 118 | to create and retrieve a webhook URL for a specific channel, then enable it in 119 | discord-irc's config as follows: 120 | 121 | ```json 122 | "webhooks": { 123 | "#discord-channel": "https://discordapp.com/api/webhooks/id/token" 124 | } 125 | ``` 126 | 127 | ### Encodings 128 | 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`. 129 | The bot will produce a warning when started if the IRC library is unable to convert between encodings. 130 | 131 | Further information can be found in [the installation section of irc-upd](https://github.com/Throne3d/node-irc#character-set-detection). 132 | 133 | ## Tests 134 | Run the tests with: 135 | ```bash 136 | $ npm test 137 | ``` 138 | 139 | ## Style Guide 140 | discord-irc follows the [Airbnb Style Guide](https://github.com/airbnb/javascript). 141 | [ESLint](http://eslint.org/) is used to make sure this is followed correctly, which can be run with: 142 | 143 | ```bash 144 | $ npm run lint 145 | ``` 146 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | ## [2.6.2] - 2018-09-19 5 | ### Changed 6 | * Upgraded dependencies. 7 | 8 | ## [2.6.1] - 2018-05-11 9 | ### Changed 10 | * Upgraded dependencies. 11 | 12 | ## [2.6.0] - 2018-03-22 13 | ### Added 14 | * Support for posting messages to Discord using webhooks (thanks to 15 | [Fiaxhs](https://github.com/reactiflux/discord-irc/pull/230)!). 16 | 17 | Webhooks lets you override nicknames and avatars, so messages coming from IRC 18 | can appear as regular Discord messages: 19 | 20 | ![discord-webhook](http://i.imgur.com/lNeJIUI.jpg) 21 | 22 | To enable webhooks, follow part 1 of [this 23 | guide](https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks) 24 | to create and retrieve a webhook URL for a specific channel, then enable it in 25 | discord-irc's config as follows: 26 | 27 | ```json 28 | "webhooks": { 29 | "#discord-channel": "https://discordapp.com/api/webhooks/id/token" 30 | } 31 | ``` 32 | 33 | ## [2.5.1] - 2018-01-18 34 | ### Fixed 35 | * Upgraded dependencies. 36 | 37 | ## [2.5.0] - 2017-10-27 38 | ### Added 39 | * Support multi-character command prefixes - [#301](https://github.com/reactiflux/discord-irc/pull/301) 40 | 41 | * 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) 42 | 43 | * Add the ability to ignore IRC/Discord users by nickname - [#322](https://github.com/reactiflux/discord-irc/pull/322) 44 | 45 | ### Fixed 46 | * Improve IRC → Discord mentions around non-word characters and nickname prefix matches - [#273](https://github.com/reactiflux/discord-irc/pull/273) 47 | 48 | * Default to UTF-8 encoding when bridging messages to prevent character corruption - [#315](https://github.com/reactiflux/discord-irc/pull/315) 49 | 50 | * Fix a crash when using the bot in a group DM - [#316](https://github.com/reactiflux/discord-irc/pull/316) 51 | 52 | * 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) 53 | 54 | * Update dependencies: 55 | 56 | - discord.js to 11.2.1 57 | - sinon to ^4.0.1 58 | - irc-upd to 0.8.0 - [#313](https://github.com/reactiflux/discord-irc/pull/313) 59 | - simple-markdown to ^0.3.1 60 | - coveralls to ^3.0.0 61 | - mocha to ^4.0.0 62 | - winston to 2.4.0 63 | 64 | ### Changed 65 | * Add a link to the IRC spec in the README - [#307](https://github.com/reactiflux/discord-irc/pull/307) 66 | 67 | * Drop testing for Node 7, add testing for Node 8 - [#329](https://github.com/reactiflux/discord-irc/pull/329) 68 | 69 | ## [2.4.2] - 2017-08-21 70 | ### Fixed 71 | * Tests: Use globbing instead of `find` so tests work on Windows - [#279](https://github.com/reactiflux/discord-irc/pull/279) 72 | 73 | ### Changed 74 | * 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) 75 | 76 | * Tests: Use Discord objects to simplify code - [#272](https://github.com/reactiflux/discord-irc/pull/272) 77 | 78 | ## [2.4.1] - 2017-07-16 79 | ### Added 80 | * Falsy command preludes are no longer sent (previously would choose default prelude) - [#260](https://github.com/reactiflux/discord-irc/pull/260) 81 | 82 | ### Fixed 83 | * 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) 84 | 85 | * Update dependency commander to 2.11.0 - [#262](https://github.com/reactiflux/discord-irc/pull/262) 86 | 87 | * Fix deprecation warning on `TextChannel#sendMessage` - [#267](https://github.com/reactiflux/discord-irc/pull/267) 88 | 89 | * Fix reconnection by updating dependency irc-upd to 0.6.2 - [#270](https://github.com/reactiflux/discord-irc/pull/270) 90 | 91 | ## [2.4.0] - 2017-07-01 92 | 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. 93 | 94 | ### Added 95 | * Allow commandCharacters to work for messages sent to Discord - [#221](https://github.com/reactiflux/discord-irc/pull/221). 96 | 97 | * 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). 98 | 99 | * Translate custom emoji references from IRC to Discord - [#256](https://github.com/reactiflux/discord-irc/pull/256). 100 | 101 | ### Fixed 102 | * 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). 103 | 104 | * 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). 105 | 106 | * Update dependencies: 107 | 108 | - discord.js to 11.1.0 109 | - check-env to 1.3.0 110 | - chai to ^4.0.2 111 | - nyc to ^11.0.3 112 | - commander to 2.10.0 113 | - eslint to ^4.1.1 114 | 115 | ## [2.3.3] - 2017-04-29 116 | ### Fixed 117 | * Warn if a part/quit is received and no channelUsers is set - 118 | [#218](https://github.com/reactiflux/discord-irc/pull/218). 119 | 120 | ## [2.3.2] - 2017-04-27 121 | ### Fixed 122 | * Fix ircStatucNotices when channels are not lowercase - 123 | [#219](https://github.com/reactiflux/discord-irc/pull/219). 124 | 125 | ## [2.3.1] - 2017-04-05 126 | ### Fixed 127 | * Fix IRC quit messages sending to all channels by tracking users - [#214](https://github.com/reactiflux/discord-irc/pull/214#pullrequestreview-31156291). 128 | 129 | ## [2.3.0] - 2017-04-03 130 | A huge thank you to [@Throne3d](https://github.com/Throne3d), 131 | [@rahatarmanahmed](https://github.com/rahatarmanahmed), [@mraof](https://github.com/mraof) 132 | and [@Ratismal](https://github.com/Ratismal) for all the fixes and features 133 | in this release. 134 | 135 | ### Added 136 | * Bridge IRC join/part/quit messages to Discord 137 | (enable by setting ircStatusNotices to true) - 138 | [#207](https://github.com/reactiflux/discord-irc/pull/207). 139 | 140 | * Convert text styles between IRC and Discord 141 | [#205](https://github.com/reactiflux/discord-irc/pull/205). 142 | 143 | * Allow users to configure the patterns of messages on 144 | IRC and Discord using the format options object 145 | [#204](https://github.com/reactiflux/discord-irc/pull/204). 146 | 147 | * Add Discord channel ID matching to the channel mapping 148 | [#202](https://github.com/reactiflux/discord-irc/pull/202). 149 | 150 | ### Fixed 151 | * Parse role mentions appropriately, as with channel and user mentions 152 | [#203](https://github.com/reactiflux/discord-irc/pull/203). 153 | 154 | * Make the bot not crash when a channel mentioned by ID fails to exist 155 | [#201](https://github.com/reactiflux/discord-irc/pull/201). 156 | 157 | ### Changed 158 | * Convert username mentions even if nickname is set - 159 | [#208](https://github.com/reactiflux/discord-irc/pull/208). 160 | 161 | ## [2.2.1] - 2017-03-12 162 | ### Fixed 163 | * Reverts the changes in 2.2.0 due to incompatibilities with different clients. 164 | See https://github.com/reactiflux/discord-irc/issues/196 for more 165 | information. 166 | 167 | ## [2.2.0] - 2017-03-06 168 | ### Fixed 169 | * Added a zero width character between each letter of the IRC nicknames, to 170 | avoid unwanted highlights. Fixed by @Sanqui in 171 | [#193](https://github.com/reactiflux/discord-irc/pull/193). 172 | 173 | ## [2.1.6] - 2017-01-10 174 | ### Fixed 175 | * Upgraded discord.js. 176 | 177 | ## [2.1.6] - 2016-12-08 178 | ### Fixed 179 | * Listen to warn events from Discord. 180 | * Listen to debug events from Discord (only in development). 181 | * Log info events upon connection, instead of debug 182 | 183 | ## [2.1.5] - 2016-11-17 184 | ### Fixed 185 | * Upgraded node-irc to 0.5.1, fixing #129. 186 | 187 | ## [2.1.4] - 2016-11-14 188 | ### Fixed 189 | * Added support for highlighting users by their nicknames, 190 | thanks to @DarkSpyro003. 191 | * Upgraded to discord.js v10. 192 | 193 | ## [2.1.3] - 2016-11-02 194 | ### Fixed 195 | * Send text messages only to text channels (thanks @dustinlacewell). 196 | 197 | ## [2.1.2] - 2016-11-01 198 | ### Fixed 199 | * Use nickname, not username, in command prelude. 200 | Thanks to @williamjacksn. 201 | 202 | ## [2.1.1] - 2016-10-21 203 | ### Fixed 204 | * A bug where Discord attachment URLs weren't posted to IRC, thanks to @LordAlderaan for the report. 205 | 206 | ## [2.1.0] - 2016-10-09 207 | ### Added 208 | * Messages sent to IRC will now use the correct server nickname, 209 | instead of the user's global username (thanks to @DarkSpyro003). 210 | 211 | ## [2.0.2] - 2016-10-02 212 | ### Fixed 213 | - Display custom emojis correctly, thanks to @macdja38. 214 | 215 | ## [2.0.0] - 2016-09-25 216 | ### Fixed 217 | - Upgrade to version 9.3 of discord.js. 218 | This removes support for Node.js versions older than v6, 219 | as that's the oldest discord.js supports. 220 | 221 | ## [1.0.3] - 2016-09-09 222 | ### Fixed 223 | - Replace changes in 1.0.2 with the #indev-old version 224 | of discord.js. 225 | 226 | ## [1.0.2] - 2016-09-09 227 | ### Fixed 228 | - Discord's API now requires bot tokens 229 | to be prefixed with "Bot". This adds 230 | a hotfix that does exactly that. 231 | 232 | ## [1.0.1] - 2016-06-19 233 | ### Fixed 234 | - Upgraded dependencies. 235 | 236 | ## [1.0.0] - 2016-05-06 237 | ### Changed 238 | - Breaking: discord-irc now uses tokens for authentication, instead of 239 | email/password, thanks to @TheDoctorsLife. See the README for more instructions. 240 | 241 | ## [0.8.2] - 2016-04-21 242 | ### Fixed 243 | - Enable auto reconnect for IRC and Discord. 244 | 245 | ## [0.8.1] - 2016-04-21 246 | ### Fixed 247 | - Upgrade discord.js to 7.0.1. 248 | 249 | ## [0.8.0] - 2016-04-04 250 | Implemented by @rce: 251 | ### Added 252 | - Support for messages containing both attachments and text. 253 | 254 | ### Changed 255 | - Attachment URLs are now posted by themselves, instead of including a 256 | preliminary message explaining that it's an attachment. 257 | 258 | ## [0.7.0] - 2016-04-04 259 | ### Added 260 | - Added the config option `ircNickColor` to make it possible to 261 | disable nick colors for messages sent to IRC. 262 | 263 | ## [0.6.1] - 2016-04-04 264 | ### Fixed 265 | - Upgrade dependencies. 266 | 267 | ## [0.6.0] - 2016-02-24 268 | ### Added 269 | - Highlight Discord users when they're mentioned on IRC (thanks to @rce). 270 | 271 | ## [0.5.0] - 2016-02-08 272 | ### Added 273 | - Discord attachments will be linked to on IRC when 274 | they're posted (fixed by @rce). 275 | 276 | ## [0.4.3] - 2016-01-23 277 | ### Fixed 278 | - Upgraded dependencies. 279 | - istanbul -> nyc for coverage. 280 | 281 | ## [0.4.1] - 2015-12-22 282 | ### Changed 283 | - Comments are now stripped from JSON configs before they're parsed. 284 | - Upgraded dependencies. 285 | 286 | ## [0.4.0] - 2015-11-11 287 | ### Added 288 | - Colors to IRC nicks. 289 | 290 | ## [0.3.0] - 2015-10-28 291 | ### Changed 292 | - Rewrote everything to ES6. 293 | 294 | ## [0.2.0] - 2015-10-28 295 | ### Added 296 | - Support for channel and username highlights from Discord to IRC. 297 | This means that e.g. #general will no longer result in something like #512312. 298 | 299 | ### Added 300 | - Working tests for all functionality. 301 | 302 | ## [0.1.1] - 2015-10-27 303 | ### Changed 304 | - Made `discord.irc` a regular dependency, instead of a devDependency. 305 | 306 | ## [0.1.0] - 2015-10-13 307 | ### Added 308 | - Initial implementation. 309 | -------------------------------------------------------------------------------- /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 ClientStub from './stubs/irc-client-stub'; 11 | import config from './fixtures/single-test-config.json'; 12 | 13 | chai.should(); 14 | chai.use(sinonChai); 15 | 16 | describe('Bot Events', function () { 17 | const sandbox = sinon.sandbox.create({ 18 | useFakeTimers: false, 19 | useFakeServer: false 20 | }); 21 | 22 | const createBot = (optConfig = null) => { 23 | const useConfig = optConfig || config; 24 | const bot = new Bot(useConfig); 25 | bot.sendToIRC = sandbox.stub(); 26 | bot.sendToDiscord = sandbox.stub(); 27 | bot.sendExactToDiscord = sandbox.stub(); 28 | return bot; 29 | }; 30 | 31 | beforeEach(function () { 32 | this.infoSpy = sandbox.stub(logger, 'info'); 33 | this.debugSpy = sandbox.stub(logger, 'debug'); 34 | this.warnSpy = sandbox.stub(logger, 'warn'); 35 | this.errorSpy = sandbox.stub(logger, 'error'); 36 | this.sendStub = sandbox.stub(); 37 | this.getUserStub = sandbox.stub(); 38 | irc.Client = ClientStub; 39 | discord.Client = createDiscordStub(this.sendStub, this.getUserStub); 40 | ClientStub.prototype.send = sandbox.stub(); 41 | ClientStub.prototype.join = sandbox.stub(); 42 | this.bot = createBot(); 43 | this.bot.connect(); 44 | }); 45 | 46 | afterEach(function () { 47 | sandbox.restore(); 48 | }); 49 | 50 | it('should log on discord ready event', function () { 51 | this.bot.discord.emit('ready'); 52 | this.infoSpy.should.have.been.calledWithExactly('Connected to Discord'); 53 | }); 54 | 55 | it('should log on irc registered event', function () { 56 | const message = 'registered'; 57 | this.bot.ircClient.emit('registered', message); 58 | this.infoSpy.should.have.been.calledWithExactly('Connected to IRC'); 59 | this.debugSpy.should.have.been.calledWithExactly('Registered event: ', message); 60 | }); 61 | 62 | it('should try to send autoSendCommands on registered IRC event', function () { 63 | this.bot.ircClient.emit('registered'); 64 | ClientStub.prototype.send.should.have.been.calledTwice; 65 | ClientStub.prototype.send.getCall(0) 66 | .args.should.deep.equal(config.autoSendCommands[0]); 67 | ClientStub.prototype.send.getCall(1) 68 | .args.should.deep.equal(config.autoSendCommands[1]); 69 | }); 70 | 71 | it('should error log on error events', function () { 72 | const discordError = new Error('discord'); 73 | const ircError = new Error('irc'); 74 | this.bot.discord.emit('error', discordError); 75 | this.bot.ircClient.emit('error', ircError); 76 | this.errorSpy.getCall(0).args[0].should.equal('Received error event from Discord'); 77 | this.errorSpy.getCall(0).args[1].should.equal(discordError); 78 | this.errorSpy.getCall(1).args[0].should.equal('Received error event from IRC'); 79 | this.errorSpy.getCall(1).args[1].should.equal(ircError); 80 | }); 81 | 82 | it('should warn log on warn events from discord', function () { 83 | const discordError = new Error('discord'); 84 | this.bot.discord.emit('warn', discordError); 85 | const [message, error] = this.warnSpy.firstCall.args; 86 | message.should.equal('Received warn event from Discord'); 87 | error.should.equal(discordError); 88 | }); 89 | 90 | it('should send messages to irc if correct', function () { 91 | const message = { 92 | type: 'message' 93 | }; 94 | 95 | this.bot.discord.emit('message', message); 96 | this.bot.sendToIRC.should.have.been.calledWithExactly(message); 97 | }); 98 | 99 | it('should send messages to discord', function () { 100 | const channel = '#channel'; 101 | const author = 'user'; 102 | const text = 'hi'; 103 | this.bot.ircClient.emit('message', author, channel, text); 104 | this.bot.sendToDiscord.should.have.been.calledWithExactly(author, channel, text); 105 | }); 106 | 107 | it('should send notices to discord', function () { 108 | const channel = '#channel'; 109 | const author = 'user'; 110 | const text = 'hi'; 111 | const formattedText = `*${text}*`; 112 | this.bot.ircClient.emit('notice', author, channel, text); 113 | this.bot.sendToDiscord.should.have.been.calledWithExactly(author, channel, formattedText); 114 | }); 115 | 116 | it('should not send name change event to discord', function () { 117 | const channel = '#channel'; 118 | const oldnick = 'user1'; 119 | const newnick = 'user2'; 120 | this.bot.ircClient.emit('nick', oldnick, newnick, [channel]); 121 | this.bot.sendExactToDiscord.should.not.have.been.called; 122 | }); 123 | 124 | it('should send name change event to discord', function () { 125 | const channel1 = '#channel1'; 126 | const channel2 = '#channel2'; 127 | const channel3 = '#channel3'; 128 | const oldNick = 'user1'; 129 | const newNick = 'user2'; 130 | const user3 = 'user3'; 131 | const bot = createBot({ ...config, ircStatusNotices: true }); 132 | const staticChannel = new Set([bot.nickname, user3]); 133 | bot.connect(); 134 | bot.ircClient.emit('names', channel1, { [bot.nickname]: '', [oldNick]: '' }); 135 | bot.ircClient.emit('names', channel2, { [bot.nickname]: '', [user3]: '' }); 136 | const channelNicksPre = new Set([bot.nickname, oldNick]); 137 | bot.channelUsers.should.deep.equal({ '#channel1': channelNicksPre, '#channel2': staticChannel }); 138 | const formattedText = `*${oldNick}* is now known as ${newNick}`; 139 | const channelNicksAfter = new Set([bot.nickname, newNick]); 140 | bot.ircClient.emit('nick', oldNick, newNick, [channel1, channel2, channel3]); 141 | bot.sendExactToDiscord.should.have.been.calledWithExactly(channel1, formattedText); 142 | bot.channelUsers.should.deep.equal({ '#channel1': channelNicksAfter, '#channel2': staticChannel }); 143 | }); 144 | 145 | it('should send actions to discord', function () { 146 | const channel = '#channel'; 147 | const author = 'user'; 148 | const text = 'hi'; 149 | const formattedText = '_hi_'; 150 | const message = {}; 151 | this.bot.ircClient.emit('action', author, channel, text, message); 152 | this.bot.sendToDiscord.should.have.been.calledWithExactly(author, channel, formattedText); 153 | }); 154 | 155 | it('should keep track of users through names event when irc status notices enabled', function () { 156 | const bot = createBot({ ...config, ircStatusNotices: true }); 157 | bot.connect(); 158 | bot.channelUsers.should.be.an('object'); 159 | const channel = '#channel'; 160 | // nick => '' means the user is not a special user 161 | const nicks = { 162 | [bot.nickname]: '', user: '', user2: '@', user3: '+' 163 | }; 164 | bot.ircClient.emit('names', channel, nicks); 165 | const channelNicks = new Set([bot.nickname, 'user', 'user2', 'user3']); 166 | bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); 167 | }); 168 | 169 | it('should lowercase the channelUsers mapping', function () { 170 | const bot = createBot({ ...config, ircStatusNotices: true }); 171 | bot.connect(); 172 | const channel = '#channelName'; 173 | const nicks = { [bot.nickname]: '' }; 174 | bot.ircClient.emit('names', channel, nicks); 175 | const channelNicks = new Set([bot.nickname]); 176 | bot.channelUsers.should.deep.equal({ '#channelname': channelNicks }); 177 | }); 178 | 179 | it('should send join messages to discord when config enabled', function () { 180 | const bot = createBot({ ...config, ircStatusNotices: true }); 181 | bot.connect(); 182 | const channel = '#channel'; 183 | bot.ircClient.emit('names', channel, { [bot.nickname]: '' }); 184 | const nick = 'user'; 185 | const text = `*${nick}* has joined the channel`; 186 | bot.ircClient.emit('join', channel, nick); 187 | bot.sendExactToDiscord.should.have.been.calledWithExactly(channel, text); 188 | const channelNicks = new Set([bot.nickname, nick]); 189 | bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); 190 | }); 191 | 192 | it('should not announce itself joining by default', function () { 193 | const bot = createBot({ ...config, ircStatusNotices: true }); 194 | bot.connect(); 195 | const channel = '#channel'; 196 | bot.ircClient.emit('names', channel, { [bot.nickname]: '' }); 197 | const nick = bot.nickname; 198 | bot.ircClient.emit('join', channel, nick); 199 | bot.sendExactToDiscord.should.not.have.been.called; 200 | const channelNicks = new Set([bot.nickname]); 201 | bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); 202 | }); 203 | 204 | it('should announce the bot itself when config enabled', function () { 205 | // self-join is announced before names (which includes own nick) 206 | // hence don't trigger a names and don't expect anything of bot.channelUsers 207 | const bot = createBot({ ...config, ircStatusNotices: true, announceSelfJoin: true }); 208 | bot.connect(); 209 | const channel = '#channel'; 210 | const nick = this.bot.nickname; 211 | const text = `*${nick}* has joined the channel`; 212 | bot.ircClient.emit('join', channel, nick); 213 | bot.sendExactToDiscord.should.have.been.calledWithExactly(channel, text); 214 | }); 215 | 216 | it('should send part messages to discord when config enabled', function () { 217 | const bot = createBot({ ...config, ircStatusNotices: true }); 218 | bot.connect(); 219 | const channel = '#channel'; 220 | const nick = 'user'; 221 | bot.ircClient.emit('names', channel, { [bot.nickname]: '', [nick]: '' }); 222 | const originalNicks = new Set([bot.nickname, nick]); 223 | bot.channelUsers.should.deep.equal({ '#channel': originalNicks }); 224 | const reason = 'Leaving'; 225 | const text = `*${nick}* has left the channel (${reason})`; 226 | bot.ircClient.emit('part', channel, nick, reason); 227 | bot.sendExactToDiscord.should.have.been.calledWithExactly(channel, text); 228 | // it should remove the nickname from the channelUsers list 229 | const channelNicks = new Set([bot.nickname]); 230 | bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); 231 | }); 232 | 233 | it('should not announce itself leaving a channel', function () { 234 | const bot = createBot({ ...config, ircStatusNotices: true }); 235 | bot.connect(); 236 | const channel = '#channel'; 237 | bot.ircClient.emit('names', channel, { [bot.nickname]: '', user: '' }); 238 | const originalNicks = new Set([bot.nickname, 'user']); 239 | bot.channelUsers.should.deep.equal({ '#channel': originalNicks }); 240 | const reason = 'Leaving'; 241 | bot.ircClient.emit('part', channel, bot.nickname, reason); 242 | bot.sendExactToDiscord.should.not.have.been.called; 243 | // it should remove the nickname from the channelUsers list 244 | bot.channelUsers.should.deep.equal({}); 245 | }); 246 | 247 | it('should only send quit messages to discord for channels the user is tracked in', function () { 248 | const bot = createBot({ ...config, ircStatusNotices: true }); 249 | bot.connect(); 250 | const channel1 = '#channel1'; 251 | const channel2 = '#channel2'; 252 | const channel3 = '#channel3'; 253 | const nick = 'user'; 254 | bot.ircClient.emit('names', channel1, { [bot.nickname]: '', [nick]: '' }); 255 | bot.ircClient.emit('names', channel2, { [bot.nickname]: '' }); 256 | bot.ircClient.emit('names', channel3, { [bot.nickname]: '', [nick]: '' }); 257 | const reason = 'Quit: Leaving'; 258 | const text = `*${nick}* has quit (${reason})`; 259 | // send quit message for all channels on server, as the node-irc library does 260 | bot.ircClient.emit('quit', nick, reason, [channel1, channel2, channel3]); 261 | bot.sendExactToDiscord.should.have.been.calledTwice; 262 | bot.sendExactToDiscord.getCall(0).args.should.deep.equal([channel1, text]); 263 | bot.sendExactToDiscord.getCall(1).args.should.deep.equal([channel3, text]); 264 | }); 265 | 266 | it('should not crash with join/part/quit messages and weird channel casing', function () { 267 | const bot = createBot({ ...config, ircStatusNotices: true }); 268 | bot.connect(); 269 | 270 | function wrap() { 271 | const nick = 'user'; 272 | const reason = 'Leaving'; 273 | bot.ircClient.emit('names', '#Channel', { [bot.nickname]: '' }); 274 | bot.ircClient.emit('join', '#cHannel', nick); 275 | bot.ircClient.emit('part', '#chAnnel', nick, reason); 276 | bot.ircClient.emit('join', '#chaNnel', nick); 277 | bot.ircClient.emit('quit', nick, reason, ['#chanNel']); 278 | } 279 | (wrap).should.not.throw(); 280 | }); 281 | 282 | it('should be possible to disable join/part/quit messages', function () { 283 | const bot = createBot({ ...config, ircStatusNotices: false }); 284 | bot.connect(); 285 | const channel = '#channel'; 286 | const nick = 'user'; 287 | const reason = 'Leaving'; 288 | 289 | bot.ircClient.emit('names', channel, { [bot.nickname]: '' }); 290 | bot.ircClient.emit('join', channel, nick); 291 | bot.ircClient.emit('part', channel, nick, reason); 292 | bot.ircClient.emit('join', channel, nick); 293 | bot.ircClient.emit('quit', nick, reason, [channel]); 294 | bot.sendExactToDiscord.should.not.have.been.called; 295 | }); 296 | 297 | it('should warn if it receives a part/quit before a names event', function () { 298 | const bot = createBot({ ...config, ircStatusNotices: true }); 299 | bot.connect(); 300 | const channel = '#channel'; 301 | const reason = 'Leaving'; 302 | 303 | bot.ircClient.emit('part', channel, 'user1', reason); 304 | bot.ircClient.emit('quit', 'user2', reason, [channel]); 305 | this.warnSpy.should.have.been.calledTwice; 306 | this.warnSpy.getCall(0).args.should.deep.equal([`No channelUsers found for ${channel} when user1 parted.`]); 307 | this.warnSpy.getCall(1).args.should.deep.equal([`No channelUsers found for ${channel} when user2 quit, ignoring.`]); 308 | }); 309 | 310 | it('should not crash if it uses a different name from config', function () { 311 | // this can happen when a user with the same name is already connected 312 | const bot = createBot({ ...config, nickname: 'testbot' }); 313 | bot.connect(); 314 | const newName = 'testbot1'; 315 | bot.ircClient.nick = newName; 316 | function wrap() { 317 | bot.ircClient.emit('join', '#channel', newName); 318 | } 319 | (wrap).should.not.throw; 320 | }); 321 | 322 | it('should not listen to discord debug messages in production', function () { 323 | logger.level = 'info'; 324 | const bot = createBot(); 325 | bot.connect(); 326 | const listeners = bot.discord.listeners('debug'); 327 | listeners.length.should.equal(0); 328 | }); 329 | 330 | it('should listen to discord debug messages in development', function () { 331 | logger.level = 'debug'; 332 | const bot = createBot(); 333 | bot.connect(); 334 | const listeners = bot.discord.listeners('debug'); 335 | listeners.length.should.equal(1); 336 | }); 337 | 338 | it('should join channels when invited', function () { 339 | const channel = '#irc'; 340 | const author = 'user'; 341 | this.bot.ircClient.emit('invite', channel, author); 342 | const firstCall = this.debugSpy.getCall(1); 343 | firstCall.args[0].should.equal('Received invite:'); 344 | firstCall.args[1].should.equal(channel); 345 | firstCall.args[2].should.equal(author); 346 | 347 | ClientStub.prototype.join.should.have.been.calledWith(channel); 348 | const secondCall = this.debugSpy.getCall(2); 349 | secondCall.args[0].should.equal('Joining channel:'); 350 | secondCall.args[1].should.equal(channel); 351 | }); 352 | 353 | it('should not join channels that aren\'t in the channel mapping', function () { 354 | const channel = '#wrong'; 355 | const author = 'user'; 356 | this.bot.ircClient.emit('invite', channel, author); 357 | const firstCall = this.debugSpy.getCall(1); 358 | firstCall.args[0].should.equal('Received invite:'); 359 | firstCall.args[1].should.equal(channel); 360 | firstCall.args[2].should.equal(author); 361 | 362 | ClientStub.prototype.join.should.not.have.been.called; 363 | const secondCall = this.debugSpy.getCall(2); 364 | secondCall.args[0].should.equal('Channel not found in config, not joining:'); 365 | secondCall.args[1].should.equal(channel); 366 | }); 367 | }); 368 | -------------------------------------------------------------------------------- /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 | const REQUIRED_FIELDS = ['server', 'nickname', 'channelMapping', 'discordToken']; 10 | const NICK_COLORS = ['light_blue', 'dark_blue', 'light_red', 'dark_red', 'light_green', 11 | 'dark_green', 'magenta', 'light_magenta', 'orange', 'yellow', 'cyan', 'light_cyan']; 12 | const patternMatch = /{\$(.+?)}/g; 13 | 14 | /** 15 | * An IRC bot, works as a middleman for all communication 16 | * @param {object} options - server, nickname, channelMapping, outgoingToken, incomingURL 17 | */ 18 | class Bot { 19 | constructor(options) { 20 | REQUIRED_FIELDS.forEach((field) => { 21 | if (!options[field]) { 22 | throw new ConfigurationError(`Missing configuration field ${field}`); 23 | } 24 | }); 25 | 26 | validateChannelMapping(options.channelMapping); 27 | 28 | this.discord = new discord.Client({ autoReconnect: true }); 29 | 30 | this.server = options.server; 31 | this.nickname = options.nickname; 32 | this.ircOptions = options.ircOptions; 33 | this.discordToken = options.discordToken; 34 | this.commandCharacters = options.commandCharacters || []; 35 | this.ircNickColor = options.ircNickColor !== false; // default to true 36 | this.channels = _.values(options.channelMapping); 37 | this.ircStatusNotices = options.ircStatusNotices; 38 | this.announceSelfJoin = options.announceSelfJoin; 39 | this.webhookOptions = options.webhooks; 40 | 41 | // Nicks to ignore 42 | this.ignoreUsers = options.ignoreUsers || {}; 43 | this.ignoreUsers.irc = this.ignoreUsers.irc || []; 44 | this.ignoreUsers.discord = this.ignoreUsers.discord || []; 45 | 46 | // "{$keyName}" => "variableValue" 47 | // author/nickname: nickname of the user who sent the message 48 | // discordChannel: Discord channel (e.g. #general) 49 | // ircChannel: IRC channel (e.g. #irc) 50 | // text: the (appropriately formatted) message content 51 | this.format = options.format || {}; 52 | 53 | // "{$keyName}" => "variableValue" 54 | // displayUsername: nickname with wrapped colors 55 | // attachmentURL: the URL of the attachment (only applicable in formatURLAttachment) 56 | this.formatIRCText = this.format.ircText || '<{$displayUsername}> {$text}'; 57 | this.formatURLAttachment = this.format.urlAttachment || '<{$displayUsername}> {$attachmentURL}'; 58 | // "{$keyName}" => "variableValue" 59 | // side: "Discord" or "IRC" 60 | if ('commandPrelude' in this.format) { 61 | this.formatCommandPrelude = this.format.commandPrelude; 62 | } else { 63 | this.formatCommandPrelude = 'Command sent from {$side} by {$nickname}:'; 64 | } 65 | 66 | // "{$keyName}" => "variableValue" 67 | // withMentions: text with appropriate mentions reformatted 68 | this.formatDiscord = this.format.discord || '**<{$author}>** {$withMentions}'; 69 | 70 | // Keep track of { channel => [list, of, usernames] } for ircStatusNotices 71 | this.channelUsers = {}; 72 | 73 | this.channelMapping = {}; 74 | this.webhooks = {}; 75 | 76 | // Remove channel passwords from the mapping and lowercase IRC channel names 77 | _.forOwn(options.channelMapping, (ircChan, discordChan) => { 78 | this.channelMapping[discordChan] = ircChan.split(' ')[0].toLowerCase(); 79 | }); 80 | 81 | this.invertedMapping = _.invert(this.channelMapping); 82 | this.autoSendCommands = options.autoSendCommands || []; 83 | } 84 | 85 | connect() { 86 | logger.debug('Connecting to IRC and Discord'); 87 | this.discord.login(this.discordToken); 88 | 89 | // Extract id and token from Webhook urls and connect. 90 | _.forOwn(this.webhookOptions, (url, channel) => { 91 | const [id, token] = url.split('/').slice(-2); 92 | const client = new discord.WebhookClient(id, token); 93 | this.webhooks[channel] = { 94 | id, 95 | client 96 | }; 97 | }); 98 | 99 | const ircOptions = { 100 | userName: this.nickname, 101 | realName: this.nickname, 102 | channels: this.channels, 103 | floodProtection: true, 104 | floodProtectionDelay: 500, 105 | retryCount: 10, 106 | autoRenick: true, 107 | // options specified in the configuration file override the above defaults 108 | ...this.ircOptions 109 | }; 110 | 111 | // default encoding to UTF-8 so messages to Discord aren't corrupted 112 | if (!Object.prototype.hasOwnProperty.call(ircOptions, 'encoding')) { 113 | if (irc.canConvertEncoding()) { 114 | ircOptions.encoding = 'utf-8'; 115 | } else { 116 | logger.warn('Cannot convert message encoding; you may encounter corrupted characters with non-English text.\n' + 117 | 'For information on how to fix this, please see: https://github.com/Throne3d/node-irc#character-set-detection'); 118 | } 119 | } 120 | 121 | this.ircClient = new irc.Client(this.server, this.nickname, ircOptions); 122 | this.attachListeners(); 123 | } 124 | 125 | attachListeners() { 126 | this.discord.on('ready', () => { 127 | logger.info('Connected to Discord'); 128 | }); 129 | 130 | this.ircClient.on('registered', (message) => { 131 | logger.info('Connected to IRC'); 132 | logger.debug('Registered event: ', message); 133 | this.autoSendCommands.forEach((element) => { 134 | this.ircClient.send(...element); 135 | }); 136 | }); 137 | 138 | this.ircClient.on('error', (error) => { 139 | logger.error('Received error event from IRC', error); 140 | }); 141 | 142 | this.discord.on('error', (error) => { 143 | logger.error('Received error event from Discord', error); 144 | }); 145 | 146 | this.discord.on('warn', (warning) => { 147 | logger.warn('Received warn event from Discord', warning); 148 | }); 149 | 150 | this.discord.on('message', (message) => { 151 | // Ignore bot messages and people leaving/joining 152 | this.sendToIRC(message); 153 | }); 154 | 155 | this.ircClient.on('message', this.sendToDiscord.bind(this)); 156 | 157 | this.ircClient.on('notice', (author, to, text) => { 158 | this.sendToDiscord(author, to, `*${text}*`); 159 | }); 160 | 161 | this.ircClient.on('nick', (oldNick, newNick, channels) => { 162 | if (!this.ircStatusNotices) return; 163 | channels.forEach((channelName) => { 164 | const channel = channelName.toLowerCase(); 165 | if (this.channelUsers[channel]) { 166 | if (this.channelUsers[channel].has(oldNick)) { 167 | this.channelUsers[channel].delete(oldNick); 168 | this.channelUsers[channel].add(newNick); 169 | this.sendExactToDiscord(channel, `*${oldNick}* is now known as ${newNick}`); 170 | } 171 | } else { 172 | logger.warn(`No channelUsers found for ${channel} when ${oldNick} changed.`); 173 | } 174 | }); 175 | }); 176 | 177 | this.ircClient.on('join', (channelName, nick) => { 178 | logger.debug('Received join:', channelName, nick); 179 | if (!this.ircStatusNotices) return; 180 | if (nick === this.ircClient.nick && !this.announceSelfJoin) return; 181 | const channel = channelName.toLowerCase(); 182 | // self-join is announced before names (which includes own nick) 183 | // so don't add nick to channelUsers 184 | if (nick !== this.ircClient.nick) this.channelUsers[channel].add(nick); 185 | this.sendExactToDiscord(channel, `*${nick}* has joined the channel`); 186 | }); 187 | 188 | this.ircClient.on('part', (channelName, nick, reason) => { 189 | logger.debug('Received part:', channelName, nick, reason); 190 | if (!this.ircStatusNotices) return; 191 | const channel = channelName.toLowerCase(); 192 | // remove list of users when no longer in channel (as it will become out of date) 193 | if (nick === this.ircClient.nick) { 194 | logger.debug('Deleting channelUsers as bot parted:', channel); 195 | delete this.channelUsers[channel]; 196 | return; 197 | } 198 | if (this.channelUsers[channel]) { 199 | this.channelUsers[channel].delete(nick); 200 | } else { 201 | logger.warn(`No channelUsers found for ${channel} when ${nick} parted.`); 202 | } 203 | this.sendExactToDiscord(channel, `*${nick}* has left the channel (${reason})`); 204 | }); 205 | 206 | this.ircClient.on('quit', (nick, reason, channels) => { 207 | logger.debug('Received quit:', nick, channels); 208 | if (!this.ircStatusNotices || nick === this.ircClient.nick) return; 209 | channels.forEach((channelName) => { 210 | const channel = channelName.toLowerCase(); 211 | if (!this.channelUsers[channel]) { 212 | logger.warn(`No channelUsers found for ${channel} when ${nick} quit, ignoring.`); 213 | return; 214 | } 215 | if (!this.channelUsers[channel].delete(nick)) return; 216 | this.sendExactToDiscord(channel, `*${nick}* has quit (${reason})`); 217 | }); 218 | }); 219 | 220 | this.ircClient.on('names', (channelName, nicks) => { 221 | logger.debug('Received names:', channelName, nicks); 222 | if (!this.ircStatusNotices) return; 223 | const channel = channelName.toLowerCase(); 224 | this.channelUsers[channel] = new Set(Object.keys(nicks)); 225 | }); 226 | 227 | this.ircClient.on('action', (author, to, text) => { 228 | this.sendToDiscord(author, to, `_${text}_`); 229 | }); 230 | 231 | this.ircClient.on('invite', (channel, from) => { 232 | logger.debug('Received invite:', channel, from); 233 | if (!this.invertedMapping[channel]) { 234 | logger.debug('Channel not found in config, not joining:', channel); 235 | } else { 236 | this.ircClient.join(channel); 237 | logger.debug('Joining channel:', channel); 238 | } 239 | }); 240 | 241 | if (logger.level === 'debug') { 242 | this.discord.on('debug', (message) => { 243 | logger.debug('Received debug event from Discord', message); 244 | }); 245 | } 246 | } 247 | 248 | static getDiscordNicknameOnServer(user, guild) { 249 | if (guild) { 250 | const userDetails = guild.members.get(user.id); 251 | if (userDetails) { 252 | return userDetails.nickname || user.username; 253 | } 254 | } 255 | return user.username; 256 | } 257 | 258 | parseText(message) { 259 | const text = message.mentions.users.reduce((content, mention) => { 260 | const displayName = Bot.getDiscordNicknameOnServer(mention, message.guild); 261 | return content.replace(`<@${mention.id}>`, `@${displayName}`) 262 | .replace(`<@!${mention.id}>`, `@${displayName}`) 263 | .replace(`<@&${mention.id}>`, `@${displayName}`); 264 | }, message.content); 265 | 266 | return text 267 | .replace(/\n|\r\n|\r/g, ' ') 268 | .replace(/<#(\d+)>/g, (match, channelId) => { 269 | const channel = this.discord.channels.get(channelId); 270 | if (channel) return `#${channel.name}`; 271 | return '#deleted-channel'; 272 | }) 273 | .replace(/<@&(\d+)>/g, (match, roleId) => { 274 | const role = message.guild.roles.get(roleId); 275 | if (role) return `@${role.name}`; 276 | return '@deleted-role'; 277 | }) 278 | .replace(//g, (match, emoteName) => emoteName); 279 | } 280 | 281 | isCommandMessage(message) { 282 | return this.commandCharacters.some(prefix => message.startsWith(prefix)); 283 | } 284 | 285 | ignoredIrcUser(user) { 286 | return this.ignoreUsers.irc.some(i => i.toLowerCase() === user.toLowerCase()); 287 | } 288 | 289 | ignoredDiscordUser(user) { 290 | return this.ignoreUsers.discord.some(i => i.toLowerCase() === user.toLowerCase()); 291 | } 292 | 293 | static substitutePattern(message, patternMapping) { 294 | return message.replace(patternMatch, (match, varName) => patternMapping[varName] || match); 295 | } 296 | 297 | sendToIRC(message) { 298 | const { author } = message; 299 | // Ignore messages sent by the bot itself: 300 | if (author.id === this.discord.user.id || 301 | Object.keys(this.webhooks).some(channel => this.webhooks[channel].id === author.id) 302 | ) return; 303 | 304 | // Do not send to IRC if this user is on the ignore list. 305 | if (this.ignoredDiscordUser(author.username)) { 306 | return; 307 | } 308 | 309 | const channelName = `#${message.channel.name}`; 310 | const ircChannel = this.channelMapping[message.channel.id] || 311 | this.channelMapping[channelName]; 312 | 313 | logger.debug('Channel Mapping', channelName, this.channelMapping[channelName]); 314 | if (ircChannel) { 315 | const fromGuild = message.guild; 316 | const nickname = Bot.getDiscordNicknameOnServer(author, fromGuild); 317 | let text = this.parseText(message); 318 | let displayUsername = nickname; 319 | if (this.ircNickColor) { 320 | const colorIndex = (nickname.charCodeAt(0) + nickname.length) % NICK_COLORS.length; 321 | displayUsername = irc.colors.wrap(NICK_COLORS[colorIndex], nickname); 322 | } 323 | 324 | const patternMap = { 325 | author: nickname, 326 | nickname, 327 | displayUsername, 328 | text, 329 | discordChannel: channelName, 330 | ircChannel 331 | }; 332 | 333 | if (this.isCommandMessage(text)) { 334 | patternMap.side = 'Discord'; 335 | logger.debug('Sending command message to IRC', ircChannel, text); 336 | // if (prelude) this.ircClient.say(ircChannel, prelude); 337 | if (this.formatCommandPrelude) { 338 | const prelude = Bot.substitutePattern(this.formatCommandPrelude, patternMap); 339 | this.ircClient.say(ircChannel, prelude); 340 | } 341 | this.ircClient.say(ircChannel, text); 342 | } else { 343 | if (text !== '') { 344 | // Convert formatting 345 | text = formatFromDiscordToIRC(text); 346 | patternMap.text = text; 347 | 348 | text = Bot.substitutePattern(this.formatIRCText, patternMap); 349 | logger.debug('Sending message to IRC', ircChannel, text); 350 | this.ircClient.say(ircChannel, text); 351 | } 352 | 353 | if (message.attachments && message.attachments.size) { 354 | message.attachments.forEach((a) => { 355 | patternMap.attachmentURL = a.url; 356 | const urlMessage = Bot.substitutePattern(this.formatURLAttachment, patternMap); 357 | 358 | logger.debug('Sending attachment URL to IRC', ircChannel, urlMessage); 359 | this.ircClient.say(ircChannel, urlMessage); 360 | }); 361 | } 362 | } 363 | } 364 | } 365 | 366 | findDiscordChannel(ircChannel) { 367 | const discordChannelName = this.invertedMapping[ircChannel.toLowerCase()]; 368 | if (discordChannelName) { 369 | // #channel -> channel before retrieving and select only text channels: 370 | let discordChannel = null; 371 | 372 | if (this.discord.channels.has(discordChannelName)) { 373 | discordChannel = this.discord.channels.get(discordChannelName); 374 | } else if (discordChannelName.startsWith('#')) { 375 | discordChannel = this.discord.channels 376 | .filter(c => c.type === 'text') 377 | .find(c => c.name === discordChannelName.slice(1)); 378 | } 379 | 380 | if (!discordChannel) { 381 | logger.info( 382 | 'Tried to send a message to a channel the bot isn\'t in: ', 383 | discordChannelName 384 | ); 385 | return null; 386 | } 387 | return discordChannel; 388 | } 389 | return null; 390 | } 391 | 392 | findWebhook(ircChannel) { 393 | const discordChannelName = this.invertedMapping[ircChannel.toLowerCase()]; 394 | return discordChannelName && this.webhooks[discordChannelName]; 395 | } 396 | 397 | getDiscordAvatar(nick, channel) { 398 | const guildMembers = this.findDiscordChannel(channel).guild.members; 399 | const findByNicknameOrUsername = caseSensitive => 400 | (member) => { 401 | if (caseSensitive) { 402 | return member.user.username === nick || member.nickname === nick; 403 | } 404 | const nickLowerCase = nick.toLowerCase(); 405 | return member.user.username.toLowerCase() === nickLowerCase 406 | || (member.nickname && member.nickname.toLowerCase() === nickLowerCase); 407 | }; 408 | 409 | // Try to find exact matching case 410 | let users = guildMembers.filter(findByNicknameOrUsername(true)); 411 | 412 | // Now let's search case insensitive. 413 | if (users.size === 0) { 414 | users = guildMembers.filter(findByNicknameOrUsername(false)); 415 | } 416 | 417 | // No matching user or more than one => no avatar 418 | if (users && users.size === 1) { 419 | return users.first().user.avatarURL; 420 | } 421 | return null; 422 | } 423 | 424 | // compare two strings case-insensitively 425 | // for discord mention matching 426 | static caseComp(str1, str2) { 427 | return str1.toUpperCase() === str2.toUpperCase(); 428 | } 429 | 430 | // check if the first string starts with the second case-insensitively 431 | // for discord mention matching 432 | static caseStartsWith(str1, str2) { 433 | return str1.toUpperCase().startsWith(str2.toUpperCase()); 434 | } 435 | 436 | sendToDiscord(author, channel, text) { 437 | const discordChannel = this.findDiscordChannel(channel); 438 | if (!discordChannel) return; 439 | 440 | // Do not send to Discord if this user is on the ignore list. 441 | if (this.ignoredIrcUser(author)) { 442 | return; 443 | } 444 | 445 | // Convert text formatting (bold, italics, underscore) 446 | const withFormat = formatFromIRCToDiscord(text); 447 | 448 | const patternMap = { 449 | author, 450 | nickname: author, 451 | displayUsername: author, 452 | text: withFormat, 453 | discordChannel: `#${discordChannel.name}`, 454 | ircChannel: channel 455 | }; 456 | 457 | if (this.isCommandMessage(text)) { 458 | patternMap.side = 'IRC'; 459 | logger.debug('Sending command message to Discord', `#${discordChannel.name}`, text); 460 | if (this.formatCommandPrelude) { 461 | const prelude = Bot.substitutePattern(this.formatCommandPrelude, patternMap); 462 | discordChannel.send(prelude); 463 | } 464 | discordChannel.send(text); 465 | return; 466 | } 467 | 468 | const { guild } = discordChannel; 469 | const withMentions = withFormat.replace(/@([^\s#]+)#(\d+)/g, (match, username, discriminator) => { 470 | // @username#1234 => mention 471 | // skips usernames including spaces for ease (they cannot include hashes) 472 | // checks case insensitively as Discord does 473 | const user = guild.members.find(x => 474 | Bot.caseComp(x.user.username, username.toUpperCase()) 475 | && x.user.discriminator === discriminator); 476 | if (user) return user; 477 | 478 | return match; 479 | }).replace(/@([^\s]+)/g, (match, reference) => { 480 | // this preliminary stuff is ultimately unnecessary 481 | // but might save time over later more complicated calculations 482 | // @nickname => mention, case insensitively 483 | const nickUser = guild.members.find(x => 484 | x.nickname !== null && Bot.caseComp(x.nickname, reference)); 485 | if (nickUser) return nickUser; 486 | 487 | // @username => mention, case insensitively 488 | const user = guild.members.find(x => Bot.caseComp(x.user.username, reference)); 489 | if (user) return user; 490 | 491 | // @role => mention, case insensitively 492 | const role = guild.roles.find(x => x.mentionable && Bot.caseComp(x.name, reference)); 493 | if (role) return role; 494 | 495 | // No match found checking the whole word. Check for partial matches now instead. 496 | // @nameextra => [mention]extra, case insensitively, as Discord does 497 | // uses the longest match, and if there are two, whichever is a match by case 498 | let matchLength = 0; 499 | let bestMatch = null; 500 | let caseMatched = false; 501 | 502 | // check if a partial match is found in reference and if so update the match values 503 | const checkMatch = function (matchString, matchValue) { 504 | // if the matchString is longer than the current best and is a match 505 | // or if it's the same length but it matches by case unlike the current match 506 | // set the best match to this matchString and matchValue 507 | if ((matchString.length > matchLength && Bot.caseStartsWith(reference, matchString)) 508 | || (matchString.length === matchLength && !caseMatched 509 | && reference.startsWith(matchString))) { 510 | matchLength = matchString.length; 511 | bestMatch = matchValue; 512 | caseMatched = reference.startsWith(matchString); 513 | } 514 | }; 515 | 516 | // check users by username and nickname 517 | guild.members.forEach((member) => { 518 | checkMatch(member.user.username, member); 519 | if (bestMatch === member || member.nickname === null) return; 520 | checkMatch(member.nickname, member); 521 | }); 522 | // check mentionable roles by visible name 523 | guild.roles.forEach((member) => { 524 | if (!member.mentionable) return; 525 | checkMatch(member.name, member); 526 | }); 527 | 528 | // if a partial match was found, return the match and the unmatched trailing characters 529 | if (bestMatch) return bestMatch.toString() + reference.substring(matchLength); 530 | 531 | return match; 532 | }).replace(/:(\w+):/g, (match, ident) => { 533 | // :emoji: => mention, case sensitively 534 | const emoji = guild.emojis.find(x => x.name === ident && x.requiresColons); 535 | if (emoji) return emoji; 536 | 537 | return match; 538 | }); 539 | 540 | // Webhooks first 541 | const webhook = this.findWebhook(channel); 542 | if (webhook) { 543 | logger.debug('Sending message to Discord via webhook', withMentions, channel, '->', `#${discordChannel.name}`); 544 | const avatarURL = this.getDiscordAvatar(author, channel); 545 | webhook.client.sendMessage(withMentions, { 546 | username: author, 547 | text, 548 | avatarURL 549 | }).catch(logger.error); 550 | return; 551 | } 552 | 553 | patternMap.withMentions = withMentions; 554 | 555 | // Add bold formatting: 556 | // Use custom formatting from config / default formatting with bold author 557 | const withAuthor = Bot.substitutePattern(this.formatDiscord, patternMap); 558 | logger.debug('Sending message to Discord', withAuthor, channel, '->', `#${discordChannel.name}`); 559 | discordChannel.send(withAuthor); 560 | } 561 | 562 | /* Sends a message to Discord exactly as it appears */ 563 | sendExactToDiscord(channel, text) { 564 | const discordChannel = this.findDiscordChannel(channel); 565 | if (!discordChannel) return; 566 | 567 | logger.debug('Sending special message to Discord', text, channel, '->', `#${discordChannel.name}`); 568 | discordChannel.send(text); 569 | } 570 | } 571 | 572 | export default Bot; 573 | -------------------------------------------------------------------------------- /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 | chai.should(); 16 | chai.use(sinonChai); 17 | 18 | describe('Bot', function () { 19 | const sandbox = sinon.sandbox.create({ 20 | useFakeTimers: false, 21 | useFakeServer: false 22 | }); 23 | 24 | const createGuildStub = () => ({ 25 | roles: new discord.Collection(), 26 | members: new discord.Collection(), 27 | emojis: new discord.Collection() 28 | }); 29 | 30 | beforeEach(function () { 31 | this.infoSpy = sandbox.stub(logger, 'info'); 32 | this.debugSpy = sandbox.stub(logger, 'debug'); 33 | this.errorSpy = sandbox.stub(logger, 'error'); 34 | this.sendStub = sandbox.stub(); 35 | 36 | this.discordUsers = new discord.Collection(); 37 | irc.Client = ClientStub; 38 | this.guild = createGuildStub(); 39 | discord.Client = createDiscordStub(this.sendStub, this.guild, this.discordUsers); 40 | 41 | ClientStub.prototype.say = sandbox.stub(); 42 | ClientStub.prototype.send = sandbox.stub(); 43 | ClientStub.prototype.join = sandbox.stub(); 44 | this.sendWebhookMessageStub = sandbox.stub(); 45 | discord.WebhookClient = createWebhookStub(this.sendWebhookMessageStub); 46 | this.bot = new Bot(config); 47 | this.bot.connect(); 48 | 49 | this.addUser = function (user, member = null) { 50 | const userObj = new discord.User(this.bot.discord, user); 51 | const guildMember = Object.assign({}, member || user, { user: userObj }); 52 | guildMember.nick = guildMember.nickname; // nick => nickname in Discord API 53 | const memberObj = new discord.GuildMember(this.guild, guildMember); 54 | this.guild.members.set(userObj.id, memberObj); 55 | this.discordUsers.set(userObj.id, userObj); 56 | return memberObj; 57 | }; 58 | 59 | this.addRole = function (role) { 60 | const roleObj = new discord.Role(this.bot.discord, role); 61 | this.guild.roles.set(roleObj.id, roleObj); 62 | return roleObj; 63 | }; 64 | 65 | this.addEmoji = function (emoji) { 66 | const emojiObj = new discord.Emoji(this.bot.discord, emoji); 67 | this.guild.emojis.set(emojiObj.id, emojiObj); 68 | return emojiObj; 69 | }; 70 | }); 71 | 72 | afterEach(function () { 73 | sandbox.restore(); 74 | }); 75 | 76 | const createAttachments = (url) => { 77 | const attachments = new discord.Collection(); 78 | attachments.set(1, { url }); 79 | return attachments; 80 | }; 81 | 82 | it('should invert the channel mapping', function () { 83 | this.bot.invertedMapping['#irc'].should.equal('#discord'); 84 | }); 85 | 86 | it('should send correctly formatted messages to discord', function () { 87 | const username = 'testuser'; 88 | const text = 'test message'; 89 | const formatted = `**<${username}>** ${text}`; 90 | this.bot.sendToDiscord(username, '#irc', text); 91 | this.sendStub.should.have.been.calledWith(formatted); 92 | }); 93 | 94 | it('should lowercase channel names before sending to discord', function () { 95 | const username = 'testuser'; 96 | const text = 'test message'; 97 | const formatted = `**<${username}>** ${text}`; 98 | this.bot.sendToDiscord(username, '#IRC', text); 99 | this.sendStub.should.have.been.calledWith(formatted); 100 | }); 101 | 102 | it( 103 | 'should not send messages to discord if the channel isn\'t in the channel mapping', 104 | function () { 105 | this.bot.sendToDiscord('user', '#no-irc', 'message'); 106 | this.sendStub.should.not.have.been.called; 107 | } 108 | ); 109 | 110 | it( 111 | 'should not send messages to discord if it isn\'t in the channel', 112 | function () { 113 | this.bot.sendToDiscord('user', '#otherirc', 'message'); 114 | this.sendStub.should.not.have.been.called; 115 | } 116 | ); 117 | 118 | it('should send to a discord channel ID appropriately', function () { 119 | const username = 'testuser'; 120 | const text = 'test message'; 121 | const formatted = `**<${username}>** ${text}`; 122 | this.bot.sendToDiscord(username, '#channelforid', text); 123 | this.sendStub.should.have.been.calledWith(formatted); 124 | }); 125 | 126 | it( 127 | 'should not send special messages to discord if the channel isn\'t in the channel mapping', 128 | function () { 129 | this.bot.sendExactToDiscord('#no-irc', 'message'); 130 | this.sendStub.should.not.have.been.called; 131 | } 132 | ); 133 | 134 | it( 135 | 'should not send special messages to discord if it isn\'t in the channel', 136 | function () { 137 | this.bot.sendExactToDiscord('#otherirc', 'message'); 138 | this.sendStub.should.not.have.been.called; 139 | } 140 | ); 141 | 142 | it( 143 | 'should send special messages to discord', 144 | function () { 145 | this.bot.sendExactToDiscord('#irc', 'message'); 146 | this.sendStub.should.have.been.calledWith('message'); 147 | this.debugSpy.should.have.been.calledWith('Sending special message to Discord', 'message', '#irc', '->', '#discord'); 148 | } 149 | ); 150 | 151 | it('should not color irc messages if the option is disabled', function () { 152 | const text = 'testmessage'; 153 | const newConfig = { ...config, ircNickColor: false }; 154 | const bot = new Bot(newConfig); 155 | bot.connect(); 156 | const message = { 157 | content: text, 158 | mentions: { users: [] }, 159 | channel: { 160 | name: 'discord' 161 | }, 162 | author: { 163 | username: 'otherauthor', 164 | id: 'not bot id' 165 | }, 166 | guild: this.guild 167 | }; 168 | 169 | bot.sendToIRC(message); 170 | const expected = `<${message.author.username}> ${text}`; 171 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 172 | }); 173 | 174 | it('should send correct messages to irc', function () { 175 | const text = 'testmessage'; 176 | const message = { 177 | content: text, 178 | mentions: { users: [] }, 179 | channel: { 180 | name: 'discord' 181 | }, 182 | author: { 183 | username: 'otherauthor', 184 | id: 'not bot id' 185 | }, 186 | guild: this.guild 187 | }; 188 | 189 | this.bot.sendToIRC(message); 190 | // Wrap in colors: 191 | const expected = `<\u000304${message.author.username}\u000f> ${text}`; 192 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 193 | }); 194 | 195 | it('should send to IRC channel mapped by discord channel ID if available', function () { 196 | const text = 'test message'; 197 | const message = { 198 | content: text, 199 | mentions: { users: [] }, 200 | channel: { 201 | id: 1234, 202 | name: 'namenotinmapping' 203 | }, 204 | author: { 205 | username: 'test', 206 | id: 'not bot id' 207 | }, 208 | guild: this.guild 209 | }; 210 | 211 | // Wrap it in colors: 212 | const expected = `<\u000312${message.author.username}\u000f> test message`; 213 | this.bot.sendToIRC(message); 214 | ClientStub.prototype.say 215 | .should.have.been.calledWith('#channelforid', expected); 216 | }); 217 | 218 | it('should send to IRC channel mapped by discord channel name if ID not available', function () { 219 | const text = 'test message'; 220 | const message = { 221 | content: text, 222 | mentions: { users: [] }, 223 | channel: { 224 | id: 1235, 225 | name: 'discord' 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('#irc', expected); 239 | }); 240 | 241 | it('should send attachment URL to IRC', function () { 242 | const attachmentUrl = 'https://image/url.jpg'; 243 | const message = { 244 | content: '', 245 | mentions: { users: [] }, 246 | attachments: createAttachments(attachmentUrl), 247 | channel: { 248 | name: 'discord' 249 | }, 250 | author: { 251 | username: 'otherauthor', 252 | id: 'not bot id' 253 | }, 254 | guild: this.guild 255 | }; 256 | 257 | this.bot.sendToIRC(message); 258 | const expected = `<\u000304${message.author.username}\u000f> ${attachmentUrl}`; 259 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 260 | }); 261 | 262 | it('should send text message and attachment URL to IRC if both exist', function () { 263 | const text = 'Look at this cute cat picture!'; 264 | const attachmentUrl = 'https://image/url.jpg'; 265 | const message = { 266 | content: text, 267 | attachments: createAttachments(attachmentUrl), 268 | mentions: { users: [] }, 269 | channel: { 270 | name: 'discord' 271 | }, 272 | author: { 273 | username: 'otherauthor', 274 | id: 'not bot id' 275 | }, 276 | guild: this.guild 277 | }; 278 | 279 | this.bot.sendToIRC(message); 280 | 281 | ClientStub.prototype.say.should.have.been.calledWith( 282 | '#irc', 283 | `<\u000304${message.author.username}\u000f> ${text}` 284 | ); 285 | 286 | const expected = `<\u000304${message.author.username}\u000f> ${attachmentUrl}`; 287 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 288 | }); 289 | 290 | it('should not send an empty text message with an attachment to IRC', function () { 291 | const message = { 292 | content: '', 293 | attachments: createAttachments('https://image/url.jpg'), 294 | mentions: { users: [] }, 295 | channel: { 296 | name: 'discord' 297 | }, 298 | author: { 299 | username: 'otherauthor', 300 | id: 'not bot id' 301 | }, 302 | guild: this.guild 303 | }; 304 | 305 | this.bot.sendToIRC(message); 306 | 307 | ClientStub.prototype.say.should.have.been.calledOnce; 308 | }); 309 | 310 | it('should not send its own messages to irc', function () { 311 | const message = { 312 | author: { 313 | username: 'bot', 314 | id: this.bot.discord.user.id 315 | }, 316 | guild: this.guild 317 | }; 318 | 319 | this.bot.sendToIRC(message); 320 | ClientStub.prototype.say.should.not.have.been.called; 321 | }); 322 | 323 | it( 324 | 'should not send messages to irc if the channel isn\'t in the channel mapping', 325 | function () { 326 | const message = { 327 | channel: { 328 | name: 'wrongdiscord' 329 | }, 330 | author: { 331 | username: 'otherauthor', 332 | id: 'not bot id' 333 | }, 334 | guild: this.guild 335 | }; 336 | 337 | this.bot.sendToIRC(message); 338 | ClientStub.prototype.say.should.not.have.been.called; 339 | } 340 | ); 341 | 342 | it('should parse text from discord when sending messages', function () { 343 | const text = '<#1234>'; 344 | const message = { 345 | content: text, 346 | mentions: { users: [] }, 347 | channel: { 348 | name: 'discord' 349 | }, 350 | author: { 351 | username: 'test', 352 | id: 'not bot id' 353 | }, 354 | guild: this.guild 355 | }; 356 | 357 | // Wrap it in colors: 358 | const expected = `<\u000312${message.author.username}\u000f> #${message.channel.name}`; 359 | this.bot.sendToIRC(message); 360 | ClientStub.prototype.say 361 | .should.have.been.calledWith('#irc', expected); 362 | }); 363 | 364 | it('should use #deleted-channel when referenced channel fails to exist', function () { 365 | const text = '<#1235>'; 366 | const message = { 367 | content: text, 368 | mentions: { users: [] }, 369 | channel: { 370 | name: 'discord' 371 | }, 372 | author: { 373 | username: 'test', 374 | id: 'not bot id' 375 | }, 376 | guild: this.guild 377 | }; 378 | 379 | // Discord displays "#deleted-channel" if channel doesn't exist (e.g. <#1235>) 380 | // Wrap it in colors: 381 | const expected = `<\u000312${message.author.username}\u000f> #deleted-channel`; 382 | this.bot.sendToIRC(message); 383 | ClientStub.prototype.say 384 | .should.have.been.calledWith('#irc', expected); 385 | }); 386 | 387 | it('should convert user mentions from discord', function () { 388 | const message = { 389 | mentions: { 390 | users: [{ 391 | id: 123, 392 | username: 'testuser' 393 | }], 394 | }, 395 | content: '<@123> hi', 396 | guild: this.guild 397 | }; 398 | 399 | this.bot.parseText(message).should.equal('@testuser hi'); 400 | }); 401 | 402 | it('should convert user nickname mentions from discord', function () { 403 | const message = { 404 | mentions: { 405 | users: [{ 406 | id: 123, 407 | username: 'testuser' 408 | }], 409 | }, 410 | content: '<@!123> hi', 411 | guild: this.guild 412 | }; 413 | 414 | this.bot.parseText(message).should.equal('@testuser hi'); 415 | }); 416 | 417 | it('should convert twitch emotes from discord', function () { 418 | const message = { 419 | mentions: { users: [] }, 420 | content: '<:SCGWat:230473833046343680>' 421 | }; 422 | 423 | this.bot.parseText(message).should.equal(':SCGWat:'); 424 | }); 425 | 426 | it('should convert animated emoji from discord', function () { 427 | const message = { 428 | mentions: { users: [] }, 429 | content: '' 430 | }; 431 | 432 | this.bot.parseText(message).should.equal(':in_love:'); 433 | }); 434 | 435 | it('should convert user mentions from IRC', function () { 436 | const testUser = this.addUser({ username: 'testuser', id: '123' }); 437 | 438 | const username = 'ircuser'; 439 | const text = 'Hello, @testuser!'; 440 | const expected = `**<${username}>** Hello, <@${testUser.id}>!`; 441 | 442 | this.bot.sendToDiscord(username, '#irc', text); 443 | this.sendStub.should.have.been.calledWith(expected); 444 | }); 445 | 446 | it('should not convert user mentions from IRC if such user does not exist', function () { 447 | const username = 'ircuser'; 448 | const text = 'See you there @5pm'; 449 | const expected = `**<${username}>** See you there @5pm`; 450 | 451 | this.bot.sendToDiscord(username, '#irc', text); 452 | this.sendStub.should.have.been.calledWith(expected); 453 | }); 454 | 455 | it('should convert multiple user mentions from IRC', function () { 456 | const testUser = this.addUser({ username: 'testuser', id: '123' }); 457 | const anotherUser = this.addUser({ username: 'anotheruser', id: '124' }); 458 | 459 | const username = 'ircuser'; 460 | const text = 'Hello, @testuser and @anotheruser, was our meeting scheduled @5pm?'; 461 | const expected = `**<${username}>** Hello, <@${testUser.id}> and <@${anotherUser.id}>,` + 462 | ' was our meeting scheduled @5pm?'; 463 | 464 | this.bot.sendToDiscord(username, '#irc', text); 465 | this.sendStub.should.have.been.calledWith(expected); 466 | }); 467 | 468 | it('should convert emoji mentions from IRC', function () { 469 | this.addEmoji({ id: '987', name: 'testemoji', require_colons: true }); 470 | 471 | const username = 'ircuser'; 472 | const text = 'Here is a broken :emojitest:, a working :testemoji: and another :emoji: that won\'t parse'; 473 | const expected = `**<${username}>** Here is a broken :emojitest:, a working <:testemoji:987> and another :emoji: that won't parse`; 474 | this.bot.sendToDiscord(username, '#irc', text); 475 | this.sendStub.should.have.been.calledWith(expected); 476 | }); 477 | 478 | it('should convert newlines from discord', function () { 479 | const message = { 480 | mentions: { users: [] }, 481 | content: 'hi\nhi\r\nhi\r' 482 | }; 483 | 484 | this.bot.parseText(message).should.equal('hi hi hi '); 485 | }); 486 | 487 | it('should hide usernames for commands to IRC', function () { 488 | const text = '!test command'; 489 | const message = { 490 | content: text, 491 | mentions: { users: [] }, 492 | channel: { 493 | name: 'discord' 494 | }, 495 | author: { 496 | username: 'test', 497 | id: 'not bot id' 498 | }, 499 | guild: this.guild 500 | }; 501 | 502 | this.bot.sendToIRC(message); 503 | ClientStub.prototype.say.getCall(0).args.should.deep.equal([ 504 | '#irc', 'Command sent from Discord by test:' 505 | ]); 506 | ClientStub.prototype.say.getCall(1).args.should.deep.equal(['#irc', text]); 507 | }); 508 | 509 | it('should support multi-character command prefixes', function () { 510 | const bot = new Bot({ ...config, commandCharacters: ['@@'] }); 511 | const text = '@@test command'; 512 | const message = { 513 | content: text, 514 | mentions: { users: [] }, 515 | channel: { 516 | name: 'discord' 517 | }, 518 | author: { 519 | username: 'test', 520 | id: 'not bot id' 521 | }, 522 | guild: this.guild 523 | }; 524 | bot.connect(); 525 | 526 | bot.sendToIRC(message); 527 | ClientStub.prototype.say.getCall(0).args.should.deep.equal([ 528 | '#irc', 'Command sent from Discord by test:' 529 | ]); 530 | ClientStub.prototype.say.getCall(1).args.should.deep.equal(['#irc', text]); 531 | }); 532 | 533 | it('should hide usernames for commands to Discord', function () { 534 | const username = 'ircuser'; 535 | const text = '!command'; 536 | 537 | this.bot.sendToDiscord(username, '#irc', text); 538 | this.sendStub.getCall(0).args.should.deep.equal(['Command sent from IRC by ircuser:']); 539 | this.sendStub.getCall(1).args.should.deep.equal([text]); 540 | }); 541 | 542 | it('should use nickname instead of username when available', function () { 543 | const text = 'testmessage'; 544 | const newConfig = { ...config, ircNickColor: false }; 545 | const bot = new Bot(newConfig); 546 | const id = 'not bot id'; 547 | const nickname = 'discord-nickname'; 548 | this.guild.members.set(id, { nickname }); 549 | bot.connect(); 550 | const message = { 551 | content: text, 552 | mentions: { users: [] }, 553 | channel: { 554 | name: 'discord' 555 | }, 556 | author: { 557 | username: 'otherauthor', 558 | id 559 | }, 560 | guild: this.guild 561 | }; 562 | 563 | bot.sendToIRC(message); 564 | const expected = `<${nickname}> ${text}`; 565 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 566 | }); 567 | 568 | it('should convert user nickname mentions from IRC', function () { 569 | const testUser = this.addUser({ username: 'testuser', id: '123', nickname: 'somenickname' }); 570 | 571 | const username = 'ircuser'; 572 | const text = 'Hello, @somenickname!'; 573 | const expected = `**<${username}>** Hello, ${testUser}!`; 574 | 575 | this.bot.sendToDiscord(username, '#irc', text); 576 | this.sendStub.should.have.been.calledWith(expected); 577 | }); 578 | 579 | it('should convert username mentions from IRC even if nickname differs', function () { 580 | const testUser = this.addUser({ username: 'testuser', id: '123', nickname: 'somenickname' }); 581 | 582 | const username = 'ircuser'; 583 | const text = 'Hello, @testuser!'; 584 | const expected = `**<${username}>** Hello, ${testUser}!`; 585 | 586 | this.bot.sendToDiscord(username, '#irc', text); 587 | this.sendStub.should.have.been.calledWith(expected); 588 | }); 589 | 590 | it('should convert username-discriminator mentions from IRC properly', function () { 591 | const user1 = this.addUser({ username: 'user', id: '123', discriminator: '9876' }); 592 | const user2 = this.addUser({ 593 | username: 'user', 594 | id: '124', 595 | discriminator: '5555', 596 | nickname: 'secondUser' 597 | }); 598 | 599 | const username = 'ircuser'; 600 | const text = 'hello @user#9876 and @user#5555 and @fakeuser#1234'; 601 | const expected = `**<${username}>** hello ${user1} and ${user2} and @fakeuser#1234`; 602 | 603 | this.bot.sendToDiscord(username, '#irc', text); 604 | this.sendStub.should.have.been.calledWith(expected); 605 | }); 606 | 607 | it('should convert role mentions from discord', function () { 608 | this.addRole({ name: 'example-role', id: '12345' }); 609 | const text = '<@&12345>'; 610 | const message = { 611 | content: text, 612 | mentions: { users: [] }, 613 | channel: { 614 | name: 'discord' 615 | }, 616 | author: { 617 | username: 'test', 618 | id: 'not bot id' 619 | }, 620 | guild: this.guild 621 | }; 622 | 623 | this.bot.parseText(message).should.equal('@example-role'); 624 | }); 625 | 626 | it('should use @deleted-role when referenced role fails to exist', function () { 627 | this.addRole({ name: 'example-role', id: '12345' }); 628 | 629 | const text = '<@&12346>'; 630 | const message = { 631 | content: text, 632 | mentions: { users: [] }, 633 | channel: { 634 | name: 'discord' 635 | }, 636 | author: { 637 | username: 'test', 638 | id: 'not bot id' 639 | }, 640 | guild: this.guild 641 | }; 642 | 643 | // Discord displays "@deleted-role" if role doesn't exist (e.g. <@&12346>) 644 | this.bot.parseText(message).should.equal('@deleted-role'); 645 | }); 646 | 647 | it('should convert role mentions from IRC if role mentionable', function () { 648 | const testRole = this.addRole({ name: 'example-role', id: '12345', mentionable: true }); 649 | 650 | const username = 'ircuser'; 651 | const text = 'Hello, @example-role!'; 652 | const expected = `**<${username}>** Hello, <@&${testRole.id}>!`; 653 | 654 | this.bot.sendToDiscord(username, '#irc', text); 655 | this.sendStub.should.have.been.calledWith(expected); 656 | }); 657 | 658 | it('should not convert role mentions from IRC if role not mentionable', function () { 659 | this.addRole({ name: 'example-role', id: '12345', mentionable: false }); 660 | 661 | const username = 'ircuser'; 662 | const text = 'Hello, @example-role!'; 663 | const expected = `**<${username}>** Hello, @example-role!`; 664 | 665 | this.bot.sendToDiscord(username, '#irc', text); 666 | this.sendStub.should.have.been.calledWith(expected); 667 | }); 668 | 669 | it('should convert overlapping mentions from IRC properly and case-insensitively', function () { 670 | const user = this.addUser({ username: 'user', id: '111' }); 671 | const nickUser = this.addUser({ username: 'user2', id: '112', nickname: 'userTest' }); 672 | const nickUserCase = this.addUser({ username: 'user3', id: '113', nickname: 'userTEST' }); 673 | const role = this.addRole({ name: 'userTestRole', id: '12345', mentionable: true }); 674 | 675 | const username = 'ircuser'; 676 | const text = 'hello @User, @user, @userTest, @userTEST, @userTestRole and @usertestrole'; 677 | const expected = `**<${username}>** hello ${user}, ${user}, ${nickUser}, ${nickUserCase}, ${role} and ${role}`; 678 | 679 | this.bot.sendToDiscord(username, '#irc', text); 680 | this.sendStub.should.have.been.calledWith(expected); 681 | }); 682 | 683 | it('should convert partial matches from IRC properly', function () { 684 | const user = this.addUser({ username: 'user', id: '111' }); 685 | const longUser = this.addUser({ username: 'user-punc', id: '112' }); 686 | const nickUser = this.addUser({ username: 'user2', id: '113', nickname: 'nick' }); 687 | const nickUserCase = this.addUser({ username: 'user3', id: '114', nickname: 'NiCK' }); 688 | const role = this.addRole({ name: 'role', id: '12345', mentionable: true }); 689 | 690 | const username = 'ircuser'; 691 | const text = '@user-ific @usermore, @user\'s friend @user-punc, @nicks and @NiCKs @roles'; 692 | const expected = `**<${username}>** ${user}-ific ${user}more, ${user}'s friend ${longUser}, ${nickUser}s and ${nickUserCase}s ${role}s`; 693 | 694 | this.bot.sendToDiscord(username, '#irc', text); 695 | this.sendStub.should.have.been.calledWith(expected); 696 | }); 697 | 698 | it('should successfully send messages with default config', function () { 699 | const bot = new Bot(configMsgFormatDefault); 700 | bot.connect(); 701 | 702 | bot.sendToDiscord('testuser', '#irc', 'test message'); 703 | this.sendStub.should.have.been.calledOnce; 704 | const message = { 705 | content: 'test message', 706 | mentions: { users: [] }, 707 | channel: { 708 | name: 'discord' 709 | }, 710 | author: { 711 | username: 'otherauthor', 712 | id: 'not bot id' 713 | }, 714 | guild: this.guild 715 | }; 716 | 717 | bot.sendToIRC(message); 718 | this.sendStub.should.have.been.calledOnce; 719 | }); 720 | 721 | it('should not replace unmatched patterns', function () { 722 | const format = { discord: '{$unmatchedPattern} stays intact: {$author} {$text}' }; 723 | const bot = new Bot({ ...configMsgFormatDefault, format }); 724 | bot.connect(); 725 | 726 | const username = 'testuser'; 727 | const msg = 'test message'; 728 | const expected = `{$unmatchedPattern} stays intact: ${username} ${msg}`; 729 | bot.sendToDiscord(username, '#irc', msg); 730 | this.sendStub.should.have.been.calledWith(expected); 731 | }); 732 | 733 | it('should respect custom formatting for Discord', function () { 734 | const format = { discord: '<{$author}> {$ircChannel} => {$discordChannel}: {$text}' }; 735 | const bot = new Bot({ ...configMsgFormatDefault, format }); 736 | bot.connect(); 737 | 738 | const username = 'test'; 739 | const msg = 'test @user <#1234>'; 740 | const expected = ` #irc => #discord: ${msg}`; 741 | bot.sendToDiscord(username, '#irc', msg); 742 | this.sendStub.should.have.been.calledWith(expected); 743 | }); 744 | 745 | it('should successfully send messages with default config', function () { 746 | this.bot = new Bot(configMsgFormatDefault); 747 | this.bot.connect(); 748 | 749 | this.bot.sendToDiscord('testuser', '#irc', 'test message'); 750 | this.sendStub.should.have.been.calledOnce; 751 | const message = { 752 | content: 'test message', 753 | mentions: { users: [] }, 754 | channel: { 755 | name: 'discord' 756 | }, 757 | author: { 758 | username: 'otherauthor', 759 | id: 'not bot id' 760 | }, 761 | guild: this.guild 762 | }; 763 | 764 | this.bot.sendToIRC(message); 765 | this.sendStub.should.have.been.calledOnce; 766 | }); 767 | 768 | it('should not replace unmatched patterns', function () { 769 | const format = { discord: '{$unmatchedPattern} stays intact: {$author} {$text}' }; 770 | this.bot = new Bot({ ...configMsgFormatDefault, format }); 771 | this.bot.connect(); 772 | 773 | const username = 'testuser'; 774 | const msg = 'test message'; 775 | const expected = `{$unmatchedPattern} stays intact: ${username} ${msg}`; 776 | this.bot.sendToDiscord(username, '#irc', msg); 777 | this.sendStub.should.have.been.calledWith(expected); 778 | }); 779 | 780 | it('should respect custom formatting for regular Discord output', function () { 781 | const format = { discord: '<{$author}> {$ircChannel} => {$discordChannel}: {$text}' }; 782 | this.bot = new Bot({ ...configMsgFormatDefault, format }); 783 | this.bot.connect(); 784 | 785 | const username = 'test'; 786 | const msg = 'test @user <#1234>'; 787 | const expected = ` #irc => #discord: ${msg}`; 788 | this.bot.sendToDiscord(username, '#irc', msg); 789 | this.sendStub.should.have.been.calledWith(expected); 790 | }); 791 | 792 | it('should respect custom formatting for commands in Discord output', function () { 793 | const format = { commandPrelude: '{$nickname} from {$ircChannel} sent command to {$discordChannel}:' }; 794 | this.bot = new Bot({ ...configMsgFormatDefault, format }); 795 | this.bot.connect(); 796 | 797 | const username = 'test'; 798 | const msg = '!testcmd'; 799 | const expected = 'test from #irc sent command to #discord:'; 800 | this.bot.sendToDiscord(username, '#irc', msg); 801 | this.sendStub.getCall(0).args.should.deep.equal([expected]); 802 | this.sendStub.getCall(1).args.should.deep.equal([msg]); 803 | }); 804 | 805 | it('should respect custom formatting for regular IRC output', function () { 806 | const format = { ircText: '<{$nickname}> {$discordChannel} => {$ircChannel}: {$text}' }; 807 | this.bot = new Bot({ ...configMsgFormatDefault, format }); 808 | this.bot.connect(); 809 | const message = { 810 | content: 'test message', 811 | mentions: { users: [] }, 812 | channel: { 813 | name: 'discord' 814 | }, 815 | author: { 816 | username: 'testauthor', 817 | id: 'not bot id' 818 | }, 819 | guild: this.guild 820 | }; 821 | const expected = ' #discord => #irc: test message'; 822 | 823 | this.bot.sendToIRC(message); 824 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 825 | }); 826 | 827 | it('should respect custom formatting for commands in IRC output', function () { 828 | const format = { commandPrelude: '{$nickname} from {$discordChannel} sent command to {$ircChannel}:' }; 829 | this.bot = new Bot({ ...configMsgFormatDefault, format }); 830 | this.bot.connect(); 831 | 832 | const text = '!testcmd'; 833 | const message = { 834 | content: text, 835 | mentions: { users: [] }, 836 | channel: { 837 | name: 'discord' 838 | }, 839 | author: { 840 | username: 'testauthor', 841 | id: 'not bot id' 842 | }, 843 | guild: this.guild 844 | }; 845 | const expected = 'testauthor from #discord sent command to #irc:'; 846 | 847 | this.bot.sendToIRC(message); 848 | ClientStub.prototype.say.getCall(0).args.should.deep.equal(['#irc', expected]); 849 | ClientStub.prototype.say.getCall(1).args.should.deep.equal(['#irc', text]); 850 | }); 851 | 852 | it('should respect custom formatting for attachment URLs in IRC output', function () { 853 | const format = { urlAttachment: '<{$nickname}> {$discordChannel} => {$ircChannel}, attachment: {$attachmentURL}' }; 854 | this.bot = new Bot({ ...configMsgFormatDefault, format }); 855 | this.bot.connect(); 856 | 857 | const attachmentUrl = 'https://image/url.jpg'; 858 | const message = { 859 | content: '', 860 | mentions: { users: [] }, 861 | attachments: createAttachments(attachmentUrl), 862 | channel: { 863 | name: 'discord' 864 | }, 865 | author: { 866 | username: 'otherauthor', 867 | id: 'not bot id' 868 | }, 869 | guild: this.guild 870 | }; 871 | 872 | this.bot.sendToIRC(message); 873 | const expected = ` #discord => #irc, attachment: ${attachmentUrl}`; 874 | ClientStub.prototype.say.should.have.been.calledWith('#irc', expected); 875 | }); 876 | 877 | it('should not bother with command prelude if falsy', function () { 878 | const format = { commandPrelude: null }; 879 | this.bot = new Bot({ ...configMsgFormatDefault, format }); 880 | this.bot.connect(); 881 | 882 | const text = '!testcmd'; 883 | const message = { 884 | content: text, 885 | mentions: { users: [] }, 886 | channel: { 887 | name: 'discord' 888 | }, 889 | author: { 890 | username: 'testauthor', 891 | id: 'not bot id' 892 | }, 893 | guild: this.guild 894 | }; 895 | 896 | this.bot.sendToIRC(message); 897 | ClientStub.prototype.say.should.have.been.calledOnce; 898 | ClientStub.prototype.say.getCall(0).args.should.deep.equal(['#irc', text]); 899 | 900 | const username = 'test'; 901 | const msg = '!testcmd'; 902 | this.bot.sendToDiscord(username, '#irc', msg); 903 | this.sendStub.should.have.been.calledOnce; 904 | this.sendStub.getCall(0).args.should.deep.equal([msg]); 905 | }); 906 | 907 | it('should create webhooks clients for each webhook url in the config', function () { 908 | this.bot.webhooks.should.have.property('#withwebhook'); 909 | }); 910 | 911 | it('should extract id and token from webhook urls', function () { 912 | this.bot.webhooks['#withwebhook'].id.should.equal('id'); 913 | }); 914 | 915 | it('should find the matching webhook when it exists', function () { 916 | this.bot.findWebhook('#ircwebhook').should.not.equal(null); 917 | }); 918 | 919 | it('should prefer webhooks to send a message when possible', function () { 920 | const newConfig = { ...config, webhooks: { '#discord': 'https://discordapp.com/api/webhooks/id/token' } }; 921 | const bot = new Bot(newConfig); 922 | bot.connect(); 923 | bot.sendToDiscord('nick', '#irc', 'text'); 924 | this.sendWebhookMessageStub.should.have.been.called; 925 | }); 926 | 927 | it('should find a matching username, case sensitive, when looking for an avatar', function () { 928 | const newConfig = { ...config, webhooks: { '#discord': 'https://discordapp.com/api/webhooks/id/token' } }; 929 | const bot = new Bot(newConfig); 930 | bot.connect(); 931 | const userObj = { id: 123, username: 'Nick', avatar: 'avatarURL' }; 932 | const memberObj = { nickname: 'Different' }; 933 | this.addUser(userObj, memberObj); 934 | this.bot.getDiscordAvatar('Nick', '#irc').should.equal('/avatars/123/avatarURL.png?size=2048'); 935 | }); 936 | 937 | it('should find a matching username, case insensitive, when looking for an avatar', function () { 938 | const newConfig = { ...config, webhooks: { '#discord': 'https://discordapp.com/api/webhooks/id/token' } }; 939 | const bot = new Bot(newConfig); 940 | bot.connect(); 941 | const userObj = { id: 124, username: 'nick', avatar: 'avatarURL' }; 942 | const memberObj = { nickname: 'Different' }; 943 | this.addUser(userObj, memberObj); 944 | this.bot.getDiscordAvatar('Nick', '#irc').should.equal('/avatars/124/avatarURL.png?size=2048'); 945 | }); 946 | 947 | it('should find a matching nickname, case sensitive, when looking for an avatar', function () { 948 | const newConfig = { ...config, webhooks: { '#discord': 'https://discordapp.com/api/webhooks/id/token' } }; 949 | const bot = new Bot(newConfig); 950 | bot.connect(); 951 | const userObj = { id: 125, username: 'Nick', avatar: 'avatarURL' }; 952 | const memberObj = { nickname: 'Different' }; 953 | this.addUser(userObj, memberObj); 954 | this.bot.getDiscordAvatar('Different', '#irc').should.equal('/avatars/125/avatarURL.png?size=2048'); 955 | }); 956 | 957 | it('should not return an avatar with two matching usernames when looking for an avatar', function () { 958 | const newConfig = { ...config, webhooks: { '#discord': 'https://discordapp.com/api/webhooks/id/token' } }; 959 | const bot = new Bot(newConfig); 960 | bot.connect(); 961 | const userObj1 = { id: 126, username: 'common', avatar: 'avatarURL' }; 962 | const userObj2 = { id: 127, username: 'Nick', avatar: 'avatarURL' }; 963 | const memberObj1 = { nickname: 'Different' }; 964 | const memberObj2 = { nickname: 'common' }; 965 | this.addUser(userObj1, memberObj1); 966 | this.addUser(userObj2, memberObj2); 967 | chai.should().equal(this.bot.getDiscordAvatar('common', '#irc'), null); 968 | }); 969 | 970 | it('should not return an avatar when no users match and should handle lack of nickname, when looking for an avatar', function () { 971 | const newConfig = { ...config, webhooks: { '#discord': 'https://discordapp.com/api/webhooks/id/token' } }; 972 | const bot = new Bot(newConfig); 973 | bot.connect(); 974 | const userObj1 = { id: 128, username: 'common', avatar: 'avatarURL' }; 975 | const userObj2 = { id: 129, username: 'Nick', avatar: 'avatarURL' }; 976 | const memberObj1 = {}; 977 | const memberObj2 = { nickname: 'common' }; 978 | this.addUser(userObj1, memberObj1); 979 | this.addUser(userObj2, memberObj2); 980 | chai.should().equal(this.bot.getDiscordAvatar('nonexistent', '#irc'), null); 981 | }); 982 | 983 | it( 984 | 'should not send messages to Discord if IRC user is ignored', 985 | function () { 986 | this.bot.sendToDiscord('irc_ignored_user', '#irc', 'message'); 987 | this.sendStub.should.not.have.been.called; 988 | } 989 | ); 990 | 991 | it( 992 | 'should not send messages to IRC if Discord user is ignored', 993 | function () { 994 | const message = { 995 | content: 'text', 996 | mentions: { users: [] }, 997 | channel: { 998 | name: 'discord' 999 | }, 1000 | author: { 1001 | username: 'discord_ignored_user', 1002 | id: 'some id' 1003 | }, 1004 | guild: this.guild 1005 | }; 1006 | 1007 | this.bot.sendToIRC(message); 1008 | ClientStub.prototype.say.should.not.have.been.called; 1009 | } 1010 | ); 1011 | }); 1012 | --------------------------------------------------------------------------------