Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display joins/nicks/parts/quits in single Discord channel #515

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
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