Skip to content
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 71 additions & 15 deletions lib/bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand All @@ -211,21 +240,30 @@ 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]) {
logger.warn(`No channelUsers found for ${channel} when ${nick} quit, ignoring.`);
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) => {
Expand Down Expand Up @@ -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;
120 changes: 108 additions & 12 deletions test/bot-events.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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 () {
Expand All @@ -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 });
});

Expand Down Expand Up @@ -184,19 +211,50 @@ 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();
const channel = '#channel';
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 });
});
Expand All @@ -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 () {
Expand All @@ -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 });
Expand All @@ -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({});
});
Expand All @@ -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 () {
Expand Down Expand Up @@ -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 () {
Expand Down
Loading