SockDrawer/SockBot

View on GitHub
lib/commands.js

Summary

Maintainability
F
3 days
Test Coverage
File `commands.js` has 370 lines of code (exceeds 250 allowed). Consider refactoring.
'use strict';
/**
* NodeBB provider module User class
* @module sockbot.lib.commands
* @author Accalia
* @license MIT
*/
 
const debug = require('debug')('sockbot:commands');
const utils = require('./utils');
 
/**
* Create a Commands class and bind it to a forum instance.
*
* @param {Provider} forum A forum Provider instance to bind to constructed Commands class
* @returns {Commands} A Commands class bound to the provided `forum` instance
*/
Function `bindCommands` has 362 lines of code (exceeds 25 allowed). Consider refactoring.
Function `bindCommands` has a Cognitive Complexity of 40 (exceeds 5 allowed). Consider refactoring.
exports.bindCommands = function bindCommands(forum) {
/**
* Command Handlers
*
* @default
*/
const handlers = {
help: {
help: 'print command help listing',
handler: cmdHelp
}
},
 
/**
* Shadow Command Handlers
*
* @default
*/
shadowHandlers = {},
 
/**
* Commands forbidden by the forum provider
*
* @default
*/
forbiddenCmds = {},
 
/**
* Extended help topics
*
*/
helpTopics = {};
 
/**
* Command Class. Represents a single command within a post
*
* @inner
*/
class Command {
/**
* Create a Command from a defintiton
*
* @public
* @class
*
* @param {object} definition Parsed Command defintition
* @param {Commands} parent Commands instnace that created this Command
*/
constructor(definition, parent) {
let handler = (handlers[definition.command] || {}).handler;
handler = handler || shadowHandlers[definition.command];
const values = {
line: definition.line,
command: definition.command,
commandText: definition.commandText,
args: definition.args,
mention: definition.mention,
parent: parent,
handler: handler || defaultHandler,
replyText: ''
};
values.executable = !values.mention || values.handler !== defaultHandler;
utils.mapSet(this, values);
}
 
/**
* Full Command line definition
*
* @public
*
* @type {string}
*/
get line() {
return utils.mapGet(this, 'line');
}
 
/**
* Normalized command name
*
* @public
*
* @type {string}
*/
get command() {
return utils.mapGet(this, 'command');
}
 
/**
* Unnormalized command name
*
* @public
*
* @type {string}
*/
get commandText() {
return utils.mapGet(this, 'commandText');
}
 
/**
* Is Command a mention command?
*
* @public
*
* @type {boolean}
*/
get mention() {
return utils.mapGet(this, 'mention');
}
 
/**
* Command arguments
*
* @public
*
* @type {Array<string>}
*/
get args() {
return utils.mapGet(this, 'args');
}
 
/**
* Parent Commands object
*
* @public
*
* @type {Commands}
*/
get parent() {
return utils.mapGet(this, 'parent');
}
 
/**
* Text to post as a reply to the command
*
* @public
*
* @type {string}
*/
get replyText() {
return utils.mapGet(this, 'replyText');
}
 
/**
* Indicates if this command will execute any handler
*
* @public
*
* @type {boolean}
*/
get executable() {
return utils.mapGet(this, 'executable');
}
 
/**
* Execute the command handler for this command
*
* @public
*
* @returns {Promise} Resolves when command has fully executed
*/
execute() {
if (!this.executable) {
return Promise.resolve();
}
debug(`executing command "${this.commandText}"`);
return utils.mapGet(this, 'handler')(this);
}
 
/**
* Get Full Post the command refers to
*
* @public
*
* @returns {Promise<Post>} Resolves to retrieved Post
*/
getPost() {
return this.parent.getPost();
}
 
/**
* Get Topic command was posted to
*
* @public
*
* @returns {Promise<Topic>} Resolves to retrieved Topic
*/
getTopic() {
return this.parent.getTopic();
}
 
/**
* Get User who posted the command
*
* @public
*
* @returns {Promise<User>} Resolved to retrieved User
*/
getUser() {
return this.parent.getUser();
}
 
/**
* Get PrivateMessage who posted the command
*
* @public
*
* @returns {Promise<PrivateMessage>} Resolved to retrieved PrivateMessage
*/
getPM() {
return this.parent.getPM();
}
 
/**
* Reply to command with content
*
* @public
*
* @param {string} content Content to reply with
*/
reply(content) {
utils.mapSet(this, 'replyText', content);
}
 
/**
* Append content to the reply of command
*
* @public
*
* @param {string} content Content to append to reply
*/
appendReply(content) {
let reply = utils.mapGet(this, 'replyText') || '';
if (reply) {
reply += '\n\n';
}
utils.mapSet(this, 'replyText', reply + content);
}
}
 
/**
* Commands class. Represents all commands for a Notification
*
* @public
*/
class Commands {
/**
* Reply Handler
*
* @function
* @typedef {ReplyHandler}
*
* @param {string} content Text to reply with
* @returns Promise Resolves when reply is completed
*
*/
/**
* Construct a Commands object from notification
*
* @public
* @class
*
* @param {Ids} ids Useful Ids
* @param {string} postBody Raw Content of post containing commands
* @param {ReplyHandler} handler Reply handler
*/
constructor(ids, postBody, handler) {
const values = {
ids: ids,
postBody: postBody,
_replyFn: handler,
commands: postBody.split('\n')
.map(parseLine)
.filter((definition) => definition)
.map((definition) => new Command(definition, this))
.filter((command) => command.executable)
};
utils.mapSet(this, values);
}
 
/**
* Object Ids
*
* @typedef {Ids}
* @property {number} post Post Id
* @property {number} topic Topic Id
* @property {number} user User Id
* @property {number} room Chat Room Id
*/
/**
* Ids relevant to this Commands object
*
* @public
*
* @type {Ids}
*/
get ids() {
return utils.mapGet(this, 'ids');
}
 
/**
* Commands contained in this Commands object
*
* @public
*
* @type {Array<Command>}
*/
get commands() {
return utils.mapGet(this, 'commands');
}
 
/**
* Text this Commands object parsed
*
* @public
*
* @type {string}
*/
get text() {
return utils.mapGet(this, 'postBody');
}
 
/**
* Reply function for these commands
*
* @private
*
* @type {ReplyHandler}
*/
get _replyFn() {
return utils.mapGet(this, '_replyFn');
}
 
/**
* Get an item from cache or retrieve from server if not cached
*
* @private
*
* @param {string} name Name to cache results under
* @param {number} id Object id to retrieve
* @param {object} obj Forum object that has a `get` function that will accept `id` to retrieve item
* @returns {Promise<*>} Resolves to requested item
*/
_getItem(name, id, obj) {
const item = utils.mapGet(this, name);
if (item) {
return Promise.resolve(item);
}
return obj.get(id)
.then((thing) => {
utils.mapSet(this, name, thing);
return thing;
});
}
 
_setItem(name, obj) {
const item = utils.mapGet(this, name);
if (item) {
throw new Error('E_ALREADY_SET');
}
utils.mapSet(this, name, obj);
}
 
/**
* Get the Post this Commands object referrs to
*
* @public
*
* @returns {Promise<Post>} Resolves to the retrieved Post
*/
getPost() {
return this._getItem('post', this.ids.post, forum.Post);
}
 
/**
* Set the Post this Commands object refers to
*
* @public
*
* @param {Post} post The Post to cache
*/
setPost(post) {
this._setItem('post', post);
}
 
/**
* Get the Topic this Commands object referrs to
*
* @public
*
* @returns {Promise<Topic>} Resolves to the retrieved Topic
*/
getTopic() {
return this._getItem('topic', this.ids.topic, forum.Topic);
}
 
/**
* Set the Topic this Commands object refers to
*
* @public
*
* @param {Topic} topic The Topic to cache
*/
setTopic(topic) {
this._setItem('topic', topic);
}
 
/**
* Get the PrivateMessage this Commands object referrs to
*
* @public
*
* @returns {Promise<ChatRoom>} Resolves to the retrieved ChatRoom
*/
getPM() {
return this._getItem('pm', this.ids.pm, forum.PrivateMessage);
}
 
/**
* Set the PrivateMessage this Commands object refers to
*
* @public
*
* @param {PrivateMessage} pm The pm to cache
*/
setPM(pm) {
this._setItem('pm', pm);
}
 
/**
* Get the user who sent these commands
*
* @public
*
* @returns {Promise<User>} Resolved to the retrieved User
*/
getUser() {
return this._getItem('user', this.ids.user, forum.User);
}
 
/**
* Set the User this Commands object refers to
*
* @public
*
* @param {User} user The User to cache
*/
setUser(user) {
this._setItem('user', user);
}
 
/**
* Execute the commands this object contains
*
* @public
*
* @returns {Promise<Commands>} Resolves to self when all commands have been processed
*/
execute() {
const cmds = this.commands.slice();
if (cmds.length > 10) {
forum.emit('logError', `Refusing to execute ${cmds.length}, over limit.`);
return onLimitCommands(this).then(() => this);
}
const exec = () => {
const cmd = cmds.shift();
if (!cmd) {
return Promise.resolve();
}
return cmd.execute().then(exec);
};
return exec()
.then(() => onComplete(this))
.catch((reason) => {
return onError(reason, this);
})
.catch(() => forum.emit('logError', 'Command error occured and posting response failed.'))
.then(() => this);
}
 
/**
* Get Commands from a notification
*
* @public
* @static
*
* @param {Notification} notification Notification to get commands for
* @param {string} postBody Post Body to parse for commands
* @param {ReplyHandler} handler Reply function for commands
* @returns {Promise<Commands>} Resolves to parsed commands
*/
static get(notification, postBody, handler) {
return new Promise((resolve) => {
const rawData = utils.htmlToRaw(postBody);
resolve(new Commands(notification, rawData, handler));
});
}
 
/**
* Command Handler
*
* @typedef {CommandHandler}
* @function
*
* @param {Command} command Command to handle
* @returns {Promise} Resolves when command is processed
*/
 
/**
* Add a command to this forum instance
*
* @public
* @static
*
* @param {string} command Command to be added
* @param {string} helpText Short help text for command
* @param {CommandHandler} handler Function to handle the command
* @param {object} [context] `this` context for the command. If not provided context will be a bare Object.
* @returns {Promise} Resolves when command has been added
*/
static add(command, helpText, handler, context) {
return new Promise((resolve, reject) => {
debug(`Registering command: ${command}`);
const cmd = command.toLowerCase(),
shadowingMsg = 'will override alias.',
conflictMsg = 'cannot override existing command.',
forbiddenMsg = 'not allowed by the active forum provider.';
checkAvailable(shadowHandlers, cmd, 'log', shadowingMsg);
if (!checkAvailable(handlers, cmd, 'error', conflictMsg)) {
return reject(new Error('E_ALREADY_REGISTERED'));
}
if (!checkAvailable(forbiddenCmds, cmd, 'error', forbiddenMsg)) {
return reject(new Error('E_FORBIDDEN_COMMAND'));
}
if (context === undefined) {
context = new Object();
}
handlers[cmd] = {
commandText: command,
help: helpText,
handler: handler.bind(context)
};
return resolve(this);
});
}
 
/**
* Add a command alias to this forum instance
*
* @public
* @static
*
* @param {string} command Command alias to be added
* @param {CommandHandler} handler Function to handle the command
* @param {object} [context] `this` context for the command. If not provided context will be a bare Object.
* @returns {Promise} Resolves when command has been added
*/
static addAlias(command, handler, context) {
return new Promise((resolve, reject) => {
debug(`Registering command alias: ${command}`);
const cmd = command.toLowerCase(),
shadowedMsg = 'existing command will override.',
shadowConflict = 'cannot override existing alias.',
forbiddenMsg = 'not allowed by the active forum provider.';
checkAvailable(handlers, cmd, 'log', shadowedMsg);
if (!checkAvailable(shadowHandlers, cmd, 'error', shadowConflict)) {
return reject(new Error('E_ALREADY_REGISTERED'));
}
if (!checkAvailable(forbiddenCmds, cmd, 'error', forbiddenMsg)) {
return reject(new Error('E_FORBIDDEN_COMMAND'));
}
if (context === undefined) {
context = new Object();
}
shadowHandlers[cmd] = handler.bind(context);
return resolve(this);
});
}
 
/**
* Add an extended help message for a command
*
* @param {string} command The command to add help text to
* @param {string} text The help text to add for a command
* @returns {Promise} Resolves when help message has been added
*
*/
static addExtendedHelp(command, text) {
return new Promise((resolve, reject) => {
const ext = command.replace(/\s+/g, ' ');
if (!ext || !text || /^\s*$/.test(ext) || /^\s*$/.test(text)) {
return reject(new Error('E_INVALID_HELP_TOPIC'));
}
helpTopics[ext] = text;
return resolve(this);
});
}
 
/**
* Forbid a command from being registered.
*
* This is to allow a provider the ability to prevent commands from being registered or triggered that
* would be problematic or prone to being accidentally triggered on the target system.
*
* @param {string} command The forbidden command
* @returns {boolean} True if the command was already forbidden, false otherwise.
*
*/
static forbidCommand(command) {
command = command.toLowerCase();
const forbidden = Boolean(forbiddenCmds[command]);
forbiddenCmds[command] = 1;
return forbidden;
}
}
 
/**
* Parse command definitions from a line of text
*
* @private
*
* @param {string} line Text to parse
* @returns {object} Parsed command definition
*/
function parseLine(line) {
let args, mention, commandText;
if (new RegExp(`^@${forum.username}\\s\\S{3,}(\\s|$)`, 'i').test(line)) {
args = line.split(/\s+/);
args.shift();
commandText = args.shift();
mention = true;
} else if (/^!\w\S{2,}(\s|$)/.test(line)) {
args = line.split(/\s+/);
commandText = args.shift().substring(1);
mention = false;
}
if (commandText) {
return {
commandText: commandText,
command: commandText.toLowerCase(),
args: args,
line: line,
mention: mention
};
}
return null;
}
 
/**
* Handle Commands object once execution is complete.
*
* Post a reply if `replyText` of any contained command is non empty
*
* @private
*
* @param {Commands} commands Commands that have completed execution
* @returns {Promise} Resolves when after execution processing is complete
*/
function onComplete(commands) {
const content = commands.commands.map((command) => command.replyText)
.filter((text) => (text || '').trim())
.join('\n\n---\n\n');
if (!content) {
return Promise.resolve();
}
return commands._replyFn(content);
}
 
/**
* Handle Commands object that error'd durring execution
*
* Post an error message in reply to post
*
* @private
*
* @param {string} reason Error Reason
* @param {Commands} commands Commands that errored
* @returns {Promise} Resolves after onError steps have completed
*/
function onError(reason, commands) {
if (typeof reason !== 'string' && typeof reason.message === 'string') {
reason = reason.message;
}
return commands._replyFn(`An unexpected error \`${reason}\` occured and your commands could not be processed!`);
}
 
/**
* Handle Commands object that refused execution due to antispam protection
*
* Post an error message in reply to post
*
* @private
*
* @param {Commands} commands Commands that errored
* @returns {Promise} Resolves after onError steps have completed
*/
function onLimitCommands(commands) {
debug(`refusing to execute ${commands.commands.length} commands, Limit is 10 commands`);
return commands._replyFn('Your request contained too many commands to process.\n' +
'\nPlease try again with fewer commands.');
}
 
/**
* Default Handler for unknown commands
*
* Post an error reply only if command is not a mention command
*
* @private
*
* @param {Command} command Unknown Command
* @returns {Promise} Resolves after action is taken
*/
function defaultHandler(command) {
debug(`executing default handler for unrecognized command ${command.commandText}`);
if (!command.mention) {
command.reply(`Command \`${command.command}\` is not recognized`);
}
return Promise.resolve();
}
 
/**
* Reply with help to the command !help
*
* @private
*
* @param {command} command help command
* @returns {Promise} Resolves after help text is generated
*/
function cmdHelp(command) {
let ext = '';
if (command.args && command.args.length > 0) {
ext = command.args.join(' ');
}
if (ext && helpTopics[ext]) {
const txt = `Help topic for \`${ext}\`\n\n${helpTopics[ext]}` +
'\n\nIssue the `help` command without any parameters to see all available commands';
command.reply(txt);
 
} else {
const help = `${getCommandHelps()}\n\n\\* Help topic available.\n\nIssue the \`help\` command with an ` +
'available help topic as a parameter to read additonal help';
command.reply(help);
}
return Promise.resolve();
}
 
/**
* Get help text for commands
*
* @private
*
* @returns {string} Command help text
*/
Function `getCommandHelps` has 31 lines of code (exceeds 25 allowed). Consider refactoring.
function getCommandHelps() {
const cmds = {},
topics = {},
result = ['Registered commands:'];
let keys = {};
Object.keys(handlers).map((key) => {
keys[key] = 1;
});
Object.keys(helpTopics).map((key) => {
keys[key] = 1;
});
Object.keys(keys).map((key) => {
if (handlers[key]) {
cmds[key] = handlers[key].help;
if (helpTopics[key]) {
cmds[key] += ' *';
}
} else {
topics[key] = 'Extended help topic';
}
});
keys = Object.keys(cmds);
keys.sort();
keys.forEach((cmd) => result.push(`${cmd}: ${cmds[cmd]}`));
keys = Object.keys(topics);
if (keys.length) {
result.push('');
result.push('Help Topics:');
keys.sort();
keys.forEach((topic) => result.push(`${topic}: ${topics[topic]}`));
}
return result.join('\n');
}
 
/**
* Check the availability of a command
*
* @param {object} map A map of commands to check availability against
* @param {string} command Name of command to check
* @param {string} logType Type of log to log if command isn't available
* @param {string} logMsg Message to log if command is not available
* @returns {boolean} True if available, false otherwise
*/
function checkAvailable(map, command, logType, logMsg) {
if (map[command]) {
const prefix = logType === 'error' ? 'ERROR' : 'WARNING';
forum.emit(logType, `${prefix}, ${command} is already registered: ${logMsg}`);
return false;
}
return true;
}
 
/* istanbul ignore else */
if (typeof global.describe === 'function') {
exports.internals = {
handlers: handlers,
shadowHandlers: shadowHandlers,
forbiddenCmds: forbiddenCmds,
helpTopics: helpTopics,
Commands: Commands,
Command: Command,
parseLine: parseLine,
getCommandHelps: getCommandHelps,
cmdHelp: cmdHelp,
defaultHandler: defaultHandler,
onError: onError,
onComplete: onComplete
};
}
 
return Commands;
};
 
/* istanbul ignore else */
if (typeof global.describe === 'function') {
exports.internals = {};
}