Pronto logo

Pronto

source

commands/evaluate.js

View on GitHub

/* eslint-disable no-unused-vars */
'use strict';

const Discord = require('discord.js');
const Typings = require('../typings');

const { dateTimeGroup, jsCodeBlock, ...modules } = require('../modules');
const { commandError, deleteMsg, findGuildConfiguration, sendMsg, ...handlers } = require('../handlers');

/**
 * @member {commands.Command} commands.evaluate Evaluate JavaScript code directly from a Discord message
 */

/**
 * Complete the \<Command> object from a \<BaseCommand>
 * @param {Discord.Guild} guild The \<Guild> that the member shares with the bot
 * @returns {Promise<Typings.Command>} The complete \<Command> object with a \<Command.execute()> method
 */
module.exports = async guild => {
	const { commands: { evaluate, ...commands }, colours, _doc: config, ...document } = await findGuildConfiguration(guild);

	/**
	 * @param {Typings.CommandParameters} parameters The \<CommandParameters> to execute this command
	 */
	evaluate.execute = async ({ msg, args }) => {
		const { bot } = require('../pronto');

		// Parse the short flags from the message, which consist of single letters that may be joined together under a single '-'
		// Construct a string[] of each individual flag letter
		const shortFlags = args.filter(arg => arg.match(/(?<![-a-zA-Z])-[A-z]+(?![\s\S]*})/g))
			.flatMap(flags => [...flags.replace('-', '')]);

		// Flag for whether to display the evaluated result in a code block
		const codeBlock = !(args.includes('--no-code') || args.includes('--plain') || shortFlags.includes('P'));

		// Flag for whether to supress result
		const silent = (args.includes('--silent') || shortFlags.includes('s'));

		// Flag for whether to delete the command message
		if (args.includes('--delete') || shortFlags.includes('d')) deleteMsg(msg);

		// Filter out all flags from the message prior to evaluation
		// Use regex to ensure only flags containing letters are filtered, and not numbers or standalone '-' characters
		args = args.filter(arg => !arg.match(/(?<!(?<!--\w*)[a-zA-Z])-{1,2}[a-zA-Z]+/g));

		// If the arguments contain a codeblock, separate arguments by newlines rather than spaces, and filter out the codeblocks
		if (args.includes('```')) args = args.join(' ').split('\n').filter(arg => !arg.includes('```'));

		// If there is no code to evaluate, return an error
		if (args.length === 0) return commandError(msg, 'You must enter something to evaluate.', evaluate.error);

		// Join the processed arguments together to form the code to evaluate
		const code = args.join(' ');

		try {
			// Initialise an embed
			const embed = new Discord.MessageEmbed();

			// Only evaluate the code if there is no attempt to extract the bot token
			let result = (!code.toLowerCase().includes('token'))
				? await eval(`(async () => { return ${code} })()`)
				: '*'.repeat(bot.token.length);

			// If the embed has been modified, automatically send it into the message channel
			if (code.includes('embed.')) sendMsg(msg.channel, { embeds: [embed] });

			// Supress result if flag was present
			if (silent) return;

			// If the evaluated result is not a string, convert it
			if (typeof result !== 'string') result = convertToString(result);

			// Attempt to remove any sensitive information from evaluated result
			// WARNING: This is primarily to prevent erroneous output, and IS NOT a robust implementation
			result = removeSensitive(result);

			// Pass the evaluated result into msgSplit(), breaking at the '},' substring
			// Remove any existing codeblocks, so they can be added again after the string has been split
			msgSplit(result.replace(/```j?s?/g, ''), '},');
		}

		// If there is an issue in the evaluation of the code, pass it into msgSplit(), breaking at the ')' substring
		catch (error) { msgSplit(error.stack, ')'); }

		/**
		 * Split a string into the necessary number of messages to accommodate for Discord's message character limit, splitting at a specified convenient substring
		 * @function commands.evaluate~msgSplit
		 * @param {string} str The string to split into (potentially) multiple messages
		 * @param {string} [substr] The substring at which to break
		 */
		function msgSplit(str, substr) {
			// Loop while the string has not yet been sent in its entirety
			while (str) {
				// The maximum index at which to break the message
				// This is the maximum character limit of a Discord <Message>
				const MAXIMUM_INDEX = 2000;

				// Use the codeblock boolean flag to determine whether to wrap result in a codeblock
				// If so, ensure the MAXIMUM_INDEX accounts for the codeblock characters
				const breakIndex = (codeBlock)
					? findBreakIndex(str, substr, MAXIMUM_INDEX)
					: findBreakIndex(str, substr, MAXIMUM_INDEX - '```js\n```'.length);

				// Wrap the message in a codeblock if flag is true, and send the message payload
				(codeBlock)
					? sendMsg(msg.channel, { content: jsCodeBlock(str.substr(0, breakIndex)) })
					: sendMsg(msg.channel, { content: str.substr(0, breakIndex) });

				// Remove the sent message content from the string before next iteration
				str = str.substr(breakIndex);
			}
		}
	};

	return evaluate;
};

/**
 * Convert an object (or other non-string type) to its string representation
 * @function commands.evaluate~convertToString
 * @param {Object | *} toConvert An object, or any other data type, to be inspected and converted into a string
 * @returns {string} A string representation of the input
 */
function convertToString(toConvert) {
	// Use util.inspect() to inspect and convert the data type to a string
	return require('util').inspect(toConvert);
}

/**
 * Censor sensitive information from an input string, and return the result
 * @function commands.evaluate~removeSensitive
 * @param {string} str The string to be censored
 * @returns {string} The resultant censored string
 */
function removeSensitive(str) {
	// Parse the githook through convertToString()
	const githook = convertToString(process.env.githook);
	// string[] of highly sensitive information to be censored
	// WARNING: This IS NOT a comprehensive or robust list
	// Strip the opening and closing {} from parsed githook string
	const sensitives = [process.env.TOKEN, githook.substr(1, githook.length - 2), process.env.SECRET, process.env.MONGOURI];

	// Loop through each sensitive string
	for (let i = 0; i < sensitives.length; i++) {
		// Replace any occurrences of the sensitive string with '*'
		// WARNING: This filter DOES NOT cover substrings of sensitive strings
		str = str.replace(new RegExp(sensitives[i], 'g'), '*'.repeat(sensitives[i].length));
	}

	// Return the censored string
	return str;
}

/**
 * Find the last occurrence of a given substring within a string to ensure a comfortable message split position
 * @function commands.evaluate~findBreakIndex
 * @param {string} str The string to search within
 * @param {?string} substr The substring at which to break
 * @param {number} maximumIndex The maximum length that the message can be
 * @returns {number} The index of the last occurrence of the substring
 */
function findBreakIndex(str, substr, maximumIndex) {
	// Find the last occurrence of the specified substring, but before the maximum index (accounting for the length of the substring itself)
	const substrIndex = str.lastIndexOf(substr, maximumIndex - substr.length);
	const hasSubstr = substrIndex !== -1;

	// Find the last occurrence of a ' ' character as a fallback
	const spaceIndex = str.lastIndexOf(' ', maximumIndex - 1);
	const hasSpace = spaceIndex !== -1;

	return (str.length > maximumIndex)
		// If the length of the string is greater than the maximum index and must be broken, follow the truthy tree
		? (hasSubstr)
			// If the substring is present, return the index immediately after the last occurrence of the substring
			? substrIndex + substr.length
			: (hasSpace)
				// Otherwise, return the index of the last space character if present
				? spaceIndex
				// If there is not a space character, break at the maximum index
				: maximumIndex
		// Otherwise, simply return the maximum index
		: maximumIndex;
}