The irc module does not appear to be very actively maintained, and has quite a few issues causing issues in this project itself. This fork, maintained by me, has a few fixes to some major issues already. Changelogs: - v0.6.0 - https://github.com/Throne3d/node-irc/releases/tag/v0.6.0 - v0.6.1 - https://github.com/Throne3d/node-irc/releases/tag/v0.6.1 Should fix #199, #200, as well as some issues not previously noted (crash if unbanning a user who is not banned, crash in circumstances with a poor internet connection). It may also allow us to remove our workaround for the quit and nick events having all channels in the associated array.
396 lines
14 KiB
JavaScript
396 lines
14 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;
|
|
|
|
// "{$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.nickname && !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.nickname) 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.nickname) {
|
|
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.nickname) 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;
|