discord-irc/lib/bot.js
Edward Jones 76fda25a19 Use ircClient.nick instead of nickname when checking current nick
As the irc library can change the nick of the bot when
connecting (e.g. because the nick is currently taken, by
some other user), `this.nickname` may not correspond to the
current nickname and so when receiving a join event upon
joining a channel (prior to the names event), the current
guard against this can pass despite it being an event for
the bot.

This modifies it to check against ircClient.nick where
relevant, and also modifies the test stubs to allow adding
the nickname to the config and exposing this functionality
in tests.

To see the failure before this patch, enable
`ircStatusNotices` and run two copies of the bot; the
second should crash upon joining a channel, as it will have
a modified nickname (e.g. 'testbot1').
2017-07-01 01:11:49 +01:00

396 lines
14 KiB
JavaScript

import _ from 'lodash';
import irc from 'irc';
import logger from 'winston';
import discord from 'discord.js';
import { ConfigurationError } from './errors';
import { validateChannelMapping } from './validators';
import { formatFromDiscordToIRC, formatFromIRCToDiscord } from './formatting';
const REQUIRED_FIELDS = ['server', 'nickname', 'channelMapping', 'discordToken'];
const NICK_COLORS = ['light_blue', 'dark_blue', 'light_red', 'dark_red', 'light_green',
'dark_green', 'magenta', 'light_magenta', 'orange', 'yellow', 'cyan', 'light_cyan'];
const patternMatch = /{\$(.+?)}/g;
/**
* An IRC bot, works as a middleman for all communication
* @param {object} options - server, nickname, channelMapping, outgoingToken, incomingURL
*/
class Bot {
constructor(options) {
REQUIRED_FIELDS.forEach((field) => {
if (!options[field]) {
throw new ConfigurationError(`Missing configuration field ${field}`);
}
});
validateChannelMapping(options.channelMapping);
this.discord = new discord.Client({ autoReconnect: true });
this.server = options.server;
this.nickname = options.nickname;
this.ircOptions = options.ircOptions;
this.discordToken = options.discordToken;
this.commandCharacters = options.commandCharacters || [];
this.ircNickColor = options.ircNickColor !== false; // default to true
this.channels = _.values(options.channelMapping);
this.ircStatusNotices = options.ircStatusNotices;
this.announceSelfJoin = options.announceSelfJoin;
// "{$keyName}" => "variableValue"
// author/nickname: nickname of the user who sent the message
// discordChannel: Discord channel (e.g. #general)
// ircChannel: IRC channel (e.g. #irc)
// text: the (appropriately formatted) message content
this.format = options.format || {};
// "{$keyName}" => "variableValue"
// displayUsername: nickname with wrapped colors
// attachmentURL: the URL of the attachment (only applicable in formatURLAttachment)
this.formatIRCText = this.format.ircText || '<{$displayUsername}> {$text}';
this.formatURLAttachment = this.format.urlAttachment || '<{$displayUsername}> {$attachmentURL}';
// "{$keyName}" => "variableValue"
// side: "Discord" or "IRC"
this.formatCommandPrelude = this.format.commandPrelude || 'Command sent from {$side} by {$nickname}:';
// "{$keyName}" => "variableValue"
// withMentions: text with appropriate mentions reformatted
this.formatDiscord = this.format.discord || '**<{$author}>** {$withMentions}';
// Keep track of { channel => [list, of, usernames] } for ircStatusNotices
this.channelUsers = {};
this.channelMapping = {};
// Remove channel passwords from the mapping and lowercase IRC channel names
_.forOwn(options.channelMapping, (ircChan, discordChan) => {
this.channelMapping[discordChan] = ircChan.split(' ')[0].toLowerCase();
});
this.invertedMapping = _.invert(this.channelMapping);
this.autoSendCommands = options.autoSendCommands || [];
}
connect() {
logger.debug('Connecting to IRC and Discord');
this.discord.login(this.discordToken);
const ircOptions = {
userName: this.nickname,
realName: this.nickname,
channels: this.channels,
floodProtection: true,
floodProtectionDelay: 500,
retryCount: 10,
...this.ircOptions
};
this.ircClient = new irc.Client(this.server, this.nickname, ircOptions);
this.attachListeners();
}
attachListeners() {
this.discord.on('ready', () => {
logger.info('Connected to Discord');
});
this.ircClient.on('registered', (message) => {
logger.info('Connected to IRC');
logger.debug('Registered event: ', message);
this.autoSendCommands.forEach((element) => {
this.ircClient.send(...element);
});
});
this.ircClient.on('error', (error) => {
logger.error('Received error event from IRC', error);
});
this.discord.on('error', (error) => {
logger.error('Received error event from Discord', error);
});
this.discord.on('warn', (warning) => {
logger.warn('Received warn event from Discord', warning);
});
this.discord.on('message', (message) => {
// Ignore bot messages and people leaving/joining
this.sendToIRC(message);
});
this.ircClient.on('message', this.sendToDiscord.bind(this));
this.ircClient.on('notice', (author, to, text) => {
this.sendToDiscord(author, to, `*${text}*`);
});
this.ircClient.on('nick', (oldNick, newNick, channels) => {
if (!this.ircStatusNotices) 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}`);
}
} else {
logger.warn(`No channelUsers found for ${channel} when ${oldNick} changed.`);
}
});
});
this.ircClient.on('join', (channelName, nick) => {
logger.debug('Received join:', channelName, nick);
if (!this.ircStatusNotices) 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`);
});
this.ircClient.on('part', (channelName, nick, reason) => {
logger.debug('Received part:', channelName, nick, reason);
if (!this.ircStatusNotices) 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) {
logger.debug('Deleting channelUsers as bot parted:', channel);
delete this.channelUsers[channel];
return;
}
if (this.channelUsers[channel]) {
this.channelUsers[channel].delete(nick);
} else {
logger.warn(`No channelUsers found for ${channel} when ${nick} parted.`);
}
this.sendExactToDiscord(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;
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})`);
});
});
this.ircClient.on('names', (channelName, nicks) => {
logger.debug('Received names:', channelName, nicks);
if (!this.ircStatusNotices) return;
const channel = channelName.toLowerCase();
this.channelUsers[channel] = new Set(Object.keys(nicks));
});
this.ircClient.on('action', (author, to, text) => {
this.sendToDiscord(author, to, `_${text}_`);
});
this.ircClient.on('invite', (channel, from) => {
logger.debug('Received invite:', channel, from);
if (!this.invertedMapping[channel]) {
logger.debug('Channel not found in config, not joining:', channel);
} else {
this.ircClient.join(channel);
logger.debug('Joining channel:', channel);
}
});
if (logger.level === 'debug') {
this.discord.on('debug', (message) => {
logger.debug('Received debug event from Discord', message);
});
}
}
static getDiscordNicknameOnServer(user, guild) {
const userDetails = guild.members.get(user.id);
if (userDetails) {
return userDetails.nickname || user.username;
}
return user.username;
}
parseText(message) {
const text = message.mentions.users.reduce((content, mention) => {
const displayName = Bot.getDiscordNicknameOnServer(mention, message.guild);
return content.replace(`<@${mention.id}>`, `@${displayName}`)
.replace(`<@!${mention.id}>`, `@${displayName}`)
.replace(`<@&${mention.id}>`, `@${displayName}`);
}, message.content);
return text
.replace(/\n|\r\n|\r/g, ' ')
.replace(/<#(\d+)>/g, (match, channelId) => {
const channel = this.discord.channels.get(channelId);
if (channel) return `#${channel.name}`;
return '#deleted-channel';
})
.replace(/<@&(\d+)>/g, (match, roleId) => {
const role = message.guild.roles.get(roleId);
if (role) return `@${role.name}`;
return '@deleted-role';
})
.replace(/<(:\w+:)\d+>/g, (match, emoteName) => emoteName);
}
isCommandMessage(message) {
return this.commandCharacters.indexOf(message[0]) !== -1;
}
static substitutePattern(message, patternMapping) {
return message.replace(patternMatch, (match, varName) => patternMapping[varName] || match);
}
sendToIRC(message) {
const author = message.author;
// Ignore messages sent by the bot itself:
if (author.id === this.discord.user.id) return;
const channelName = `#${message.channel.name}`;
const ircChannel = this.channelMapping[message.channel.id] ||
this.channelMapping[channelName];
logger.debug('Channel Mapping', channelName, this.channelMapping[channelName]);
if (ircChannel) {
const fromGuild = message.guild;
const nickname = Bot.getDiscordNicknameOnServer(author, fromGuild);
let text = this.parseText(message);
let displayUsername = nickname;
if (this.ircNickColor) {
const colorIndex = (nickname.charCodeAt(0) + nickname.length) % NICK_COLORS.length;
displayUsername = irc.colors.wrap(NICK_COLORS[colorIndex], nickname);
}
const patternMap = {
author: nickname,
nickname,
displayUsername,
text,
discordChannel: channelName,
ircChannel
};
if (this.isCommandMessage(text)) {
patternMap.side = 'Discord';
const prelude = Bot.substitutePattern(this.formatCommandPrelude, patternMap);
logger.debug('Sending command message to IRC', ircChannel, text);
this.ircClient.say(ircChannel, prelude);
this.ircClient.say(ircChannel, text);
} else {
if (text !== '') {
// Convert formatting
text = formatFromDiscordToIRC(text);
patternMap.text = text;
text = Bot.substitutePattern(this.formatIRCText, patternMap);
logger.debug('Sending message to IRC', ircChannel, text);
this.ircClient.say(ircChannel, text);
}
if (message.attachments && message.attachments.size) {
message.attachments.forEach((a) => {
patternMap.attachmentURL = a.url;
const urlMessage = Bot.substitutePattern(this.formatURLAttachment, patternMap);
logger.debug('Sending attachment URL to IRC', ircChannel, urlMessage);
this.ircClient.say(ircChannel, urlMessage);
});
}
}
}
}
findDiscordChannel(ircChannel) {
const discordChannelName = this.invertedMapping[ircChannel.toLowerCase()];
if (discordChannelName) {
// #channel -> channel before retrieving and select only text channels:
const discordChannel = discordChannelName.startsWith('#') ? this.discord.channels
.filter(c => c.type === 'text')
.find('name', discordChannelName.slice(1)) : this.discord.channels.get(discordChannelName);
if (!discordChannel) {
logger.info('Tried to send a message to a channel the bot isn\'t in: ',
discordChannelName);
return null;
}
return discordChannel;
}
return null;
}
sendToDiscord(author, channel, text) {
const discordChannel = this.findDiscordChannel(channel);
if (!discordChannel) return;
// Convert text formatting (bold, italics, underscore)
const withFormat = formatFromIRCToDiscord(text);
const patternMap = {
author,
nickname: author,
text: withFormat,
discordChannel: `#${discordChannel.name}`,
ircChannel: channel
};
if (this.isCommandMessage(text)) {
patternMap.side = 'IRC';
const prelude = Bot.substitutePattern(this.formatCommandPrelude, patternMap);
logger.debug('Sending command message to Discord', `#${discordChannel.name}`, text);
discordChannel.sendMessage(prelude);
discordChannel.sendMessage(text);
return;
}
const withMentions = withFormat.replace(/@[^\s]+\b/g, (match) => {
const search = match.substring(1);
const guild = discordChannel.guild;
const nickUser = guild.members.find('nickname', search);
if (nickUser) {
return nickUser;
}
const user = this.discord.users.find('username', search);
if (user) {
return user;
}
const role = guild.roles.find('name', search);
if (role && role.mentionable) {
return role;
}
return match;
});
patternMap.withMentions = withMentions;
// Add bold formatting:
// Use custom formatting from config / default formatting with bold author
const withAuthor = Bot.substitutePattern(this.formatDiscord, patternMap);
logger.debug('Sending message to Discord', withAuthor, channel, '->', `#${discordChannel.name}`);
discordChannel.sendMessage(withAuthor);
}
/* Sends a message to Discord exactly as it appears */
sendExactToDiscord(channel, text) {
const discordChannel = this.findDiscordChannel(channel);
if (!discordChannel) return;
logger.debug('Sending special message to Discord', text, channel, '->', `#${discordChannel.name}`);
discordChannel.sendMessage(text);
}
}
export default Bot;