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