From b06b8f1a0143abc71c60295b4903fd94bf27b6a0 Mon Sep 17 00:00:00 2001 From: MCHost Date: Tue, 1 Oct 2024 23:31:27 -0400 Subject: [PATCH] first commit --- .gitignore | 4 + README.md | 157 ++++++++++++++++++++++ package.json | 18 +++ public_bot.js | 352 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 531 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 public_bot.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90095d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +config.json +package-lock.json +tokens.json +node_modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..a405d7d --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +Here's a detailed `README.md` file for the provided code, which is a Discord bot for managing Minecraft servers using the MyMCLib API. This README includes installation instructions, configuration steps, usage, and a breakdown of the commands. + +### README.md + +--- + +# My-MC Link Discord Bot (BETA) + +Welcome to the **My-MC Link Discord Bot**, a Discord bot designed to manage your Minecraft servers directly through Discord commands! This bot uses the MyMCLib API to interact with your server, allowing you to perform essential tasks like starting/stopping servers, managing players, installing mods, and much more. + +## Table of Contents +- [Features](#features) +- [Installation](#installation) +- [Configuration](#configuration) +- [Commands](#commands) +- [Token Handling](#token-handling) +- [Usage](#usage) +- [Contributing](#contributing) + +## Features +- Start, stop, and restart your Minecraft server with simple commands. +- Check real-time server statistics like memory usage, CPU, and more. +- Manage player bans/unbans, send messages to players, and view online players. +- Install, uninstall, and list mods directly from Discord. +- View real-time server logs and connection details (SFTP, P2P, etc.). +- Works as a **user-installable app**! + +## Installation + +### Prerequisites +- **Node.js**: Ensure you have Node.js (v16 or later) installed on your machine. +- **npm**: Comes bundled with Node.js for installing dependencies. +- **Discord Bot Token**: You will need to create a Discord bot and get its token. [Learn how](https://discordjs.guide/preparations/setting-up-a-bot-application.html#creating-your-bot). + +### Steps + +1. **Clone the Repository** + ```bash + git clone https://git.ssh.surf/hypermc/hypermc-api-user-install-bot + cd hypermc-api-user-install-bot + ``` + +2. **Install Dependencies** + Install all necessary dependencies using `npm`: + ```bash + npm install + ``` + +3. **Configuration** + You need to create a `config.json` file for the bot's configuration. Here's an example: + ```json + { + "token": "YOUR_DISCORD_BOT_TOKEN", + "clientId": "YOUR_DISCORD_CLIENT_ID", + "endpoint": "YOUR_AUTHENTICATION_ENDPOINT", + "password": "YOUR_AUTHENTICATION_PASSWORD" + } + ``` + +4. **Run the Bot** + Once everything is configured, start the bot: + ```bash + node bot.js + ``` + +## Configuration + +The `config.json` file must contain the following: + +- **token**: The token for your Discord bot. +- **clientId**: The client ID of your bot from the Discord developer portal. +- **endpoint**: The authentication endpoint for obtaining user API tokens. +- **password**: The password required by the authentication service. + +Here is an example `config.json` file: +```json +{ + "token": "YOUR_DISCORD_BOT_TOKEN", + "clientId": "YOUR_DISCORD_CLIENT_ID", + "endpoint": "https://your-auth-endpoint.com/auth", + "password": "your-auth-password" +} +``` + +## Commands + +The bot provides the following commands to manage your Minecraft server: + +- `/server-time` – Get the current server time. +- `/server-stats` – Get server statistics, including CPU and memory usage. +- `/server-log` – Retrieve the real-time server log URL. +- `/start-server` – Start the Minecraft server. +- `/stop-server` – Stop the Minecraft server. +- `/restart-server` – Restart the Minecraft server. +- `/create-link` – Create a custom server link. +- `/create-sftp-link` – Create an SFTP link. +- `/get-connection-hash` – Retrieve the server's P2P connection hash. +- `/get-connection-hash-sftp` – Retrieve the server's SFTP connection hash. +- `/get-players` – Get a list of players currently online on the server. +- `/get-website-url` – Get the server's website URL. +- `/get-map-url` – Get the server's map URL. +- `/ban-player` – Ban a player by username. +- `/unban-player` – Unban a player by username. +- `/send-message` – Send a message to all players on the server. +- `/send-private-message` – Send a private message to a specific player. +- `/execute-console-command` – Execute a command in the server console. +- `/give-item` – Give an item to a player. +- `/install-mod` – Install a mod on the server by its ID. +- `/uninstall-mod` – Uninstall a mod from the server by its ID. +- `/get-installed-mods` – List all installed mods on the server. + +## Token Handling + +This bot automatically handles user API tokens. When a user runs any command, the bot checks if a valid token exists for that user. If not, it fetches and stores a new token via the configured authentication endpoint. + +Tokens are stored in a `tokens.json` file on the server. + +- **fetchAndSaveToken**: Automatically requests and stores a new API token if needed. +- **getToken**: Retrieves a stored token or requests a new one if none exists or if the token is invalid. + +### Token Persistence + +The tokens are stored locally in a `tokens.json` file using the `jsonfile` package: +```json +{ + "userId": "user-token" +} +``` + +The bot will load tokens from this file when needed and automatically refresh them if expired or invalid. + +## Usage + +Once the bot is up and running, invite it to your Discord server. Users can run commands like `/server-time` or `/start-server` to interact with the Minecraft server. + +Commands can be run either in public channels or privately, depending on how you've set up the bot. Some responses can also be made ephemeral (visible only to the user who executed the command). + +For example: +- Running `/server-stats` will display current server stats like CPU and memory usage. +- Running `/install-mod modid` will install a specified mod on the server. + +### Example Command Usage + +```bash +/server-stats +# Displays server statistics + +/ban-player username:exampleUser +# Bans 'exampleUser' from the Minecraft server + +/give-item username:exampleUser item:diamond amount:5 +# Gives 5 diamonds to 'exampleUser' +``` + +## Contributing + +Feel free to open an issue or submit a pull request if you'd like to contribute to this project. Any improvements or additional features are welcome! diff --git a/package.json b/package.json new file mode 100644 index 0000000..9289f77 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "dependencies": { + "discord.js": "^14.16.3", + "jsonfile": "^6.1.0", + "mymc-lib": "^1.1.0" + }, + "name": "public-bot", + "version": "1.0.0", + "type": "module", + "main": "public_bot.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" +} diff --git a/public_bot.js b/public_bot.js new file mode 100644 index 0000000..7dfc27f --- /dev/null +++ b/public_bot.js @@ -0,0 +1,352 @@ +import { Client, GatewayIntentBits, SlashCommandBuilder, REST, Routes, EmbedBuilder } from 'discord.js'; +import jsonfile from 'jsonfile'; +import MyMCLib from 'mymc-lib'; +import unirest from 'unirest'; +import { readFileSync } from 'fs'; + +// Paths to config and tokens files +const tokensFile = './tokens.json'; +const config = JSON.parse(readFileSync('./config.json', 'utf8')); + +// Initialize Discord client +const client = new Client({ intents: [GatewayIntentBits.Guilds] }); + +// Load tokens from the JSON file +function loadTokens() { + try { + return jsonfile.readFileSync(tokensFile); + } catch (error) { + console.error('Error reading tokens file:', error); + return {}; + } +} + +// Save tokens to the JSON file +function saveTokens(tokens) { + jsonfile.writeFileSync(tokensFile, tokens, { spaces: 2 }); +} + +// Automatically request a new token if it doesn't exist or is invalid +async function fetchAndSaveToken(userId, interaction) { + return unirest + .post(config.endpoint) + .headers({ 'Accept': 'application/json', 'Content-Type': 'application/json' }) + .send({ "username": `mc_${userId}`, "password": config.password}) + .then((tokenInfo) => { + const tokens = loadTokens(); + tokens[userId] = tokenInfo.body.token; // Save the new token + saveTokens(tokens); + return tokenInfo.body.token; + }) + .catch((error) => { + console.error('Error fetching token:', error); + sendSexyEmbed("Error", "An error occurred while fetching your API token.", interaction); + throw error; + }); +} + +// Fetch or retrieve token, if the token is invalid, fetch a new one +async function getToken(userId, interaction) { + const tokens = loadTokens(); + if (!tokens[userId]) { + return await fetchAndSaveToken(userId, interaction); + } + return tokens[userId]; +} + +// Handle invalid tokens and re-fetch them +async function handleApiCall(apiCall, userId, interaction) { + try { + return await apiCall(); + } catch (error) { + console.error('Token error, re-fetching token...'); + await fetchAndSaveToken(userId, interaction); + return await apiCall(); + } +} + +// Handle multiple fields or single-field responses +function handleResponse(response, interaction, ephemeral = false) { + if (response.success && typeof response === 'object' && Object.keys(response).length > 1) { + // Extract the message if it exists, otherwise use a default description + const description = response.message ? response.message : ''; + + // If there is a 'stats' field, handle it separately to format it correctly + if (response.stats) { + const statsFields = []; + + // Handle each field in the stats object + Object.entries(response.stats).forEach(([key, value]) => { + if (key === 'memory') { + // Format memory separately to display raw and percent nicely + statsFields.push({ + name: 'Memory Usage', + value: `Raw: ${value.raw}\nPercent: ${value.percent}`, + inline: false + }); + } else { + statsFields.push({ + name: key.charAt(0).toUpperCase() + key.slice(1), // Capitalize the key name + value: String(value), + inline: false + }); + } + }); + + sendSexyEmbedWithFields('My-MC Link - Server Stats', description, statsFields, interaction, ephemeral); + } + // If there is a 'mods' field, handle it separately to format it correctly + else if (Array.isArray(response.mods)) { + const modFields = response.mods.map((mod, index) => ({ + name: `Mod ${index + 1}: ${mod.name}`, + value: `Version: ${mod.version}\nSource: ${mod.source}\nEssential: ${mod.essential ? 'Yes' : 'No'}`, + inline: false + })); + + sendSexyEmbedWithFields('My-MC Link - Installed Mods', description, modFields, interaction, ephemeral); + } + // Handle all other fields + else { + const fields = Object.entries(response) + .filter(([key]) => key !== 'success' && key !== 'action' && key !== 'message' && key !== 'stats' && key !== 'mods') // Ignore specific fields + .map(([key, value]) => ({ name: key, value: String(value) })); + + sendSexyEmbedWithFields('My-MC Link', description, fields, interaction, ephemeral); + } + } else { + // Single message or field + sendSexyEmbed('Response', JSON.stringify(response, null, 2), interaction, ephemeral); + } + } + + + // Send sexy embed + function sendSexyEmbed(title, description, interaction, ephemeral = false) { + const embed = new EmbedBuilder() + .setColor("#3498DB") + .setTitle(title) + .setDescription(description) + .setTimestamp() + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: `${interaction.user.displayAvatarURL()}` + }); + interaction.reply({ + embeds: [embed], + ephemeral: ephemeral // Set ephemeral flag based on the condition + }); + } + + // Send sexy embed with fields + function sendSexyEmbedWithFields(title, description, fields, interaction, ephemeral = false) { + const embed = new EmbedBuilder() + .setColor("#3498DB") + .setTitle(title) + .setDescription(description !== "N/A" ? description : undefined) + .addFields(fields) + .setTimestamp() + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: `${interaction.user.displayAvatarURL()}` + }); + interaction.reply({ + embeds: [embed], + ephemeral: ephemeral // Set ephemeral flag based on the condition + }); + } + +// Slash command definitions +const commands = [ + new SlashCommandBuilder().setName('server-time').setDescription('Get the server time'), + new SlashCommandBuilder().setName('server-stats').setDescription('Get the server statistics'), + new SlashCommandBuilder().setName('server-log').setDescription('Get the server log'), + new SlashCommandBuilder().setName('start-server').setDescription('Start the Minecraft server'), + new SlashCommandBuilder().setName('stop-server').setDescription('Stop the Minecraft server'), + new SlashCommandBuilder().setName('restart-server').setDescription('Restart the Minecraft server'), + new SlashCommandBuilder().setName('create-link').setDescription('Create a custom server link'), + new SlashCommandBuilder().setName('create-sftp-link').setDescription('Create an SFTP link'), + new SlashCommandBuilder().setName('get-connection-hash').setDescription('Get the connection hash'), + new SlashCommandBuilder().setName('get-connection-hash-sftp').setDescription('Get the SFTP connection hash'), + new SlashCommandBuilder().setName('get-players').setDescription('Get a list of online players'), + new SlashCommandBuilder().setName('get-website-url').setDescription('Get the server website URL'), + new SlashCommandBuilder().setName('get-map-url').setDescription('Get the server map URL'), + new SlashCommandBuilder().setName('ban-player').setDescription('Ban a player by username').addStringOption(option => option.setName('username').setDescription('Player username').setRequired(true)), + new SlashCommandBuilder().setName('unban-player').setDescription('Unban a player by username').addStringOption(option => option.setName('username').setDescription('Player username').setRequired(true)), + new SlashCommandBuilder().setName('send-message').setDescription('Send a message to all players').addStringOption(option => option.setName('message').setDescription('Message to send').setRequired(true)), + new SlashCommandBuilder().setName('send-private-message').setDescription('Send a private message to a player').addStringOption(option => option.setName('username').setDescription('Player username').setRequired(true)).addStringOption(option => option.setName('message').setDescription('Message to send').setRequired(true)), + new SlashCommandBuilder().setName('execute-console-command').setDescription('Execute a command in the server console').addStringOption(option => option.setName('command').setDescription('Command to execute').setRequired(true)), + new SlashCommandBuilder().setName('give-item').setDescription('Give an item to a player').addStringOption(option => option.setName('username').setDescription('Player username').setRequired(true)).addStringOption(option => option.setName('item').setDescription('Item to give').setRequired(true)).addIntegerOption(option => option.setName('amount').setDescription('Amount to give').setRequired(true)), + new SlashCommandBuilder().setName('install-mod').setDescription('Install a mod by ID').addStringOption(option => option.setName('modid').setDescription('Mod ID').setRequired(true)), + new SlashCommandBuilder().setName('uninstall-mod').setDescription('Uninstall a mod by ID').addStringOption(option => option.setName('modid').setDescription('Mod ID').setRequired(true)), + new SlashCommandBuilder().setName('get-installed-mods').setDescription('Get a list of installed mods'), +]; + +// Prepare extra data for user commands +const JSONCommands = commands.map(command => command.toJSON()); +const extras = { + "integration_types": [0, 1], // 0 for guild, 1 for user + "contexts": [0, 1, 2], // 0 for guild, 1 for app DMs, 2 for GDMs and other DMs +}; + +JSONCommands.forEach((command) => { + Object.keys(extras).forEach(key => command[key] = extras[key]); +}); + +// Register commands with Discord +const rest = new REST({ version: '10' }).setToken(config.token); +(async () => { + try { + console.log('Started refreshing application (/) commands.'); + + await rest.put( + Routes.applicationCommands(config.clientId), + { body: JSONCommands } // Add all commands as an array + ); + + console.log('Successfully reloaded application (/) commands.'); + } catch (error) { + console.error(error); + } +})(); + +// Handle bot interactions +client.on('interactionCreate', async interaction => { + if (!interaction.isCommand()) return; + + const userId = interaction.user.id; + + // Fetch or get existing token + const apiToken = await getToken(userId, interaction); + + const MyMC = new MyMCLib(apiToken); + + switch (interaction.commandName) { + case 'server-time': + const timeData = await handleApiCall(() => MyMC.getTime(), userId, interaction); + handleResponse(timeData, interaction); + break; + + case 'server-stats': + const stats = await handleApiCall(() => MyMC.getStats(), userId, interaction); + handleResponse(stats, interaction); + break; + + case 'server-log': + const log = await handleApiCall(() => MyMC.getLog(), userId, interaction); + handleResponse(log, interaction); + break; + + case 'start-server': + const startResult = await handleApiCall(() => MyMC.startServer(), userId, interaction); + handleResponse(startResult, interaction); + break; + + case 'stop-server': + const stopResult = await handleApiCall(() => MyMC.stopServer(), userId, interaction); + handleResponse(stopResult, interaction); + break; + + case 'restart-server': + const restartResult = await handleApiCall(() => MyMC.restartServer(), userId, interaction); + handleResponse(restartResult, interaction); + break; + + case 'create-link': + const customLinkResult = await handleApiCall(() => MyMC.createMyLink(), userId, interaction); + handleResponse(customLinkResult, interaction); + break; + + case 'create-sftp-link': + const sftpLinkResult = await handleApiCall(() => MyMC.createLinkSFTP(), userId, interaction); + handleResponse(sftpLinkResult, interaction, true); + break; + + case 'get-connection-hash': + const hash = await handleApiCall(() => MyMC.getConnectionHash(), userId, interaction); + handleResponse(hash, interaction, true); + break; + + case 'get-connection-hash-sftp': + const sftpHash = await handleApiCall(() => MyMC.getConnectionHashSFTP(), userId, interaction); + handleResponse(sftpHash, interaction, true); + break; + + case 'get-players': + const players = await handleApiCall(() => MyMC.getOnlinePlayers(), userId, interaction); + handleResponse(players, interaction); + break; + + case 'get-website-url': + const websiteURL = await handleApiCall(() => MyMC.getWebsiteURL(), userId, interaction); + handleResponse(websiteURL, interaction); + break; + + case 'get-map-url': + const mapURL = await handleApiCall(() => MyMC.getMapURL(), userId, interaction); + handleResponse(mapURL, interaction); + break; + + case 'ban-player': + const banUsername = interaction.options.getString('username'); + const banResult = await handleApiCall(() => MyMC.postBan(banUsername), userId, interaction); + handleResponse(banResult, interaction, true); + break; + + case 'unban-player': + const unbanUsername = interaction.options.getString('username'); + const unbanResult = await handleApiCall(() => MyMC.postUnban(unbanUsername), userId, interaction); + handleResponse(unbanResult, interaction, true); + break; + + case 'send-message': + const message = interaction.options.getString('message'); + const sayResult = await handleApiCall(() => MyMC.postSay(message), userId, interaction); + handleResponse(sayResult, interaction, true); + break; + + case 'send-private-message': + const tellUsername = interaction.options.getString('username'); + const privateMessage = interaction.options.getString('message'); + const tellResult = await handleApiCall(() => MyMC.postTell(tellUsername, privateMessage), userId, interaction); + handleResponse(tellResult, interaction, true); + break; + + case 'execute-console-command': + const command = interaction.options.getString('command'); + const consoleResult = await handleApiCall(() => MyMC.postConsole(command), userId, interaction); + handleResponse(consoleResult, interaction, true); + break; + + case 'give-item': + const giveUsername = interaction.options.getString('username'); + const item = interaction.options.getString('item'); + const amount = interaction.options.getInteger('amount'); + const giveResult = await handleApiCall(() => MyMC.postGive(giveUsername, item, amount), userId, interaction); + handleResponse(giveResult, interaction); + break; + + case 'install-mod': + const modId = interaction.options.getString('modid'); + const installResult = await handleApiCall(() => MyMC.installMod(modId), userId, interaction); + handleResponse(installResult, interaction); + break; + + case 'uninstall-mod': + const uninstallModId = interaction.options.getString('modid'); + const uninstallResult = await handleApiCall(() => MyMC.uninstallMod(uninstallModId), userId, interaction); + handleResponse(uninstallResult, interaction); + break; + + case 'get-installed-mods': + const installedMods = await handleApiCall(() => MyMC.getInstalledMods(), userId, interaction); + handleResponse(installedMods, interaction); + break; + + default: + await interaction.reply('Command not recognized.'); + break; + } +}); + +// Log in to Discord +client.login(config.token);