438 lines
16 KiB
JavaScript
438 lines
16 KiB
JavaScript
import _ from 'lodash';
|
|
import irc from 'irc-upd';
|
|
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;
|
|
|
|
// IRC nicks do not respect case so ignore case
|
|
this.ircIgnoreUsers = options.ircIgnoreUsers || [];
|
|
this.ircIgnoreUsers = this.ircIgnoreUsers.map(function(x){ return x.toLowerCase(); });
|
|
|
|
// "{$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"
|
|
if ('commandPrelude' in this.format) {
|
|
this.formatCommandPrelude = this.format.commandPrelude;
|
|
} else {
|
|
this.formatCommandPrelude = '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,
|
|
autoRenick: true,
|
|
// options specified in the configuration file override the above defaults
|
|
...this.ircOptions
|
|
};
|
|
|
|
// default encoding to UTF-8 so messages to Discord aren't corrupted
|
|
if (!Object.prototype.hasOwnProperty.call(ircOptions, 'encoding')) {
|
|
if (irc.canConvertEncoding()) {
|
|
ircOptions.encoding = 'utf-8';
|
|
} else {
|
|
logger.warn('Cannot convert message encoding; you may encounter corrupted characters with non-English text.\n' +
|
|
'For information on how to fix this, please see: https://github.com/Throne3d/node-irc#character-set-detection');
|
|
}
|
|
}
|
|
|
|
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.some(prefix => message.startsWith(prefix));
|
|
}
|
|
|
|
ignoredIrcUser(user) {
|
|
return this.ircIgnoreUsers.some(ignoredUser => ignoredUser === user.toLowerCase());
|
|
}
|
|
|
|
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';
|
|
logger.debug('Sending command message to IRC', ircChannel, text);
|
|
// if (prelude) this.ircClient.say(ircChannel, prelude);
|
|
if (this.formatCommandPrelude) {
|
|
const prelude = Bot.substitutePattern(this.formatCommandPrelude, patternMap);
|
|
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
|
|
};
|
|
|
|
// Do not send to Discord if this user is on the ignore list.
|
|
if (this.ignoredIrcUser(author)) {
|
|
return;
|
|
}
|
|
|
|
if (this.isCommandMessage(text)) {
|
|
patternMap.side = 'IRC';
|
|
logger.debug('Sending command message to Discord', `#${discordChannel.name}`, text);
|
|
if (this.formatCommandPrelude) {
|
|
const prelude = Bot.substitutePattern(this.formatCommandPrelude, patternMap);
|
|
discordChannel.send(prelude);
|
|
}
|
|
discordChannel.send(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;
|
|
}).replace(/:(\w+):/g, (match, ident) => {
|
|
const guild = discordChannel.guild;
|
|
const emoji = guild.emojis.find(x => x.name === ident && x.requiresColons);
|
|
if (emoji) {
|
|
return `<:${emoji.identifier}>`; // identifier = name + ":" + id
|
|
}
|
|
|
|
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.send(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.send(text);
|
|
}
|
|
}
|
|
|
|
export default Bot;
|