// 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();