// Copyright 2018-2022 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
// Increase maximum listener count because we often burst many listeners for a
// short time.
// process.setMaxListeners(50);
// Temporarily remove this limit since IDK how to fix it atm...
process.setMaxListeners(0);
/**
* DiscordJS base object.
*
* @external Discord
* @see {@link https://discord.js.org/}
*/
const Discord = require('discord.js');
const fs = require('fs');
const childProcess = require('child_process');
// Auth is not constant and will be reloaded with common.js.
let auth = require('../auth.js');
// common.js is also required, but is managed within the SpikeyBot class.
// Hijack the BigInt object to support converting to JSON.
// eslint-disable-next-line no-extend-native
BigInt.prototype['toJSON'] = function() {
return this.toString();
};
/**
* Handler for an unhandledRejection or uncaughtException, to prevent the bot
* from silently crashing without an error.
*
* @private
* @param {...*} args All information to log.
* @listens Process#unhandledRejection
* @listens Process#uncaughtException
*/
function unhandledRejection(...args) {
const pid = ('00000' + process.pid).slice(-5);
if (args[0] && args[0].name == 'DiscordAPIError') {
const e = args[0];
const str = `ERR:${pid} Uncaught ${e.name}: ${e.message} ` +
`${e.method} ${e.code} (${e.path})`;
console.log(str);
} else if (args[0] && args[0].message == 'No Perms') {
console.log(`ERR:${pid}`, args[0]);
} else if (args[0] && args[0].code == 'ERR_IPC_CHANNEL_CLOSED') {
console.log(`ERR:${pid}`, 'Uncaught', args[0]);
console.error(
'THIS ERROR MEANS THIS PROCESS HAS BEEN ORPHANED.',
'THIS SHOULD NOT HAPPEN. THIS PROCESS WILL SUICIDE.');
process.exit(0);
} else {
console.log(`ERR:${pid}`, 'Uncaught', args[0]);
}
}
process.on('unhandledRejection', unhandledRejection);
process.on('uncaughtException', unhandledRejection);
// Catch MaxListenersExceededWarning and provide more useful information.
const EventEmitter = require('events').EventEmitter;
const originalAddListener = EventEmitter.prototype.addListener;
/* eslint-disable no-invalid-this */
const addListener = function(...args) {
originalAddListener.apply(this, args);
const type = args[0];
const numListeners = this.listeners(type).length;
const max = typeof this._maxListeners === 'number' ? this._maxListeners : 10;
if (max !== 0 && numListeners > max) {
const error = new Error(
'Too many listeners of type "' + type +
'" added to EventEmitter. Max is ' + max + ' and we\'ve added ' +
numListeners + '.');
throw error;
}
return this;
};
/* eslint-enable no-invalid-this */
EventEmitter.prototype.addListener = addListener;
EventEmitter.prototype.on = addListener;
/**
* @classdesc Main class that manages the bot.
* @class
* @listens Discord~Client#ready
* @listens Discord~Client#message
* @listens Command#updateGame
* @listens Command#reboot
* @listens Command#mainreload
*/
function SpikeyBot() {
const self = this;
/**
* The current bot version parsed from package.json.
*
* @public
* @type {string}
* @constant
*/
this.version = JSON.parse(fs.readFileSync('package.json')).version + '#' +
childProcess.execSync('git rev-parse --short HEAD').toString().trim();
/**
* The fully qualified domain name of this host.
*
* @public
* @type {string}
* @constant
*/
this.fqdn = childProcess.execSync('hostname -f').toString().trim();
/**
* Timestamp at which this process was started.
*
* @public
* @type {number}
* @constant
*/
this.startTimestamp = Date.now();
/**
* Is the bot currently responding as a unit test.
*
* @private
* @type {boolean}
*/
let testMode = false;
/**
* Is the bot started with the intent of solely running a unit test. Reduces
* messages sent that are unnecessary.
*
* @private
* @type {boolean}
*/
let testInstance = false;
/**
* Do everything normally, except don't ever actually attempt to login
* to Discord.
*
* @private
* @type {boolean}
*/
let noLogin = process.env.SHARDING_MASTER === 'true';
/**
* The filename of the Command mainModule.
*
* @private
* @constant
* @default
* @type {string}
*/
const commandFilename = './commands.js';
/**
* The filename of the SMLoader mainModule.
*
* @private
* @constant
* @default
* @type {string}
*/
const smLoaderFilename = './smLoader.js';
/**
* Filename without file extension where information about the bot rebooting
* is stored.
*
* @see {@link fullRebootFilename}
*
* @private
* @constant
* @default
* @type {string}
*/
const rebootFilename = './save/reboot';
/**
* The current instance of Command.
*
* @private
* @type {Command}
*/
let command;
/**
* The current instance of SMLoader.
*
* @private
* @type {SMLoader}
*/
let smLoader;
/**
* Filename of which to load additional MainModule names. The file must be a
* valid JSON array of strings.
*
* @private
* @constant
* @default
* @type {string}
*/
const mainModuleListFile = './mainModules.json';
/**
* The list of all mainModules to load. Always includes
* {@link SpikeyBot~commandFilename} and {@link SpikeyBot~smListFilename}.
* Additional mainModules can be loaded from
* {@link SpikeyBot~mainModuleListFile}.
*
* @private
* @type {string[]}
*/
let mainModuleNames = [commandFilename, smLoaderFilename];
try {
mainModuleNames =
mainModuleNames.concat(JSON.parse(fs.readFileSync(mainModuleListFile)));
} catch (err) {
if (err.code !== 'ENOENT') {
console.error(err);
}
}
/**
* Is this bot running in development mode.
*
* @private
* @type {boolean}
*/
let setDev = false;
/**
* Is this bot managing backup status monitoring.
*
* @private
* @type {boolean}
*/
let isBackup = false;
/**
* Should this bot only load minimal features as to not overlap with multiple
* instances.
*
* @private
* @type {boolean}
*/
let minimal = false;
/**
* Instances of MainModules currently loaded.
*
* @private
* @type {MainModule[]}
*/
const mainModules = [];
/**
* Reason the bot was disconnected from Discord's servers.
*
* @private
* @default
* @type {?string}
*/
let disconnectReason = null;
/**
* Whether or not to spawn the bot as multiple shards. Enabled with `--shards`
* cli argument.
*
* @private
* @default
* @type {boolean}
*/
let enableSharding = false;
/**
* The number of shards to use if sharding is enabled. 0 to let Discord
* decide. Set from `--shards=#` cli argument, or `SHARD_COUNT` environment
* variable.
*
* @private
* @default
* @type {number}
*/
let numShards = process.env.SHARD_COUNT || 0;
/**
* The shard ID of this process. This is passed from the Shard Master using
* the `SHARDS` environment variable.
*
* @private
* @default
* @type {?number}
*/
const shardId = process.env.SHARDS;
/**
* Number of bytes to allocate for each shard memory. Passed as
* `--max-old-space-size=` to the spawned node process. Null for default.
*
* @private
* @default
* @type {?number}
*/
let shardMem = null;
/**
* The name of the client secret to use. Defaults to release either release or
* dev depending on the --dev flag.
*
* @private
* @default
* @type {string}
*/
let botName = null;
/**
* Number of milliseconds to delay the call to client.login in order to
* prevent race conditions of multiple bots in the same directory. This is set
* with the `--delay` flag. `--delay` with no value will default to 5000
* milliseconds.
*
* @private
* @default
* @type {number}
*/
let delayBoot = 0;
/**
* Enable inspecting/profiling for a shard to launch. Set via cli flags, -1 to
* disable. Currently only supports enabling. The `--inspect` flag will be
* sent to all shards that are started. This is due to limitations of
* {@link Discord~ShardingManager}.
*
* @private
* @default
* @type {number}
*/
let inspectShard = -1;
/**
* Is the bot currently rebooting.
*
* @private
* @default
* @type {boolean}
*/
let rebooting = false;
/**
* Getter for the bot's name. If name is null, it is most likely because there
* is no custom name and common.isRelease should be used instead.
*
* @see {@link SpikeyBot~botName}
*
* @public
* @returns {?string} The bot's name or null if it has not been defined yet or
* there is no custom name.
*/
this.getBotName = function() {
if (isBackup) return 'FALLBACK';
return botName;
};
/**
* Getter for the bot's name. If botName is null, this will give either
* `release` or `dev`.
*
* @see {@link SpikeyBot~botName}
*
* @public
* @returns {string} The bot's name.
*/
this.getFullBotName = function() {
if (isBackup) return 'FALLBACK';
return botName || (isDev ? 'dev' : 'release');
};
// Parse cli args.
for (let i = 2; i < process.argv.length; i++) {
if (process.argv[i] === '--dev') {
setDev = true;
} else if (process.argv[i].startsWith('--botname')) {
if (process.argv[i].indexOf('=') > -1) {
botName = process.argv[i].split('=')[1] || '';
} else if (process.argv.length > i + 1) {
botName = process.argv[i + 1] || '';
i++;
}
} else if (process.argv[i] === '--minimal') {
minimal = true;
} else if (process.argv[i] === '--test') {
testInstance = true;
} else if (process.argv[i] === '--nologin') {
noLogin = true;
} else if (process.argv[i].startsWith('--shards')) {
if (process.argv[i].indexOf('=') > -1) {
numShards = process.argv[i].split('=')[1] * 1 || 0;
}
} else if (process.argv[i].startsWith('--shardmem')) {
if (process.argv[i].indexOf('=') > -1) {
shardMem = process.argv[i].split('=')[1] * 1 || null;
}
if (!shardMem) {
throw new Error(`Bad Memory Amount '${process.argv[i]}'`);
}
} else if (process.argv[i] === '--backup') {
isBackup = true;
} else if (process.argv[i].startsWith('--delay')) {
delayBoot = 5000;
if (process.argv[i].indexOf('=') > -1) {
delayBoot = process.argv[i].split('=')[1] * 1 || 0;
}
} else if (process.argv[i].startsWith('--inspect')) {
inspectShard = 0;
if (process.argv[i].indexOf('=') > -1) {
inspectShard = process.argv[i].split('=')[1] * 1 || 0;
}
} else {
throw new Error(`Unrecognized argument '${process.argv[i]}'`);
}
}
enableSharding = numShards && shardId === undefined;
const isDev = setDev;
let common;
/**
* Delete cache and re-require common.js and auth.js.
*
* @public
*/
this.reloadCommon = function() {
delete require.cache[require.resolve('../auth.js')];
auth = require('../auth.js');
delete require.cache[require.resolve('./common.js')];
common = require('./common.js');
common.begin(testInstance, !isDev);
for (const m of mainModules) {
m.begin(Discord, client, command, common, self);
}
};
self.reloadCommon();
if (common.isSlave || common.isMaster) {
// Create interval to ensure the parent process hasn't orphaned us.
setInterval(() => {
if (!process.channel) {
console.error(
'THIS ERROR MEANS THIS PROCESS HAS BEEN ORPHANED.',
'THIS SHOULD NOT HAPPEN. THIS PROCESS WILL SUICIDE.',
'(NO IPC CHANNEL)');
process.exit(0);
} else {
process.send('ping', (err) => {
if (!err) return;
common.error('Failed to send IPC heartbeat ping.');
console.error(err);
if (err.code === 'ERR_IPC_CHANNEL_CLOSED') {
console.error(
'THIS ERROR MEANS THIS PROCESS HAS BEEN ORPHANED.',
'THIS SHOULD NOT HAPPEN. THIS PROCESS WILL SUICIDE.',
'(IPC PING FAILED)');
process.exit(0);
}
});
}
}, 1000);
}
/**
* Create a ShardingManager and spawn shards. This shall only be called at
* most once, and `login()` shall not be called after this.
*
* @private
*/
function createShards() {
common.log(
'Sharding enabled with ' + (numShards || 'auto'), 'ShardingManager');
const argv = inspectShard > -1 ? ['--inspect'] : [];
if (shardMem != null) argv.push(`--max-old-space-size=${shardMem}`);
argv.push('--experimental-worker');
const manager = new Discord.ShardingManager('./src/SpikeyBot.js', {
token: !noLogin && ((botName && auth[botName]) ||
(setDev ? auth.dev : auth.release)) ||
undefined,
totalShards: numShards || 'auto',
shardArgs: process.argv.slice(2).filter(
(arg) => !arg.startsWith('--shards') && !arg.startsWith('--delay')),
execArgv: argv,
});
manager.on('shardCreate', (shard) => {
common.log('Launched shard ' + shard.id, 'ShardingManager');
shard.on('message', (msg) => {
if (msg._eval) return;
common.logDebug(
'Received message from shard ' + shard.id + ': ' +
JSON.stringify(msg));
if (msg === 'reboot hard' || msg === 'reboot hard force') {
common.logWarning('TRIGGERED HARD REBOOT!');
manager.shards.forEach((s) => s.process.kill('SIGHUP'));
process.exit(-1);
} else if (typeof msg === 'string' && msg.startsWith('reboot')) {
const idList = msg.match(/\b\d+\b/g);
if (msg.indexOf('force') > -1) {
manager.shards.forEach((s) => {
if (!idList || idList.find((el) => el == s.id)) {
s.process.send(`reboot ${s.id}`);
s.respawn();
}
});
} else {
manager.shards.forEach((s) => {
if (!idList || idList.find((el) => el == s.id)) {
s.process.send(`reboot ${s.id}`);
}
});
}
}
});
});
manager.spawn();
}
if (enableSharding) {
if (delayBoot) {
setTimeout(createShards, delayBoot);
} else {
createShards();
}
return;
}
let intents = [
Discord.IntentsBitField.Flags.Guilds,
Discord.IntentsBitField.Flags.GuildMembers,
Discord.IntentsBitField.Flags.GuildEmojisAndStickers,
Discord.IntentsBitField.Flags.GuildWebhooks,
Discord.IntentsBitField.Flags.GuildVoiceStates,
Discord.IntentsBitField.Flags.GuildPresences,
Discord.IntentsBitField.Flags.GuildMessages,
Discord.IntentsBitField.Flags.GuildMessageReactions,
Discord.IntentsBitField.Flags.DirectMessages,
Discord.IntentsBitField.Flags.DirectMessageReactions,
Discord.IntentsBitField.Flags.MessageContent,
];
let defaultPresence = {
status: 'idle',
activities: [{
name: 'Somehow still kicking!',
type: Discord.ActivityType.Watching,
}],
};
if (isDev) {
defaultPresence.activities[0].name = `Version: ${self.version}`;
}
if (isBackup) {
defaultPresence = {
status: 'dnd',
/* activity: {
name: 'OFFLINE',
type: 'PLAYING',
}, */
};
intents = [];
}
// If we are not managing shards, just start normally.
const client = new Discord.Client({
intents: intents,
presence: defaultPresence,
});
/**
* The full filename where information about the bot rebooting is stored.
*
* @see {@link rebootFilename}
*
* @private
* @constant
* @default
* @type {string}
*/
const fullRebootFilename = client.shard ?
`${rebootFilename}-${client.shard.ids[0]}.json` :
`${rebootFilename}.json`;
if (!isBackup) {
// Attempt to load mainmodules.
for (let i = 0; i < mainModuleNames.length; i++) {
process.stdout.write(
'DBG:' + ('00000' + process.pid).slice(-5) + ' Loading ' +
mainModuleNames[i]);
try {
mainModules[i] = require(mainModuleNames[i]);
mainModules[i].modifiedTime =
fs.statSync(__dirname + '/' + mainModuleNames[i]).mtime;
if (mainModuleNames[i] == commandFilename) {
command = mainModules[i];
} else if (mainModuleNames[i] == smLoaderFilename) {
smLoader = mainModules[i];
}
process.stdout.write('DBG: DONE\n');
} catch (err) {
process.stdout.write('ERR: ERROR\n');
console.error(mainModuleNames[i], err);
}
}
} else {
mainModuleNames = [];
mainModules.splice(0);
}
const defaultPrefix = isDev ? '~' : '?';
if (minimal) common.log('STARTING IN MINIMAL MODE');
/**
* Has the bot been initialized already.
*
* @private
* @default
* @type {boolean}
*/
let initialized = false;
/**
* The Interval in which we will save and purge data on all mainmodules.
* Begins after onReady.
*
* @see {@link SpikeyBot~onReady()}
* @see {@link SpikeyBot~saveFrequency}
*
* @private
* @type {Interval}
*/
let saveInterval;
/**
* The frequency at which saveInterval will run.
*
* @see {@link SpikeyBot~saveInterval}
*
* @private
* @constant
* @default 2 Minutes
* @type {number}
*/
const saveFrequency = 2 * 60 * 1000;
/**
* Cache of all loaded guild's command prefixes. Populated asyncronously after
* client ready event.
*
* @private
* @type {object.<string>}
*/
const guildPrefixes = {};
/**
* The path in the guild's subdirectory where we store custom prefixes.
*
* @private
* @constant
* @default
* @type {string}
*/
const guildPrefixFile = '/prefix.txt';
/**
* The path in the guild's subdirectory where we store custom prefixes for
* bots with custom names.
*
* @private
* @constant
* @default
* @type {string}
*/
const guildCustomPrefixFile = '/prefixes.json';
/**
* Checks if given message is the given command.
*
* @private
* @param {Discord~Message} msg Message from Discord to check if it is the
* given
* command.
* @param {string} cmd Command to check if the message is this command.
* @returns {boolean} True if msg is the given command.
*/
function isCmd(msg, cmd) {
return msg.content.startsWith(msg.prefix + cmd);
}
/**
* Changes the bot's status message.
*
* @private
* @param {string} game New message to set game to.
* @param {string} [type='WATCHING'] The type of activity.
*/
function updateGame(game, type) {
if (!client.user) return;
client.user.setPresence({
activities: [{
name: game,
type: type || 'WATCHING',
url: 'https://www.spikeybot.com',
}],
status: ((testInstance || isBackup) ? 'dnd' : 'online'),
});
}
// BEGIN //
client.on('ready', onReady);
/**
* The bot has become ready.
*
* @private
* @listens Discord~Client#ready
*/
function onReady() {
const tag = client.user && client.user.tag || '';
common.log(`Logged in as ${tag} (${self.version})`);
if (!minimal || isBackup) {
if (testInstance) {
updateGame('Running unit test...');
} else if (isDev) {
updateGame(`Version: ${self.version}`);
} else if (self.getFullBotName() !== 'release') {
updateGame('');
} else if (isBackup) {
// updateGame('OFFLINE', 'PLAYING');
} else {
// updateGame('SpikeyBot.com');
updateGame('Thanks for the memories.');
}
}
let logChannel = client.channels.resolve(common.logChannel);
if (client.user && !logChannel && auth.logWebhookId &&
auth.logWebhookToken) {
logChannel = new Discord.WebhookClient(
{id: auth.logWebhookId, token: auth.logWebhookToken});
}
if (testInstance) {
client.users.fetch(common.spikeyId)
.then((u) => {
u.send(
{content: `Beginning in unit test mode (JS${self.version})`});
})
.catch((err) => {
common.error('Failed to find SpikeyRobot\'s DMs');
console.error(err);
logChannel.send({
content: 'Beginning in unit test mode (JS' + self.version +
') (FAILED TO FIND SpikeyRobot\'s DMs!)',
});
});
}
if (!isBackup) {
// Initialize all mainmodules even if we have already initialized the bot,
// because this will updated the reference to the current client if this
// was changed during reconnection.
// @TODO: This may be unnecessary to do more than once.
for (const i in mainModules) {
if (!(mainModules[i] instanceof Object) || !mainModules[i].begin) {
continue;
}
try {
mainModules[i].begin(Discord, client, command, common, self);
} catch (err) {
common.error(
'Failed to initialize MainModule: ' + mainModuleNames[i]);
console.log(err);
if (logChannel) {
// logChannel.send('Failed to initialize ' + mainModuleNames[i]);
}
}
}
if (mainModules.length != mainModuleNames.length) {
common.error('Loaded mainmodules does not match modules to load.');
if (logChannel) {
/* logChannel.send(
'Failed to compile a mainmodule. Check log for more info. ' +
'Previous initialization errors may be incorrect.'); */
}
}
if (!minimal && !initialized) {
fs.readFile(fullRebootFilename, (err, file) => {
if (err) {
if (err.code !== 'ENOENT') {
self.error(`Failed to read ${fullRebootFilename}`);
console.error(err);
}
return;
}
console.log(file.toString());
const parsed = JSON.parse(file);
const crashed = parsed.running;
if (crashed) {
common.logWarning(
'Either the previous instance crashed, or another instance of' +
' this bot is already running. Neither of these options ' +
'should happen.');
}
parsed.running = true;
common.mkAndWrite(
fullRebootFilename, null, JSON.stringify(parsed), (err) => {
if (err) {
common.error('Failed to set file state to running.');
console.error(err);
}
});
const channel = client.channels.resolve(parsed.channel);
if (channel) {
channel.messages.fetch(parsed.id)
.then((msg_) => {
const embed = new Discord.EmbedBuilder();
embed.setTitle('Reboot complete.');
embed.setColor([255, 0, 255]);
return msg_.edit({embeds: [embed]});
})
.catch((err) => {
common.error('Failed to edit reboot message.');
console.error(err);
});
} else if (parsed.channel) {
common.error('Failed to find channel: ' + parsed.channel);
}
if (logChannel && !isDev && !testInstance && !botName) {
let additional = '';
if (client.shard) {
additional += ' Shard: ' + client.shard.ids.join(' ') + ' of ' +
client.shard.count;
}
if (crashed) {
// additional += ' due to rapid unscheduled dissassembly!';
additional += '*';
} else if (disconnectReason) {
additional +=
' after disconnecting from Discord!\n' + disconnectReason;
disconnectReason = 'Unknown reason for disconnect.';
} else if (!initialized) {
additional += ' from cold stop. ';
}
if (process.env.SHARDING_NAME) {
additional += '(ID: ' + process.env.SHARDING_NAME + ')';
}
if (self.fqdn) {
additional += `[FQDN: ${self.fqdn}]`;
}
logChannel.send({
content: 'I just rebooted (JS' + self.version + ') ' +
(minimal ? 'MINIMAL' : 'FULL') + additional,
});
}
});
}
if (!initialized) {
loadGuildPrefixes(Array.from([...client.guilds.cache.values()]));
}
}
const req = require('https').request(
{
method: 'POST',
hostname: 'www.spikeybot.com',
path: '/webhook/botstart',
headers: {
'Content-Type': 'application/json',
'User-Agent': common.ua,
},
},
() => {});
req.on('error', () => {});
const user = client.user;
const id = user && user.id || 'NOID';
req.end(JSON.stringify({
text: `${tag}:${id} JS${self.version}`,
tag: tag,
id: id,
guild_count: client.guilds.cache.size,
shard_count: client.shard ? client.shard.count : '0',
shard_id: client.shard ? client.shard.ids : 'null',
version: self.version,
fqdn: self.fqdn,
}));
// Reset save interval
clearInterval(saveInterval);
saveInterval = setInterval(saveAll, saveFrequency);
initialized = true;
}
client.on('shardReady', (id) => common.log('Shard Ready', `Shard ${id}`));
client.on('disconnect', onDisconnect);
/**
* The bot has disconnected from Discord and will not be attempting to
* reconnect.
*
* @private
* @listens Discord~Client#disconnect
* @param {CloseEvent} event The websocket close event.
*/
function onDisconnect(event) {
disconnectReason = event.reason || 'Unknown';
common.error(
'Disconnected from Discord! ' + event.code + ' ' + event.reason);
}
client.on('reconnecting', onReconnecting);
/**
* The bot has disconnected from Discord, and is reconnecting.
*
* @private
* @listens Discord~Client#reconnecting
*/
function onReconnecting() {
disconnectReason = 'Reconnecting to network.';
common.error('Reconnecting to Discord!');
}
if (isBackup) {
client.on('presenceUpdate', onPresenceUpdate);
}
/**
* Attempt to detect when the main bot goes offline by the presence changing.
*
* @private
* @param {Discord~GuildMember} oldMem Member before presence update.
* @param {Discord~GuildMember} newMem Member after presence update.
*/
function onPresenceUpdate(oldMem, newMem) {
if (!newMem || newMem.id !== client.user.id) return;
common.log(
'Presence updated: ' + newMem.presence.status + ': ' +
(newMem.presence.activity && newMem.presence.activity.name ||
'NoActivity'));
}
if (!isBackup) {
client.on('messageCreate', onMessage);
client.on('interactionCreate', async (interaction) => {
await interaction.deferReply();
setTimeout(() => {
if (!interaction.replied) {
interaction.editReply({content: '¯\\_(ツ)_/¯'});
}
}, 2000);
await onInteraction(interaction);
});
}
/**
* Handle a message sent.
*
* @private
* @param {Discord~Message} msg Message that was sent in Discord.
* @fires Command
* @listens Discord~Client#messageCreate
*/
function onMessage(msg) {
if (typeof client.totalMessageCount !== 'number' ||
isNaN(client.totalMessageCount)) {
client.totalMessageCount = 0;
}
client.totalMessageCount++;
// Message was sent by Discord, not a user.
if (msg.system) return;
if (testInstance) {
if (!testMode && msg.author.id === client.user.id &&
msg.channel.id == common.testChannel) {
if (isDev && msg.content === '~`RUN UNIT TESTS`~') {
testMode = true;
msg.channel.send({content: '~`UNIT TEST MODE ENABLED`~'});
}
return;
} else if (testMode && msg.author.id !== client.user.id) {
return;
} else if (
testMode && msg.author.id === client.user.id &&
msg.content === '~`END UNIT TESTS`~' &&
msg.channel.id == common.testChannel) {
testMode = false;
msg.channel.send({content: '~`UNIT TEST MODE DISABLED`~'});
return;
}
}
// Only respond to messages in the test channel if we are in unit test mode.
// In unit test mode, only respond to messages in the test channel.
if (testMode != (msg.channel.id == common.testChannel)) return;
if (!testMode && msg.author.bot) return;
msg.prefix = self.getPrefix(msg.guild);
if (self.getLocale && msg.guild) msg.locale = self.getLocale(msg.guild.id);
if (msg.guild === null && !msg.content.startsWith(msg.prefix)) {
msg.content = `${msg.prefix}${msg.content}`;
}
if (isCmd(msg, '')) {
let commandSuccess = command.validate(msg.content.split(/ |\n/)[0], msg);
let logged = '';
if (!minimal || isBackup) {
const postLog = `${client.shard ? client.shard.ids[0] : ''} SpikeyBot`;
const content = msg.content.replace(/\n/g, '\\n');
let author;
if (msg.guild !== null) {
author = `${msg.guild.id}#${msg.channel.id}@${msg.author.id}`;
} else {
author = `PM:${msg.author.id}@${msg.author.tag}`;
}
if (!commandSuccess) {
logged = `${author} ${content}`;
common.log(logged, postLog);
} else {
logged = `${author} ${commandSuccess} ${content}`;
common.logDebug(logged, postLog);
}
}
const start = Date.now();
commandSuccess = command.trigger(msg);
const delta = Date.now() - start;
if (delta > 20) {
const toLog = logged || msg.content;
common.logDebug(`${toLog} took an excessive ${delta}ms`);
}
if (!commandSuccess && msg.guild === null && !minimal && !testMode) {
if (msg.content.split(/ |\n/)[0].indexOf('chat') < 0 &&
!command.trigger('chat', msg)) {
msg.channel.send({
content:
'Oops! I\'m not sure how to help with that! Type **help** ' +
'for a list of commands I know how to respond to.',
});
}
}
}
}
/**
* Handle a command or interaction received.
*
* @private
* @param {Discord~BaseInteraction} interaction The interaction that was
* created.
* @fires Command
* @listens Discord~Client#interactionCreate
*/
function onInteraction(interaction) {
if (typeof client.totalMessageCount !== 'number' ||
isNaN(client.totalMessageCount)) {
client.totalMessageCount = 0;
}
client.totalMessageCount++;
// Interaction was not a command.
if (!interaction.isChatInputCommand()) return;
if (self.getLocale && interaction.guild) {
interaction.locale = self.getLocale(interaction.guild.id);
}
interaction.prefix = self.getPrefix(interaction.guild);
interaction.content = `${interaction.prefix}${interaction.commandName} ${
interaction.options.getString('input') ?? ''}`;
interaction.author = interaction.user;
if (!minimal || isBackup) {
const postLog = `${client.shard ? client.shard.ids[0] : ''} SpikeyBot`;
const content = interaction.content.replace(/\n/g, '\\n');
let logged = '';
let author;
if (interaction.guild !== null) {
author = `${interaction.guild.id}#${interaction.channel.id}@${
interaction.user.id}/`;
} else {
author = `PM:${interaction.user.id}@${interaction.user.tag}/`;
}
let commandSuccess =
command.validate(interaction.commandName, interaction);
if (!commandSuccess) {
logged = `${author} ${content}`;
common.log(logged, postLog);
} else {
logged = `${author} ${commandSuccess} ${content}`;
common.logDebug(logged, postLog);
}
const start = Date.now();
commandSuccess = command.trigger(interaction);
const delta = Date.now() - start;
if (delta > 20) {
const toLog = logged || interaction.content;
common.logDebug(`${toLog} took an excessive ${delta}ms`);
}
if (!commandSuccess && interaction.guild === null && !minimal &&
!testMode) {
if (interaction.content.split(/ |\n/)[0].indexOf('chat') < 0 &&
!command.trigger('chat', interaction)) {
interaction.channel.send({
content:
'Oops! I\'m not sure how to help with that! Type **help** ' +
'for a list of commands I know how to respond to.',
});
}
}
}
}
if (!minimal && !isBackup) {
command.on('updategame', commandUpdateGame);
command.on(
new command.SingleCommand(['changeprefix'], commandChangePrefix, {
validOnlyInGuild: true,
defaultDisabled: true,
permissions: Discord.PermissionsBitField.Flags.ManageGuild,
}));
/**
* Change the command prefix for the given guild.
*
* @public
*
* @param {string} gId The guild id of which to change the command prefix.
* @param {string} newPrefix The new prefix to set.
*/
this.changePrefix = function(gId, newPrefix) {
guildPrefixes[gId] = newPrefix;
if (botName) {
common.readAndParse(
`${common.guildSaveDir}${gId}${guildCustomPrefixFile}`,
(err, parsed) => {
let finalPrefix = newPrefix;
if (parsed) {
parsed[botName] = newPrefix;
finalPrefix = JSON.stringify(parsed);
} else {
const newData = {};
newData[botName] = newPrefix;
finalPrefix = JSON.stringify(newData);
}
const dir = `${common.guildSaveDir}${gId}`;
const fn = `${dir}${guildCustomPrefixFile}`;
common.mkAndWrite(fn, dir, finalPrefix, (err) => {
if (err) {
common.error(
'Failed to save guild custom prefix! ' + gId + ' (' +
botName + ': ' + newPrefix + ')');
console.error(err);
} else {
common.logDebug(
'Guild ' + gId + ' updated prefix to ' + botName + ': ' +
newPrefix);
}
});
});
} else {
const dir = `${common.guildSaveDir}${gId}`;
const fn = `${dir}${guildPrefixFile}`;
common.mkAndWrite(fn, dir, newPrefix, (err) => {
if (err) {
common.error(
'Failed to save guild custom prefix! ' + gId + ' (' +
newPrefix + ')');
console.error(err);
} else {
common.logDebug(`Guild ${gId} updated prefix to ${newPrefix}`);
}
});
}
};
}
/**
* Change current status message.
*
* @private
* @type {commandHandler}
* @param {Discord~Message} msg Message that triggered command.
* @listens Command#updateGame
*/
function commandUpdateGame(msg) {
if (msg.author.id !== common.spikeyId) {
common.reply(msg, 'I\'m sorry, but you are not allowed to do that. :(\n');
} else {
const game = msg.content.replace(msg.prefix + 'updategame ', '');
const first = game.split(' ')[0].toLowerCase();
let type = null;
switch (first) {
case 'watching':
case 'playing':
case 'streaming':
case 'listening':
type = first;
break;
}
if (type) {
updateGame(game.split(' ').slice(1).join(' '), type.toUpperCase());
common.reply(
msg, 'I changed my status to "' + type.toUpperCase() + ': ' +
game.split(' ').slice(1).join(' ') + '".');
} else {
updateGame(game);
common.reply(msg, 'I changed my status to "' + game + '"!');
}
}
}
/**
* Change the custom prefix for the given guild.
*
* @private
* @type {commandHandler}
* @param {Discord~Message} msg Message that triggered command.
* @listens Command#changePrefix
*/
function commandChangePrefix(msg) {
const canReact = msg.channel.permissionsFor(client.user)
.has(Discord.PermissionsBitField.Flags.AddReactions);
const confirmEmoji = '✅';
const newPrefix = msg.text.slice(1);
if (newPrefix.length < 1) {
common.reply(msg, 'Please specify a new prefix after the command.');
} else if (newPrefix.indexOf('`') > -1) {
common.reply(
msg,
'Sorry, but custom prefixes may not contain the `\\`` character.');
} else if (newPrefix.match(/\s/)) {
common.reply(
msg, 'Sorry, but custom prefixes may not contain any whitespace.');
} else {
common
.reply(
msg, 'Change prefix from `' + self.getPrefix(msg.guild.id) +
'` to `' + newPrefix + '`?',
canReact ? null : `React with ${confirmEmoji} to confirm.`)
.then((msg_) => {
if (canReact) msg_.react(confirmEmoji);
const filter = (reaction, user) => {
if (user.id !== msg.author.id) return false;
return reaction.emoji.name == confirmEmoji;
};
msg_.awaitReactions({filter, max: 1, time: 60000})
.then((reactions) => {
msg_.reactions.removeAll().catch(() => {});
if (reactions.size == 0) {
msg_.edit({
content:
'Changing custom prefix timed out. Enter command ' +
'again if you still wish to change the command ' +
'prefix.',
});
return;
}
msg_.edit({
content: common.mention(msg) + ' Prefix changed to `' +
newPrefix + '`!',
});
self.changePrefix(msg.guild.id, newPrefix);
});
});
}
}
if (!isBackup) {
command.on('reboot', commandReboot);
}
/**
* Trigger a reboot of the bot. Actually just gracefully shuts down, and
* expects to be immediately restarted.
*
* @todo Support scheduled reload across multiple shards. Currently the bot
* waits for the shard at which the command was sent to be ready for reboot
* instead of all shard deciding on their own when they're ready to reboot.
* This will also need to check that we are obeying Discord's rebooting rate
* limits to help reduce downtime.
*
* @private
* @type {commandHandler}
* @param {Discord~Message} msg Message that triggered command.
* @param {boolean} [silent=false] Suppress reboot scheduling messages.
* @param {boolean} [onlySelf=false] Prevent sending reboot command to other
* shards if possible.
* @listens Command#reboot
*/
function commandReboot(msg, silent, onlySelf) {
/**
* Actually do the reboot process. Send kill signals and save the reboot
* information file.
*
* @private
* @param {boolean} force Is this reboot forced.
* @param {boolean} hard Is this a hard reboot.
* @param {Discord~Message} [msg_] Our message sent informing user of
* reboot status.
*/
function reboot(force, hard, msg_) {
if (!rebooting) {
if (!msg_) {
msg_ = {channel: {}};
}
const toSave = {
id: msg_.id,
channel: msg_.channel.id,
running: false,
};
common.mkAndWriteSync(fullRebootFilename, null, JSON.stringify(toSave));
}
if (onlySelf || !client.shard || !hard) {
process.exit(-1);
} else if (hard) {
if (force) {
client.shard.send('reboot hard force');
} else {
client.shard.send('reboot hard');
}
} else {
client.shard.respawnAll();
}
rebooting = true;
}
if ((!msg && silent) || common.trustedIds.includes(msg.author.id)) {
const content = (msg || {content: ''}).content;
const master = content.indexOf('master') > -1;
if (master) {
client.shard.send('reboot master');
if (!silent && msg) common.reply(msg, 'Requested reboot master');
return;
}
const force = content.indexOf(' force') > -1;
const doHardReboot = content.indexOf('hard') > -1;
if (!doHardReboot) {
const idList = content.match(/\b\d+\b/g);
const requestedSelf = !idList || !client.shard ||
idList.find((el) => el == client.shard.ids[0]);
const requestedOthers = !idList ||
(client.shard && idList.find((el) => el != client.shard.ids[0]));
if (requestedOthers && client.shard && !onlySelf) {
client.shard.send(
'reboot ' + (force ? 'force ' : '') +
(idList ? idList.join(' ') : ''));
}
if (!requestedSelf) {
if (!silent && msg) {
common.reply(
msg, 'Requested reboot ' + (force ? 'force ' : '') +
(idList ? idList.join(' ') : ''));
}
return;
}
}
if (!force) {
for (let i = 0; i < mainModules.length; i++) {
if (mainModules[i] && !mainModules[i].unloadable()) {
if (!silent && msg) {
common.reply(
msg, 'Reboot scheduled. Waiting on at least ' +
mainModuleNames[i]);
}
setTimeout(() => commandReboot(msg, true, onlySelf), 10000);
return;
}
}
}
for (let i = 0; i < mainModules.length; i++) {
try {
if (mainModules[i] && mainModules[i].save) mainModules[i].save();
} catch (e) {
common.error(mainModuleNames[i] + ' failed to save on reboot.');
console.error(e);
}
try {
if (mainModules[i] && mainModules[i].terminate) {
mainModules[i].terminate();
}
} catch (e) {
common.error(mainModuleNames[i] + ' failed to terminate properly.');
console.error(e);
}
try {
if (mainModules[i] && mainModules[i].end) mainModules[i].end();
} catch (e) {
common.error(mainModuleNames[i] + ' failed to shutdown properly.');
console.error(e);
}
}
if (minimal) {
reboot(force, doHardReboot);
} else {
const extra = doHardReboot ? ' (HARD)' : '';
if (msg) {
common.reply(msg, 'Rebooting...' + extra)
.then((msg_) => reboot(force, doHardReboot, msg_))
.catch(() => reboot(force, doHardReboot));
} else {
reboot(force, doHardReboot);
}
}
} else if (msg) {
common.reply(
msg, 'LOL! Good try!',
'It appears SpikeyRobot doesn\'t trust you enough with this ' +
'command. Sorry!');
}
}
if (!isBackup) {
command.on('mainreload', commandReload);
}
/**
* Reload all mainmodules by unloading then re-requiring.
*
* @private
* @type {commandHandler}
* @param {Discord~Message} msg Message that triggered command.
* @listens Command#mainreload
*/
function commandReload(msg) {
if (common.trustedIds.includes(msg.author.id)) {
if (client.shard) {
client.shard.broadcastEval(((text, ids) => {
return (client) => client.commandMainReload(text, ids);
})(msg.text, client.shard.ids[0]));
}
const toReload = msg.text.split(' ').splice(1);
const reloaded = [];
common.reply(msg, 'Reloading main modules...').then((warnMessage) => {
if (reloadMainModules(toReload, reloaded)) {
const embed = new Discord.EmbedBuilder();
embed.setTitle('Reload completed with errors.');
embed.setDescription(reloaded.join(' ') || 'NOTHING reloaded');
embed.setColor([255, 0, 255]);
warnMessage.edit({content: common.mention(msg), embeds: [embed]});
} else if (minimal) {
warnMessage.delete();
} else {
const embed = new Discord.EmbedBuilder();
embed.setTitle('Reload complete.');
embed.setDescription(reloaded.join(' ') || 'NOTHING reloaded');
embed.setColor([255, 0, 255]);
warnMessage.edit({content: common.mention(msg), embeds: [embed]});
}
});
} else {
common.reply(
msg, 'LOL! Good try!',
'It appears SpikeyRobot doesn\'t trust you enough with this ' +
'command. Sorry!');
}
}
if (client.shard) {
/**
* @description When another shard requests that we reload MainModules.
* @private
* @param {string} message Message relevant to reloading.
*/
client.commandMainReload = function(message) {
const toReload = message.split(' ').splice(1);
const reloaded = [];
reloadMainModules(toReload, reloaded);
};
}
/**
* Reloads mainmodules from file. Reloads all modules if `toReload` is not
* specified. `reloaded` will contain the list of messages describing which
* mainmodules were reloaded, or not.
*
* @private
*
* @param {?string|string[]} [toReload] Specify mainmodules to reload, or null
* to reload all mainmodules.
* @param {string[]} [reloaded] Reference to a variable to store output status
* information about outcomes of attempting to reload mainmodules.
* @param {boolean} [schedule=true] Automatically re-schedule reload for
* mainmodules if they are in an unloadable state.
* @returns {boolean} True if something failed and not all mainmodules were
* reloaded.
*/
function reloadMainModules(toReload, reloaded, schedule) {
if (!Array.isArray(reloaded)) reloaded = [];
if (!toReload) {
toReload = [];
} else if (typeof toReload === 'string') {
toReload = [toReload];
}
if (typeof schedule === 'undefined') schedule = true;
let error = false;
let force = false;
let noSchedule = false;
let numArg = 0;
if (toReload.find((el) => '--force' == el)) {
force = true;
numArg++;
}
if (toReload.find((el) => '--no-schedule' == el)) {
noSchedule = true;
numArg++;
}
for (let i = 0; i < mainModules.length; i++) {
if (toReload.length > numArg) {
if (!toReload.find((el) => mainModuleNames[i] == el)) {
continue;
}
}
if (!force) {
try {
if (fs.statSync(__dirname + '/' + mainModuleNames[i]).mtime -
mainModules[i].modifiedTime ==
0) {
continue;
}
} catch (err) {
common.error(
'Failed to stat mainmodule: ' + __dirname + '/' +
mainModuleNames[i]);
console.error(err);
reloaded.push('(' + mainModuleNames[i] + ': failed to stat)');
}
}
if (!noSchedule) {
if (mainModules[i]) {
if (!mainModules[i].unloadable()) {
if (schedule) {
reloaded.push('(' + mainModuleNames[i] + ': reload scheduled)');
setTimeout(() => reloadMainModules(mainModuleNames[i]), 10000);
} else {
reloaded.push('(' + mainModuleNames[i] + ': not unloadable)');
}
continue;
}
}
}
try {
try {
if (typeof mainModules[i].save === 'function') {
mainModules[i].save();
} else {
common.error(
'Mainmodule ' + mainModuleNames[i] +
' does not have a save() function.');
}
if (typeof mainModules[i].end === 'function') {
mainModules[i].end();
} else {
common.error(
'Mainmodule ' + mainModuleNames[i] +
' does not have an end() function.');
}
} catch (err) {
common.error('Error on unloading ' + mainModuleNames[i]);
console.log(err);
}
const exported = mainModules[i].export();
if (!exported) {
self.error(
'THIS IS POTENTIALLY A FATAL ERROR! FAILED TO EXPORT DATA ' +
'FROM A MAIN MODULE!');
}
delete require.cache[require.resolve(mainModuleNames[i])];
process.stdout.write(
'DBG:' + ('00000' + process.pid).slice(-5) + ' Loading ' +
mainModuleNames[i]);
try {
mainModules[i] = require(mainModuleNames[i]);
mainModules[i].modifiedTime =
fs.statSync(__dirname + '/' + mainModuleNames[i]).mtime;
process.stdout.write(': DONE\n');
} catch (err) {
process.stdout.write(': ERROR\n');
throw (err);
}
mainModules[i].import(exported);
if (mainModuleNames[i] == commandFilename) {
command = mainModules[i];
} else if (mainModuleNames[i] == smLoaderFilename) {
smLoader = mainModules[i];
}
for (let j = 0; j < mainModules.length; j++) {
mainModules[j].begin(Discord, client, command, common, self);
}
reloaded.push(mainModuleNames[i]);
} catch (err) {
error = true;
common.error('Failed to reload ' + mainModuleNames[i]);
console.log(err);
}
}
return error;
}
/**
* Trigger all mainmodules to save their data.
*
* @private
*/
function saveAll() {
common.logDebug('Saving all bot data to file...');
for (let i = 0; i < mainModules.length; i++) {
if (typeof mainModules[i].save === 'function') {
try {
const start = Date.now();
mainModules[i].save('async');
const delta = Date.now() - start;
if (delta > 10) {
common.logWarning(
mainModuleNames[i] + ' took an excessive ' + delta +
'ms to start saving data!');
}
} catch (err) {
common.error('Saving failed for mainModule ' + mainModuleNames[i]);
console.error(err);
}
}
}
}
if (!isBackup) {
command.on('saveall', commandSaveAll);
}
/**
* Trigger all mainModules to save their data.
*
* @see {@link SpikeyBot~saveAll()}
*
* @private
* @type {commandHandler}
* @param {Discord~Message} msg Message that triggered command.
* @listens Command#saveAll
*/
function commandSaveAll(msg) {
if (!common.trustedIds.includes(msg.author.id)) {
common.reply(
msg, 'LOL! Good try!',
'It appears SpikeyRobot doesn\'t trust you enough with this ' +
'command. Sorry!');
return;
}
saveAll();
msg.channel.send({content: common.mention(msg) + ' `Triggered data save`'});
}
/**
* Check current loaded mainModule commit to last modified commit, and reload
* if the file has changed for all mainModules.
*
* @public
*/
client.reloadUpdatedMainModules = function() {
delete require.cache[require.resolve('../auth.js')];
auth = require('../auth.js');
let smReloaded = false;
try {
common.log('Reloading updated mainModules.');
for (let i = 0; i < mainModules.length; i++) {
childProcess
.exec(
'git diff-index --quiet ' + mainModules[i].commit +
' -- ./src/' + mainModuleNames[i])
.on('close', ((name) => {
return (code) => {
if (code) {
const out = [];
reloadMainModules(name, out);
if (out && out.length > 0) common.log(out.join(' '));
if (name == smLoaderFilename) {
smReloaded = true;
}
} else {
common.logDebug(name + ' unchanged (' + code + ')');
}
};
})(mainModuleNames[i]));
}
} catch (err) {
common.error('Failed to reload updated mainModules!');
console.error(err);
}
if (!smReloaded) {
smLoader.reload();
}
};
/**
* Get this guild's custom prefix. Returns the default prefix otherwise.
*
* @public
*
* @param {?Discord~Guild|string|number} id The guild id or guild to lookup.
* @returns {string} The prefix for all commands in the given guild.
*/
this.getPrefix = function(id) {
if (!id) return defaultPrefix;
if (typeof id === 'object') id = id.id;
return guildPrefixes[id] || defaultPrefix;
};
/**
* Load prefixes from file for the given guilds asynchronously.
*
* @private
*
* @param {Discord~Guild[]} guilds Array of guilds to fetch the custom
* prefixes of.
*/
function loadGuildPrefixes(guilds) {
if (guilds.length == 0) return;
const id = guilds.splice(0, 1)[0].id;
const guildFile = common.guildSaveDir + id +
(botName ? guildCustomPrefixFile : guildPrefixFile);
const onFileRead = function(id) {
return function(err, data) {
if (!err && data.toString().length > 0) {
if (botName) {
const parsed = JSON.parse(data);
if (parsed && parsed[botName]) {
guildPrefixes[id] = parsed[botName];
}
} else {
guildPrefixes[id] = data.toString().replace(/\s/g, '');
}
}
if (typeof guildPrefixes[id] != 'string' ||
guildPrefixes[id].length < 1) {
delete guildPrefixes[id];
}
if (guilds.length > 0) {
loadGuildPrefixes(guilds);
} else {
common.logDebug('Finished loading custom prefixes.');
}
};
};
common.readFile(guildFile, onFileRead(id));
}
if (delayBoot > 0) {
if (!noLogin) {
setTimeout(login, delayBoot);
} else {
setTimeout(onReady, delayBoot);
}
} else {
if (!noLogin) {
login();
} else {
onReady();
}
}
process.on('SIGINT', exit);
process.on('SIGHUP', exit);
process.on('SIGTERM', exit);
process.on('exit', exit);
/**
* Trigger a graceful shutdown with process signals. Does not trigger shutdown
* if exit is -1.
*
* @private
*
* @param {...*} info Information about the signal.
*
* @listens SIGINT
* @listens SIGHUP
* @listens SIGTERM
* @listens process#exit
*/
function exit(...info) {
common.logWarning('Caught exit! (' + info.join(' ') + ')');
if (info[0] != -1) {
commandReboot(null, true, true);
}
}
/**
* Login to Discord. This shall only be called at most once.
*
* @private
*/
function login() {
let token = auth.release;
if (botName) {
token = auth[botName];
if (!token) {
common.error('Failed to find auth entry for ' + botName);
process.exit(1);
}
} else if (isDev) {
token = auth.dev;
}
client.login(token).catch((err) => {
console.error(err);
process.exit(1);
});
}
}
module.exports = new SpikeyBot();