mrnodebot
Version:
Your Friendly NodeJS IRC Bot
662 lines (558 loc) • 24.1 kB
JavaScript
const c = require('irc-colors');
const _ = require('lodash');
const t = require('./localize');
const logger = require('./logger');
const helpers = require('../helpers');
/**
* Invalid empty command response options
* @type {[String]}
*/
const replyOptions = [
'Quoi de neuf?',
'What\'s up?',
'Que passa?',
'Miten menee?',
'Was ist los?',
];
class IrcWrappers {
/**
* IRC Wrapper helpers
* @param appInstance
*/
constructor(appInstance) {
this.app = appInstance;
}
/**
* Normalize text, replacing non print chars with nothing and fake space chars with a real space
* @param {string} text The text to normalize
*/
static _normalizeText(text) {
if (_.isUndefined(text) || !_.isString(text)) return text;
return c
.stripColorsAndStyle(text) // Strip styles and color
.replace(helpers.RemoveNonPrintChars, '') // Remove non printable characters
.replace(helpers.FakeSpaceChars, '\u0020') // Replace fake spaces with space
.trim();
}
/**
* IRC Action handler
* @param {string} from - Nick sending the message
* @param {string} to - Nick/Channel the message was received on
* @param {string} text - The message content
* @param {object} message - IRC information such as user, and host
*/
handleAction(from, to, text, message) {
const normalizedText = IrcWrappers._normalizeText(text);
// Do not handle our own actions, or those on the ignore list
if (from === this.app.nick || _.includes(this.app.Ignore, _.toLower(from))) return;
this.app.OnAction.forEach(async (command, key) => {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, from, to, normalizedText, message);
if (isPromise) return await call();
call();
} catch (err) {
this.app._errorHandler(t('errors.genericError', {
command: 'onAction',
}), err);
}
});
}
/**
* IRC on Kick Handler
* @param {string} channel - Channel observed
* @param {string} nick - Nick being kicked
* @param {string} by - Nick doing kick
* @param {string} reason - Reason for kick
* @param {object} message - IRC information such as user, and host
*/
handleOnKick(channel, nick, by, reason, message) {
const normalizedReason = IrcWrappers._normalizeText(reason);
// Handle Ignore
if (_.includes(this.app.Ignore, _.toLower(nick))) return;
if (nick === this.app.nick) {
logger.info(t('events.kickLoggingBy', {
channel,
by,
reason,
}));
}
if (by === this.app.nick) {
logger.info(t('events.kickLoggingFrom', {
nick,
channel,
normalizedReason,
}));
}
this.app.OnKick.forEach(async (command, key) => {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, channel, nick, by, normalizedReason, message);
if (isPromise) return await call();
call();
} catch (err) {
this.app._errorHandler(t('errors.genericError', {
command: 'onKick',
}), err);
}
});
}
/**
* IRC Nick changes handler
* @param {string} oldNick - Original nickname received
* @param {string} newNick - The new nick the user has taken
* @param {string} channels - The IRC channels this was observed on
* @param {object} message - IRC information such as user, and host
*/
handleNickChanges(oldNick, newNick, channels, message) {
// Return if user is on ignore list
if (_.includes(this.app.Ignore, _.toLower(oldNick)) || _.includes(this.app.Ignore, _.toLower(newNick))) return;
// track if the bots nick was changed
if (oldNick === this.app.nick) this.app.nick = newNick;
// Run events
this.app.NickChanges.forEach(async (command, key) => {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, oldNick, newNick, channels, message);
if (isPromise) return await call();
call();
} catch (err) {
this.app._errorHandler(t('errors.genericError', {
command: 'nickChange',
}), err);
}
});
}
/**
* IRC Notice handler
* @param {string} from Nick sending the message
* @param {string} to Nick/Channel the message was received on
* @param {string} text The message content
* @param {object} message IRC information such as user, and host
*/
handleOnNotice(from, to, text, message) {
// Do not handle our own actions, or those on the ignore list
if (from === this.app.nick || _.includes(this.app.Ignore, _.toLower(from))) return;
this.app.OnNotice.forEach(async (command, key) => {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, from, to, text, message);
if (isPromise) return await call();
call();
} catch (err) {
this.app._errorHandler(t('errors.genericError', {
command: 'onNotice',
}), err);
}
});
}
/**
* IRC On Join Handler
* @param {string} channel The Channel observed
* @param {string} nick The Nick that joined
* @param {object} message IRC information such as user, and host
*/
handleOnJoin(channel, nick, message) {
// Handle Ignore
if (_.includes(this.app.Ignore, _.toLower(nick))) return;
if (nick === this.app.nick) {
logger.info(t('events.channelJoined', {
channel,
}));
}
this.app.OnJoin.forEach(async (command, key) => {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, channel, nick, message);
if (isPromise) return await call();
call();
} catch (err) {
this.app._errorHandler(t('errors.genericError', {
command: 'onJoin',
}), err);
}
});
}
/**
* IRC On Part Handler
* @param {string} channel The Channel observed
* @param {string} nick The Nick observed
* @param {string} reason The part reason
* @param {object} message IRC information such as user, and host
*/
handleOnPart(channel, nick, reason, message) {
const normalizedReason = IrcWrappers._normalizeText(reason);
// Handle Ignore
if (_.includes(this.app.Ignore, _.toLower(nick))) return;
if (nick === this.app.nick) {
logger.info(t('events.channelParted', {
channel,
normalizedReason,
}));
}
this.app.OnPart.forEach(async (command, key) => {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, channel, nick, normalizedReason, message);
if (isPromise) return await call();
call();
} catch (err) {
this.app._errorHandler(t('errors.genericError', {
command: 'onPart',
}), err);
}
});
}
/**
* IRC on Quit Handler
* @param {string} nick Nick being kicked
* @param {string} reason Reason for kick
* @param {array} channels List of channels observed
* @param {object} message IRC information such as user, and host
*/
handleOnQuit(nick, reason, channels, message) {
const normalizedReason = IrcWrappers._normalizeText(reason);
// Handle Ignore
if (_.includes(this.app.Ignore, _.toLower(nick))) return;
if (nick === this.app.nick) {
logger.info(t('events.quitLogging', {
channels: _.isObject(channels) ? Object.keys(channels).join(', ') : channels,
normalizedReason,
}));
}
this.app.OnQuit.forEach(async (command, key) => {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, nick, normalizedReason, channels, message);
if (isPromise) return await call();
call();
} catch (err) {
this.app._errorHandler(t('errors.genericError', {
command: 'onQuit',
}), err);
}
});
}
/**
* IRC on Topic Handler
* @param {string} channel Channel observed having topic changed
* @param {string} topic Topic set
* @param {string} nick Nick observed setting the topic
* @param {object} message IRC information such as user, and host
*/
handleOnTopic(channel, topic, nick, message) {
const normalizedTopic = IrcWrappers._normalizeText(topic);
// Handle Ignore
if (_.includes(this.app.Ignore, _.toLower(nick))) return;
if (nick === this.app.nick) {
logger.info(t('events.topicLogging', {
channel,
normalizedTopic,
}));
}
this.app.OnTopic.forEach(async (command, key) => {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, channel, normalizedTopic, nick, message);
if (isPromise) return await call();
call();
} catch (err) {
this.app._errorHandler(t('errors.genericError', {
command: 'opTopic',
}), err);
}
});
}
/**
* IRC on CTCP Handler
* @param {string} from Nick sending the CTCP Message
* @param {string} to Channel / Nick sent the CTCP Message
* @param {string} text Content of more message
* @param {string} type The type of CTCP mMessage
* @param {object} message IRC information such as user, and host
*/
handleCtcpCommands(from, to, text, type, message) {
const normalizedText = IrcWrappers._normalizeText(text);
// Bail on self or ignore
if (from === this.app.nick || _.includes(this.app.Ignore, _.toLower(from)) || (type === 'privmsg' && normalizedText.startsWith('ACTION'))) return;
this.app.OnCtcp.forEach(async (command, key) => {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, from, to, normalizedText, type, message);
if (isPromise) return await call();
call();
} catch (err) {
this.app._errorHandler(t('errors.genericError', {
command: 'ctcpCommands',
}), err);
}
});
}
/**
* IRC on Registered handler
* @param {object} message Message returned from server
*/
handleRegistered(message) {
logger.info(t('events.registeredToIrc'));
this.app.Registered.forEach(async (command, key) => {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, message);
if (isPromise) return await call();
call();
} catch (err) {
this.app._errorHandler(t('errors.genericError', {
command: 'handleRegistered',
}), err);
}
});
}
/**
* IRC Bot Command Handler
* @param {string} from Nick the Command originated from
* @param {string} to Nick / Channel the command is to
* @param {string} text Content of the message
* @param {object} message IRC information such as user, and host
*/
async handleCommands(from, to, text, message) {
const normalizedText = IrcWrappers._normalizeText(text);
// Build the is object to pass along to the command router
const is = {
ignored: _.includes(this.app.Ignore, _.toLower(from)),
self: from === this.app._ircClient.nick,
privateMsg: to === from,
};
// Format the text, extract the command, and remove the trigger / command to send to handler
const textArray = normalizedText.split(' ');
const cmd = is.privateMsg ? textArray[0] : textArray[1];
// Remove command trigger
textArray.splice(0, is.privateMsg ? 1 : 2);
// Rejoin the final output
const output = textArray.join(' ');
const nickMatched = normalizedText.startsWith(this.app._ircClient.nick);
const hasCommand = this.app.Commands.has(cmd);
const validMatchedCommand = nickMatched && hasCommand;
const matchedInvalidCommand = nickMatched && !hasCommand;
// Check on trigger for private messages
is.triggered = (is.privateMsg && hasCommand) || validMatchedCommand;
// Process the listeners
if (!is.triggered && !is.ignored && !is.self) {
this.app.Listeners.forEach(async (command, key) => {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, to, from, normalizedText, message, is);
if (isPromise) return await call();
call();
} catch (err) {
this.app._errorHandler(t('errors.genericError', {
command: 'onCommand OnListeners',
}), err);
}
});
}
// Invalid Matched Command
if (matchedInvalidCommand) {
// No command given
if (!cmd || _.isEmpty(cmd)) {
this.app.say(to, `${from}, {${replyOptions.join('|')}}`);
return false;
}
const actualText = `${cmd} ${output}`.trim();
this.app.say(from, t('errors.invalidCommand', {
from,
cmd: actualText,
}));
return false;
}
// Nothing to see here
if (!is.triggered || is.ignored || is.self || !hasCommand) return false;
// Grab Command
const command = this.app.Commands.get(cmd);
// Identifiers
const owner = () => from === this.app.Config.owner.nick && message.host === this.app.Config.owner.host;
const guestCommand = () => command.access === this.app.Config.accessLevels.guest;
const validChannelVoiceUser = () => command.access === this.app.Config.accessLevels.channelVoice && this.app._ircClient.isVoiceInChannel(to, from);
const channelOpUser = () => command.access === this.app.Config.accessLevels.channelOp && this.app._ircClient.isOpOrVoiceInChannel(to, from);
// Hand the Identifier functions over to is to get passed into the script command
Object.assign(is, {
owner,
guestCommand,
validChannelVoiceUser,
channelOpUser,
});
// Requires
const requiresIdentified = () => command.access === this.app.Config.accessLevels.identified;
const requiresAdmin = () => command.access === this.app.Config.accessLevels.admin;
const requiresChannelVoiceIdentified = () => command.access === this.app.Config.accessLevels.channelVoiceIdentified;
const requiresChannelOpIdentified = () => command.access === this.app.Config.accessLevels.channelOpIdentified;
// Handle commands with non identified status
if (owner() || guestCommand() || validChannelVoiceUser() || channelOpUser()) {
try {
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, to, from, output, message, is);
if (isPromise) await call();
else call();
// Record Stats
this.app.Stats.set(cmd, this.app.Stats.has(cmd) ? this.app.Stats.get(cmd) + 1 : 1);
// Log
logger.info(t('events.commandTriggered', {
from,
to,
cmd,
group: helpers.AccessString(command.access),
}));
} catch (err) {
this.app._errorHandler(t('errors.procCommand', {
command: cmd,
}), err);
}
}
// The following administration levels piggy back on services, thus we check the acc status of the account and defer
else if (requiresIdentified() || requiresAdmin() || requiresChannelVoiceIdentified() || requiresChannelOpIdentified()) {
// Append timestamp to prevent unique collisions
this.app.AdmCallbacks.set(`${from}.${Date.now()}`, {
cmd, from, to, text: normalizedText, message, is,
});
// Send a check to nickserv to see if the user is registered
// Will spawn the notice listener to do the rest of the work
const first = this.app.Config.nickserv.host ? `@${this.app.Config.nickserv.host}` : '';
this.app.say(`${this.app.Config.nickserv.nick}${first}`, `acc ${from}`);
}
// Invalid Command
else {
this.app.say(from, t('errors.invalidCommand', {
from,
cmd: cmd.trim(),
}));
}
}
/**
* IRC Identified Command Handler
* @param {string} nick Nick Command was fired by
* @param {string} to Nick / Channel the Command was fired on
* @param {string} text Message Content
* @param {object} message IRC information such as user, and host
* @returns {boolean} command status
*/
async handleAuthenticatedCommands(nick, to, text, message) {
const normalizedText = IrcWrappers._normalizeText(text);
// Parse vars
const [user, acc, code] = normalizedText.split(' ');
// TODO, A little to magic
let currentIndex = 0;
let currentUser;
let currentTimestamp;
// Find an Admin command request matching the name of the response
this.app.AdmCallbacks.forEach((v, i, m) => {
// Short circuit
if (currentIndex) return;
// Initial validation gate
if (!_.isString(i) || _.isEmpty(i) || !_.isObject(v)) return;
// Match against the time format used to ensure uniqueness
const matches = i.match(/([^.]*).(.*)/);
// No matches available, malformed, return
if (!matches[0] || !matches[1] || !matches[2]) return;
// If the match belongs to the current user, assign values
if (matches[1] === user) {
currentIndex = matches[0];
currentUser = matches[1];
currentTimestamp = matches[2];
}
});
// Does not exist in call back, return
if (!currentUser || !currentTimestamp || !currentIndex) return false;
const admCall = this.app.AdmCallbacks.get(currentIndex);
const admCmd = this.app.Commands.get(admCall.cmd);
const admTextArray = admCall.text.split(' ');
// Check if the user is identified, pass it along in the is object
admCall.is.identified = code === this.app.Config.nickserv.accCode;
// Clean the output
admTextArray.splice(0, admCall.to === admCall.from ? 1 : 2);
const output = admTextArray.join(' ');
// This is a identified command and the user is not identified
if (!admCall.is.identified) {
this.app.say(admCall.to, t('auth.notIdentified', {
cmd: admCall.cmd,
from: admCall.from,
}));
// Remove the index
this.app.AdmCallbacks.delete(currentIndex);
return false;
}
const invalidAdmin = () => admCmd.access === this.app.Config.accessLevels.admin && !_.includes(this.app.Admins, _.toLower(admCall.from));
const invalidChannelOp = () => admCmd.access === this.app.Config.accessLevels.channelOpIdentified && !this.app._ircClient.isOpInChannel(admCall.to, admCall.from);
const invalidChannelVoice = () => admCmd.access === this.app.Config.accessLevels.channelVoiceIdentified && !this.app._ircClient.isOpOrVoiceInChannel(admCall.to, admCall.from);
// Gate
if (invalidAdmin() || invalidChannelOp() || invalidChannelVoice()) {
const group = helpers.AccessString(admCmd.access);
this.app.say(admCall.to, t('auth.notMemberOfGroup', {
group,
}));
logger.error(t('auth.notMemberOfGroupLogging', {
nick: admCall.from,
channel: admCall.to,
group,
type: admCall.cmd,
}));
this.app.AdmCallbacks.delete(currentIndex);
return false;
}
// Mark that this command was triggered by an identified response
admCall.is.triggerdByIdent = true;
// Launch the command
try {
// Call the command
const command = this.app.Commands.get(admCall.cmd);
// Is the callback a promise?
const isPromise = helpers.isAsync(command.call);
// Call Function
const call = command.call.bind(this.app, admCall.to, admCall.from, output, admCall.message, admCall.is);
if (isPromise) return await call();
call();
// Record Stats
this.app.Stats.set(admCall.cmd, this.app.Stats.has(admCall.cmd) ? this.app.Stats.get(admCall.cmd) + 1 : 1);
// Log
logger.info(t('events.commandTriggered', {
from: admCall.from,
to: admCall.to,
cmd: admCall.cmd,
group: helpers.AccessString(command.access),
}));
} catch (err) {
this.app._errorHandler(t('errors.invalidIdentCommand', {
cmd: admCall.cmd,
from: admCall.from,
to: admCall.to,
}), err);
this.app.say(admCall.to, `Something must really have gone wrong with the ${amdCall.cmd}, ${amdCall.from}`);
} finally {
// Remove the callback from the stack
this.app.AdmCallbacks.delete(currentIndex);
}
}
}
module.exports = IrcWrappers;