// Copyright 2018-2020 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) const fs = require('fs'); const childProcess = require('child_process'); require('./mainModule.js')(SMLoader); // Extends the MainModule class. /** * @classdesc Manages loading, unloading, and reloading of all SubModules. * @class * @augments MainModule */ function SMLoader() { const self = this; /** Timeout of next slash command update to Discord API. */ let nextSlashCommandPush = null; /** * Delay after a load/unload event until we push the change to Discord API. */ const slashCommandPushDelay = 5000; /** @inheritdoc */ this.myName = 'SMLoader'; /** @inheritdoc */ this.import = function(data) { if (!data) return; subModules = data.subModules; subModuleNames = data.subModuleNames; }; /** @inheritdoc */ this.export = function() { const output = { subModules: subModules, subModuleNames: subModuleNames, }; subModules = null; subModuleNames = null; return output; }; /** @inheritdoc */ this.terminate = function() { for (const i in subModules) { if (subModules[i] && subModules[i].end) { subModules[i].end(); } } }; /** @inheritdoc */ this.initialize = function() { self.command.on('reload', commandReload); self.command.on('unload', commandUnload); self.command.on('load', commandLoad); self.command.on( new self.command.SingleCommand(['help', 'commands'], commandHelp)); Object.assign(self.bot, toAssign.bot); Object.assign(self.client, toAssign.client); self.common.readAndParse(smListFilename, (err, parsed) => { if (err) { self.error( 'Failed to read list of subModules from file: ' + smListFilename); console.error(err); return; } goalSubModuleNames = parsed[self.bot.getFullBotName()]; if (!goalSubModuleNames) { self.error( 'Unable to find subModule list for bot: (' + self.bot.getFullBotName() + ') ' + smListFilename); goalSubModuleNames = parsed['FALLBACK']; return; } self.reload(); }); if (self.client.shard) { /* eslint-disable no-unused-vars */ /** * Receive message from another shard asking for us to reload subModules. * * @see {@link SMLoader~shardReload} * * @private */ self.client.commandReload = shardReload; /** * Receive message from another shard asking for us to unload subModules. * * @see {@link SMLoader~shardUnload} * * @private */ self.client.commandUnload = shardUnload; /** * Receive message from another shard asking for us to load subModules. * * @see {@link SMLoader~shardLoad} * * @private */ self.client.commandLoad = shardLoad; /* eslint-enable no-unused-vars */ } }; this.shutdown = function() { self.command.removeListener('reload'); self.command.removeListener('unload'); self.command.removeListener('load'); self.command.removeListener('help'); if (self.client.shard) { self.client.commandReload = null; self.client.commandUnload = null; self.client.commandLoad = null; } }; /** @inheritdoc */ this.unloadable = function() { return subModuleNames.findIndex((el) => !subModules[el].unloadable()) < 0; }; /** @inheritdoc */ this.save = function(...args) { for (const i in subModules) { if (subModules[i] && subModules[i].save) { const start = Date.now(); subModules[i].save.apply(null, args); const delta = Date.now() - start; if (delta > 10) { this.common.logWarning( i + ' took an excessive ' + delta + 'ms to start saving data!'); } } } }; /** * @description Timeout for delay to save SMList. * @private * @type {?Timeout} * @default */ let saveTimeout = null; /** * @description A module has been loaded or unloaded, wait a moment for * updates to finish, then push changes to Discord API. * @private */ function triggerSlashCommandUpdate() { if (self.client.shard && self.client.shard.ids[0] != 0) return; clearTimeout(nextSlashCommandPush); nextSlashCommandPush = setTimeout(() => { self.command.registerSlashCommands() .then(() => self.log('Registered slash commands.')) .catch((err) => { self.error('Failed to register slash commands.'); console.error(err); }); }, slashCommandPushDelay); } /** * @description Save the current goal submodules to file. * @private * @param {boolean} [force=false] Force immediately saving instead of delaying * a bit. */ function saveSMList(force) { if (!force) { clearTimeout(saveTimeout); saveTimeout = setTimeout(() => saveSMList(true), 1000); return; } self.common.readAndParse(smListFilename, (err, parsed) => { if (err) { self.error('Failed to save SM List!'); console.error(err); return; } parsed[self.bot.getFullBotName()] = goalSubModuleNames; self.common.mkAndWriteSync( smListFilename, null, JSON.stringify(parsed, null, 2)); }); } /** * Properties to merge into other objects. `bot` is merged into self.bot, * `client` is merged into self.client. * * @private * @type {Class} */ const toAssign = {bot: {}, client: {}}; /** * The filename storing the list of all SubModules to load. * * @private * @constant * @default * @type {string} */ const smListFilename = './subModules.json'; /** * The list of all submodule names currently loaded. * * @private * @type {string[]} */ let subModuleNames = []; /** * The list of all submodules that we are intended to have loaded currently. * This should reflect the file at {@link SMloader~smListFilename}. Null means * the data is not available, and no action should be taken. * * @private * @type {null|string[]} */ let goalSubModuleNames = null; /** * Instances of SubModules currently loaded mapped by their name. * * @private * @type {object.<SubModule>} */ let subModules = {}; /** * Timeouts for retrying to unload submodules that are currently not in an * unloadable state. Mapped by name of submodule. * * @private * @type {object.<Timeout>} */ const unloadTimeouts = {}; /** * Callbacks for when a scheduled module to unload, has been unloaded. Mapped * by name of subModule, then array of all callbacks. * * @private * @type {object.<Array.<Function>>} */ const unloadCallbacks = {}; /** * Discord IDs that are allowed to reboot the bot. * * @private * @type {string[]} * @constant */ const trustedIds = [ '124733888177111041', // Me '126464376059330562', // Rohan ]; /** * The message sent to the channel where the user asked for help. * * @private * @type {string} * @constant */ const helpmessagereply = 'I sent you a DM with commands!'; /** * The message sent to the channel where the user asked to be DM'd, but we * were unable to deliver the DM. * * @private * @type {string} * @constant */ const blockedmessage = 'I couldn\'t send you a message, you probably blocked me :('; /** * @description Get array of all submodule names and the commit they were last * loaded from. * * @public * @returns {Array.<{name: string, commit: string}>} Array of submodule names * and commit short hashes. */ toAssign.bot.getSubmoduleCommits = function() { return subModuleNames.map((el) => { return {name: el, commit: subModules[el].commit || 'Unknown'}; }); }; /** * @description Get a reference to a submodule with the given name. * * @public * @param {string} name The name of the submodule. * @returns {?SubModule} Reference to the currently loaded submodule with the * given name, or null if not loaded. */ toAssign.bot.getSubmodule = function(name) { if (!subModules[name]) { return null; } return subModules[name]; }; /** * Unloads submodules that is currently loaded. * * @public * * @param {string} name Specify submodule to unload. If it is already * unloaded, it will be ignored and return successful. * @param {object} [opts] Options object. * @param {boolean} [opts.schedule=true] Automatically re-schedule unload for * submodule if it is in an unloadable state. * @param {boolean} [opts.ignoreUnloadable=false] Force a submodule to unload * even if it is not in an unloadable state. * @param {boolean} [opts.updateGoal=true] Update the goal state of the * subModule to unloaded. * @param {Function} [cb] Callback to fire once the operation is complete. * Single parameter is null if success, or string if error. */ this.unload = function(name, opts, cb) { if (!opts) { opts = { schedule: true, updateGoal: true, ignoreUnloadable: false, reloading: false, }; } else { if (opts.schedule == null) opts.schedule = true; if (opts.updateGoal == null) opts.updateGoal = true; } const sm = subModules[name]; if (!sm) { const nameIndex = subModuleNames.findIndex((el) => el == name); if (nameIndex >= 0) { self.error( 'Unloaded module still exists in list of names!' + ' This should not happen!'); subModuleNames.splice(nameIndex, 1); } cb(null); return; } if (!opts.ignoreUnloadable) { if (!sm.unloadable() || (opts.reloading && (sm.reloadable && !sm.reloadable()))) { if (opts.schedule) { if (unloadTimeouts[name]) { if (!unloadCallbacks[name]) unloadCallbacks[name] = []; unloadCallbacks[name].push(cb); } else { unloadTimeouts[name] = setTimeout(function() { delete unloadTimeouts[name]; self.unload(name, opts, cb); }, 10000); } } else { cb('Not Unloadable'); } return; } } try { if (subModules[name].save) { subModules[name].save(); } else { self.error('Submodule ' + name + ' does not have a save() function.'); } if (subModules[name].end) { subModules[name].end(); } else { self.error('Submodule ' + name + ' does not have an end() function.'); } } catch (err) { self.error('Error on unloading ' + name); console.log(err); } let message; try { delete require.cache[require.resolve(name)]; const index = subModuleNames.findIndex((el) => el == name); if (index < 0) { self.error( 'Failed to find submodule name in list of loaded submodules! ' + name); console.log(subModuleNames); } else { subModuleNames.splice(index, 1); } if (opts.updateGoal) { const goalIndex = goalSubModuleNames.findIndex((el) => el == name); if (goalIndex < 0) { self.error( 'Failed to find submodule name in list of goal submodules! ' + name); console.log(goalSubModuleNames); } else { goalSubModuleNames.splice(goalIndex, 1); } } delete subModules[name]; message = null; } catch (err) { self.error('Failed to clear: ' + name); console.log(err); message = 'Failed to Unload'; } saveSMList(); cb(message); if (unloadCallbacks[name]) { unloadCallbacks[name].splice(0).forEach((el) => { el(message); }); } triggerSlashCommandUpdate(); }; /** * Loads submodules from file. * * @public * * @param {string} name Specify submodule to load. If it is already loaded, * they will be ignored and return successful. * @param {object} [opts] Options object. * @param {boolean} [opts.updateGoal=true] Update the goal state of the * subModule to loaded. * @param {Function} [cb] Callback to fire once the operation is complete. * Single parameter is null if success, or string if error. */ this.load = function(name, opts, cb) { if (!opts) { opts = {updateGoal: true}; } else { if (opts.updateGoal == null) opts.updateGoal = true; } if (subModules[name]) { if (opts.updateGoal && !goalSubModuleNames.includes(name)) { goalSubModuleNames.push(name); } } try { subModules[name] = require(name); subModules[name].modifiedTime = fs.statSync(__dirname + '/' + name).mtime; if (subModuleNames.includes(name)) { self.error( 'Submodule that is not loaded already exists in list of ' + 'loaded names! This should not happen!'); } else { subModuleNames.push(name); } if (opts.updateGoal && !goalSubModuleNames.includes(name)) { goalSubModuleNames.push(name); } } catch (err) { cb('Failed to Load'); if (err.message.startsWith('Cannot find module')) { self.error( 'Failed to load submodule: ' + name + ' (' + err.message + ')'); } else { self.error('Failed to load submodule: ' + name); console.error(err); } return; } try { subModules[name].begin( self.Discord, self.client, self.command, self.common, self.bot); } catch (err) { self.error('Failed to initialize submodule: ' + name); console.error(err); delete require.cache[require.resolve(name)]; cb('Failed to Initialize'); return; } cb(null); triggerSlashCommandUpdate(); }; /** * @description Reloads submodules from file. Reloads currently loaded modules * if `name` is not specified. If a submodule is specified that is not loaded, * it will skip the unload step, bull will still be attempted to be loaded. * @public * * @param {?string|string[]} [name] Specify submodules to reload, or null to * reload all submodules to their goal state. * @param {object} [opts] Options object. * @param {boolean} [opts.schedule=true] Automatically re-schedule reload for * submodules if they are not in an unloadable state. * @param {boolean} [opts.ignoreUnloadable=false] Force a submodule to unload * even if it is not in an unloadable state. * @param {boolean} [opts.force=false] Reload a submodule even if the * currently loaded version is identical to the version on file. If false it * will not be reloaded if the version would not be changed due to a reload. * @param {Function} [cb] Callback to fire once the operation is complete. * Single parameter has array of strings of status of each module attempted to * be reloaded. */ this.reload = function(name, opts, cb) { if (typeof cb !== 'function') cb = function() {}; if (typeof name === 'string') name = [name]; if (!name || name.length === 0) name = goalSubModuleNames; if (!Array.isArray(name) || name.length === 0) { cb([]); return; } if (!opts) { opts = {schedule: true, force: false, ignoreUnloadable: false}; } else if (opts.schedule == null) { opts.schedule = true; } opts.reloading = true; opts.updateGoal = false; const numTotal = name.length; let numComplete = 0; const output = []; for (let i = 0; i < numTotal; i++) { if (!opts.force && subModules[name[i]]) { try { const mtime = fs.statSync(`${__dirname}/${name[i]}`).mtime; // For some reason, directly comparing these two for equality does not // work. if (mtime - subModules[name[i]].modifiedTime == 0) { output.push(`~~${name[i]}~~`); done(); continue; } } catch (err) { self.error( 'Failed to stat submodule: ' + __dirname + '/' + name[i]); console.error(err); output.push('(' + name[i] + ': failed to stat)'); } } reloadSingle(name[i]); } /** * Actually trigger the reload process for a single submodule. * * @private * @param {string} name The submodule name to reload. */ function reloadSingle(name) { self.unload(name, opts, (err) => { if (err) { output.push(`${name}: ${err}`); done(); return; } self.load(name, opts, (err2) => { if (err2) { output.push(`${name}: ${err2}`); done(); return; } output.push(`${name}: \`Success\``); done(); }); }); } /** * Called when a submodule's reload process is completed. Fires main * callback once all submodules reloads have been completed. * * @private */ function done() { numComplete++; if (numComplete != numTotal) return; cb(output); } }; /** * Reload all sub modules by unloading then re-requiring. * * @private * @type {Command~commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#reload */ function commandReload(msg) { if (trustedIds.includes(msg.author.id)) { if (self.client.shard) { const message = encodeURIComponent(msg.text); self.client.shard.broadcastEval( eval(`((client) => client.commandReload("${message}",${ self.client.shard.ids[0]}))`)); } let toReload = msg.text.split(' ').splice(1); const opts = {}; toReload = toReload.filter((el) => { switch (el) { case '--force': opts.force = true; return false; case '--no-schedule': opts.ignoreUnloadable = true; return false; case '--immediate': opts.schedule = false; return false; default: return true; } }); self.common .reply( msg, 'Reloading modules... (waiting until users ' + 'won\'t notice interruption)') .then((warnMessage) => { self.reload(toReload, opts, (out) => { const embed = new self.Discord.EmbedBuilder(); embed.setTitle('Reload complete.'); embed.setColor([255, 0, 255]); embed.setDescription(out.join('\n') || 'NOTHING reloaded'); warnMessage.edit( {content: self.common.mention(msg), embeds: [embed]}); }); }); } else { self.common.reply( msg, 'LOL! Good try!', 'It appears SpikeyRobot doesn\'t trust you enough with this ' + 'command. Sorry!'); } } /** * @description Other shard has requested a reload command. * @private * @param {string} message The command message to parse. * @param {number} id Shard id requesting this. */ function shardReload(message, id) { if (id == self.client.shard.ids[0]) return; let toReload = decodeURIComponent(message).split(' ').splice(1); const opts = {}; toReload = toReload.filter((el) => { switch (el) { case '--force': opts.force = true; return false; case '--no-schedule': opts.ignoreUnloadable = true; return false; case '--immediate': opts.schedule = false; return false; default: return true; } }); self.reload(toReload, opts, () => {}); } /** * @description Other shard has requested an unload command. * @private * @param {string} message The command message to parse. * @param {number} id Shard id requesting this. */ function shardUnload(message, id) { if (id == self.client.shard.ids[0]) return; let toUnload = decodeURIComponent(message).split(' ').splice(1); const opts = {}; toUnload = toUnload.filter((el) => { switch (el) { case 'force': opts.force = true; return false; case 'no-schedule': opts.ignoreUnloadable = true; return false; case 'immediate': opts.schedule = false; return false; default: return true; } }); for (let i = 0; i < toUnload.length; i++) { self.unload(toUnload[i], opts, () => {}); } } /** * @description Other shard has requested a load command. * @private * @param {string} message The command message to parse. * @param {number} id Shard id requesting this. */ function shardLoad(message, id) { if (id == self.client.shard.ids[0]) return; const toLoad = decodeURIComponent(message).split(' ').splice(1); for (let i = 0; i < toLoad.length; i++) { self.load(toLoad[i], null, () => {}); } } /** * Unload specific sub modules. * * @private * @type {Command~commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#unload */ function commandUnload(msg) { if (trustedIds.includes(msg.author.id)) { if (self.client.shard) { const message = encodeURIComponent(msg.text); self.client.shard.broadcastEval( eval(`((client) => client.commandUnload("${message}",${ self.client.shard.ids[0]}))`)); } let toUnload = msg.text.split(' ').splice(1); const opts = {}; toUnload = toUnload.filter((el) => { switch (el) { case 'force': opts.force = true; return false; case 'no-schedule': opts.ignoreUnloadable = true; return false; case 'immediate': opts.schedule = false; return false; default: return true; } }); self.common.reply(msg, 'Unloading modules...').then((warnMessage) => { const numTotal = toUnload.length; let numComplete = 0; const outs = []; for (let i = 0; i < numTotal; i++) { unloadSingle(toUnload[i]); } /** * Begins actually loading a module. * * @private * * @param {string} name The name of the module. */ function unloadSingle(name) { self.unload(name, opts, (out) => { outs.push(name + ': ' + (out || 'Success')); done(); }); } /** * Triggered on each completed action. * * @private */ function done() { numComplete++; if (numComplete < numTotal) return; const embed = new self.Discord.EmbedBuilder(); embed.setTitle('Unload complete.'); embed.setColor([255, 0, 255]); embed.setDescription(outs.join(' ') || 'NOTHING unloaded'); warnMessage.edit( {content: self.common.mention(msg), embeds: [embed]}); } if (numTotal == 0) done(); }); } else { self.common.reply( msg, 'LOL! Good try!', 'It appears SpikeyRobot doesn\'t trust you enough with this ' + 'command. Sorry!'); } } /** * Load specific sub modules. * * @private * @type {Command~commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#load */ function commandLoad(msg) { if (trustedIds.includes(msg.author.id)) { if (self.client.shard) { const message = encodeURIComponent(msg.text); self.client.shard.broadcastEval( eval(`((client) => client.commandLoad("${message}",${ self.client.shard.ids[0]}))`)); } const toLoad = msg.text.split(' ').splice(1); self.common.reply(msg, 'Loading modules...').then((warnMessage) => { const numTotal = toLoad.length; let numComplete = 0; const outs = []; for (let i = 0; i < numTotal; i++) { loadSingle(toLoad[i]); } /** * Begins actually loading a module. * * @private * @param {string} name The name of the subModule. */ function loadSingle(name) { self.load(name, null, (out) => { outs.push(name + ': ' + (out || 'Success')); done(); }); } /** * Triggered on each completed action. * * @private */ function done() { numComplete++; if (numComplete < numTotal) return; const embed = new self.Discord.EmbedBuilder(); embed.setTitle('Load complete.'); embed.setColor([255, 0, 255]); embed.setDescription(outs.join(' ') || 'NOTHING loaded'); warnMessage.edit( {content: self.common.mention(msg), embeds: [embed]}); } if (numTotal == 0) done(); }); } else { self.common.reply( msg, 'LOL! Good try!', 'It appears SpikeyRobot doesn\'t trust you enough with this ' + 'command. Sorry!'); } } /** * Send help message to user who requested it. * * @private * @type {Command~commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#help */ function commandHelp(msg) { let error = false; /** * Send the help message. * * @private * @param {Discord~EmbedBuilder} help THe message to send. */ function send(help) { const message = typeof help === 'string' ? {content: help} : {embeds: [help]}; msg.author.send(message).catch((err) => { if (msg.guild !== null && !error) { error = true; self.common .reply( msg, 'Oops! I wasn\'t able to send you the help!\n' + 'Did you block me?', err.message) .catch(() => {}); self.error( 'Failed to send help message in DM to user: ' + msg.author.id + ' ' + help.title); console.error(err); } }); } try { for (const i in subModules) { if (!(subModules[i] instanceof Object) || !subModules[i].helpMessage) { continue; } if (!Array.isArray(subModules[i].helpMessage)) { subModules[i].helpMessage = [subModules[i].helpMessage]; } subModules[i].helpMessage.forEach(send); } if (msg.guild !== null) { self.common .reply( msg, helpmessagereply, 'Tip: https://www.spikeybot.com/help/ also has more ' + 'information.') .catch((err) => { self.error( 'Unable to reply to help command in channel: ' + msg.channel.id); console.log(err); }); } } catch (err) { self.common.reply(msg, blockedmessage); self.error('An error occured while sending help message!'); console.error(err); } } /** * Get a list of the current SubModules intended to be loaded. * * @public * @returns {string[]} Array of the names of the SubModules (ex: * './connect4.js'). */ toAssign.bot.getGoalSubModules = function() { return goalSubModuleNames.slice(0); }; /** * Check current loaded submodule commit to last modified commit, and reload * if the file has changed. * * @public */ toAssign.client.reloadUpdatedSubModules = function() { try { self.log('Reloading updated submodules.'); for (let i = 0; i < subModuleNames.length; i++) { childProcess .exec( 'git diff-index --quiet ' + subModules[subModuleNames[i]].commit + ' -- ./src/' + subModuleNames[i]) .on('close', ((name) => { return (code) => { if (code) { self.reload( name, {force: true}, (out) => self.log(out.join(' '))); } else { self.debug(`${name} unchanged (${code})`); } }; })(subModuleNames[i])); } } catch (err) { self.error('Failed to reload updated submodules!'); console.error(err); } }; } module.exports = new SMLoader();