diff --git a/backend/api/get/localRoutes.js b/backend/api/get/localRoutes.js index e36038f..b6d4d93 100644 --- a/backend/api/get/localRoutes.js +++ b/backend/api/get/localRoutes.js @@ -7,7 +7,7 @@ require('dotenv').config({ path: './.env' }); const router = express.Router(); -const defaultSettings = require('./public/default_server.json'); +const defaultSettings = require('../../public/default_server.json'); router.get('/authenticatedToken', async (req, res) => { const jwtToken = req.cookies.Authenticated; @@ -85,9 +85,35 @@ router.get('/generate-uuid/:guild_id', (req, res) => { fs.writeFile('./public/servers.json', JSON.stringify(servers, null, 2), 'utf8', (err) => { if (err) throw err; - res.json({ server_uuid }) }); }); + + fs.readFile('./public/server_info.json', 'utf8', (err, data) => { + if (err) throw err; + + const serverInfo = JSON.parse(data || '{}'); + + // Check if guild ID and server UUID are present, and initialize with default settings if not + if (!serverInfo[guild_id]) { + serverInfo[guild_id] = {}; + } + if (!serverInfo[guild_id][server_uuid]) { + serverInfo[guild_id][server_uuid] = { + 'players': [], + 'ping': [], + 'map': null, + 'status': null, + 'last_restart': null, + 'message_id': null + }; + } + + fs.writeFile('./public/server_info.json', JSON.stringify(serverInfo, null, 2), 'utf8', (err) => { + if (err) throw err; + }); + }); + + res.json({ server_uuid }) }); router.get('/servers/:guildId', (req, res) => { diff --git a/backend/api/post/postRoutes.js b/backend/api/post/postRoutes.js index cb80136..1cb7cb4 100644 --- a/backend/api/post/postRoutes.js +++ b/backend/api/post/postRoutes.js @@ -165,7 +165,7 @@ function queueWrite(data, res) { } router.post('/write-server-info', async (req, res) => { - const { guild_id, server_uuid, server_info } = req.body; + const { guildID, serverUUID, queryState } = req.body; try { let data = null; @@ -180,6 +180,8 @@ router.post('/write-server-info', async (req, res) => { await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for 1 second } } + + console.log(queryState.map); // If data is still null after 5 attempts, handle the error if (!data) { @@ -188,12 +190,12 @@ router.post('/write-server-info', async (req, res) => { const jsonData = JSON.parse(data || '{}'); - if (!(guild_id in jsonData)) { - jsonData[guild_id] = {}; + if (!(guildID in jsonData)) { + jsonData[guildID] = {}; } - if (!(server_uuid in jsonData[guild_id])) { - jsonData[guild_id][server_uuid] = { + if (!(serverUUID in jsonData[guildID])) { + jsonData[guildID][serverUUID] = { map: null, players: [], ping: [], @@ -203,12 +205,32 @@ router.post('/write-server-info', async (req, res) => { }; } - jsonData[guild_id][server_uuid].map = server_info.map; - jsonData[guild_id][server_uuid].players.push(server_info.active_players); - jsonData[guild_id][server_uuid].ping.push(server_info.ping); - jsonData[guild_id][server_uuid].status = server_info.status; - jsonData[guild_id][server_uuid].last_restart = server_info.last_restart; - jsonData[guild_id][server_uuid].message_id = server_info.message_id; + jsonData[guildID][serverUUID].map = queryState.map; + jsonData[guildID][serverUUID].players.push(queryState.active_players); + jsonData[guildID][serverUUID].ping.push(queryState.ping); + jsonData[guildID][serverUUID].status = queryState.status; + jsonData[guildID][serverUUID].last_restart = queryState.last_restart; + + queueWrite(jsonData, res); + } catch (err) { + console.log(`Error reading or processing file: ${err}`); + res.status(500).send(err.message); + } +}); + +router.post('/write-message-id', async (req, res) => { + const { guildID, serverUUID, messageID } = req.body; + + try { + let data = await fs.promises.readFile('./public/server_info.json', 'utf8'); + + const jsonData = JSON.parse(data || '{}'); + + if (!(guildID in jsonData) || !(serverUUID in jsonData[guildID])) { + return res.status(404).send('Guild or server not found'); + } + + jsonData[guildID][serverUUID].message_id = messageID; queueWrite(jsonData, res); } catch (err) { @@ -226,22 +248,22 @@ router.delete('/delete-server', async (req, res) => { try { // Read and parse server_info.json - let serverInfoData = JSON.parse(await fs.readFile('./public/server_info.json', 'utf8')); + let serverInfoData = JSON.parse(await fs.promises.readFile('./public/server_info.json', 'utf8')); // Delete server_uuid if (serverInfoData[guild_id]) { delete serverInfoData[guild_id][server_uuid]; } // Write the modified data back to server_info.json - await fs.writeFile('./public/server_info.json', JSON.stringify(serverInfoData, null, 2)); + await fs.promises.writeFile('./public/server_info.json', JSON.stringify(serverInfoData, null, 2)); // Read and parse servers.json - let serversData = JSON.parse(await fs.readFile('./public/servers.json', 'utf8')); + let serversData = JSON.parse(await fs.promises.readFile('./public/servers.json', 'utf8')); // Delete server_uuid if (serversData[guild_id]) { delete serversData[guild_id][server_uuid]; } // Write the modified data back to servers.json - await fs.writeFile('./public/servers.json', JSON.stringify(serversData, null, 2)); + await fs.promises.writeFile('./public/servers.json', JSON.stringify(serversData, null, 2)); res.json({ message: 'Server UUID deleted successfully.' }); } catch (err) { diff --git a/discord/.eslintrc.json b/discord/.eslintrc.json new file mode 100644 index 0000000..cdd5136 --- /dev/null +++ b/discord/.eslintrc.json @@ -0,0 +1,15 @@ +{ + "env": { + "browser": true, + "commonjs": true, + "es2021": true + }, + "extends": "google", + "parserOptions": { + "ecmaVersion": "latest" + }, + "rules": { + "max-len": "off", + "linebreak-style": "off" + } +} diff --git a/discord/api/index.js b/discord/api/index.js index 71f1acf..d39f02e 100644 --- a/discord/api/index.js +++ b/discord/api/index.js @@ -1,47 +1,102 @@ const axios = require('axios'); -require('dotenv').config({ path: './.env' }); +const logger = require('../utils/logger'); +require('dotenv').config({path: './.env'}); -async function getGuilds(page = 0, pageSize = 10) { - const guilds = await axios.get(`${process.env.BACKEND_URL}api/get/bot/guilds`, { - params: { - page: page, - pageSize: pageSize - } - }); - return guilds; +/** + * + * @return {object} + */ +async function getGuilds() { + logger.debug(`Fetching guilds`); + const guilds = await axios.get(`${process.env.BACKEND_URL}api/get/bot/guilds`); + return guilds.data; } -async function getServerUUIDsForGuild(guild_id) { - const servers = await axios.get(`${process.env.BACKEND_URL}api/get/bot/${guild_id}/servers`, {}); - return servers; +/** + * + * @param {string} guildID + * @return {object} + */ +async function getServerUUIDsForGuild(guildID) { + logger.debug(`Fetching server UUIDs for guild: ${guildID}`); + const servers = await axios.get(`${process.env.BACKEND_URL}api/get/bot/${guildID}/servers`, {}); + return servers; } -async function getServerDetails(guild_id, server_uuid) { - const details = await axios.get(`${process.env.BACKEND_URL}api/get/bot/${guild_id}/server/${server_uuid}`, {}); - return details; +/** + * + * @param {string} guildID + * @param {string} serverUUID + * @return {object} + */ +async function getServerSettings(guildID, serverUUID) { + logger.debug(`Fetching server details for guild: ${guildID} and server UUID: ${serverUUID}`); + const settings = await axios.get(`${process.env.BACKEND_URL}api/get/bot/${guildID}/server/${serverUUID}`, {}); + return settings.data; } -async function getServerInfo(guild_id, server_uuid) { - const serverInfo = await axios.get(`${process.env.BACKEND_URL}api/get/bot/${guild_id}/serverInfo/${server_uuid}`, {}); - return serverInfo; +/** + * + * @param {string} guildID + * @param {string} serverUUID + * @return {object} + */ +async function getServerInfo(guildID, serverUUID) { + logger.debug(`Fetching server info for guild: ${guildID} and server UUID: ${serverUUID}`); + let serverInfo; + try { + serverInfo = await axios.get(`${process.env.BACKEND_URL}api/get/bot/${guildID}/serverInfo/${serverUUID}`, {}); + } catch { + logger.warn(`Couldn't find info for guild: ${guildID} and server UUID: ${serverUUID}`); + logger.info(`Creating new info for guild: ${guildID} and server UUID: ${serverUUID}`); + serverInfo = {'players': 0, 'ping': 0, 'map': null, 'status': null, 'last_restart': null, 'message_id': null}; + writeServerInfo(guildID, serverUUID, serverInfo); + } + return serverInfo; } -async function writeServerInfo(guild_id, server_uuid, server_info) { - try { - const response = await axios.post(`${process.env.BACKEND_URL}api/post/write-server-info`, { - guild_id, - server_uuid, - server_info, - }); - } catch (error) { - console.error('Error: ', error); - } +/** + * + * @param {string} guildID + * @param {string} serverUUID + * @param {object} queryState + */ +async function writeServerInfo(guildID, serverUUID, queryState) { + try { + logger.debug(`Writing server info for guild: ${guildID} and server UUID: ${serverUUID}`); + await axios.post(`${process.env.BACKEND_URL}api/post/write-server-info`, { + guildID, + serverUUID, + queryState, + }); + } catch (error) { + logger.error(`Error writing server info for guild: ${guildID} and server UUID: ${serverUUID}, error: ${error}`); + } +} +/** + * + * @param {string} guildID + * @param {string} serverUUID + * @param {object} messageID + */ +async function writeMessageID(guildID, serverUUID, messageID) { + try { + logger.debug(`Writing server info for guild: ${guildID} and server UUID: ${serverUUID}`); + await axios.post(`${process.env.BACKEND_URL}api/post/write-message-id`, { + guildID, + serverUUID, + messageID, + }); + } catch (error) { + logger.error(`Error writing server info for guild: ${guildID} and server UUID: ${serverUUID}, error: ${error}`); + } } module.exports = { - getGuilds, - getServerUUIDsForGuild, - getServerDetails, - getServerInfo, - writeServerInfo -}; \ No newline at end of file + getGuilds, + getServerUUIDsForGuild, + getServerSettings, + getServerInfo, + writeServerInfo, + writeMessageID, +}; diff --git a/discord/bot.js b/discord/bot.js index 5fa6628..4a3ac97 100644 --- a/discord/bot.js +++ b/discord/bot.js @@ -1,192 +1,306 @@ -require('dotenv').config({ path: '../.env' }); -const { Client, GatewayIntentBits, DiscordAPIError, RateLimitError, HTTPError } = require('discord.js'); -const { getGuilds, getServerUUIDsForGuild, getServerDetails, writeServerInfo, getServerInfo } = require('./api'); -const queryServer = require('./query'); +require('dotenv').config({path: './.env'}); +const {Client, GatewayIntentBits} = require('discord.js'); +const {getGuilds, getServerUUIDsForGuild, getServerSettings, writeMessageID, getServerInfo} = require('./api'); +const serverQuery = require('./query'); const createGraph = require('./graphs'); -const { createEmbed } = require('./embeds'); -const async = require('async'); +const createEmbed = require('./embeds'); +const logger = require('./utils/logger'); -const client = new Client({ intents: [GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.Guilds] }); +const client = new Client({intents: [GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.Guilds]}); -const serverQueue = async.queue((task, callback) => { - handleServer(task.guild_id, task.server, task.server_settings).then(callback); -}, 1); // '1' is the number of simultaneous tasks allowed. +// This keeps track of all the servers intializations, and only allows servers to be initialized once +const initializedServers = new Set(); +/** + * Retrieves the guild object from the Discord API using a specified guild ID. + * If the guild is not found, a warning is logged, and the function returns null. + * + * @async + * @param {string} guildID - The ID of the guild to retrieve. + * @return {Object | null} - The object if found, or null if the guild with the given ID does not exist. + */ +async function getGuild(guildID) { + logger.debug(`Checking guild ${guildID}`); + const guild = client.guilds.cache.get(guildID); + if (!guild) { + logger.warn(`Guild with ID ${guildID} not found`); -client.on('ready', async () => { - console.log(`Shard ${client.shard.ids[0]} is ready`); - manageServers(); - setInterval(manageServers, 30 * 1000); -}); + return null; + } + logger.debug(`Finished checking guild ${guildID}`); -async function manageServers() { - console.log('Getting New Servers') - let response = await getGuilds(); - let guild_info = response.data; - const managedGuilds = client.guilds.cache.filter(guild => guild_info[guild.id]); - - console.log(`Shard ${client.shard.ids[0]} is managing ${managedGuilds.size} guild(s)`); - - for (const guild of managedGuilds.values()) { - const server_uuids = await getServerUUIDsForGuild(guild.id); - - for (const server of server_uuids.data) { - let response = await getServerDetails(guild.id, server); - let server_settings = response.data; - - // Check if the server has been deleted - if (!server_settings) { - console.log(`Server with UUID ${server} in guild ${guild.id} has been deleted.`); - continue; // Skip to the next server - } - - // Convert refresh interval to milliseconds - //let interval = server_settings.bot_settings.refresh_interval * 1000; - let interval = 30 * 1000; - - // Start an independent interval for each server - startInterval(guild.id, server, server_settings, interval); - } - } + return guild; } -async function startInterval(guild_id, server, server_settings, interval) { - serverQueue.push({ - guild_id, - server, - server_settings - }); +/** + * Retrieves a specific channel from a given guild using the channel ID found in the server settings. + * The function logs the process of checking the channel and returns the channel object if found. + * If the channel is not found within the guild, a warning is logged, and the function returns null. + * + * @param {Object} guild - The guild object where the channel is expected to be found. + * @param {String} guildID - The ID of the guild associated with the channel. + * @param {Object} serverCustomizationSettings - The server settings containing the channel ID for retrieval. + * @return {Object | null} - The channel object if found, or null if the channel with the given ID does not exist in the guild. + */ +async function getChannel(guild, guildID, serverCustomizationSettings) { + logger.debug(`Checking channel ${serverCustomizationSettings.bot_settings.channel_id} in guild ${guildID}`); + const channel = await guild.channels.cache.get(serverCustomizationSettings.bot_settings.channel_id); + if (!channel) { + logger.warn(`Channel with ID ${serverCustomizationSettings.bot_settings.channel_id} not found in guild ${guildID}`); - setTimeout(() => startInterval(guild_id, server, server_settings, interval), interval); + return null; + } + logger.debug(`Finished checking guild ${guildID}`); + + return channel; } -async function handleServer(guild_id, server_uuid, server_settings) { - console.log('handleServer') - try { - // Get the guild object - const guild = client.guilds.cache.get(guild_id); - console.log('getting Guild with Guild ID: ', guild_id) +/** + * Retrieves a specific message from a given channel using the message ID found in the server state. + * The function logs the process of checking the channel and returns the message object if found. + * If the message is not found within the channel, an error is logged, and the function returns null. + * + * @param {Object} channel - The channel object where the message is expected to be found. + * @param {String} guildID - The ID of the guild associated with the channel. + * @param {Object} serverCustomizationSettings - The server customization settings containing the channel ID for verification. + * @param {Object} serverInfo - The current state of the server, including the message ID to be fetched. + * @return {Object | null} - The message object if found, or null if the message with the given ID does not exist in the channel. + */ +async function getMessage(channel, guildID, serverCustomizationSettings, serverInfo) { + logger.debug(`Checking channel ${serverCustomizationSettings.bot_settings.channel_id} in guild ${guildID}`); + const message = await channel.messages.fetch(serverInfo.data.message_id); + if (!message) { + logger.error(`Message with ID ${queryState.message_id} not found in channel ${channel.id} in guild ${guildID}`); - console.log('Checking If Guild Exists...') + return null; + } + logger.debug(`Finished getting message: ${message.id}`); - // Check if the guild exists - if (!guild) { - console.log(`Guild ${guild_id} not found`); - return; - } + return message; +} - console.log('Guild Exist\'s, moving on!') +/** + * Queries a specific game server using the provided customization settings, guild ID, and server UUID. + * The server's IP, query port, and query protocol are extracted from the customization settings and used + * to perform the query. The result of the query is an object containing the current state of the server. + * If an error occurs during the query process, it is logged, and the function may return undefined. + * + * @param {String} guildID - The ID of the guild associated with the server being queried. + * @param {String} serverUUID - The unique identifier for the server being queried. + * @param {Object} serverCustomizationSettings - The server customization settings containing the IP, query port, and query protocol. + * @return {Object} - An object representing the current state of the queried server, or undefined if an error occurred. + */ +async function queryServer(guildID, serverUUID, serverCustomizationSettings ) { + logger.debug(`Querying server for guild ${guildID} and server ${serverUUID}`); + let queryState; + try { + queryState = await serverQuery( + serverCustomizationSettings.server_settings.ip, + serverCustomizationSettings.server_settings.query_port, + serverCustomizationSettings.server_settings.query_protocol, + guildID, + serverUUID, + ); + } catch (err) { + logger.error(`There was an error while querying server for guild ${guildID} and server ${serverUUID}`); + logger.error(`Error: ${err}`); + } + logger.debug(`Finished querying server for guild ${guildID} and server ${serverUUID}`); + return queryState; +} - // Get the channel object - const channel = guild.channels.cache.get(server_settings.bot_settings.channel_id); - console.log('getting Channel with Channel ID: ', server_settings.bot_settings.channel_id) +/** + * Generates a graph for a specific server within a guild based on the server customization settings. + * The graph is created by calling an external function `createGraph`, and the URL of the generated graph is returned. + * Any errors encountered during the graph generation process are logged. + * + * @param {String} guildID - The ID of the guild for which the graph is being generated. + * @param {String} serverUUID - The unique identifier for the server associated with the graph. + * @param {Object} serverCustomizationSettings - Customization settings for the graph, such as title, labels, colors, etc. + * @return {string} - The URL of the generated graph, or undefined if an error occurred. + */ +async function generateGraph(guildID, serverUUID, serverCustomizationSettings) { + logger.debug(`Generating graph for guild ${guildID} and server ${serverUUID}`); + let graphURL; + try { + graphURL = createGraph(guildID, serverUUID, serverCustomizationSettings); + } catch (err) { + logger.error(`There was an error while generating graph for guild ${guildID} and server ${serverUUID}`); + logger.error(`Error: ${err}`); + } + logger.debug(`Finished generating graph for guild ${guildID} and server ${serverUUID}`); - console.log('Checking If Channel Exists...') - // Check if the channel exists - if (!channel) { - console.log(`Channel with ID ${server_settings.bot_settings.channel_id} not found in guild ${guild_id}`); - return; - } + return graphURL; +} - console.log('Channel Exist\'s, moving on!') +/** + * Creates a new message in a specified channel within a guild. The message includes an embed + * and an attachment, generated based on the server customization settings, server profile, and graph URL. + * The newly created message's ID is then updated in the server state and written to the server info. + * + * @param {String} guildID - The ID of the guild where the message will be posted. + * @param {String} serverUUID - The unique identifier for the server associated with the message. + * @param {Object} channel - The channel object where the message will be posted. + * @param {Object} serverCustomizationSettings - Customization settings for the bot's behavior and appearance. + * @param {String} graphURL - The URL of the graph to be embedded in the message. + * @param {Object} queryState - The state of the server, which will be updated with the new message's ID. + * @return {Object | null} - The created message object if successful, or null if an error occurred (e.g., missing permissions, rate-limited). + */ +async function createMessage(guildID, serverUUID, channel, serverCustomizationSettings, graphURL, queryState) { + console.log(channel); + logger.debug(`Creating a new message for guild ${guildID} and server ${serverUUID} in channel: ${channel.id}`); + let message; + try { + const [embed, attachment] = await createEmbed(serverCustomizationSettings, graphURL, queryState); - console.log('Querying Game Server...') - // Query the server - const { server_info: server_query } = await queryServer(server_settings.server_settings.ip, server_settings.server_settings.query_port,server_settings.server_settings.query_protocol, guild_id, server_uuid); + message = await channel.send({embeds: [embed], files: [attachment]}); + await writeMessageID(guildID, serverUUID, message.id); + logger.debug(`Finished creating a new message for guild ${guildID} and server ${serverUUID} in channel: ${channel.id}`); - console.log('Finished Querying Game Server!') + return message; + } catch (err) { + if (err.message.includes('Missing Permissions')) { + logger.error(`Missing permissions to send message in channel ${channel.id} in guild ${guildID}`); + } else if (err.code === 429) { // Rate limited + logger.error(`Rate limited while sending message in channel ${channel.id} in guild ${guildID}`); + } else { + logger.error(`An error occurred while sending the message in channel ${channel.id} in guild ${guildID}: ${err}`); + } - // Create the Player Graph - if (!server_settings.graph_settings.disable) { - console.log('Creating Player Graph!') - graph_url = await createGraph(guild_id, server_uuid); - console.log('Done! Graph can be found here: ', graph_url) - } + return null; + } +} + +/** + * Edits an existing message in a specified channel within a guild. The message is modified + * to include a newly created embed and attachment, both of which are generated based on the + * provided bot settings, server information, and graph URL. + * + * @param {String} guildID - The ID of the guild where the message is located. + * @param {String} serverUUID - The unique identifier for the server associated with the message. + * @param {Object} channel - The channel object where the message is posted. + * @param {Object} serverCustomizationSettings - Customization settings for the bot's behavior and appearance. + * @param {String} graphURL - The URL of the graph to be embedded in the message. + * @param {Object} queryState - Information related to the server, such as map, players, etc. + * @param {Object} serverInfo - Information related to the server, such as map, players, etc. + * @return {Object | null} - The edited message object if successful, or null if an error occurred (e.g., missing permissions, rate-limited). + */ +async function editMessage(guildID, serverUUID, channel, serverCustomizationSettings, graphURL, queryState, serverInfo) { + logger.debug(`Editing message for guild ${guildID} and server ${serverUUID} in channel: ${channel.id}`); + try { + const message = await getMessage(channel, guildID, serverCustomizationSettings, serverInfo); + const [embed, attachment] = await createEmbed(serverCustomizationSettings, graphURL, queryState); + await message.edit({embeds: [embed], files: [attachment]}); + logger.debug(`Finished editing message for guild ${guildID} and server ${serverUUID} in channel: ${channel.id}`); + + return message; + } catch (err) { + if (err.message.includes('Missing Permissions')) { + logger.error(`Missing permissions to edit message in channel ${channel.id} in guild ${guildID}`); + } else if (err.code === 429) { // Rate limited + logger.error(`Rate limited while editing message in channel ${channel.id} in guild ${guildID}`); + } else { + logger.error(`An error occurred while editing the message in channel ${channel.id} in guild ${guildID}: ${err.message}`); + } + + return null; + } +} + +/** + * Queries a server, generates a player graph, and creates/edits an embed. + * @param {*} guildID + * @param {*} serverUUID + * @param {*} serverCustomizationSettings + */ +async function queryAndUpdateServer(guildID, serverUUID, serverCustomizationSettings) { + const serverInfo = await getServerInfo(guildID, serverUUID); + const guild = await getGuild(guildID); + const channel = await getChannel(guild, guildID, serverCustomizationSettings); + const queryState = await queryServer(guildID, serverUUID, serverCustomizationSettings); + // add an if statement to stop generating the graph if the setting is disabled + const graphURL = await generateGraph(guildID, serverUUID, serverCustomizationSettings); + try { + if (serverInfo.data.message_id) { + await editMessage(guildID, serverUUID, channel, serverCustomizationSettings, graphURL, queryState, serverInfo); + } else { + await createMessage(guildID, serverUUID, channel, serverCustomizationSettings, graphURL, queryState); + } + } catch { + logger.warn(`Couldn't read message ID for guild ${guildID} and server ${serverUUID}`); + logger.warn(`Creating a new message, for guild ${guildID} in channel ${channel.id}`); + await createMessage(guildID, serverUUID, channel, serverCustomizationSettings, graphURL, queryState); + } +} - console.log('Attempting to build and send the discord embed') - - let message; - if (server_query.message_id === null) { - console.log('message_id is null, attempting to send a new one') - - const [embed, attachment] = await createEmbed(server_settings, graph_url, server_query); - - message = await channel.send({ embeds: [embed], files: [attachment] }).catch(console.error); - - console.log('New Message Sent! New message_id is: ', message) - server_query.message_id = message.id; - - console.log('Attempting to write new message id to Server Info JSON file!') - await writeServerInfo(guild_id, server_uuid, server_query); - console.log('New message ID has been written to Server Info JSON file!') - } else { - let message = null; - let attempts = 0; - let shouldSendNewMessage = false; - - while (!message && attempts < 5) { - try { - message = await channel.messages.fetch(server_query.message_id); - const [embed, attachment] = await createEmbed(server_settings, graph_url, server_query); - await message.edit({ embeds: [embed], files: [attachment] }).catch(console.error); - } catch (err) { - console.error(`Error fetching message: ${err}`); - if (err instanceof DiscordAPIError) { - console.log(err.code) - if (err.code === 10008) { // 'Unknown Message' error - console.error('Message not found, it might have been deleted.'); - shouldSendNewMessage = true; - break; - } else if (err.code === 50001) { // 'Missing Access' error - console.error('Bot does not have access to the channel.'); - break; - } else if (err.code === 50013) { // 'Missing Permissions' error - console.error('Bot does not have permission to read the message.'); - break; - } - // Add more error codes as needed based on your requirements - } - - // If it's a RateLimitError, you can wait for the duration specified by the 'retry_after' property before trying again - if (err instanceof RateLimitError) { - await new Promise(resolve => setTimeout(resolve, err.retry_after * 1000)); // 'retry_after' is in seconds - continue; // Try again - } - - // If it's a HTTPError (like a network error), you can wait for a bit before trying again - if (err instanceof HTTPError) { - await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for 1 second - continue; // Try again - } - - // If it's some other type of error, you might not know how to handle it, so just log it and exit the loop - console.error('Unknown error type, not retrying.'); - break; - } - - attempts += 1; - } - - if (!message && shouldSendNewMessage) { - console.log('the message is null, sending a new message') - console.log('Creating embed...'); - const [embed, attachment] = await createEmbed(server_settings, graph_url, server_query); - console.log('Embed created.'); - - console.log('Sending message...'); - message = await channel.send({ embeds: [embed], files: [attachment] }).catch(console.error); - console.log('Message sent.'); - - console.log('Attempting to write new message id to Server Info JSON file!') - server_query.message_id = message.id; - await writeServerInfo(guild_id, server_uuid, server_query); - console.log('New message ID has been written to Server Info JSON file!') - } +/** + * Validates the server settings to ensure all required fields are present. + * @param {*} serverCustomizationSettings + * @return {boolean} True if all required settings are present, false otherwise. + */ +function areServerSettingsValid(serverCustomizationSettings) { + const botSettings = serverCustomizationSettings.bot_settings; + const serverSettings = serverCustomizationSettings.server_settings; + + return ( + botSettings.channel_id && + serverSettings.ip && + serverSettings.connection_port && + serverSettings.query_port && + serverSettings.game && + serverSettings.query_protocol + ); +} + +/** + * Sets up the query and update cycle for all servers in all guilds. + */ +async function setupAllServers() { + const guilds = Object.keys(await getGuilds()); + for (const guildID of guilds) { + const servers = await getServerUUIDsForGuild(guildID); + if (Object.keys(servers.data).length > 0) { + const serverPromises = servers.data.map(async (serverUUID) => { + if (!initializedServers.has(serverUUID)) { + const serverCustomizationSettings = await getServerSettings(guildID, serverUUID); + if (areServerSettingsValid(serverCustomizationSettings)) { + initializedServers.add(serverUUID); + return initializeServerQueryCycle(guildID, serverUUID); + } else { + logger.warn(`Settings are not valid for guild ${guildID} and server ${serverUUID}`); + } } - } catch (err) { - console.error(`Error handling server: ${err}`); + }).filter(Boolean); // Filter out undefined values + await Promise.all(serverPromises); } + } } -client.on('error', console.error); +/** + * Initializes the query and update cycle for a specific server. + * @param {*} guildID + * @param {*} serverUUID + */ +async function initializeServerQueryCycle(guildID, serverUUID) { + const serverCustomizationSettings = await getServerSettings(guildID, serverUUID); + const refreshInterval = serverCustomizationSettings.bot_settings.refresh_interval; + + await queryAndUpdateServer(guildID, serverUUID, serverCustomizationSettings); + + setInterval(async () => { + const startTime = Date.now(); + await queryAndUpdateServer(guildID, serverUUID, serverCustomizationSettings); + const endTime = Date.now(); + const executionTime = endTime - startTime; + logger.debug(`Server ${serverUUID} query execution time: ${executionTime/1000}s`); + logger.debug(`Server ${serverUUID}: Waiting ${refreshInterval - executionTime/1000}s before refreshing`); + }, refreshInterval * 1000); +} + +client.on('ready', async () => { + await setupAllServers(); + setInterval(async () => { + await setupAllServers(); + }, 60000); +}); client.login(process.env.BOT_TOKEN); diff --git a/discord/embeds/index.js b/discord/embeds/index.js index b59abc6..8962f1f 100644 --- a/discord/embeds/index.js +++ b/discord/embeds/index.js @@ -1,92 +1,115 @@ -require('dotenv').config(); -const axios = require('axios'); -const { EmbedBuilder } = require('discord.js'); -const { AttachmentBuilder } = require('discord.js'); +require('dotenv').config({path: './.env'}); +const {EmbedBuilder} = require('discord.js'); +const {AttachmentBuilder} = require('discord.js'); +const logger = require('../utils/logger'); -function replaceVariables(str, server_settings, server_query) { - if (str) { - let server_variables = { - game: server_settings.server_settings.game.toString(), - map: server_query.map.toString(), - ip: server_settings.server_settings.ip.toString(), - connection_port: server_settings.server_settings.connection_port.toString(), - query_port: server_settings.server_settings.query_port.toString(), - players_active: server_query.status ? server_query.active_players.toString() : "--", - players_max: server_query.status ? server_query.max_players.toString() : "--", - server_name: server_settings.bot_settings.server_name.toString(), - server_status: server_query.status - }; +/** + * Replaces text variables within a string with specific server information. + * + * @param {string} str - The input string containing placeholders enclosed in curly braces. + * @param {object} serverCustomizationSettings - The server settings object containing game, IP, port, and other details. + * @param {object} queryState - The server query object containing information about the map, active players, max players, and server status. + * @return {string} The input string with placeholders replaced with actual server information. + */ +function replaceVariables(str, serverCustomizationSettings, queryState) { + if (str) { + const serverVariables = { + game: serverCustomizationSettings?.server_settings?.game?.toString() || '', + map: queryState.map?.toString() || '', + ip: serverCustomizationSettings?.server_settings?.ip?.toString() || '', + connection_port: serverCustomizationSettings?.server_settings?.connection_port?.toString() || '', + query_port: serverCustomizationSettings?.server_settings?.query_port?.toString() || '', + players_active: queryState.status ? queryState.active_players?.toString() : '--', + players_max: queryState.status ? queryState.max_players?.toString() : '--', + server_name: serverCustomizationSettings?.bot_settings?.server_name?.toString() || '', + server_status: queryState.status || '', + }; - return str.replace(/\{([a-z_]+)\}/gi, function(match, variable) { - return server_variables[variable.toLowerCase()] || match; - }); - } else { - return; - } + return str.replace(/\{([a-z_]+)\}/gi, function(match, variable) { + return serverVariables[variable.toLowerCase()] || match; + }); + } } -async function createEmbed(server_settings, graph_url, server_query) { - let embed = new EmbedBuilder() - .setTitle(replaceVariables(server_settings.embed_settings.embed_title, server_settings, server_query)) - .setDescription(replaceVariables(server_settings.embed_settings.embed_description, server_settings, server_query)) - .setColor(server_settings.embed_settings.embed_color); - - if (!server_settings.embed_settings.disable_timestamp) { - embed.setTimestamp(); - } - if (!server_settings.embed_settings.thumbnail_settings.disable_thumbnail) { - embed.setThumbnail(server_settings.embed_settings.thumbnail_settings.thumbnail_url); +/** + * Creates an embed object with server details, including title, description, color, fields, and attachments. + * + * @async + * @param {object} serverCustomizationSettings - The server settings object containing embed settings, field settings, thumbnail, footer, etc. + * @param {string} graphURL - The URL for the graph image to be attached to the embed. + * @param {object} queryState - The server query object containing information about the server status, players, etc. + * @return {Array} An array containing the embed object and attachment (graph image). + */ +async function createEmbed(serverCustomizationSettings, graphURL, queryState) { + logger.debug('Creating embed...'); + const embed = new EmbedBuilder(); + + logger.debug(`Setting Embed Title: ${replaceVariables(serverCustomizationSettings.embed_settings.embed_title, serverCustomizationSettings, queryState)}`); + embed.setTitle(replaceVariables(serverCustomizationSettings.embed_settings.embed_title, serverCustomizationSettings, queryState)); + logger.debug(`Setting Embed Description: ${serverCustomizationSettings.embed_settings.embed_description}`); + embed.setDescription(replaceVariables(serverCustomizationSettings.embed_settings.embed_description, serverCustomizationSettings, queryState)); + logger.debug(`Setting Embed Color: ${serverCustomizationSettings.embed_settings.embed_color}`); + embed.setColor(serverCustomizationSettings.embed_settings.embed_color); + + if (!serverCustomizationSettings.embed_settings.disable_timestamp) { + logger.debug('Timestamp is enabled!'); + embed.setTimestamp(); + } + if (!serverCustomizationSettings.embed_settings.thumbnail_settings.disable_thumbnail) { + logger.debug('Thumbnail is enabled!'); + embed.setThumbnail(serverCustomizationSettings.embed_settings.thumbnail_settings.thumbnail_url); + } + if (!serverCustomizationSettings.embed_settings.footer_settings.disable_footer) { + logger.debug('Footer is enabled!'); + embed.setFooter({text: serverCustomizationSettings.embed_settings.footer_settings.footer_text, iconURL: serverCustomizationSettings.embed_settings.footer_settings.footer_url}); + } + + // Field Assignment + const fieldsArray = Object.entries(serverCustomizationSettings.embed_field_settings.embed_fields) + .map(([key, value]) => ({name: key, ...value})) + .filter((field) => !field.disable) + .sort((a, b) => a.index - b.index); + + fieldsArray.forEach((field) => { + let fieldName = field.name; + let fieldValue; + + if (field.name === 'status') { + // For the status field, use online/offline text as the value + fieldValue = queryState.status ? `${field.online_emoji} ${replaceVariables(field.online_text, serverCustomizationSettings, queryState)}` : `${field.offline_emoji} ${replaceVariables(field.offline_text, serverCustomizationSettings, queryState)}`; + } else { + fieldValue = ''; + if (field.emoji) { + fieldValue = fieldValue + field.emoji; + } + if (field.text) { + fieldValue = `${fieldValue} ${replaceVariables(field.text, serverCustomizationSettings, queryState)}`; + } } - if (!server_settings.embed_settings.footer_settings.disable_footer) { - embed.setFooter({ text: server_settings.embed_settings.footer_settings.footer_text, iconURL: server_settings.embed_settings.footer_settings.footer_url }) + fieldName = fieldName.replace(/_/g, ' ').split(' ').map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); + // Check that both name and value are defined before adding the field + if (fieldName && fieldValue) { + embed.addFields({name: fieldName, value: fieldValue, inline: field.inline}); } + }); - // Field Assignment - let fieldsArray = Object.entries(server_settings.embed_field_settings.embed_fields) - .map(([key, value]) => ({ name: key, ...value })) - .filter(field => !field.disable) - .sort((a, b) => a.index - b.index); + if (!serverCustomizationSettings.embed_settings.disable_player_names) { + if (queryState.players && queryState.players.length > 0) { + const playerNames = queryState.players.map((player) => player.name); + const playerList = '\n' + playerNames.join('\n'); - fieldsArray.forEach(field => { - let fieldName = field.name; - let fieldValue; - - if (field.name === 'status') { - // For the status field, use online/offline text as the value - fieldValue = server_query.status ? `${field.online_emoji} ${replaceVariables(field.online_text, server_settings, server_query)}` : `${field.offline_emoji} ${replaceVariables(field.offline_text, server_settings, server_query)}` ; - } else { - fieldValue = ''; - if (field.emoji) { - fieldValue = fieldValue + field.emoji; - } - if (field.text) { - fieldValue = `${fieldValue} ${replaceVariables(field.text, server_settings, server_query)}`; - } - } - fieldName = fieldName.replace(/_/g, ' ').split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); - - // Check that both name and value are defined before adding the field - if (fieldName && fieldValue) { - embed.addFields({name: fieldName, value: fieldValue, inline: field.inline}); - } - }); - - if (!server_settings.embed_settings.disable_player_names) { - if (server_query.players && server_query.players.length > 0) { - const playerNames = server_query.players.map(player => player.name); - const playerList = '\n' + playerNames.join('\n'); - - embed.addFields({name: 'Online Players', value: `\`\`\`${playerList}\`\`\``, inline: false}); - } + embed.addFields({name: 'Online Players', value: `\`\`\`${playerList}\`\`\``, inline: false}); } + } - // Image - const attachment = new AttachmentBuilder(graph_url, { name: 'chart.png' }); - embed.setImage('attachment://chart.png'); + logger.debug('Adding image to embed...'); + const attachment = new AttachmentBuilder() + .setFile(graphURL) + .setName('chart.png'); - return [embed, attachment]; -} + embed.setImage('attachment://chart.png'); -module.exports = { - createEmbed -}; \ No newline at end of file + logger.debug('Embed created.'); + return [embed, attachment]; +} +module.exports = createEmbed; diff --git a/discord/graphs/graph_area.js b/discord/graphs/graph_area.js index a88c6d1..fbbae81 100644 --- a/discord/graphs/graph_area.js +++ b/discord/graphs/graph_area.js @@ -1,139 +1,157 @@ -function hexToRgba(hex, opacity) { - let r = parseInt(hex.slice(1, 3), 16), - g = parseInt(hex.slice(3, 5), 16), - b = parseInt(hex.slice(5, 7), 16); +const logger = require('../utils/logger'); - return `rgba(${r}, ${g}, ${b}, ${opacity})`; +/** + * + * @param {*} hex + * @param {*} opacity + * @return {string} + */ +function hexToRgba(hex, opacity) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; } -function graph_area(graphSettings, players, trend_line, labels) { - //console.log(`\n-- Configuring Area Graph --`) - //console.log(`Graph Settings: `, graphSettings) - //console.log(`Players: `, players) - //console.log(`Trend Line: `, trend_line) - //console.log(`Labels: `, labels) +/** + * + * @param {*} graphSettings + * @param {*} players + * @param {*} trendLine + * @param {*} labels + * @return {object} + */ +function graphArea(graphSettings, players, trendLine, labels) { + logger.debug('Configuring graph area'); - let playersDataForBar = []; - - let maxPlayerValue; - - if (Math.max(...players) == 0) { - maxPlayerValue = 2; + const playersDataForBar = []; + // Calculate max value for players and trend_line + let maxPlayerValue = Math.max(...players); + let maxTrendValue = Math.max(...trendLine); + // If maxPlayerValue is zero, set it to 2 + if (maxPlayerValue === 0) { + maxPlayerValue = 2; + } else { + maxPlayerValue = maxPlayerValue * 2; + } + // If maxTrendValue is zero, set it to 2 + if (maxTrendValue === 0) { + maxTrendValue = 2; + } else { + maxTrendValue = maxTrendValue * 2; + } + // Choose the maximum value between maxPlayerValue and maxTrendValue + const graphHeight = Math.max(maxPlayerValue, maxTrendValue); + for (let i = 0; i < players.length; i++) { + if (players[i] === -1) { + playersDataForBar[i] = graphHeight; + players[i] = 0; } else { - maxPlayerValue = 2 * Math.max(...players); + playersDataForBar[i] = null; } - - for (let i = 0; i < players.length; i++) { - if (players[i] === -1) { - playersDataForBar[i] = maxPlayerValue; - players[i] = 0; - } else { - playersDataForBar[i] = null; - } + } + for (let i = 0; i < trendLine.length; i++) { + if (trendLine[i] < 0) { + trendLine[i] = 0; } - - for(let i = 0; i < trend_line.length; i++) { - if(trend_line[i] < 0) { - trend_line[i] = 0; - } - } - - const configuration = { - type: 'line', - data: { - labels: labels, - datasets: [ - { - label: 'Server Offline', - type: 'bar', - data: playersDataForBar, - backgroundColor: 'red' - }, - { - label: 'Players', - data: players, - borderColor: hexToRgba(graphSettings.graph_line_settings.player_settings.line_color, 1), - fill: !graphSettings.graph_line_settings.player_settings.disable, - backgroundColor: hexToRgba(graphSettings.graph_line_settings.player_settings.fill_color, graphSettings.graph_line_settings.player_settings.fill_opacity), - pointRadius: 0, - borderWidth: 1 - }, - { - label: 'Trends', - data: trend_line, - borderColor: hexToRgba(graphSettings.graph_line_settings.trend_settings.color, 1), - borderDash: [5, 5], - fill: !graphSettings.graph_line_settings.trend_settings.disable, - pointRadius: 0, - borderWidth: 1 - } - ] + } + const configuration = { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: 'Server Offline', + type: 'bar', + data: playersDataForBar, + backgroundColor: 'red', }, - options: { - plugins: { - legend: { - display: !graphSettings.legend_settings.disable, - labels: { - color: hexToRgba(graphSettings.legend_settings.background_color, 1), - borderColor: hexToRgba(graphSettings.legend_settings.border_color, 1), - } - }, - title: { - display: !graphSettings.title_settings.disable, - text: graphSettings.title_settings.text, - color: hexToRgba(graphSettings.title_settings.color, 1), - } + { + label: 'Players', + data: players, + borderColor: hexToRgba(graphSettings.graph_line_settings.player_settings.line_color, 1), + fill: !graphSettings.graph_line_settings.player_settings.disable, + backgroundColor: hexToRgba(graphSettings.graph_line_settings.player_settings.fill_color, graphSettings.graph_line_settings.player_settings.fill_opacity), + pointRadius: 0, + borderWidth: 1, + }, + { + label: 'Trends', + data: trendLine, + borderColor: hexToRgba(graphSettings.graph_line_settings.trend_settings.color, 1), + borderDash: [5, 5], + fill: !graphSettings.graph_line_settings.trend_settings.disable, + pointRadius: 0, + borderWidth: 1, + }, + ], + }, + options: { + plugins: { + legend: { + display: !graphSettings.legend_settings.disable, + labels: { + color: hexToRgba(graphSettings.legend_settings.background_color, 1), + borderColor: hexToRgba(graphSettings.legend_settings.border_color, 1), + }, + }, + title: { + display: !graphSettings.title_settings.disable, + text: graphSettings.title_settings.text, + color: hexToRgba(graphSettings.title_settings.color, 1), + }, + }, + scales: { + x: { + display: true, + time: { + unit: 'hour', + }, + title: { + display: true, + text: graphSettings.y_label_settings.text, + color: hexToRgba(graphSettings.x_label_settings.color, 1), + }, + ticks: { + major: true, + autoSkip: false, + min: 10, + max: 24, + grace: '5%', + color: hexToRgba(graphSettings.tick_settings.x_color, 1), + afterBuildTicks: function(scale, ticks) { + const majorTicks = ticks.filter((tick) => { + return tick.major; + }); + return majorTicks; }, - scales: { - x: { - display: true, - time: { - unit: 'hour' - }, - title: { - display: true, - text: graphSettings.y_label_settings.text, - color: hexToRgba(graphSettings.x_label_settings.color, 1), - }, - ticks: { - major: true, - autoSkip: false, - min: 10, - max: 24, - grace: '5%', - color: hexToRgba(graphSettings.tick_settings.x_color, 1), - afterBuildTicks: function(scale, ticks) { - var majorTicks = ticks.filter((tick) => { - return tick.major; - }); - return majorTicks; - } - }, - grid: { - display: false, - color: hexToRgba(graphSettings.graph_line_settings.grid_settings.color, graphSettings.graph_line_settings.grid_settings.opacity), - } - }, - y: { - min: 0, - max: maxPlayerValue, - display: true, - title: { - display: true, - text: graphSettings.x_label_settings.text, - color: hexToRgba(graphSettings.y_label_settings.color, 1), - }, - ticks: { - color: hexToRgba(graphSettings.tick_settings.y_color, 1), - }, - grid: { - color: hexToRgba(graphSettings.graph_line_settings.grid_settings.color, graphSettings.graph_line_settings.grid_settings.opacity), - } - } - } - } - }; - return configuration; + }, + grid: { + display: false, + color: hexToRgba(graphSettings.graph_line_settings.grid_settings.color, graphSettings.graph_line_settings.grid_settings.opacity), + }, + }, + y: { + min: 0, + max: graphHeight, + display: true, + title: { + display: true, + text: graphSettings.x_label_settings.text, + color: hexToRgba(graphSettings.y_label_settings.color, 1), + }, + ticks: { + color: hexToRgba(graphSettings.tick_settings.y_color, 1), + }, + grid: { + color: hexToRgba(graphSettings.graph_line_settings.grid_settings.color, graphSettings.graph_line_settings.grid_settings.opacity), + }, + }, + }, + }, + }; + logger.debug('Graph area configured'); + return configuration; } -module.exports = graph_area; \ No newline at end of file +module.exports = graphArea; diff --git a/discord/graphs/index.js b/discord/graphs/index.js index 3e2037e..8c4c5ac 100644 --- a/discord/graphs/index.js +++ b/discord/graphs/index.js @@ -1,129 +1,136 @@ -const { ChartJSNodeCanvas } = require('chartjs-node-canvas'); -const { sma } = require('moving-averages'); -const path = require('path'); -const fs = require('fs'); - -const { getServerDetails, getServerInfo } = require('../api'); +/* eslint-disable no-extend-native */ +const {ChartJSNodeCanvas} = require('chartjs-node-canvas'); +const {sma} = require('moving-averages'); +const {join} = require('path'); +const {writeFile} = require('fs'); +const logger = require('../utils/logger'); +const {getServerInfo} = require('../api'); // Graph Configurations -const graph_area = require('./graph_area'); +const graphArea = require('./graph_area'); -async function getServerPlayers(guild_id, server_uuid) { - return getServerInfo(guild_id, server_uuid) - .then(response => { - return response.data.players; - }) - .catch(console.error); -} -async function getServerData(guild_id, server_uuid) { - return getServerDetails(guild_id, server_uuid) - .then(response => { - return response.data; - }) - .catch(console.error); +/** + * + * @param {string} guildID + * @param {string} serverUUID + * @return {object} + */ +async function getServerPlayers(guildID, serverUUID) { + logger.debug(`Getting server players for guild: ${guildID}, server: ${serverUUID}`); + return getServerInfo(guildID, serverUUID) + .then((response) => { + logger.debug(`Server players retrieved`); + return response.data.players; + }) + .catch((error) => { + logger.error(`Error getting server players: ${error}`); + }); } -async function createGraph(guild_id, server_uuid) { - //console.log(`\n-- Create Graph --`) - //console.log(`Guild ID: ${guild_id}`) - //console.log(`Server UUID: ${server_uuid}`) - - Array.prototype.sma = require('moving-averages').sma; - const width = 700; - const height = 500; - const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height }); +/** + * + * @param {*} guildID + * @param {*} serverUUID + * @param {*} serverCustomizationSettings + * @return {string} + */ +async function createGraph(guildID, serverUUID, serverCustomizationSettings) { + logger.debug(`Creating graph for guild: ${guildID}, server: ${serverUUID}`); + Array.prototype.sma = require('moving-averages').sma; + const width = 700; + const height = 500; + const chartJSNodeCanvas = new ChartJSNodeCanvas({width, height}); + // Set up Player Data, Server Data, and Player Trends Data + const playerData = await getServerPlayers(guildID, serverUUID); - // Set up Player Data, Server Data, and Player Trends Data - let player_data = await getServerPlayers(guild_id, server_uuid); - //console.log(`Player Data: `, player_data) + const secondsInDay = 24 * 60 * 60; + const entriesPerDay = Math.round(secondsInDay / serverCustomizationSettings.bot_settings.refresh_interval); + let players; + let trendData; + if (Array.isArray(playerData)) { + trendData = playerData; + players = playerData.slice(-entriesPerDay); + } else { + logger.warn('playerData is not an array'); + } - const server_data = await getServerData(guild_id, server_uuid); - //console.log(`Server Data: `, server_data) - - // Gets the entries per week based on the refresh_interval - // with a refresh_interval of 15sec that would be about 40320 points of data - const secondsInDay = 24 * 60 * 60; - let entriesPerDay = Math.round(secondsInDay / server_data.bot_settings.refresh_interval); - let players; - let trend_data; - if (Array.isArray(player_data)) { - players = player_data.slice(-entriesPerDay); - trend_data = player_data.slice(0, -entriesPerDay); - // rest of your code - } else { - console.log('player_data is not an array'); - } + if (players.length != entriesPerDay) { + logger.debug(`entriesPerDay: ${entriesPerDay}, players.length: ${players.length}`); + players = Array(entriesPerDay - players.length).fill(0).concat(players); + logger.debug(`players.length after padding: ${players.length}`); + } + if (trendData.length != entriesPerDay) { + logger.debug(`entriesPerDay: ${entriesPerDay}, trendData.length: ${trendData.length}`); + trendData = Array(entriesPerDay - trendData.length).fill(0).concat(trendData); + logger.debug(`trend_data.length after padding: ${trendData.length}`); + } + const playersTrend = sma(trendData, 60 / serverCustomizationSettings.bot_settings.refresh_interval); - let players_trend = sma(trend_data, 60 / server_data.bot_settings.refresh_interval); - - if (players != entriesPerDay) { - players = Array(entriesPerDay - players.length).fill(0).concat(players); - } + // LABELS // + // This makes labels for every hour of the day the latest (right) value being the current time + // Will add a customization in the dashboard so the user can select their timezone, for now though, we will use UTC + const userTimezone = 'UTC'; // Default Option: UTC + const use12Format = true; + // Get the current time in the user's timezone + const date = new Date(); + const formatter = new Intl.DateTimeFormat([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, // Keep this false, if set to true 12 hour format wont work... + timeZone: userTimezone, + }); + const timeParts = formatter.format(date).split(':'); + const currentHour = Number(timeParts[0]); + const currentMinute = Number(timeParts[1]); + const currentSecond = Number(timeParts[2]); + const currentTimeInSeconds = (currentHour * 3600) + (currentMinute * 60) + currentSecond; - // LABELS // - // This makes labels for every hour of the day the latest (right) value being the current time - // Will add a customization in the dashboard so the user can select their timezone, for now though, we will use UTC - let userTimezone = "America/Denver"; // Default Option: UTC - let use12Format = true; - - // Get the current time in the user's timezone - let date = new Date(); - let formatter = new Intl.DateTimeFormat([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, // Keep this false, as if set to true 12 hour format wont work... - timeZone: userTimezone + // Generate Labels 1 label for every hour + let labels = []; + // I will add a customization that will switch between 12-24 hour formats + if (use12Format) { + labels = Array.from({length: 24 * 60 * 60 / serverCustomizationSettings.bot_settings.refresh_interval}, (v, i) => { + const totalSeconds = ((i * serverCustomizationSettings.bot_settings.refresh_interval) + currentTimeInSeconds) % (24 * 60 * 60); + const hour24 = Math.floor(totalSeconds / 3600) % 24; + const hour = hour24 % 12 || 12; + const ampm = hour24 < 12 ? 'AM' : 'PM'; + const minute = Math.floor((totalSeconds / 60) % 60); + return (minute % 60 === 0) ? `${hour}:00 ${ampm}` : ''; + }); + } else { + labels = Array.from({length: 24 * 60 * 60 / serverCustomizationSettings.bot_settings.refresh_interval}, (v, i) => { + const totalSeconds = ((i * serverCustomizationSettings.bot_settings.refresh_interval) + currentTimeInSeconds) % (24 * 60 * 60); + const hour = Math.floor(totalSeconds / 3600) % 24; + const minute = Math.floor((totalSeconds / 60) % 60); + return (minute % 60 === 0) ? `${hour}:00` : ''; }); - let timeParts = formatter.format(date).split(':'); - let currentHour = Number(timeParts[0]); - let currentMinute = Number(timeParts[1]); - let currentSecond = Number(timeParts[2]); + } - let currentTimeInSeconds = (currentHour * 3600) + (currentMinute * 60) + currentSecond; - - // Generate Labels 1 label for every hour - let labels = []; - // I will add a customization that will switch between 12-24 hour formats - if (use12Format) { - labels = Array.from({ length: 24 * 60 * 60 / server_data.bot_settings.refresh_interval }, (v, i) => { - let totalSeconds = ((i * server_data.bot_settings.refresh_interval) + currentTimeInSeconds) % (24 * 60 * 60); - let hour24 = Math.floor(totalSeconds / 3600) % 24; - let hour = hour24 % 12 || 12; - let ampm = hour24 < 12 ? 'AM' : 'PM'; - let minute = Math.floor((totalSeconds / 60) % 60); - return (minute % 60 === 0) ? `${hour}:00 ${ampm}` : ''; + let configuration = {}; + // Once the graph types are implemented, i will change this so it will check which graph to use based on user input + if (true) { + configuration = graphArea(serverCustomizationSettings.graph_settings, players, playersTrend, labels, serverCustomizationSettings.bot_settings.refresh_interval); + } + return chartJSNodeCanvas.renderToBuffer(configuration) + .then((buffer) => { + const outputPath = join(process.cwd(), `./images/${guildID}.${serverUUID}.png`); + return new Promise((resolve, reject) => { + writeFile(outputPath, buffer, (err) => { + if (err) { + logger.error(`Error writing graph to file: ${err}`); + reject(err); + } else { + logger.debug(`Graph written to file`); + resolve(outputPath); + } + }); }); - } else { - labels = Array.from({ length: 24 * 60 * 60 / server_data.bot_settings.refresh_interval }, (v, i) => { - let totalSeconds = ((i * server_data.bot_settings.refresh_interval) + currentTimeInSeconds) % (24 * 60 * 60); - let hour = Math.floor(totalSeconds / 3600) % 24; - let minute = Math.floor((totalSeconds / 60) % 60); - return (minute % 60 === 0) ? `${hour}:00` : ''; - }); - } - - const graphSettings = server_data.graph_settings; - let configuration = {}; - - // Once the graph types are implemented, i will change this so it will check which graph to use based on user input - // paddedPlayers is the player data after it has been padded with zero's - // I need to add the functionality to pad the player data. - if (true) { - configuration = graph_area(graphSettings, players, players_trend, labels, server_data.bot_settings.refresh_interval) - } - - return chartJSNodeCanvas.renderToBuffer(configuration) - .then((buffer) => { - let outputPath = path.join(process.cwd(), `./images/${guild_id}.${server_uuid}.png`); - return new Promise((resolve, reject) => { - fs.writeFile(outputPath, buffer, (err) => { - if (err) reject(err); - else resolve(outputPath); - }); - }); - }) - .catch(console.error); + }) + .catch((error) => { + logger.error(`Error creating graph: ${error}`); + }); } -module.exports = createGraph; \ No newline at end of file + +module.exports = createGraph; diff --git a/discord/index.js b/discord/index.js index acd87bb..58b2548 100644 --- a/discord/index.js +++ b/discord/index.js @@ -1,38 +1,26 @@ -const { ShardingManager } = require('discord.js'); -const path = require('path'); -const { getGuilds } = require('./api'); -require('dotenv').config({ path: '../.env' }); - -process.on('unhandledRejection', (error) => { - console.error('Unhandled promise rejection:', error); -}); - -process.on('error', (error) => { - console.error('Unhandled error:', error); -}); - -async function setup() { - - // Guild threshold before sharding - const guildThreshold = 2500; - - let guilds = await getGuilds(); - let numGuilds = Object.keys(guilds.data).length; - - // Calculate the number of shards we need - let numShards = Math.ceil(numGuilds / guildThreshold); - - // Start the sharding manager - const manager = new ShardingManager(path.join(__dirname, 'bot.js'), { - token: process.env.BOT_TOKEN, - totalShards: numShards +const {ShardingManager} = require('discord.js'); +const {getGuildCount} = require('./bot'); + +const SHARDING_THRESHOLD = 2500; + +/** + * Checks if the bot is more than 2500 guilds, and will start the bot with sharding or not + */ +async function startBot() { + const guildCount = await getGuildCount(); + if (guildCount < SHARDING_THRESHOLD) { + // Run without sharding + require('./bot'); + } else { + // Run with sharding + const manager = new ShardingManager('./bot.js', { + totalShards: 'auto', + }); + manager.on('shardCreate', (shard) => { + console.log(`Launched shard ${shard.id}`); }); - - // Spawn shards manager.spawn(); - - // Log when a shard is created - manager.on('shardCreate', shard => console.log(`Shard ${shard.id} created`)); + } } -setup(); \ No newline at end of file +startBot(); diff --git a/discord/query/index.js b/discord/query/index.js index dfac373..f32c24e 100644 --- a/discord/query/index.js +++ b/discord/query/index.js @@ -1,52 +1,49 @@ const Gamedig = require('gamedig'); const packageData = require('./packageData'); +const logger = require('../utils/logger'); -async function queryServer(ip, query_port, query_protocol, guild_id, server_uuid) { - //console.log(`\n-- Query --`) - //console.log(`Guild ID: ${guild_id}`) - //console.log(`Server UUID: ${server_uuid}`) - //console.log(`IP: ${ip}`) - //console.log(`Query Port: ${query_port}`) - //console.log(`Query Protocol: ${query_protocol}`) - - console.log('queryServer') - - console.log('Attempting to query server') - const maxRetries = 5; - let retries = 0; - let state; - while (retries < maxRetries) { - console.log('trying to query server. Retries: ', retries) - try { - console.log(`Trying to Query Game Server: ${server_uuid}`) - state = await Gamedig.query({ - type: query_protocol, - host: ip, - port: query_port - }); - console.log(`Finished Querying Game Server: ${server_uuid}`) - break; - } catch (error) { - console.log(`Failed to Query Game Server: ${server_uuid}`) - //console.error('Error querying server:', error); - retries++; - if (retries < maxRetries) { - console.log(`Retrying query (${retries}/${maxRetries})...`); - } - } - } - - console.log('Finished querying game server!') - - if (!state) { - console.log('Server query failed after maximum retries. Treating server as offline.'); - state = undefined; +/** + * + * @param {*} ip + * @param {*} queryPort + * @param {*} queryProtocol + * @param {*} guildID + * @param {*} serverUUID + * @return {object} + */ +async function serverQuery(ip, queryPort, queryProtocol, guildID, serverUUID) { + logger.debug('Starting server query...'); + const maxRetries = 5; + let retries = 0; + let state; + while (retries < maxRetries) { + logger.debug(`Attempting to query server. Attempt: ${retries + 1}`); + try { + logger.debug(`Querying game server with UUID: ${serverUUID}`); + state = await Gamedig.query({ + type: queryProtocol, + host: ip, + port: queryPort, + }); + logger.debug(`Successfully queried game server with UUID: ${serverUUID}`); + break; + } catch (error) { + logger.warn(`Failed to query game server with UUID: ${serverUUID}. Error: ${error}`); + retries++; + if (retries < maxRetries) { + logger.warn(`Retrying query (${retries}/${maxRetries})...`); + } } - - console.log('Sending State to packageData') - const packagedData = await packageData(state, guild_id, server_uuid); - console.log('Sending packagedData to handleServer') - return packagedData; + } + logger.debug('Finished querying game server.'); + if (!state) { + logger.warn('Server query failed after maximum retries. Treating server as offline.'); + state = undefined; + } + logger.debug('Sending info to packageData!'); + const packagedData = await packageData(state, guildID, serverUUID); + logger.debug('Data packaged successfully. Sending data to handleServer.'); + return packagedData; } -module.exports = queryServer; \ No newline at end of file +module.exports = serverQuery; diff --git a/discord/query/packageData.js b/discord/query/packageData.js index 1f6b1c8..8858ce1 100644 --- a/discord/query/packageData.js +++ b/discord/query/packageData.js @@ -1,92 +1,79 @@ -const { writeServerInfo, getServerInfo } = require('../api/index'); +const {writeServerInfo, getServerInfo} = require('../api/index'); +const logger = require('../utils/logger'); -async function packageData(state, guild_id, server_uuid) { - //console.log(`\n-- Packaging Data --`) - //console.log(`Guild ID: ${guild_id}`) - //console.log(`Server UUID: ${server_uuid}`) - //console.log(`State: `, state) - - console.log('packageData') - - let server_info = {}; - let info = {}; - let did_restart = ''; - let map = ''; - - console.log('Getting server info') - await getServerInfo(guild_id, server_uuid) - .then(response => { - info = response.data; - console.log('Got server info') - }) - .catch(error => { - if (error.response && error.response.status === 404) { - console.log('Server info not found, initializing new server info'); - info = { - map: null, - players: [], - ping: [], - status: null, - last_restart: null, - message_id: null, - }; - } else { - console.error('Error getting server info:', error); - throw error; - } - }); - - //console.log(`API Server Info: `, info) - console.log('Setting Server Info details') - - if (!info.status && state) { - did_restart = new Date().toISOString(); - } else { - did_restart = info.last_restart; - } - if (!state) { // server is offline - if (info.map) { - map = info.map; - } else { - map = 'Unavailable'; - } - } else { // server is online - map = state.map; +/** + * + * @param {*} guildID + * @param {*} serverUUID + * @return {object} + */ +async function fetchServerInfo(guildID, serverUUID) { + try { + const response = await getServerInfo(guildID, serverUUID); + return response.data; + } catch (error) { + if (error.response && error.response.status === 404) { + logger.warn('Server info not found, initializing new server info.'); + return { + map: null, + players: [], + ping: [], + status: null, + last_restart: null, + }; } + logger.error(`Error getting server info: ${error}`); + throw error; + } +} - if (!state) { - server_info = { - name: null, - map: map, - active_players: -1, - max_players: -1, - players: null, - ping: -1, - status: false, - last_restart: 'Offline', - message_id: info.message_id ? info.message_id : null, - }; - } else { - server_info = { - name: state.name, - map: map, - active_players: state.players.length, - max_players: state.maxplayers, - players: state.players.map(player => player), - ping: state.ping, - status: true, - last_restart: did_restart, - message_id: info.message_id ? info.message_id : null, - }; - } +/** + * + * @param {*} serverInfo + * @param {*} state + * @return {object} + */ +function buildQueryState(serverInfo, state) { + const didRestart = !serverInfo.status && state ? new Date().toISOString() : serverInfo.last_restart; + const map = (!state ? serverInfo.map || 'Unavailable' : state.map); + + return state ? { + name: state.name, + map: map, + active_players: state.players.length, + max_players: state.maxplayers, + players: state.players.map((player) => player), + ping: state.ping, + status: true, + last_restart: didRestart, + } : { + name: null, + map: map, + active_players: -1, + max_players: -1, + players: null, + ping: -1, + status: false, + last_restart: 'Offline', + }; +} - //console.log(`Generated Server Info: `, server_info) - console.log('Writing Server Info to Server Info JSON...') - await writeServerInfo(guild_id, server_uuid, server_info); - console.log('Finished writing Server Info to Server Info JSON!') - - console.log('Sending back server_info to queryServer') - return { server_info }; +/** + * + * @param {*} state + * @param {*} guildID + * @param {*} serverUUID + * @return {object} + */ +async function packageData(state, guildID, serverUUID) { + logger.debug('Packaging data...'); + const serverInfo = await fetchServerInfo(guildID, serverUUID); + const queryState = buildQueryState(serverInfo, state); + logger.debug('Writing server info to server info JSON...'); + await writeServerInfo(guildID, serverUUID, queryState); + logger.debug('Finished writing server info to server info JSON.'); + logger.debug('Packaged data successfully. Sending data back to queryServer.'); + return queryState; } -module.exports = packageData; \ No newline at end of file +module.exports = packageData; diff --git a/discord/utils/logger.js b/discord/utils/logger.js new file mode 100644 index 0000000..059cc6e --- /dev/null +++ b/discord/utils/logger.js @@ -0,0 +1,118 @@ +require('dotenv').config({path: '../.env'}); +const fs = require('fs'); +const path = require('path'); +const schedule = require('node-schedule'); +const chalk = require('chalk'); + +const logDir = path.join(`${__dirname}/../`, 'logs'); +const logFile = path.join(logDir, '_latest.log'); + +// Ensure logs directory exists +if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir); +} + +// Ensure _latest.txt exists +if (!fs.existsSync(logFile)) { + fs.writeFileSync(logFile, ''); +} + +// Debug log function +/** + * Debug log function + * @param {...any} messages + */ +function debug(...messages) { + writeLog('DEBUG', messages, chalk.green); +} + +/** + * Info log function + * @param {...any} messages + */ +function info(...messages) { + writeLog('INFO ', messages, chalk.white); +} + +/** + * Warning log function + * @param {...any} messages + */ +function warn(...messages) { + writeLog('WARN ', messages, chalk.yellow); +} + +/** + * Error log function + * @param {...any} messages + */ +function error(...messages) { + writeLog('ERROR', messages, chalk.red); +} + +/** + * Fatal log function + * @param {...any} messages + */ +function fatal(...messages) { + writeLog('FATAL', messages, chalk.magenta); +} + +// Function to write a log message with a given type +/** + * Function to write a log message with a given type + * @param {*} type + * @param {*} messages + * @param {*} color + */ +function writeLog(type, messages, color) { + const date = new Date(); + const timestamp = chalk.cyan(`[ ${date.toISOString()} ]`); + const coloredType = color(`[ ${type} ]`); + const logMessage = messages.map((message) => { + if (typeof message === 'object') { + const cache = new Set(); + return JSON.stringify(message, (key, value) => { + if (typeof value === 'object' && value !== null) { + if (cache.has(value)) { + return; + } + // Store value in our collection + cache.add(value); + } + return value; + }, 2); + } else { + return message; + } + }).join(' '); + if (type != 'DEBUG') { + const consoleLogMessage = `${timestamp} | ${coloredType} | ${chalk.white(logMessage)}`; + console.log(consoleLogMessage); + } else if (process.env.USE_DEBUG === 'true' && type === 'DEBUG') { + const consoleLogMessage = `${timestamp} | ${coloredType} | ${chalk.white(logMessage)}`; + console.log(consoleLogMessage); + } + fs.appendFileSync(logFile, `[ ${date.toISOString()} ] | [ ${type} ] | ${logMessage}\n`); +} + +/** + * Schedule job to rotate logs + */ +schedule.scheduleJob('0 0 * * *', function() { + const date = new Date(); + const filename = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}.txt`; + const newFilePath = path.join(logDir, filename); + // Rename _latest.txt to the new file + fs.renameSync(logFile, newFilePath); + // Create a new _latest.txt for the next day's logs + fs.writeFileSync(logFile, ''); +}); + +module.exports = { + debug, + info, + warn, + error, + fatal, +};