diff --git a/README.md b/README.md index cef053ca..38b57fbb 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,10 @@ First you need to create a Discord bot user, which you can do by following the i // Makes the bot hide the username prefix for messages that start // with one of these characters (commands): "commandCharacters": ["!", "."], - "ircStatusNotices": true, // Enables notifications in Discord when people join/part in the relevant IRC channel + // Enables notifications in Discord when people join/part in the relevant IRC channel + // Passing a channel name will cause all joins/parts to appear in that channel. For example: + // "ircStatusNotices": "#joins-and-leaves" + "ircStatusNotices": true, "ignoreUsers": { "irc": ["irc_nick1", "irc_nick2"], // Ignore specified IRC nicks and do not send their messages to Discord. "discord": ["discord_nick1", "discord_nick2"], // Ignore specified Discord nicks and do not send their messages to IRC. diff --git a/lib/bot.js b/lib/bot.js index fa3b1474..3cb67ff1 100644 --- a/lib/bot.js +++ b/lib/bot.js @@ -39,7 +39,12 @@ class Bot { this.ircNickColor = options.ircNickColor !== false; // default to true this.parallelPingFix = options.parallelPingFix === true; // default: false this.channels = _.values(options.channelMapping); - this.ircStatusNotices = options.ircStatusNotices; + if (typeof (options.ircStatusNotices) === 'string') { + this.ircStatusNotices = options.ircStatusNotices; // custom channel to announce join/quit + } else { + // default to false (don't announce) + this.ircStatusNotices = options.ircStatusNotices || false; + } this.announceSelfJoin = options.announceSelfJoin; this.webhookOptions = options.webhooks; @@ -170,35 +175,59 @@ class Bot { }); this.ircClient.on('nick', (oldNick, newNick, channels) => { - if (!this.ircStatusNotices) return; + if (this.ircStatusNotices === false) return; channels.forEach((channelName) => { const channel = channelName.toLowerCase(); if (this.channelUsers[channel]) { if (this.channelUsers[channel].has(oldNick)) { this.channelUsers[channel].delete(oldNick); this.channelUsers[channel].add(newNick); - this.sendExactToDiscord(channel, `*${oldNick}* is now known as ${newNick}`); + if (this.ircStatusNotices === true) { + this.sendExactToDiscordByIrcChannel(channel, `*${oldNick}* is now known as ${newNick}`); + } } } else { logger.warn(`No channelUsers found for ${channel} when ${oldNick} changed.`); } }); + if (typeof (this.ircStatusNotices) === 'string') { + this.sendExactToDiscordByDiscordChannel(this.ircStatusNotices, `*${oldNick}* is now known as ${newNick}`); + } }); this.ircClient.on('join', (channelName, nick) => { logger.debug('Received join:', channelName, nick); - if (!this.ircStatusNotices) return; + if (this.ircStatusNotices === false) return; if (nick === this.ircClient.nick && !this.announceSelfJoin) return; - const channel = channelName.toLowerCase(); - // self-join is announced before names (which includes own nick) - // so don't add nick to channelUsers - if (nick !== this.ircClient.nick) this.channelUsers[channel].add(nick); - this.sendExactToDiscord(channel, `*${nick}* has joined the channel`); + if (this.ircStatusNotices === true) { + const channel = channelName.toLowerCase(); + // self-join is announced before names (which includes own nick) + // so don't add nick to channelUsers + if (nick !== this.ircClient.nick) this.channelUsers[channel].add(nick); + this.sendExactToDiscordByIrcChannel(channel, `*${nick}* has joined the channel`); + } else { + const ircChannel = channelName.toLowerCase(); + const discordChannel = this.ircStatusNotices; + // Only send the message once per user. Do this by checking channelUsers + // and sending if user is being added for the first time. + if (nick !== this.ircClient.nick) { + let firstAdd = true; + Object.keys(this.channelUsers).forEach((channel) => { + if (this.channelUsers[channel].has(nick)) { + firstAdd = false; + } + }); + this.channelUsers[ircChannel].add(nick); + if (firstAdd) { + this.sendExactToDiscordByDiscordChannel(discordChannel, `*${nick}* has joined IRC`); + } + } + } }); this.ircClient.on('part', (channelName, nick, reason) => { logger.debug('Received part:', channelName, nick, reason); - if (!this.ircStatusNotices) return; + if (this.ircStatusNotices === false) return; const channel = channelName.toLowerCase(); // remove list of users when no longer in channel (as it will become out of date) if (nick === this.ircClient.nick) { @@ -211,12 +240,16 @@ class Bot { } else { logger.warn(`No channelUsers found for ${channel} when ${nick} parted.`); } - this.sendExactToDiscord(channel, `*${nick}* has left the channel (${reason})`); + if (typeof (this.ircStatusNotices) === 'string') { + this.sendExactToDiscordByDiscordChannel(this.ircStatusNotices, `*${nick}* has left ${channel} (${reason})`); + } else if (this.ircStatusNotices === true) { + this.sendExactToDiscordByIrcChannel(channel, `*${nick}* has left the channel (${reason})`); + } }); this.ircClient.on('quit', (nick, reason, channels) => { logger.debug('Received quit:', nick, channels); - if (!this.ircStatusNotices || nick === this.ircClient.nick) return; + if (this.ircStatusNotices === false || nick === this.ircClient.nick) return; channels.forEach((channelName) => { const channel = channelName.toLowerCase(); if (!this.channelUsers[channel]) { @@ -224,8 +257,13 @@ class Bot { return; } if (!this.channelUsers[channel].delete(nick)) return; - this.sendExactToDiscord(channel, `*${nick}* has quit (${reason})`); + if (this.ircStatusNotices === true) { + this.sendExactToDiscordByIrcChannel(channel, `*${nick}* has quit (${reason})`); + } }); + if (typeof (this.ircStatusNotices) === 'string') { + this.sendExactToDiscordByDiscordChannel(this.ircStatusNotices, `*${nick}* has quit (${reason})`); + } }); this.ircClient.on('names', (channelName, nicks) => { @@ -604,14 +642,32 @@ class Bot { discordChannel.send(withAuthor); } - /* Sends a message to Discord exactly as it appears */ - sendExactToDiscord(channel, text) { + /* Sends a message to Discord exactly as it appears in the passed IRC channel name */ + sendExactToDiscordByIrcChannel(channel, text) { const discordChannel = this.findDiscordChannel(channel); if (!discordChannel) return; logger.debug('Sending special message to Discord', text, channel, '->', `#${discordChannel.name}`); discordChannel.send(text); } + + /* Sends a message to Discord exactly as it appears in the passed Discord channel name */ + sendExactToDiscordByDiscordChannel(channel, text) { + const discordChannel = this.discord.channels + .filter(c => c.type === 'text') + .find('name', channel.slice(1)); + + if (!discordChannel) { + logger.info( + 'Tried to send a message to a Discord channel the bot isn\'t in: ', + channel + ); + return; + } + + logger.debug('Sending special message to Discord', text, `#${discordChannel.name}`); + discordChannel.send(text); + } } export default Bot; diff --git a/test/bot-events.test.js b/test/bot-events.test.js index 04947132..35b23a9d 100644 --- a/test/bot-events.test.js +++ b/test/bot-events.test.js @@ -24,7 +24,8 @@ describe('Bot Events', function () { const bot = new Bot(useConfig); bot.sendToIRC = sandbox.stub(); bot.sendToDiscord = sandbox.stub(); - bot.sendExactToDiscord = sandbox.stub(); + bot.sendExactToDiscordByIrcChannel = sandbox.stub(); + bot.sendExactToDiscordByDiscordChannel = sandbox.stub(); return bot; }; @@ -118,7 +119,7 @@ describe('Bot Events', function () { const oldnick = 'user1'; const newnick = 'user2'; this.bot.ircClient.emit('nick', oldnick, newnick, [channel]); - this.bot.sendExactToDiscord.should.not.have.been.called; + this.bot.sendExactToDiscordByIrcChannel.should.not.have.been.called; }); it('should send name change event to discord', function () { @@ -138,7 +139,33 @@ describe('Bot Events', function () { const formattedText = `*${oldNick}* is now known as ${newNick}`; const channelNicksAfter = new Set([bot.nickname, newNick]); bot.ircClient.emit('nick', oldNick, newNick, [channel1, channel2, channel3]); - bot.sendExactToDiscord.should.have.been.calledWithExactly(channel1, formattedText); + bot.sendExactToDiscordByIrcChannel.should.have.been.calledWithExactly(channel1, formattedText); + bot.channelUsers.should.deep.equal({ '#channel1': channelNicksAfter, '#channel2': staticChannel }); + }); + + it('should send name change event to specified discord channel', function () { + const channel1 = '#channel1'; + const channel2 = '#channel2'; + const channel3 = '#channel3'; + const oldNick = 'user1'; + const newNick = 'user2'; + const user3 = 'user3'; + const notifyChannel = '#joins-and-leaves'; + const bot = createBot({ ...config, ircStatusNotices: notifyChannel }); + const staticChannel = new Set([bot.nickname, user3]); + bot.connect(); + bot.ircClient.emit('names', channel1, { [bot.nickname]: '', [oldNick]: '' }); + bot.ircClient.emit('names', channel2, { [bot.nickname]: '', [user3]: '' }); + const channelNicksPre = new Set([bot.nickname, oldNick]); + bot.channelUsers.should.deep.equal({ '#channel1': channelNicksPre, '#channel2': staticChannel }); + const formattedText = `*${oldNick}* is now known as ${newNick}`; + const channelNicksAfter = new Set([bot.nickname, newNick]); + bot.ircClient.emit('nick', oldNick, newNick, [channel1, channel2, channel3]); + bot.sendExactToDiscordByDiscordChannel.should.have.been.calledOnce; + bot.sendExactToDiscordByDiscordChannel.should.have.been.calledWithExactly( + notifyChannel, + formattedText + ); bot.channelUsers.should.deep.equal({ '#channel1': channelNicksAfter, '#channel2': staticChannel }); }); @@ -184,11 +211,42 @@ describe('Bot Events', function () { const nick = 'user'; const text = `*${nick}* has joined the channel`; bot.ircClient.emit('join', channel, nick); - bot.sendExactToDiscord.should.have.been.calledWithExactly(channel, text); + bot.sendExactToDiscordByIrcChannel.should.have.been.calledWithExactly(channel, text); const channelNicks = new Set([bot.nickname, nick]); bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); }); + it('should send join messages to specified discord channel when config enabled', function () { + const notifyChannel = '#joins-and-leaves'; + const bot = createBot({ ...config, ircStatusNotices: notifyChannel }); + bot.connect(); + const channel = '#channel'; + bot.ircClient.emit('names', channel, { [bot.nickname]: '' }); + const nick = 'user'; + const text = `*${nick}* has joined IRC`; + bot.ircClient.emit('join', channel, nick); + bot.sendExactToDiscordByDiscordChannel.should.have.been.calledOnce; + bot.sendExactToDiscordByDiscordChannel.should.have.been.calledWithExactly(notifyChannel, text); + const channelNicks = new Set([bot.nickname, nick]); + bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); + }); + + it('should send single join message to specified discord channel when config enabled and multiple channels are joined', function () { + const notifyChannel = '#joins-and-leaves'; + const bot = createBot({ ...config, ircStatusNotices: notifyChannel }); + bot.connect(); + const channel1 = '#channel1'; + const channel2 = '#channel2'; + bot.ircClient.emit('names', channel1, { [bot.nickname]: '' }); + bot.ircClient.emit('names', channel2, { [bot.nickname]: '' }); + const nick = 'user'; + const text = `*${nick}* has joined IRC`; + bot.ircClient.emit('join', channel1, nick); + bot.ircClient.emit('join', channel2, nick); + bot.sendExactToDiscordByDiscordChannel.should.have.been.calledOnce; + bot.sendExactToDiscordByDiscordChannel.should.have.been.calledWithExactly(notifyChannel, text); + }); + it('should not announce itself joining by default', function () { const bot = createBot({ ...config, ircStatusNotices: true }); bot.connect(); @@ -196,7 +254,7 @@ describe('Bot Events', function () { bot.ircClient.emit('names', channel, { [bot.nickname]: '' }); const nick = bot.nickname; bot.ircClient.emit('join', channel, nick); - bot.sendExactToDiscord.should.not.have.been.called; + bot.sendExactToDiscordByIrcChannel.should.not.have.been.called; const channelNicks = new Set([bot.nickname]); bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); }); @@ -210,7 +268,7 @@ describe('Bot Events', function () { const nick = this.bot.nickname; const text = `*${nick}* has joined the channel`; bot.ircClient.emit('join', channel, nick); - bot.sendExactToDiscord.should.have.been.calledWithExactly(channel, text); + bot.sendExactToDiscordByIrcChannel.should.have.been.calledWithExactly(channel, text); }); it('should send part messages to discord when config enabled', function () { @@ -224,7 +282,26 @@ describe('Bot Events', function () { const reason = 'Leaving'; const text = `*${nick}* has left the channel (${reason})`; bot.ircClient.emit('part', channel, nick, reason); - bot.sendExactToDiscord.should.have.been.calledWithExactly(channel, text); + bot.sendExactToDiscordByIrcChannel.should.have.been.calledWithExactly(channel, text); + // it should remove the nickname from the channelUsers list + const channelNicks = new Set([bot.nickname]); + bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); + }); + + it('should send part messages to specified discord channel when config enabled', function () { + const notifyChannel = '#joins-and-leaves'; + const bot = createBot({ ...config, ircStatusNotices: notifyChannel }); + bot.connect(); + const channel = '#channel'; + const nick = 'user'; + bot.ircClient.emit('names', channel, { [bot.nickname]: '', [nick]: '' }); + const originalNicks = new Set([bot.nickname, nick]); + bot.channelUsers.should.deep.equal({ '#channel': originalNicks }); + const reason = 'Leaving'; + const text = `*${nick}* has left ${channel} (${reason})`; + bot.ircClient.emit('part', channel, nick, reason); + bot.sendExactToDiscordByDiscordChannel.should.have.been.calledOnce; + bot.sendExactToDiscordByDiscordChannel.should.have.been.calledWithExactly(notifyChannel, text); // it should remove the nickname from the channelUsers list const channelNicks = new Set([bot.nickname]); bot.channelUsers.should.deep.equal({ '#channel': channelNicks }); @@ -239,7 +316,7 @@ describe('Bot Events', function () { bot.channelUsers.should.deep.equal({ '#channel': originalNicks }); const reason = 'Leaving'; bot.ircClient.emit('part', channel, bot.nickname, reason); - bot.sendExactToDiscord.should.not.have.been.called; + bot.sendExactToDiscordByIrcChannel.should.not.have.been.called; // it should remove the nickname from the channelUsers list bot.channelUsers.should.deep.equal({}); }); @@ -258,9 +335,28 @@ describe('Bot Events', function () { const text = `*${nick}* has quit (${reason})`; // send quit message for all channels on server, as the node-irc library does bot.ircClient.emit('quit', nick, reason, [channel1, channel2, channel3]); - bot.sendExactToDiscord.should.have.been.calledTwice; - bot.sendExactToDiscord.getCall(0).args.should.deep.equal([channel1, text]); - bot.sendExactToDiscord.getCall(1).args.should.deep.equal([channel3, text]); + bot.sendExactToDiscordByIrcChannel.should.have.been.calledTwice; + bot.sendExactToDiscordByIrcChannel.getCall(0).args.should.deep.equal([channel1, text]); + bot.sendExactToDiscordByIrcChannel.getCall(1).args.should.deep.equal([channel3, text]); + }); + + it('should send quit messages to a specified discord channel when config enabled', function () { + const notifyChannel = '#joins-and-leaves'; + const bot = createBot({ ...config, ircStatusNotices: notifyChannel }); + bot.connect(); + const channel1 = '#channel1'; + const channel2 = '#channel2'; + const channel3 = '#channel3'; + const nick = 'user'; + bot.ircClient.emit('names', channel1, { [bot.nickname]: '', [nick]: '' }); + bot.ircClient.emit('names', channel2, { [bot.nickname]: '' }); + bot.ircClient.emit('names', channel3, { [bot.nickname]: '', [nick]: '' }); + const reason = 'Quit: Leaving'; + const text = `*${nick}* has quit (${reason})`; + // send quit message for all channels on server, as the node-irc library does + bot.ircClient.emit('quit', nick, reason, [channel1, channel2, channel3]); + bot.sendExactToDiscordByDiscordChannel.should.have.been.calledOnce; + bot.sendExactToDiscordByDiscordChannel.should.have.been.calledWithExactly(notifyChannel, text); }); it('should not crash with join/part/quit messages and weird channel casing', function () { @@ -291,7 +387,7 @@ describe('Bot Events', function () { bot.ircClient.emit('part', channel, nick, reason); bot.ircClient.emit('join', channel, nick); bot.ircClient.emit('quit', nick, reason, [channel]); - bot.sendExactToDiscord.should.not.have.been.called; + bot.sendExactToDiscordByIrcChannel.should.not.have.been.called; }); it('should warn if it receives a part/quit before a names event', function () { diff --git a/test/bot.test.js b/test/bot.test.js index bcf38e93..94449b80 100644 --- a/test/bot.test.js +++ b/test/bot.test.js @@ -127,9 +127,9 @@ describe('Bot', function () { }); it( - 'should not send special messages to discord if the channel isn\'t in the channel mapping', + 'should not send special messages to discord if the channel isn\'t in the IRC channel mapping', function () { - this.bot.sendExactToDiscord('#no-irc', 'message'); + this.bot.sendExactToDiscordByIrcChannel('#no-irc', 'message'); this.sendStub.should.not.have.been.called; } ); @@ -137,7 +137,7 @@ describe('Bot', function () { it( 'should not send special messages to discord if it isn\'t in the channel', function () { - this.bot.sendExactToDiscord('#otherirc', 'message'); + this.bot.sendExactToDiscordByIrcChannel('#otherirc', 'message'); this.sendStub.should.not.have.been.called; } ); @@ -145,12 +145,31 @@ describe('Bot', function () { it( 'should send special messages to discord', function () { - this.bot.sendExactToDiscord('#irc', 'message'); + this.bot.sendExactToDiscordByIrcChannel('#irc', 'message'); this.sendStub.should.have.been.calledWith('message'); this.debugSpy.should.have.been.calledWith('Sending special message to Discord', 'message', '#irc', '->', '#discord'); } ); + it( + 'should not send special messages to discord by Discord channel if it isn\'t in the channel', + function () { + const channel = '#notinchannel'; + this.bot.sendExactToDiscordByDiscordChannel(channel, 'message'); + this.sendStub.should.not.have.been.called; + this.infoSpy.should.have.been.calledWith('Tried to send a message to a Discord channel the bot isn\'t in: ', channel); + } + ); + + it( + 'should send special messages to discord by Discord channel', + function () { + this.bot.sendExactToDiscordByDiscordChannel('#discord', 'message'); + this.sendStub.should.have.been.calledWith('message'); + this.debugSpy.should.have.been.calledWith('Sending special message to Discord', 'message', '#discord'); + } + ); + it('should not color irc messages if the option is disabled', function () { const text = 'testmessage'; const newConfig = { ...config, ircNickColor: false };