// Copyright 2018-2022 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) const dateFormat = require('date-format'); const Discord = require('discord.js'); const fs = require('fs'); const mkdirp = require('mkdirp'); const sql = require('mysql'); const auth = require('../auth.js'); const path = require('path'); const yj = require('yieldable-json'); const crypto = require('crypto'); const encencoding = 'base64'; const hashCypher = 'md5'; const filePass = Buffer.from(auth.filePass, 'base64'); /** * Commonly required things. Helper functions and constants. * * @class */ function Common() { const self = this; /** * The number of characters reserved for the filename of the script. * * @private * @constant * @type {number} * @default */ const prefixLength = 14; /** * The color code to prefix log messages with for this script. * * @private * @type {number} * @default */ let mycolor = 0; /** * The script's filename to show in the log. * * @private * @type {string} * @constant */ const app = process.argv[1] ? process.argv[1].substring(process.argv[1].lastIndexOf('/') + 1) : ''; /** * The final formatted filename for logging. * * @private * @type {string} */ let title; /** * Whether this should be shown as a release version, or a debug version in * the log. * * @type {boolean} */ this.isRelease = false; /** * Whether this current instance is running as a unit test. * * @type {boolean} */ this.isTest = false; /** * @description Is this a shard that is being managed by our sharding system. * If this is true, webservers should not attempt to become the master, and * instead attempt to connect to the sharding master server. This is specified * using the `SHARDING_SLAVE` environment variable. * * @public * @type {boolean} * @default false */ this.isSlave = process.env.SHARDING_SLAVE === 'true'; /** * @description Is this a shard that is being managed by our sharding system, * and is the master that all children (siblings) will be connecting to. If * this is true, webservers should attempt to become the master, and not * fallback to a client sibling. This is specified using the `SHARDING_MASTER` * environment variable. * * @public * @type {boolean} * @default false */ this.isMaster = !this.isSlave && process.env.SHARDING_MASTER === 'true'; /** * @description The network host information where the master node is located * for connecting sibling sockets to. This will be populated if `isSlave` is * true. * * @private * @type {?object} * @default */ this.masterHost = null; /** * Initialize variables and settings for logging properly. * * @param {boolean} isTest Is this running as a test. * @param {boolean} isRelease Is this a release version, or a development * version of the app running. */ this.begin = function(isTest, isRelease) { self.isRelease = isRelease || false; self.isTest = isTest || false; switch (app) { case 'SpikeyBot.js': case 'ShardingMaster.js': case 'ShardingSlave.js': mycolor = 44; break; } let temptitle = app; if (self.isRelease) temptitle = 'R' + temptitle; else temptitle = 'D' + temptitle; for (let i = temptitle.length; i < prefixLength; i++) { temptitle += ' '; } if (temptitle.length > prefixLength) { temptitle = temptitle.substring(0, prefixLength); } temptitle += ' '; title = temptitle; self.log(app + ' Begin'); }; /** * Pad an IP address with zeroes. * * @param {number} str The ipv4 address as a string to format. * @returns {string} The padded address. */ this.padIp = function(str) { const dM = str.match(/\./g); const cM = str.match(/:/g); if (dM && dM.length == 3) { const res = str.split('.'); for (let i = 0; i < res.length; i++) { res[i] = ('000' + res[i]).slice(-3); res[i] = res[i].replace(':', '0'); } str = res.join('.'); } else if (cM && cM.length == 7) { const res = str.split(':'); for (let i = 0; i < res.length; i++) { res[i] = ('0000' + res[i]).slice(-4); // res[i] = res[i].replace(':', '0'); } str = res.join(':'); } for (let i = str.length; i < 45; i++) { str += ' '; } return str.substring(0, 45); }; /** * Formats a given IP address by padding with zeroes, or completely replacing * with a human readable alias if the address is a known location. * * @param {string} ip The ip address to format. * @returns {string} The formmatted address. */ this.getIPName = function(ip) { ip = self.padIp(ip); switch (ip) { default: return ip; case '': case ' ': case '127.000.000.001': return 'SELF '; case '205.167.046.140': case '205.167.046.157': case '205.167.046.15': case '204.088.159.118': return 'MVHS '; } }; /** * Format a prefix for a log message or error. Includes the ip before the * message. * * @param {string} ip The ip to include in the prefix. * @returns {string} The formatted prefix for a log message. */ this.updatePrefix = function(ip) { if (typeof ip === 'undefined') { ip = ' '; } const formattedIP = self.getIPName(ip.replace('::ffff:', '')); const date = dateFormat('mm-dd hh:MM:ss', new Date()); return `[${title}${date} ${formattedIP}]:`; }; /** * Write the final portion of the log message. * * @private * @param {string} prefix The first characters on the line. * @param {string} message The message to display. * @param {string} ip The IP address or unique identifier of the client that * caused this event to happen. * @param {number} [traceIncrease=0] Increase the distance up the stack to * show the in the log. */ function write(prefix, message, ip, traceIncrease = 0) { const output = [prefix]; output.push(getTrace(traceIncrease + 1)); if (self.isRelease) { output.push(`${self.updatePrefix(ip)}\x1B[;${mycolor}m`); } else { output.push(`\x1B[;${mycolor}m${self.updatePrefix(ip)}`); } message = message.toString().replace(/\n/g, '\\n'); output.push(` ${message}`); output.push('\x1B[1;0m\n'); process.stdout.write(output.join('')); } /** * Format a log message to be logged. Prefixed with DBG. * * @param {string} message The message to display. * @param {string} ip The IP address or unique identifier of the client that * caused this event to happen. * @param {number} [traceIncrease=0] Increase the distance up the stack to * show the in the log. */ this.logDebug = function(message, ip, traceIncrease = 0) { write('DBG:', message, ip, traceIncrease); }; /** * Format a log message to be logged. * * @param {string} message The message to display. * @param {string} ip The IP address or unique identifier of the client that * caused this event to happen. * @param {number} [traceIncrease=0] Increase the distance up the stack to * show the in the log. */ this.log = function(message, ip, traceIncrease = 0) { write('INF:', message, ip, traceIncrease); }; /** * Format a log message to be logged. Prefixed with WRN. * * @param {string} message The message to display. * @param {string} ip The IP address or unique identifier of the client that * caused this event to happen. * @param {number} [traceIncrease=0] Increase the distance up the stack to * show the in the log. */ this.logWarning = function(message, ip, traceIncrease = 0) { write('WRN:', message, ip, traceIncrease); }; /** * Format an error message to be logged. * * @param {string} message The message to display. * @param {string} ip The IP address or unique identifier of the client that * caused this event to happen. * @param {number} [traceIncrease=0] Increase the distance up the stack to * show the in the log. */ this.error = function(message, ip, traceIncrease = 0) { const output = ['ERR:']; message = `${message}`.replace(/\n/g, '\\n'); output.push(getTrace(traceIncrease)); output.push('\x1B[;31m'); output.push(`${self.updatePrefix(ip)} ${message}`); output.push('\x1B[1;0m\n'); process.stdout.write(output.join('')); }; /** * Replies to the author and channel of msg with the given message. * * @param {Discord~Message} msg Message to reply to. * @param {string} text The main body of the message. * @param {string} [post] The footer of the message. * @returns {?Promise} Promise of Discord~Message that we attempted to send, * or null if error occurred before attempting to send. */ this.reply = function(msg, text, post) { if (!msg.channel || !msg.channel.send) { return null; } const trace = getTrace(0); if (msg.editReply) { // This is actually an interaction. const res = { content: '```\n' + text + '\n```' + (post || ''), fetchReply: true, }; let promise; if (msg.deferred) { promise = msg.editReply(res); } else if (msg.replied) { promise = msg.followUp(res); } else { promise = msg.reply(res); } return promise.catch((err) => { self.error('Failed to send reply to channel: ' + msg.channel.id, trace); console.error(err); }); } const perms = msg.channel.permissionsFor && msg.client && msg.channel.permissionsFor(msg.client.user); if (perms && !perms.has(Discord.PermissionsBitField.Flags.SendMessages)) { self.logDebug( 'Failed to send reply to channel ' + msg.channel.id + ' due to lack of perms.', trace); if (msg.author) { msg.author .send({ content: 'I was unable to send a message in #' + msg.channel.name + ' because I do not have permission to send messages there.', }) .catch(() => {}); } return new Promise((resolve, reject) => reject(new Error('No Perms'))); } if (self.isTest || (perms && !perms.has(Discord.PermissionsBitField.Flags.EmbedLinks))) { return msg.channel .send({ content: Common.mention(msg) + '\n```\n' + text + '\n```' + (post || ''), }) .catch((err) => { self.error( 'Failed to send reply to channel: ' + msg.channel.id, trace); throw err; }); } else { const embed = new Discord.EmbedBuilder(); embed.setColor([255, 0, 255]); if (text.length <= 256) { embed.setTitle(text); if (post) embed.setDescription(post); } else { embed.setDescription(text + (post ? `\n${post}` : '')); } return msg.channel.send({content: Common.mention(msg), embeds: [embed]}) .catch((err) => { self.error( 'Failed to send embed reply to channel: ' + msg.channel.id, trace); throw err; }); } }; if (this.isSlave) { const configDir = './config/'; const files = fs.readdirSync(configDir); const file = files.find((el) => el.match(Common.shardConfigRegex)); if (!file) { this.error( 'Failed to find shard config file required for sibling sockets.'); } else { let data; try { data = fs.readFileSync(`${configDir}/${file}`); } catch (err) { this.error( 'Failed to read shard config file required for sibling sockets.'); console.error(err); } if (data) { try { const parsed = JSON.parse(data); this.masterHost = parsed.host; } catch (err) { this.error( 'Failed to parse shard config file required for sibling ' + 'sockets.'); console.error(err); } } } /** * @description Send an SQL query to the master database through our open * websocket. * @public * @param {string} query SQL query to send directly to the database. * @param {Function} cb Callback with optional error, otherwise returned * rows. */ this.sendSQL = function(query, cb) { const listener = (message) => { if (!message || message._sSQL !== query) return; process.removeListener('message', listener); clearTimeout(timeout); if (!message._error) { cb(null, message._result); } else { cb(message._error); } }; process.on('message', listener); const timeout = setTimeout(() => { process.removeListener('message', listener); cb('SQL IPC Send Timeout'); }, 10000); process.send({_sSQL: query}, (err) => { if (!err) return; process.removeListener('message', listener); cb(err); clearTimeout(timeout); // Rethrow to force suicide as this is a fatal error and is // unrecoverable. if (err.code === 'ERR_IPC_CHANNEL_CLOSED') throw err; }); }; /** * @description Request that the ShardingSlave sends a file to * the ShardingMaster. * @public * @param {string} filename The name of the file to send with path relative * to project directory. * @param {Function} cb Callback with optional error. */ this.sendFile = function(filename, cb) { Common.fileFetchHist[filename] = Date.now(); const listener = (message) => { if (!message || message._sWriteFile !== filename) return; process.removeListener('message', listener); clearTimeout(timeout); if (!message._error) { if (cb) cb(null, message._result); } else { if (cb) cb(message._error); } }; process.on('message', listener); const timeout = setTimeout(() => { process.removeListener('message', listener); if (cb) cb('SendFile IPC Send Timeout'); }, 30000); process.send({_sWriteFile: filename}, (err) => { if (!err) return; process.removeListener('message', listener); if (cb) cb(err); clearTimeout(timeout); // Rethrow to force suicide as this is a fatal error and is // unrecoverable. if (err.code === 'ERR_IPC_CHANNEL_CLOSED') throw err; }); }; /** * @description Request that the ShardingSlave fetches a file from * the ShardingMaster. * @public * @param {string} filename The name of the file to fetch with path relative * to project directory. * @param {Function} cb Callback with optional error. */ this.getFile = function(filename, cb) { Common.fileFetchHist[filename] = Date.now(); const listener = (message) => { if (!message || message._sGetFile !== filename) return; process.removeListener('message', listener); clearTimeout(timeout); if (!message._error) { if (cb) cb(null, message._result); } else { if (cb) cb(message._error); } }; process.on('message', listener); const timeout = setTimeout(() => { process.removeListener('message', listener); if (cb) cb('GetFile IPC Send Timeout'); }, 30000); fs.stat(filename, (err, stats) => { if (err && err.code !== 'ENOENT') { if (cb) cb(err); return; } const mtime = stats && stats.mtime.getTime() || 2; process.send({_sGetFile: filename, _sGetFileM: mtime}, (err) => { if (!err) return; process.removeListener('message', listener); if (cb) cb(err); clearTimeout(timeout); // Rethrow to force suicide as this is a fatal error and is // unrecoverable. if (err.code === 'ERR_IPC_CHANNEL_CLOSED') throw err; }); }); }; } if (this.mkAndWrite) this.mkAndWrite = this.mkAndWrite.bind(this); if (this.mkAndWriteSync) this.mkAndWriteSync = this.mkAndWriteSync.bind(this); if (this.unlink) this.unlink = this.unlink.bind(this); if (this.unlinkSync) this.unlinkSync = this.unlinkSync.bind(this); if (this.readFile) this.readFile = this.readFile.bind(this); if (this.readAndParse) this.readAndParse = this.readAndParse.bind(this); /** * Gets the name and line number of the current function stack. * * @private * * @param {number} [traceIncrease=0] Increase the distance up the stack to * show the in the log. * @returns {string} Formatted string with length 24. */ function getTrace(traceIncrease = 0) { if (typeof traceIncrease !== 'number') traceIncrease = 0; // let func = __function(traceIncrease) + ':' + __line(traceIncrease); let func = __filename(traceIncrease) + ':' + __line(traceIncrease); while (func.length < 20) func += ' '; func = ('00000' + process.pid).slice(-5) + ' ' + func.substr(func.length - 20, 20); return func; } /** * Gets the line number of the function that called a log function. * * @private * @param {number} [inc=0] Increase distance up the stack to returns. * @returns {number} Line number of call in stack. */ function __line(inc = 0) { return __stack()[3 + inc].getLineNumber(); } /** * Gets the name of the function that called a log function. * * @private * @param {number} [inc=0] Increase distance up the stack to return. * @returns {string} Function name in call stack. */ /* function __function(inc = 0) { return __stack()[3 + inc].getFunctionName(); } */ /** * Gets the name of the file that called a log function. * * @private * @param {number} [inc=0] Increase distance up the stack to returns. * @returns {string} Filename in call stack. */ function __filename(inc = 0) { return __stack()[3 + inc].getFileName(); } } /** * SpikeyRobot's Discord ID. If you are self-hosting SpikeyBot, change this to * your account ID to be able to give yourself full access to all features of * the bot. * * @type {string} * @default * @constant */ Common.prototype.spikeyId = '124733888177111041'; /** * SpikeyRobot's Discord ID. * * @type {string} * @default * @constant */ Common.spikeyId = Common.prototype.spikeyId; /** * Discord IDs that are allowed to reboot the bot, and are overall trusted * individuals/accounts. * * @type {string[]} * @constant */ Common.prototype.trustedIds = [ Common.spikeyId, // Me '126464376059330562', // Rohan '479294447184773130', // DV0RAK '165315069717118979', // Webb ]; /** * Trusted IDs. * * @see {@link Common.prototype.trustedIds} * * @constant * @type {string[]} */ Common.trustedIds = Common.prototype.trustedIds; /** * The channel id for the channel to reserve for only unit testing in. * * @public * @default * @constant * @type {string} */ Common.testChannel = '439642818084995074'; Common.prototype.testChannel = Common.testChannel; /** * Format a Discord API error. * * @param {Discord~DiscordAPIError} e DiscordAPIError to format into a string. * @returns {string} Error formatted as single line string. */ Common.prototype.fmtDAPIErr = function(e) { const pid = `00000${process.pid}`.slice(-5); return `ERR:${pid} [ SpikeyBot.js ` + `${e.name}: ${e.message} ${e.method} ${e.code} (${e.path})`; }; /** * Format a Discord API error. * * @param {Discord~DiscordAPIError} e DiscordAPIError to format into a string. * @returns {string} Error formatted as single line string. */ Common.fmtDAPIErr = Common.prototype.fmtDAPIErr; /** * The channel id for the channel to send general log messages to. * * @default * @constant * @type {string} */ Common.prototype.logChannel = '473935520821673991'; /** * The channel id for the channel to send general log messages to. * * @default * @constant * @type {string} */ Common.logChannel = Common.prototype.logChannel; /** * The website base URL for pointing to for more help and documentation. * * @type {string} * @constant * @default */ Common.prototype.webURL = 'https://www.spikeybot.com/'; /** * The website base URL for pointing to for more help and documentation. * * @type {string} * @constant */ Common.webURL = Common.prototype.webURL; /** * The website base URL for pointing to for avatar files. * * @type {string} * @constant * @default */ Common.prototype.avatarURL = 'https://kamino.spikeybot.com/'; /** * The website base URL for pointing to for avatar files. * * @type {string} * @constant */ Common.avatarURL = Common.prototype.avatarURL; /** * Whether to use the encryption specified in auth.js to encrypt users' * avatars. Could make it more difficult to serve the images if enabled. * * @type {boolean} * @constant * @default */ Common.prototype.encryptAvatars = false; /** * Whether to use the encryption specified in auth.js to encrypt users' * avatars. Could make it more difficult to serve the images if enabled. * * @type {boolean} * @constant * @default */ Common.encryptAvatars = Common.prototype.encryptAvatars; /** * The website path for more help and documentation. * * @type {string} * @constant * @default */ Common.prototype.webPath = 'help/'; /** * The website path for more help and documentation. * * @type {string} * @constant */ Common.webPath = Common.prototype.webPath; /** * The website full URL for commands help page. * * @type {string} * @constant * @default */ Common.prototype.webHelp = Common.webURL + Common.webPath; /** * The website full URL for commands help page. * * @type {string} * @constant */ Common.webHelp = Common.prototype.webHelp; /** * The root file directory for finding saved data related to individual * guilds. * * @type {string} * @constant * @default */ Common.prototype.guildSaveDir = './save/guilds/'; /** * The root file directory for finding saved data related to individual * guilds. * * @type {string} * @constant */ Common.guildSaveDir = Common.prototype.guildSaveDir; /** * The root file directory for finding saved data related to individual * users. * * @type {string} * @constant * @default */ Common.prototype.userSaveDir = './save/users/'; /** * The root file directory for finding saved data related to individual * users. * * @type {string} * @constant */ Common.userSaveDir = Common.prototype.userSaveDir; /** * Creates formatted string for mentioning the author of msg. * * @param {Discord~Message|Discord~UserResolvable} msg Message to format a * mention for the author of. * @returns {string} Formatted mention string. */ Common.prototype.mention = function(msg) { if (msg.disableMention && msg.author) { return `${msg.author.tag}`; } else if (msg.author) { return `<@${msg.author.id}>`; } else if (msg.id) { return `<@${msg.id}>`; } }; /** * Creates formatted string for mentioning the author of msg. * * @param {Discord~Message|Discord~UserResolvable} msg Message to format a * mention for the author of. * @returns {string} Formatted mention string. */ Common.mention = Common.prototype.mention; /** * @description Regular expression the name of the config file will match for a * shard configuration. * * @type {RegExp} * @constant * @default */ Common.prototype.shardConfigRegex = /^shard_[a-z]+_config\.json$/; /** * @description Regular expression the name of the config file will match for a * shard configuration. * * @type {RegExp} * @constant * @default */ Common.shardConfigRegex = Common.prototype.shardConfigRegex; /** * Write data to a file and make sure the directory exists or create it if it * doesn't. Async. * * @see {@link Common~mkAndWriteSync} * * @public * @static * @param {string} filename The name of the file including the directory. * @param {string} dir The directory path without the file's name. * @param {string|object} data The data to write to the file. * @param {Function} [cb] Callback to fire on completion. Only parameter is * optional error. * @param {boolean} encrypt Encrypt and append ".crypt" to filename if true */ Common.mkAndWrite = function(filename, dir, data, cb, encrypt = true) { if (!dir) dir = path.dirname(filename); mkdirp(dir) .then(() => { if (typeof data === 'object' && !Buffer.isBuffer(data)) { data = JSON.stringify(data); } const tmpfile = `${filename}.tmp`; const destFile = (encrypt) ? `${filename}.crypt` : filename; // Callback: After writing to tmp file, rename the tmp file to dest file const afterWriteFile = (err) => { if (err) { if (this.error) this.error(`Failed to save file: ${tmpfile}`); console.error(err); if (typeof cb === 'function') cb(err); return; } fs.rename(tmpfile, destFile, (err) => { if (err) { if (this.error) { this.error( `Failed to rename tmp file: ${tmpfile} --> ${destFile}`); } console.error(err); if (typeof cb === 'function') cb(err); return; } if (this.sendFile) this.sendFile(destFile); if (typeof cb === 'function') cb(); }); }; if (encrypt) { const iv = crypto.createHash(hashCypher).update(filename).digest(); const cipher = crypto.createCipheriv(auth.fileAlgo, filePass, iv); let encdata = cipher.update(data, 'utf-8', encencoding); encdata += cipher.final(encencoding); fs.writeFile(tmpfile, encdata, afterWriteFile); } else /* unencrypted: write directly to tmp file */ { fs.writeFile(tmpfile, data, afterWriteFile); } }) .catch((err) => { if (err.code !== 'EEXIST') { if (this.error) this.error(`Failed to make directory: ${dir}`); console.error(err); if (typeof cb === 'function') cb(err); } }); }; Common.prototype.mkAndWrite = Common.mkAndWrite; /** * Write data to a file and make sure the directory exists or create it if it * doesn't. Synchronous. * * @see {@link Common~mkAndWrite} * * @public * @param {string} filename The name of the file including the directory. * @param {string} dir The directory path without the file's name. * @param {string} data The data to write to the file. */ Common.mkAndWriteSync = function(filename, dir, data) { if (!dir) dir = path.dirname(filename); try { mkdirp.sync(dir); } catch (err) { if (this.error) this.error(`Failed to make directory: ${dir}`); console.error(err); return; } if (typeof data === 'object' && !Buffer.isBuffer(data)) { data = JSON.stringify(data); } const encfile = `${filename}.crypt`; const iv = crypto.createHash(hashCypher).update(filename).digest(); const cipher = crypto.createCipheriv(auth.fileAlgo, filePass, iv); let encdata = cipher.update(data, 'utf-8', 'base64'); encdata += cipher.final('base64'); try { fs.writeFileSync(encfile, encdata); } catch (err) { if (this.error) this.error(`Failed to save file: ${encfile}`); console.error(err); return; } if (this.sendFile) this.sendFile(encfile); }; Common.prototype.mkAndWriteSync = Common.mkAndWriteSync; /** * Delete data from file, and mark it for deletion from other shards. * * @public * @param {string} filename The name of the file to remove. * @param {Function} [cb] Callback once completed, with optional error * parameter. */ Common.unlink = function(filename, cb) { fs.unlink(filename, (err) => { if (err && err.code != 'ENOENT') { if (this.error) this.error(`Failed to delete file: ${filename}`); console.error(err); if (cb) cb(err); } else { if (this.sendFile) this.sendFile(filename); if (cb) cb(null); } }); }; Common.prototype.unlink = Common.unlink; /** * Delete data from file, and mark it for deletion from other shards. * Synchronous. * * @public * @param {string} filename The name of the file to remove. */ Common.unlinkSync = function(filename) { try { fs.unlinkSync(filename); } catch (err) { if (err.code != 'ENOENT') { if (this.error) this.error(`Failed to delete file: ${filename}`); console.error(err); } } if (this.sendFile) this.sendFile(filename); }; Common.prototype.unlinkSync = Common.unlinkSync; /** * @description Filenames and the timestamp they were last fetched from or sent * to other shards. * @public * @static * @type {object.<number>} * @default */ Common.fileFetchHist = {}; /** * @description Cooldown after fetching a file from other shards before * requesting the new version again. * @public * @static * @type {number} * @default 30 seconds */ Common.fileFetchDelay = 30000; /** * Read a file's contents, checks other shards for newer version first. This * does not have a synchronous version as the whole point of this is to * potentially fetch a newer file from another server. * * @public * @param {string} filename The name of the file to read. * @param {Function} [cb] Callback once completed, with optional error * parameter, and parameter of file contents from `fs.readFile`. * @param {boolean} [encrypt=true] Enable encryption. */ Common.readFile = function(filename, cb, encrypt = true) { if (!cb) throw new TypeError('readFile must have a callback function'); const encfile = encrypt ? `${filename}.crypt` : filename; const lastFetch = Common.fileFetchHist[filename] || 0; const onread = (err, data) => { if (err || !encrypt) { if (encrypt) { this.readFile(filename, cb, false); return; } cb(err, data); return; } let decdata = null; try { const iv = crypto.createHash(hashCypher).update(filename).digest(); const decipher = crypto.createDecipheriv(auth.fileAlgo, filePass, iv); decdata = decipher.update(data.toString(), encencoding, 'utf-8'); decdata += decipher.final('utf-8'); } catch (err) { if (this.error) this.error(`Failed to decrypt file ${encfile}`); console.error(err); cb(err, null); return; } cb(err, decdata); }; if (this.getFile && Date.now() - lastFetch > Common.fileFetchDelay) { this.getFile(encfile, (err, res) => { if (err) { if (this.error) this.error(`Failed to getFile ${encfile}`); console.error(err); } else if (res) { if (this.logDebug) this.logDebug(`getFile: ${res}`); else console.log(res); } fs.readFile(encfile, onread); }); } else { fs.readFile(encfile, onread); } }; Common.prototype.readFile = Common.readFile; /** * Parse a JSON string. * * @public * @param {string} str String to parse. * @param {Function} [cb] Callback once completed, with optional error * parameter, and parameter of parsed data. */ Common.parse = function(str, cb) { if (!cb) throw new TypeError('parse must have a callback function'); yj.parseAsync(str.toString(), cb); }; Common.prototype.parse = Common.parse; /** * Read a file's contents, checks other shards for newer version first, and * parse the file's contents info from JSON to objects. This does not have a * synchronous version as the whole point of this is to potentially fetch a * newer file from another server. * * @public * @param {string} filename The name of the file to read and parse. * @param {Function} [cb] Callback once completed, with optional error * parameter, and parameter of parsed contents. */ Common.readAndParse = function(filename, cb) { if (!cb) throw new TypeError('readAndParse must have a callback function'); const read = this.readFile || Common.readFile; const parse = this.parse || Common.parse; read(filename, (err, file) => { if (err) { cb(err, file); } else if (!file || file.length == 0) { cb(null, null); } else { parse(file, cb); } }); }; Common.prototype.readAndParse = Common.readAndParse; /** * Recursively freeze all elements of an object. * * @public * @param {object} object The object to deep freeze. * @returns {object} The frozen object. */ Common.deepFreeze = function(object) { const propNames = Object.getOwnPropertyNames(object); for (const name of propNames) { const value = object[name]; object[name] = value && typeof value === 'object' ? Common.deepFreeze(value) : value; } return Object.freeze(object); }; Common.prototype.deepFreeze = Common.deepFreeze; /** * @description Convert a string in camelcase to a human readable spaces format. * (helloWorld --> Hello World). * * @private * @param {string} str The input. * @returns {string} The output. */ Common.camelToSpaces = function(str) { return str.replace(/([A-Z])/g, ' $1').replace(/^./, function(str) { return str.toUpperCase(); }); }; Common.prototype.camelToSpaces = Common.camelToSpaces; /** * The object describing the connection with the SQL server. * * @global * @type {?sql.ConnectionConfig} */ global.sqlCon; /** * Create initial connection with sql server. The connection is injected into * the global scope as {@link sqlCon}. If a connection still exists, calling * this function just returns the current reference. * * @public * @param {boolean} [force=false] Force a new connection to be established. * @returns {sql.ConnectionConfig} Current sql connection object. */ Common.connectSQL = function(force = false) { if (global.sqlCon && !force) return global.sqlCon; if (global.sqlCon && global.sqlCon.end) global.sqlCon.end(); if (this.isSlave) { global.sqlCon = { query: this.sendSQL, format: sql.format, }; if (this.log) { this.log('SQL Connection using websocket'); } else { console.log('SQL Connection using websocket'); } } else { /* eslint-disable-next-line new-cap */ global.sqlCon = new sql.createConnection({ user: auth.sqlUsername, password: auth.sqlPassword, host: auth.sqlHost, database: auth.sqlDatabase, port: auth.sqlPort, }); global.sqlCon.on('error', (e) => { if (this.error) { this.error(`SQL connection fired error: ${e}`); } else { console.error(e); } if (e.fatal || e.code === 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR') { Common.connectSQL(true); } }); if (this.log) { this.log('SQL Connection created'); } else { console.log('SQL Connection created'); } } return global.sqlCon; }; Common.prototype.connectSQL = Common.connectSQL; /** * @description The User-Agent to send in http request headers. * @public * @type {string} * @constant */ Common.ua = 'Mozilla/5.0 (compatible; SpikeyBot/1.0; +https://www.spikeybot.com/)'; Common.prototype.ua = Common.ua; /* eslint-disable-next-line no-extend-native */ String.prototype.replaceAll = function(search, replacement) { const target = this; return target.replace(new RegExp(search, 'g'), replacement); }; /** * @description Gets the stack trace of the current function call. * * @private * @returns {Stack} Error stack for logging. */ function __stack() { const orig = Error.prepareStackTrace; Error.prepareStackTrace = function(_, stack) { return stack; }; const err = new Error(); /* eslint-disable-next-line no-caller */ Error.captureStackTrace(err, arguments.callee); const stack = err.stack; Error.prepareStackTrace = orig; return stack; } const oldErr = console.error; /** * @description Augment console.error to reformat DiscordAPIErrors to be more * pretty. * @param {*} args Arguments to pass through to console.error. */ console.error = function(...args) { if (args.length == 1 && (args[0] instanceof Discord.DiscordAPIError)) { args[0] = Common.fmtDAPIErr(args[0]); } else if (typeof args[0] !== 'string' || !args[0].startsWith('ERR:')) { const pid = `00000${process.pid}`.slice(-5); oldErr(`ERR:${pid} [ SpikeyBot.js `, ...args); return; } oldErr(...args); }; module.exports = new Common();