From 12df9b4acf9a2e7e039a74259cd94eac6d6f1875 Mon Sep 17 00:00:00 2001 From: hirzidevs Date: Mon, 29 Jul 2024 19:48:25 +0700 Subject: [PATCH] Recode PteroStats and Support Pelican Panel --- .gitignore | 4 + Indo.md | 129 ------------------ README.md | 53 ++++---- config.yml | 120 +++++++++-------- events/ready.js | 29 ----- handlers/checkStatus.js | 144 -------------------- handlers/config.js | 24 ++++ handlers/convertUnits.js | 20 +++ handlers/getNodeConfiguration.js | 13 ++ handlers/getNodesDetails.js | 44 +++++++ handlers/getServers.js | 15 +++ handlers/getStats.js | 50 +++++++ handlers/getUsers.js | 15 +++ handlers/getWingsStatus.js | 12 ++ handlers/postStatus.js | 176 ------------------------- handlers/promiseTimeout.js | 9 ++ index.js | 217 +++++++++++++++++++++++++------ package.json | 44 +++---- 18 files changed, 493 insertions(+), 625 deletions(-) create mode 100644 .gitignore delete mode 100644 Indo.md delete mode 100644 events/ready.js delete mode 100644 handlers/checkStatus.js create mode 100644 handlers/config.js create mode 100644 handlers/convertUnits.js create mode 100644 handlers/getNodeConfiguration.js create mode 100644 handlers/getNodesDetails.js create mode 100644 handlers/getServers.js create mode 100644 handlers/getStats.js create mode 100644 handlers/getUsers.js create mode 100644 handlers/getWingsStatus.js delete mode 100644 handlers/postStatus.js create mode 100644 handlers/promiseTimeout.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ecd64c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +config-dev.yml +package-lock.json +cache.json \ No newline at end of file diff --git a/Indo.md b/Indo.md deleted file mode 100644 index 3436700..0000000 --- a/Indo.md +++ /dev/null @@ -1,129 +0,0 @@ -
- -PteroStats Banner - -## Bahasa / Language -[[Indonesia]](https://github.com/HirziDevs/PteroStats/blob/dev/Indo.md) | [[Inggris]](https://github.com/HirziDevs/PteroStats/blob/dev/README.md) - -
- -## Pengenalan -PteroStats adalah bot yang dirancang untuk memeriksa status panel pterodactyl dan dikirim ke server discord - -## Contoh -- Test Panel - - Example - -- [Calvs Cloud](https://discord.gg/ssCQjhrBJN) - - Calvs Cloud - -## Instalasi - 1. [Mendapatkan apikey dari pterodactyl](#mendapatkan-apikey-dari-pterodactyl) - 2. [Membuat Discord Bot](#membuat-discord-bot) - 3. [Menginvite Discord Bot](#menginvite-discord-bot) - 4. [Mendapatkan Channel ID](#mendapatkan-channel-id) - 5. [Memulai Bot](#memulai-bot) - - - [Mengunakan custom emoji](#mengunakan-custom-emoji) - - [Blacklist Nodes](#blacklist-nodes) - -### Mendapatkan apikey dari pterodactyl -1. Pergi ke `panel admin pterodactyl` dan pergi ke `Application API` - - Admin Panel - -2. Klik tombol `Create New` - - Application API Page - -3. Set semua permission ke `read` dan untuk description kamu bisa mengisi apa saja - - Create Application API - -4. Copy apikey-nya. - - Application API List - -5. Paste panel apikeynya dan panel urlnya di config - - Panel Config - -### Membuat Discord Bot -Kalian bisa cek [website ini](https://discordjs.guide/preparations/setting-up-a-bot-application.html) - -Paste bot tokennya di config - -Bot Config - -### Menginvite Discord Bot -Kalian bisa cek [website ini](https://discordjs.guide/preparations/adding-your-bot-to-servers.html) - -### Mendapatkan Channel ID -1. Aktifkan `Developer Mode` di settings discord kamu - - Discord User Settings - -2. Klik kanan teks channel dan pilih `Copy ID` - - Right Click Channel - -3. Paste id channelnya di config - - Channel Config - -### Memulai Bot -1. Pastikan kamu telah melakukan semua yang ada diatas -2. Jalankan command `npm install` di folder yang berisi file bot -3. Jalankan command `node index` dan kamu selesai! - -Jika kamu mendapat masalah bisa dm `Hirzi#8701` didiscord atau join [server support kami](https://discord.gg/zv6maQRah3) - -### Mengunakan custom emoji -1. ketik `\` di server yang ada custom emojinya - - Type \ on the chat - -2. Pilih custom emoji yang kamu mau - - Select Custom Emoji - -3. Copy textnya! - - Copy Emoji ID - -4. Paste id emojinya di config - - Status Config - -### Blacklist Nodes -1. Pilih node yang ada di node list admin page - - Nodes List - -2. Cek urlnya dan copy id nodenya - - Node Id - -3. Masukan ke blacklist di config - - Blacklist Config - -Kamu bisa memasukan lebih dari 1 node untuk di blacklist - -Blacklist Config - -## Permission apikey - -Jika kamu mengalami error 403 coba aktifkan `read & write` di semua opsi permission - -Application API Permission - -## Links - -- [Pterodactyl Panel](https://pterodactyl.io) -- [Pterodactyl Api Documentation](https://dashflo/docs/api/pterodactyl/v1) -- [Pterodactyl Discord Server](https://discord.gg/pterodactyl) -- [PteroBot Support Server](https://discord.gg/zv6maQRah3) -- [PteroBot Support Server (Indonesia)](https://discord.gg/EYaFB7WSg6) \ No newline at end of file diff --git a/README.md b/README.md index e8a4391..5b11d67 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,20 @@
-PteroStats Banner +PteroStats Banner -## Language / Bahasa -[[English]](https://github.com/HirziDevs/PteroStats/blob/dev/README.md) | [[Indonesia]](https://github.com/HirziDevs/PteroStats/blob/dev/Indo.md)
- +### This is a development build! some features may not work. ## Introduction PteroStats is a bot designed to check Pterodactyl Panel and Nodes status and post it to your discord server ## Example - Test Panel - Example - -- [Calvs Cloud](https://discord.gg/ssCQjhrBJN) - - Calvs Cloud + Example ## Installation -1. [Getting apikey from pterodactyl](#getting-apikey-from-pterodactyl) +1. [Getting API key from pterodactyl](#getting-apikey-from-pterodactyl) 2. [Creating Discord Bot](#creating-discord-bot) 3. [Inviting Discord Bot](#inviting-discord-bot) 4. [Getting Channel ID](#getting-channel-id) @@ -29,24 +23,28 @@ PteroStats is a bot designed to check Pterodactyl Panel and Nodes status and pos - [Using Custom Emoji](#using-custom-emoji) - [Blacklist Nodes](#blacklist-nodes) -### Getting apikey from pterodactyl -1. Go to your `pterodactyl admin page` and go to `Application API`. +### Getting API key from pterodactyl - Admin Panel +> [!WARNING] +> The use of Application API keys are **deprecated**, you should use **Client API keys** in the config file -2. Click on the `Create New` button +1. Go to your `Pterodactyl Panel` and go to `Account Page`. - Application API Page + Home -3. Set all options permission to `read` and for description you can put whatever you want +2. Click on the `API Credentials` button - Create Application API + Account Page -4. Copy the apikey. +3. Fill the `Description` and click on the `Create` button - Application API List + Create Client API Key -5. Paste the panel apikey and panel url at the config +4. Copy the API key. + + API Key + +5. Paste the panel API key and panel url at the config Panel Config @@ -78,10 +76,10 @@ Please refer to [this website](https://discordjs.guide/preparations/adding-your- 2. Run `npm install` in the root directory of the bot files. 3. Run `node index` and you are done. -if you need help contact me on discord `Hirzi#8701` or join [our discord support server](https://discord.gg/zv6maQRah3) +if you need help contact me on discord `@hirzidevs` or join [our discord support server](https://discord.gg/zv6maQRah3) ### Using Custom Emoji -1. type `\` in guild that has custom emoji you want +1. type `\` in server that has custom emoji you want Type \ on the chat @@ -114,16 +112,15 @@ You can add more than one node in the blacklist Blacklist Config -## Apikey permission +## The node is online but the embed is read as offline -If you having issue with 403 error try to enable `read & write` on all options - -Application API Permission +If you having this issue, you can enable `log_error` on the config file and report it to our discord server at [Support Server](https://discord.gg/zv6maQRah3) ## Links +- [PteroStats DiscordJS v13](https://github.com/HirziDevs/PteroStats/tree/3d0512c3323ecf079101104c7ecf3c94d265e298) +- [PteroStats DiscordJS v12](https://github.com/HirziDevs/PteroStats/tree/bcfa266be64dda11955f0bf9732da086bcea522c) - [Pterodactyl Panel](https://pterodactyl.io) -- [Pterodactyl Api Documentation](https://dashflo/docs/api/pterodactyl/v1) +- [Pterodactyl API Documentation](https://github.com/devnote-dev/ptero-notes/) - [Pterodactyl Discord Server](https://discord.gg/pterodactyl) - [PteroBot Support Server](https://discord.gg/zv6maQRah3) -- [PteroBot Support Server (Indonesia)](https://discord.gg/EYaFB7WSg6) \ No newline at end of file diff --git a/config.yml b/config.yml index fe1759b..b3e07dd 100644 --- a/config.yml +++ b/config.yml @@ -2,75 +2,85 @@ # If you need help, join our discord server here: https://discord.gg/zv6maQRah3 # Bot Configuration -token: 'Put bot token here' -bot_status: - enable: false - text: 'Hosting Panel' - type: 'WATCHING' # can be 'WATCHING', 'PLAYING', 'LISTENING', or 'COMPETING'. for 'STREAMING' is not working for now +token: "Put Bot Token Here" +presence: + text: "Hosting Panel" + type: "watching" # can be 'watching', 'playing', 'listening', or 'competing'. 'streaming' is not working for now + status: "online" # can be 'online', 'idle', 'dnd', or 'invisible' # Discord Channel and Refresh Time Configuration -channel: 'Put channel id here' -refresh: 60 +channel: "Put Channel ID Here" +refresh: 10 # How much time the bot will refresh the stats +timeout: 5 # How much time to wait for a node to respond to the bot (if you change this, it will add more time to refresh the stats) # Panel Configuration panel: - url: 'Put panel url here' - key: 'Put panel apikey here' + url: "Put Panel URL Here" + key: "Put Panel APIKEY Here" -# Message Configuration -# set to false if you want to disable option +# Message and Embed Configuration +# set the option as '' if you want to disable it message: - content: false - attachment: false # If you enable attachment on message it upload the attachment first before sending or editing message and it will delay the stats + content: "" + attachment: "" # If you enable attachment on message it will upload the attachment first before sending or editing message and will result in delayed stats embed: - title: 'PteroStats' - color: '5865F2' - description: 'Next update {{time}}' # You can use {{time}} to make "in X seconds" time format - footer: 'By Hirzi#8701' - timestamp: true - thumbnail: false - image: false - field: - enable: false - title: 'Custom Field' - description: 'Custom Field' # You can use {{time}} to make "in X seconds" time format + title_nodes: "Nodes Stats" + title_panel: "Panel Stats" + author: "PteroStats" + color: "5865F2" + description: "Next update {{time}}" # You can use {{time}} to make "in X seconds" time format + footer: "By Hirzi#8701" + timestamp: true + thumbnail: "" + image: "" # Message Button Configuration button: - enable: true - btn1: - label: 'PteroStats' - url: 'https://github.com/HirziDevs/PteroStats' - btn2: - label: '' - url: '' - btn3: - label: '' - url: '' - btn4: - label: '' - url: '' - btn5: - label: '' - url: '' + enable: true + btn1: + label: "PteroStats" + url: "https://github.com/HirziDevs/PteroStats" + btn2: + label: "" + url: "" + btn3: + label: "" + url: "" + btn4: + label: "" + url: "" + btn5: + label: "" + url: "" # Status Message Configuration +# How to use custom emoji: https://github.com/HirziDevs/PteroStats#using-custom-emoji status: - online: ':green_circle: Online' - offline: ':red_circle: Offline' + online: ":green_circle: Online" + offline: ":red_circle: Offline" -# Nodes Resource -nodes_resource: - blacklist: [] # You can add node id to remove the node from status embed (Example: "blacklist: [1]") - enable: false - servers: true - location: true - allocations: true - unit: 'gb' # You can use "gb", "mb", "tb", or "percent" +# Nodes Settings +# How to get nodes id: https://github.com/HirziDevs/PteroStats#blacklist-nodes +nodes_settings: + blacklist: [] # You can add node id to remove the node from status embed (Example: "blacklist: [1]") + details: true # enable nodes details i.e memory and disk usage + servers: true + location: true + unit: "mb" # Allowed values- "gb", "mb", "tb", or "%" + limit: 100 -# Panel Users and Servers -panel_resource: - enable: true - servers: true - users: true \ No newline at end of file +# Panel Users and Servers Settings +panel_settings: + status: true # enable panel stats under nodes stats + servers: true + users: true + +# Mentions a User or Role if any node is offline (this feature is still in testing, please report if you have a problem) +mentions: # to enable atleast put 1 ID on user or role bellow + user: [] # Put User ID here (Example: "user: ['548867757517570058', '816219634390663230']") + role: [] # Put Role ID here (Example: "role: ['796083838236622858', '858198863973187585']") + channel: "" # Put Channel ID here for the logging + +# Log error to console if a server offline (enable this when you have a problem that you want to report) +log_error: false # set to "true" to enable \ No newline at end of file diff --git a/events/ready.js b/events/ready.js deleted file mode 100644 index 0927b0e..0000000 --- a/events/ready.js +++ /dev/null @@ -1,29 +0,0 @@ -const chalk = require('chalk') -const checkStatus = require('../handlers/checkStatus') - -module.exports = { - name: 'ready', - once: true, - execute(client) { - console.log(chalk.cyan('[PteroStats] ') + chalk.green('Bot is up!')) - console.log(chalk.cyan('[PteroStats] ') + chalk.green('If you need support you can join our discord server https://discord.gg/zv6maQRah3')) - - if (client.guilds.cache.size < 1) return console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! This bot is not on any discord servers')) - - if (client.config.bot_status.enable && client.config.bot_status.text.length > 0) { - if (!['PLAYING', 'WATCHING', 'LISTENING', 'COMPETING'].includes(client.config.bot_status.type.toUpperCase() || client.config.bot_status.type.length < 1)) { - console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! Invalid Status Type!, Can be "WATCHING", "PLAYING", "LISTENING", or "COMPETING"')) - } else { - client.user.setActivity(client.config.bot_status.text, { type: client.config.bot_status.type.toUpperCase() }) - } - } - - if (client.config.refresh < 10) console.log('Refresh lower than 10 seconds is not recommended!') - - checkStatus(client) - - setInterval(() => { - checkStatus(client) - }, client.config.refresh * 1000) - } -} \ No newline at end of file diff --git a/handlers/checkStatus.js b/handlers/checkStatus.js deleted file mode 100644 index 2bab4e7..0000000 --- a/handlers/checkStatus.js +++ /dev/null @@ -1,144 +0,0 @@ -const axios = require('axios') -const chalk = require('chalk') - -const postStatus = require('./postStatus') - -module.exports = function checkStatus(client) { - - if (client.config.channel.startsWith('Put')) { - console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! Invalid Channel ID')) - process.exit() - } else if (client.config.panel.url.startsWith('Put')) { - console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! Invalid Panel URL')) - process.exit() - } else if (client.config.panel.key.startsWith('Put')) { - console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! Invalid Apikey')) - process.exit() - } else if (!client.config.panel.url.startsWith('http')) { - console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! Invalid Panel URL')) - console.log(chalk.cyan('[PteroStats] ') + chalk.red('1. Make sure the panel url is starts with "https://" or "http://"')) - process.exit() - } - - if (client.config.panel.url.endsWith('/')) client.config.panel.url = client.config.panel.url.slice(0, -1) - - const nodes = [] - - const panel = { - status: false, - total_servers: -1, - total_users: -1, - } - - console.log(chalk.cyan('[PteroStats] ') + chalk.green('Getting nodes stats')) - - const panelStats = new Promise((resolve, reject) => { - axios(client.config.panel.url + '/api/application/users', { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + client.config.panel.key - } - }).then((usr) => { - axios(client.config.panel.url + '/api/application/servers', { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + client.config.panel.key - } - }).then((ser) => { - panel.total_users = usr.data.meta.pagination.total - panel.total_servers = ser.data.meta.pagination.total - panel.status = true - - resolve() - }) - }).catch((err) => { - if (err.response) { - if (err.response.status === 403) { - console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! Invalid apikey')) - console.log(chalk.cyan('[PteroStats] ') + chalk.red('1. Make sure the apikey is from admin page not account page')) - console.log(chalk.cyan('[PteroStats] ') + chalk.red('2. Make sure the apikey has read permission on all options')) - console.log(chalk.cyan('[PteroStats] ') + chalk.red('3. Make sure the apikey is exist')) - } else if (err.response.status === 404) { - console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! Invalid Panel URL')) - console.log(chalk.cyan('[PteroStats] ') + chalk.red('1. Make sure the panel url is like "https://panel.example.com"')) - } else console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! ' + err)) - } else console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! ' + err)) - resolve() - }) - }) - - const nodeStats = new Promise((resolve, reject) => { - axios(client.config.panel.url + '/api/application/nodes?include=servers,location,allocations', { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + client.config.panel.key - } - }).then((res) => { - res.data.data.forEach((node, i) => { - axios(client.config.panel.url + '/api/application/nodes/' + node.attributes.id + '/configuration', { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + client.config.panel.key - } - }).then((data) => { - const body = { - id: node.attributes.id, - name: node.attributes.name, - location: node.attributes.relationships.location.attributes.short, - allocations: node.attributes.relationships.allocations.data.length, - status: true, - maintenance: node.attributes.maintenance_mode, - total_servers: node.attributes.relationships.servers.data.length, - memory_min: node.attributes.allocated_resources.memory, - memory_max: node.attributes.memory, - disk_min: node.attributes.allocated_resources.disk, - disk_max: node.attributes.disk, - } - - const stats = new Promise((statsResolve, statsReject) => { - axios(node.attributes.scheme + '://' + node.attributes.fqdn + ':' + node.attributes.daemon_listen + '/api/servers', { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: 'Bearer ' + data.data.token - } - }).then((status) => { - return statsResolve() - }).catch((err) => { - body.status = false - return statsResolve() - }) - setTimeout(() => { - body.status = false - return statsResolve() - }, 1000) - }) - stats.then(() => { - nodes.push(body) - if (nodes.length === res.data.data.length) resolve() - }) - }).catch((err) => { - resolve() - }) - }) - }).catch((err) => { - resolve() - }) - }) - - panelStats.then(() => { - nodeStats.then(() => { - nodes.sort(function (a, b) { return a.id - b.id }) - postStatus(client, panel, nodes) - }) - }) -} \ No newline at end of file diff --git a/handlers/config.js b/handlers/config.js new file mode 100644 index 0000000..661329a --- /dev/null +++ b/handlers/config.js @@ -0,0 +1,24 @@ + +const fs = require("node:fs"); +const yaml = require("js-yaml"); +const cliColor = require("cli-color"); + +let config = yaml.load(fs.readFileSync("./config.yml", "utf8")); +if (fs.existsSync("config-dev.yml")) { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.yellow("Using development configuration...")) + config = yaml.load(fs.readFileSync("./config-dev.yml", "utf8")); +} + +validateURL(config.panel.url); + +function validateURL(url) { + try { + const testURL = new URL(url); + if (!testURL.protocol.startsWith("http")) throw new Error(); + } catch { + console.error('Config Error | Invalid URL Format! Example Correct URL: "https://panel.example.com"'); + process.exit(); + } +} + +module.exports = config \ No newline at end of file diff --git a/handlers/convertUnits.js b/handlers/convertUnits.js new file mode 100644 index 0000000..287f257 --- /dev/null +++ b/handlers/convertUnits.js @@ -0,0 +1,20 @@ +module.exports = function convertUnits(value1, value2, unit) { + unit = unit.toUpperCase(); + const units = ['MB', 'GB', 'TB']; + const conversionFactors = { + 'MB': 1, + 'GB': 1 / 1024, + 'TB': 1 / (1024 * 1024) + }; + + if (unit === '%' || unit === "PERCENTAGE" || unit === "PERCENT") { + const percentage = Math.floor((value1 / value2) * 100); + return `${!percentage ? 0 : percentage}%`; + } else if (units.includes(unit)) { + const formatValue = (value) => Number.isInteger(value) ? value.toLocaleString() : value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + return `${formatValue(value1 * conversionFactors[unit])} ${unit}/${(value2 === 0 || value2 < 1) ? "Unlimited" : formatValue(value2 * conversionFactors[unit]) + " " + unit}`; + } else { + return `${value1.toLocaleString()} MB/${value2.toLocaleString()} MB`; + } +} \ No newline at end of file diff --git a/handlers/getNodeConfiguration.js b/handlers/getNodeConfiguration.js new file mode 100644 index 0000000..00f2cdc --- /dev/null +++ b/handlers/getNodeConfiguration.js @@ -0,0 +1,13 @@ +const config = require("./config.js"); + +module.exports = async function getNodeConfiguration(id) { + return fetch(`${new URL(config.panel.url).origin}/api/application/nodes/${id}/configuration`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${config.panel.key}` + }, + }) + .then((res) => res.json()) + .then((data) => data) +} \ No newline at end of file diff --git a/handlers/getNodesDetails.js b/handlers/getNodesDetails.js new file mode 100644 index 0000000..b7ad731 --- /dev/null +++ b/handlers/getNodesDetails.js @@ -0,0 +1,44 @@ +const cliColor = require("cli-color"); +const config = require("./config.js"); +const axios = require("axios"); + +module.exports = async function getAllNodes() { + return axios(`${new URL(config.panel.url).origin}/api/application/nodes?include=servers,location,allocations`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${config.panel.key}` + }, + }) + .then((res) => res.data.data) + .catch((error) => { + if (error.code === "ENOTFOUND") { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("ENOTFOUND | DNS Error. Ensure your network connection and DNS server are functioning correctly.")); + } else if (error.code === "ECONNREFUSED") { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("ECONNREFUSED | Connection refused. Ensure the panel is running and reachable.")); + } else if (error.code === "ETIMEDOUT") { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("ETIMEDOUT | Connection timed out. The panel took too long to respond.")); + } else if (error.code === "ECONNRESET") { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("ECONNRESET | Connection reset by peer. The panel closed the connection unexpectedly.")); + } else if (error.code === "EHOSTUNREACH") { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("EHOSTUNREACH | Host unreachable. The panel is down or not reachable.")); + } else if (error.response) { + if (error.response.status === 401) { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("401 | Unauthorized. Invalid Application Key or API Key doesn't have permission to perform this action.")); + } else if (error.response.status === 403) { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("403 | Forbidden. Invalid Application Key or API Key doesn't have permission to perform this action.")); + } else if (error.response.status === 404) { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("404 | Not Found. Invalid Panel URL or the Panel doesn't exist.")); + } else if (error.response.status === 429) { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("429 | Too Many Requests. You have sent too many requests in a given amount of time.")); + } else if ([500, 502, 503, 504].includes(error.response.status)) { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("500 | Internal Server Error. This is an error with your panel, PteroStats is not the cause.")); + } else { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright(`${error.response.status} | Unexpected error: ${error.response.statusText}`)); + } + } else { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright(`Unexpected error: ${error.message}`)); + } + return false + }) +} \ No newline at end of file diff --git a/handlers/getServers.js b/handlers/getServers.js new file mode 100644 index 0000000..18bcbc0 --- /dev/null +++ b/handlers/getServers.js @@ -0,0 +1,15 @@ +const config = require("./config.js"); +const cliColor = require("cli-color"); + +module.exports = async function getServers() { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.yellow("Getting panel servers...")) + return fetch(`${new URL(config.panel.url).origin}/api/application/servers`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${config.panel.key}` + }, + }) + .then((res) => res.json()) + .then((data) => data.data.length) +} \ No newline at end of file diff --git a/handlers/getStats.js b/handlers/getStats.js new file mode 100644 index 0000000..24e73e5 --- /dev/null +++ b/handlers/getStats.js @@ -0,0 +1,50 @@ +const config = require("./config.js"); +const fs = require("node:fs"); +const getNodesDetails = require("./getNodesDetails.js"); +const getNodeConfiguration = require("./getNodeConfiguration.js"); +const getWingsStatus = require("./getWingsStatus.js"); +const getServers = require("./getServers.js"); +const getUsers = require("./getUsers.js"); +const promiseTimeout = require("./promiseTimeout.js"); +const cliColor = require("cli-color"); + +module.exports = async function getStats() { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.yellow("Getting panel nodes...")) + const nodesStats = await getNodesDetails(); + if (!nodesStats) throw new Error("Failed to get nodes attributes"); + + const statusPromises = nodesStats.slice(0, config.nodes_settings.limit).map(async (node) => { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.yellow(`Getting "${node.attributes.name}" configuration...`)) + const nodeConfig = await getNodeConfiguration(node.attributes.id); + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.yellow(`Getting "${node.attributes.name}" wings status...`)) + const nodeStatus = await promiseTimeout(getWingsStatus(node, nodeConfig.token), config.timeout * 1000); + + if (!nodeStatus) + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright(`Node "${node.attributes.name}" is offline`)) + return { + attributes: { + name: node.attributes.name, + memory: node.attributes.memory, + disk: node.attributes.disk, + cpu: node.attributes.cpu, + allocated_resources: node.attributes.allocated_resources, + relationships: { + allocations: node.attributes.relationships.allocations.data.length, + servers: node.attributes.relationships.servers.data.length + } + }, + status: nodeStatus + }; + }); + + const data = { + servers: await getServers(), + users: await getUsers(), + nodes: await Promise.all(statusPromises), + timestamp: Date.now() + } + + fs.writeFileSync("cache.json", JSON.stringify(data, null, 2), "utf8"); + + return data +} \ No newline at end of file diff --git a/handlers/getUsers.js b/handlers/getUsers.js new file mode 100644 index 0000000..55d72be --- /dev/null +++ b/handlers/getUsers.js @@ -0,0 +1,15 @@ +const config = require("./config.js"); +const cliColor = require("cli-color"); + +module.exports = async function getUsers() { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.yellow("Getting panel users...")) + return fetch(`${new URL(config.panel.url).origin}/api/application/users`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${config.panel.key}` + }, + }) + .then((res) => res.json()) + .then((data) => data.data.length) +} \ No newline at end of file diff --git a/handlers/getWingsStatus.js b/handlers/getWingsStatus.js new file mode 100644 index 0000000..f925eaf --- /dev/null +++ b/handlers/getWingsStatus.js @@ -0,0 +1,12 @@ +module.exports = async function getWingsStatus(node, nodeToken) { + return fetch(`${node.attributes.scheme}://${node.attributes.fqdn}:${node.attributes.daemon_listen}/api/servers`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${nodeToken}` + }, + }) + .then((res) => res.json()) + .then(() => true) + .catch(() => false) +} \ No newline at end of file diff --git a/handlers/postStatus.js b/handlers/postStatus.js deleted file mode 100644 index 7c57495..0000000 --- a/handlers/postStatus.js +++ /dev/null @@ -1,176 +0,0 @@ -const { EmbedBuilder, time, ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder } = require('discord.js') -const chalk = require('chalk') - -module.exports = async function postStatus(client, panel, nodes) { - - if (!client.config.nodes_resource) client.config.nodes_resource = client.config.resource - if (!client.config.nodes_resource.blacklist) client.config.nodes_resource.blacklist = [] - if (!Array.isArray(client.config.nodes_resource.blacklist) && Number.isInteger(client.config.nodes_resource.blacklist)) client.config.nodes_resource.blacklist = [client.config.nodes_resource.blacklist] - if (!client.config.message.attachment) client.config.message.attachment = client.config.message.image - - if (client.guilds.cache.size < 1) return console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! This bot is not on any discord servers')) - - const channel = await client.channels.cache.get(client.config.channel) - - if (!channel) return console.log(chalk.cyan('[PteroStats] ') + chalk.red('Err! Invalid Channel ID')) - - let messages = await channel.messages.fetch({ limit: 10 }) - messages = messages.filter(m => m.author.id === client.user.id).last(); - if (messages && messages.embeds.length < 1) { - messages.delete() - messages = null - } - - const embed = new EmbedBuilder() - - let text = '' - let desc = '' - let blacklist = 0 - - let content = null - const files = [] - - if (client.config.message.content) content = client.config.message.content - if (client.config.message.attachment) files.push(new AttachmentBuilder(client.config.message.attachment)) - - if (client.config.embed.title) embed.setTitle(client.config.embed.title) - if (client.config.embed.description) desc = client.config.embed.description + '\n' - if (client.config.embed.color) embed.setColor(client.config.embed.color) - if (client.config.embed.footer) embed.setFooter({ text: client.config.embed.footer }) - if (client.config.embed.thumbnail) embed.setThumbnail(client.config.embed.thumbnail) - if (client.config.embed.image) embed.setImage(client.config.embed.image) - - panel.total_users = panel.total_users.toLocaleString() - panel.total_servers = panel.total_servers.toLocaleString() - - const stats = new Promise((resolve, reject) => { - if (nodes.length !== 0) { - nodes.forEach((data, i) => { - if (!client.config.nodes_resource.blacklist.includes(data.id)) { - const title = data.name + ': ' + String(data.status).replace('true', client.config.status.online).replace('false', client.config.status.offline) - let description = '```' - switch (client.config.nodes_resource.unit.toLowerCase()) { - case 'tb': - description = description + - '\nMemory : ' + Math.floor(data.memory_min / (1024 * 1000)).toLocaleString() + ' TB / ' + Math.floor(data.memory_max / (1024 * 1000)).toLocaleString() + ' TB' + - '\nDisk : ' + Math.floor(data.disk_min / (1024 * 1000)).toLocaleString() + ' TB / ' + Math.floor(data.disk_max / (1024 * 1000)).toLocaleString() + ' TB' - break; - case 'gb': - description = description + - '\nMemory : ' + Math.floor(data.memory_min / 1024).toLocaleString() + ' GB / ' + Math.floor(data.memory_max / 1024).toLocaleString() + ' GB' + - '\nDisk : ' + Math.floor(data.disk_min / 1024).toLocaleString() + ' GB / ' + Math.floor(data.disk_max / 1024).toLocaleString() + ' GB' - break; - case 'percent': - description = description + - '\nMemory : ' + Math.floor(data.memory_min / data.memory_max * 100) + ' %' + - '\nDisk : ' + Math.floor(data.disk_min / data.disk_max * 100) + ' %' - break; - default: - description = description + - '\nMemory : ' + data.memory_min.toLocaleString() + ' MB / ' + data.memory_max.toLocaleString() + ' MB' + - '\nDisk : ' + data.disk_min.toLocaleString() + ' MB / ' + data.disk_max.toLocaleString() + ' MB' - } - - if (client.config.nodes_resource.servers) description = description + '\nServers : ' + data.total_servers.toLocaleString() - if (client.config.nodes_resource.location) description = description + '\nLocation : ' + data.location - if (client.config.nodes_resource.allocations) description = description + '\nAllocations : ' + data.allocations.toLocaleString() - - description = description + '\n```' - - if (client.config.nodes_resource.enable) { - text = text + '\n**' + title.replace(':', ':**') + '\n' + description - } else { - text = text + '\n**' + title.replace(':', ':**') - } - } else { - blacklist = blacklist + 1 - if (nodes.length - client.config.nodes_resource.blacklist.length < 1) text = '\nThere is no nodes to display' - } - - if (i + 1 === nodes.length) resolve() - }) - } else if (nodes.length < 1) { - if (!messages) { - text = '\nThere is no nodes to display' - resolve() - } else { - text = messages.embeds[0].description.replaceAll(client.config.status.online, client.config.status.offline) - if (!panel.status && String(String(messages.embeds[0].fields[0].value).split('\n')[2]).split('')[String(String(messages.embeds[0].fields[0].value).split('\n')[2]).length - 1] !== '`') { - panel.total_users = String(String(messages.embeds[0].fields[0].value).split('\n')[2]).split('')[String(String(messages.embeds[0].fields[0].value).split('\n')[2]).length - 1] - panel.total_servers = String(String(messages.embeds[0].fields[0].value).split('\n')[3]).split('')[String(String(messages.embeds[0].fields[0].value).split('\n')[3]).length - 1] - } - resolve() - } - } - }) - - stats.then(async () => { - const format = await time(new Date(Date.now() + client.config.refresh * 1000), 'R') - embed.setDescription(desc.replaceAll('{{time}}', format) + '\n**Nodes Stats [' + Math.floor(nodes.length - blacklist) + ']**' + text) - const EmbedFields = [] - - if (client.config.panel_resource.enable) { - let stats = '**Status:** ' + String(panel.status).replace('true', client.config.status.online).replace('false', client.config.status.offline) + '\n\n' - - if (client.config.panel_resource.users) stats = stats + 'Users: ' + String(panel.total_users).replace('-1', '`Unknown`') + '\n' - if (client.config.panel_resource.servers) stats = stats + 'Servers: ' + String(panel.total_servers).replace('-1', '`Unknown`') - - EmbedFields.push({ name: 'Panel Stats', value: stats }) - } - - if (client.config.embed.field.enable) EmbedFields.push({ name: client.config.embed.field.title, value: client.config.embed.field.description.replaceAll('{{time}}', format) }) - if (client.config.embed.timestamp) embed.setTimestamp() - if (EmbedFields.length > 0) embed.addFields(EmbedFields) - - const row = [] - - if (client.config.button.enable) { - const button = new ActionRowBuilder - if (client.config.button.btn1.label.length !== 0 && client.config.button.btn1.url.length !== 0) { - button.addComponents( - new ButtonBuilder() - .setLabel(client.config.button.btn1.label) - .setStyle(ButtonStyle.Link) - .setURL(client.config.button.btn1.url) - ) - } - if (client.config.button.btn2.label.length !== 0 && client.config.button.btn2.url.length !== 0) { - button.addComponents( - new ButtonBuilder() - .setLabel(client.config.button.btn2.label) - .setStyle(ButtonStyle.Link) - .setURL(client.config.button.btn2.url) - ) - } - if (client.config.button.btn3.label.length !== 0 && client.config.button.btn3.url.length !== 0) { - button.addComponents( - new ButtonBuilder() - .setLabel(client.config.button.btn3.label) - .setStyle(ButtonStyle.Link) - .setURL(client.config.button.btn3.url) - ) - } - if (client.config.button.btn4.label.length !== 0 && client.config.button.btn4.url.length !== 0) { - button.addComponents( - new ButtonBuilder() - .setLabel(client.config.button.btn4.label) - .setStyle(ButtonStyle.Link) - .setURL(client.config.button.btn4.url) - ) - } - if (client.config.button.btn5.label.length !== 0 && client.config.button.btn5.url.length !== 0) { - button.addComponents( - new ButtonBuilder() - .setLabel(client.config.button.btn5.label) - .setStyle(ButtonStyle.Link) - .setURL(client.config.button.btn5.url) - ) - } - row.push(button) - } - - if (!messages) channel.send({ content: content, embeds: [embed], components: row, files: files }) - else messages.edit({ content: content, embeds: [embed], components: row, files: files }) - console.log(chalk.cyan('[PteroStats] ') + chalk.green('Stats posted!')) - }) -} \ No newline at end of file diff --git a/handlers/promiseTimeout.js b/handlers/promiseTimeout.js new file mode 100644 index 0000000..510e142 --- /dev/null +++ b/handlers/promiseTimeout.js @@ -0,0 +1,9 @@ +module.exports = function promiseTimeout(promise, ms) { + const timeout = new Promise((resolve) => { + const id = setTimeout(() => { + clearTimeout(id); + resolve(false); + }, ms); + }); + return Promise.race([promise, timeout]); +} \ No newline at end of file diff --git a/index.js b/index.js index 9098250..dc3b354 100644 --- a/index.js +++ b/index.js @@ -1,52 +1,185 @@ -const fs = require('fs'); -const child = require('child_process'); +const { Client, GatewayIntentBits, EmbedBuilder, time, ActivityType } = require("discord.js"); +const config = require("./handlers/config.js"); +const convertUnits = require("./handlers/convertUnits.js"); +const getStats = require("./handlers/getStats.js"); +const cachePath = require('node:path').join(__dirname, "./cache.json"); +const fs = require("node:fs"); +const cliColor = require("cli-color"); -if (Number(process.version.split('.')[0]) < 16) { - console.log('Invalid NodeJS Version!, Please use NodeJS 16.x or upper') - process.exit() -} -if (fs.existsSync('./node_modules')) { - const check = require('./node_modules/discord.js/package.json') - if (Number(check.version.split('.')[0]) !== 14) { - console.log('Invalid Discord.JS Version!, Please use Discord.JS 14.x') - process.exit() - } -} else { - console.log('You didn\'t install the required node packages first!') - console.log('Please wait... starting to install all required node packages using child process') - try { - child.execSync('npm i') - console.log('Install complete!, please run "node index" command again!') - process.exit() - } catch (err) { - console.log('Err! ', err) - console.log('Support Server: https://discord.gg/zv6maQRah3') - process.exit() - } -} +console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.greenBright("Starting bot...")); +console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("You are using development build! some features may not work.")); -const chalk = require('chalk'); -const yaml = require('js-yaml'); -const { Client, GatewayIntentBits } = require('discord.js'); -const client = new Client({ intents: [GatewayIntentBits.Guilds] }); +const client = new Client({ + intents: [GatewayIntentBits.Guilds] +}); -const config = yaml.load(fs.readFileSync('./config.yml', 'utf8')); -client.config = config +async function startGetStatus() { + try { + const results = await getStats(); + createMessage({ + panel: true, + nodes: results.nodes, + servers: results.servers, + users: results.users, + }); + } catch { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("Panel is offline")); -const eventFiles = fs.readdirSync('./events').filter(file => file.endsWith('.js')); + fs.readFile(cachePath, (err, data) => { + if (err) { + createMessage({ cache: false, panel: false }); + return console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("Last cache was not found!")); + } -for (const file of eventFiles) { - const event = require(`./events/${file}`); - if (event.once) { - client.once(event.name, (...args) => event.execute(...args)); - } else { - client.on(event.name, (...args) => event.execute(...args)); + try { + const results = JSON.parse(data); + createMessage({ + cache: true, + panel: false, + nodes: results.nodes, + servers: results.servers, + users: results.users, + }); + } catch { + createMessage({ cache: false, panel: false }); + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("Something went wrong with cache data...")); + } + }); } } -if (client.config.token.startsWith('Put') || client.config.token.length < 1) { - console.log(chalk.cyan('[PteroStats]') + chalk.red(' Err! Invalid Discord Bot Token')) - process.exit() +client.once("ready", () => { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.greenBright(`${client.user.tag} is online!`)); + + if (config.presence.text && config.presence.type) { + switch (config.presence.type.toLowerCase()) { + case "playing": + config.presence.type = ActivityType.Playing; + break; + case "listening": + config.presence.type = ActivityType.Listening; + break; + case "competing": + config.presence.type = ActivityType.Competing; + break; + default: + config.presence.type = ActivityType.Watching; + } + + client.user.setActivity(config.presence.text, { + type: config.presence.type, + }); + } + + if (config.presence.status) { + if (!["idle", "online", "dnd", "invisible"].includes( + config.presence.status.toLowerCase() + )) + config.presence.status = "online"; + + client.user.setStatus(config.presence.status); + } + + startGetStatus(); +}); + +async function createMessage({ cache, panel, nodes, servers, users }) { + let embed = new EmbedBuilder() + .setTitle(config.embed.title_nodes) + .setColor(config.embed.color); + + let embeds = [embed]; + + if (config.nodes_settings.details) { + nodes.forEach((node, index) => { + if (index % 25 === 0 && index !== 0) { + embed = new EmbedBuilder().setColor(config.embed.color); + if (embeds.length < 9) embeds.push(embed); + } + + embed.addFields({ + name: `${node.attributes.name} - ${node.status ? config.status.online : config.status.offline}`, + value: + "```\n" + + `Memory: ${convertUnits(node.attributes.allocated_resources.memory, node.attributes.memory, config.nodes_settings.unit)}\n` + + `Disk : ${convertUnits(node.attributes.allocated_resources.disk, node.attributes.disk, config.nodes_settings.unit)}\n` + + `CPU : ${Math.floor(node.attributes.cpu / node.attributes.allocated_resources.cpu * 100) || 0}%` + + "```" + }); + }); + } else { + embeds[0].setDescription(nodes.map(node => `**${node.attributes.name}** - ${node.status ? config.status.online : config.status.offline}`).join("\n")); + } + + let panelEmbed = new EmbedBuilder() + .setColor(config.embed.color) + .setTitle(config.embed.title_panel) + .addFields({ + name: `Panel - ${panel ? config.status.online : config.status.offline}`, + value: + "```\n" + + `Nodes : ${nodes.length}\n` + + (config.panel_settings.servers ? `Servers: ${servers || "Unknown"}\n` : "") + + (config.panel_settings.users ? `Users : ${users || "Unknown"}\n` : "") + + "```" + }); + + if (config.panel_settings.status) embeds.unshift(panelEmbed); + + embeds[0] + .setAuthor(config.embed.title ? { name: config.embed.author } : null) + .setDescription( + embeds[0].data.description ? config.embed.description.replace("{{time}}", time(new Date(Date.now() + (config.refresh * 1000) + 1000), "R")) + "\n\n" + embeds[0].data.description : config.embed.description.replace("{{time}}", time(new Date(Date.now() + (config.refresh * 1000) + 1000), "R")) + ); + + embeds[embeds.length - 1] + .setFooter(config.embed.footer ? { text: config.embed.footer } : null); + + if (config.embed.timestamp) embeds[embeds.length - 1].setTimestamp(); + + if (!cache && !panel) { + embeds[embeds.length - 1].setDescription( + embeds[embeds.length - 1].data.description ? embeds[embeds.length - 1].data.description + "\n\nThere are no nodes to display!" : "There are no nodes to display!" + ); + } + + try { + const channel = await client.channels.fetch(config.channel); + const messages = await channel.messages.fetch({ limit: 10 }); + const botMessage = messages.find(msg => msg.author.id === client.user.id); + + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.greenBright("Panel stats have been posted to the channel!")); + + setTimeout(() => startGetStatus(), config.refresh * 1000); + + if (botMessage) { + await botMessage.edit({ content: config.message.content || null, embeds }); + } else { + await channel.send({ content: config.message.content || null, embeds }); + } + } catch (error) { + DiscordErrorHandler(error); + } } -client.login(config.token); \ No newline at end of file +function DiscordErrorHandler(error) { + if (error.code === "ENOTFOUND") { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("ENOTFOUND | DNS Error. Ensure your network connection and DNS server are functioning correctly.")); + } else if (error.rawError?.code === 50001) { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("Discord Error | Your discord bot doesn't have access to see/send message/edit message in the channel!")); + } else if (error.rawError?.errors && Object?.values(error.rawError.errors)[0]._errors[0].code === "MAX_EMBED_SIZE_EXCEEDED") { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("Discord Error | Embed message limit exceeded! Please limit or decrease the nodes that need to be shown in the config!")); + } else if (error.rawError?.errors && Object?.values(error.rawError.errors)[0]._errors[0].code) { + console.log(Object.values(error.rawError.errors)[0]._errors[0].message); + } else { + console.error(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("Discord Error"), error); + } + process.exit(); +} + +try { + client.login(config.token); +} catch { + console.log(cliColor.cyanBright("[PteroStats] ") + cliColor.redBright("Discord Error | Invalid Discord Bot Token! Make sure you have the correct token in the config!")); + process.exit(); +} diff --git a/package.json b/package.json index 36fbe00..988b7ad 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,24 @@ { - "name": "pterostats", - "version": "v3.0.0", - "description": "PteroStats is a bot designed to check Pterodactyl Panel and Nodes status and post it to your discord server", - "license": "MIT", - "repository": "HirziDevs/PteroStats", - "homepage": "https://pterostats.hirzidevs.net", - "bugs": { - "email": "hirzidevs@gmail.com", - "url": "https://github.com/HirziDevs/PteroStats" - }, - "scripts": { - "start": "node index.js" - }, - "dependencies": { - "axios": "^1.1.3", - "chalk": "4.1.1", - "discord.js": "^14.6.0", - "js-yaml": "^4.1.0" - }, - "engines": { - "node": "^16.x" - } + "name": "pterostats", + "version": "v4.0.0", + "description": "PteroStats is a bot designed to check Pterodactyl Panel and Nodes status and post it to your discord server", + "license": "MIT", + "repository": "HirziDevs/PteroStats", + "homepage": "https://pterostats.hirzidevs.xyz", + "bugs": { + "email": "hirzidevs@gmail.com", + "url": "https://github.com/HirziDevs/PteroStats/issues" + }, + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "axios": "^1.7.2", + "cli-color": "^2.0.4", + "discord.js": "^14.15.3", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">=16.11.0" + } } \ No newline at end of file