diff --git a/README.md b/README.md index 319f9f5..cdbbebb 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,27 @@ # Crafty Controller Discord bot + ![GitHub License](https://img.shields.io/github/license/Two-Play/Crafty-Discord-bot) ![GitHub top language](https://img.shields.io/github/languages/top/Two-Play/Crafty-Discord-bot) ![GitHub contributors](https://img.shields.io/github/contributors/Two-Play/Crafty-Discord-bot) ![GitHub Release Date](https://img.shields.io/github/release-date/Two-Play/Crafty-Discord-bot) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/d5b3f979005e4c52916f7fb741068483)](https://app.codacy.com/gh/Two-Play/Crafty-Discord-bot/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![Codacy Badge](https://app.codacy.com/project/badge/Coverage/d5b3f979005e4c52916f7fb741068483)](https://app.codacy.com/gh/Two-Play/Crafty-Discord-bot/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage) + +## Table of Contents + +1. [Introduction](#introduction) +2. [Features](#features) +3. [Roadmap](#roadmap) +4. [Installation](#installation) + - [Requirements](#requirements) + - [Docker](#docker) + - [Python](#python) +5. [Usage](#usage) +6. [Issues](#issues) +7. [Contributing](#contributing) +8. [Support the Project](#support-the-project) +9. [Donations](#donations) +10. [License](#license) ## Introduction @@ -10,19 +29,18 @@ This is a Discord bot that is designed to control the Crafty-Controller-4 server The bot is written in Python and uses the Discord.py library to interact with the Discord API. -## Table of Contents +### Features + +- **Server Status**: Get the status of the server +- **Server Start**: Start the server +- **Server Stop**: Stop the server + +## Roadmap + +- **Server Restart**: Restart the server +- **Server Backup**: Create a backup of the server +- **Web UI**: Create a web interface for the bot -1. [Introduction](#introduction) -2. [Installation](#installation) - - [Requirements](#requirements) - - [Docker](#docker) - - [Python](#python) -3. [Usage](#usage) -4. [Issues](#issues) -5. [Contributing](#contributing) -6. [Support the Project](#support-the-project) -7. [Donations](#donations) -8. [License](#license) ## Installation @@ -56,6 +74,7 @@ server and obtain the user token. You can do this by following these steps: 9. Save your user token in a safe place (you will need it later) #### Discord Bot + You will need to create a new Discord bot and obtain a bot token. You can do this by following these steps: 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) @@ -88,6 +107,7 @@ You will need to create a new Discord bot and obtain a bot token. You can do thi Congratulations! Your bot has been invited to your server ### Docker + Installing the bot using Docker is the easiest way to get started. To do this, you will need to have Docker installed on your system. If you do not have Docker installed, you can download it from the [official Docker website](https://www.docker.com/get-started). @@ -117,6 +137,7 @@ services: ``` ### Python + If you would like to install the bot using Python, you will need to have Python 3.8 or higher installed on your system. Clone the repository @@ -168,6 +189,7 @@ python main.py Replace `YOUR_DISCORD_TOKEN` with your Discord bot token and `YOUR_MONGO_URI` with your MongoDB connection string. ## Usage + ### Slash Commands (Beta) The bot supports slash commands. To use the slash commands, you will need to have the `Use slash commands` permission enabled for the bot. ```bash @@ -175,6 +197,7 @@ The bot supports slash commands. To use the slash commands, you will need to hav ``` ### Command (>) + Enter the following command to get a list of available commands: ```bash >help or >bot_help @@ -204,6 +227,7 @@ Thank you for your contribution! If you have any questions, please feel free to reach out to us ## Support the Project + If you would like to support the project, you can do so by: - Giving the project a star on GitHub @@ -212,6 +236,7 @@ If you would like to support the project, you can do so by: - Donating to the project ## Donations + If you would like to donate to the project, you can do so using the following methods: | Platform | Link | QR Code | @@ -232,4 +257,5 @@ You can click on the QR code to show a larger version of the QR code. GitHub doe | ![Monero Badge](https://img.shields.io/badge/Monero-F60?logo=monero&logoColor=fff&style=for-the-badge) | XMR | ![Monero QR Code](https://api.qrserver.com/v1/create-qr-code/?color=000000&bgcolor=FFFFFF&data=41hZYQV5uDzfiLCusRAxARST3hfTzGv7RNRyB92G1RZw64pvEQqwDo94zZHVxfvcmncLU1ockvJxbZBQToPqqDtBAor97sU&qzone=1&margin=0&size=150x150&ecc=L) | `41hZYQV5uDzfiLCusRAxARST3hfTzGv7RNRyB92G1RZw64pvEQqwDo94zZHVxfvcmncLU1ockvJxbZBQToPqqDtBAor97sU` | ## License + This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. diff --git a/core/constants.py b/core/constants.py new file mode 100644 index 0000000..93fef57 --- /dev/null +++ b/core/constants.py @@ -0,0 +1,8 @@ +# Network +# HTTP status code for a successful request +STATUS_SUCCESS = 200 + +# Main +API_ENDPOINT = '/api/v2/servers/' +GUILD_ID = 1168172802562601121 +AUTO_STOP_SLEEP = 1800 # 30 minutes \ No newline at end of file diff --git a/core/helper.py b/core/helper.py index c221700..18063f2 100644 --- a/core/helper.py +++ b/core/helper.py @@ -5,7 +5,6 @@ import os import sys - def check_env_vars(): """ Check if all required environment variables are set and exit if any are missing. diff --git a/core/imports.py b/core/imports.py new file mode 100644 index 0000000..31d4fbd --- /dev/null +++ b/core/imports.py @@ -0,0 +1,17 @@ +# core/imports.py + +import asyncio +import os +import requests +import discord +from discord.ext import commands +from discord import Interaction, app_commands +from discord.ext.commands import is_owner +from discord import ui +from dotenv import load_dotenv + +# Custom imports +from core.helper import check_env_vars +from core.network import is_response_successful, get_json_response +from core.printing import print_server_info, print_server_status +from core.server import is_server_running, stop_server, get_player_count \ No newline at end of file diff --git a/core/main.py b/core/main.py index ae06f52..b21e9c2 100644 --- a/core/main.py +++ b/core/main.py @@ -1,16 +1,20 @@ +""" +This is the main file of the bot. It contains the main loop and the event handlers. +""" + import asyncio import os -import requests import discord from discord.ext import commands -from discord import Interaction, app_commands +from discord import app_commands from discord.ext.commands import is_owner -from discord import ui from dotenv import load_dotenv -from helper import check_env_vars -from network import is_response_successful, get_json_response -from printing import print_server_info, print_server_status +from core.constants import AUTO_STOP_SLEEP, GUILD_ID, API_ENDPOINT +from core.helper import check_env_vars +from core.network import get_json_response +from core.printing import print_server_info, print_server_status +from core.server import is_server_running, stop_server, get_player_count # Load environment variables from .env file load_dotenv() @@ -20,43 +24,16 @@ if 'USERNAME' in os.environ and 'PASSWORD' in os.environ: USERNAME = os.environ['USERNAME'] PASSWORD = os.environ['PASSWORD'] -API_ENDPOINT = '/api/v2/servers/' -GUILD_ID = 1168172802562601121 -AUTO_STOP_SLEEP = 1800 # 30 minutes - intents = discord.Intents.default() intents.message_content = True bot = commands.Bot(command_prefix='>', intents=intents) -def stop_server(server_id) -> bool: - data = get_json_response(API_ENDPOINT + str(server_id) + '/action/stop_server', 'failed to stop server') - if data['status'] == "ok": - print('Server stopped', str(server_id)) - return True - else: - print('failed to stop server') - return False - -def get_player_count(server_id, ctx=None) -> int: - data = get_json_response(API_ENDPOINT + str(server_id) + '/stats', 'failed to get server stats') - if not data: - if ctx: - ctx.reply('failed to get server stats') - return -1 - return data['data']['online'] - -def is_server_running(server_id, ctx=None) -> bool: - data = get_json_response(API_ENDPOINT + str(server_id) + '/stats', 'failed to get server stats') - if not data: - if ctx: - ctx.reply('failed to get server stats') - return False - return data['data']['running'] - - # stop server after 1 hour of inactivity (no players online) async def auto_stop(): + """ + Automatically stop servers that have been inactive for a certain period of time. + """ while True: # get list of servers ids and loop through them and add only the ones that are running data = get_json_response('/api/v2/servers', 'failed to get server list') @@ -81,21 +58,21 @@ async def on_ready(): # get_token() -""" -@bot.tree.command(name="rps")/* -@app_commands.guilds(discord.Object(id=GUILD_ID))@app_commands.choices(choices=[ - app_commands.Choice(name="Rock", value="rock"), - app_commands.Choice(name="Paper", value="paper"), - app_commands.Choice(name="Scissors", value="scissors"), - ]) -async def rps(i: discord.Interaction, choices: app_commands.Choice[str]): - if (choices.value == 'rock'): - counter = 'paper' - elif (choices.value == 'paper'): - counter = 'scissors' - else: - counter = 'rock' -""" + +# @bot.tree.command(name="rps")/* +# @app_commands.guilds(discord.Object(id=GUILD_ID))@app_commands.choices(choices=[ +# app_commands.Choice(name="Rock", value="rock"), +# app_commands.Choice(name="Paper", value="paper"), +# app_commands.Choice(name="Scissors", value="scissors"), +# ]) +# async def rps(i: discord.Interaction, choices: app_commands.Choice[str]): +# if (choices.value == 'rock'): +# counter = 'paper' +# elif (choices.value == 'paper'): +# counter = 'scissors' +# else: +# counter = 'rock' + @bot.hybrid_command(name='sync', description='Sync commands') @@ -112,32 +89,24 @@ async def sync(ctx) -> None: @is_owner() async def get_token(ctx): print('get_token') - response = requests.post(SERVER_URL + '/api/v2/auth/login', json={'username': USERNAME, 'password': PASSWORD}, - verify=False) - if is_response_successful(response): - os.environ['CRAFTY_TOKEN'] = response.json()['data']['token'] - await ctx.send('Toke successful retrieved') - return True - else: - print('Login failed', response.status_code) - return False + + data = get_json_response('/api/v2/auth/login', 'failed to get token') + if not data: + await ctx.reply('failed to get token') + return + + os.environ['CRAFTY_TOKEN'] = data['data']['token'] + await ctx.send('Toke successful retrieved') @bot.hybrid_command(name='list', description='get server list') @app_commands.guilds(discord.Object(id=GUILD_ID))# get the list of servers async def get_list(ctx): print('servers') + data = get_json_response('/api/v2/servers', 'failed to get server list') + server_info_text = print_server_info(data) - response = requests.get(SERVER_URL + '/api/v2/servers', - headers={'Authorization': 'Bearer ' + os.environ['CRAFTY_TOKEN']}, verify=False) - if is_response_successful(response): - # print(json.dumps(response.json(), indent=4)) + await ctx.reply(f"Serverinformationen:\n{server_info_text}") - data = response.json() - server_info_text = print_server_info(data) - - await ctx.reply(f"Serverinformationen:\n{server_info_text}") - else: - await ctx.reply('failed to get server list') # get statistics of a server @@ -169,7 +138,8 @@ async def start(ctx, server_id): await ctx.reply('Server already running') return - data = get_json_response(API_ENDPOINT + str(server_id) + '/action/start_server', 'failed to start server') + data = get_json_response(API_ENDPOINT + str(server_id) + '/action/start_server', + 'failed to start server') if not data: await ctx.reply('failed to start server') return @@ -192,7 +162,7 @@ async def stop(ctx, server_id): # check if player is online player_count = get_player_count(server_id, ctx) if player_count != 0: - await ctx.reply('cannot stop server: {} Player(s) online'.format(player_count)) + await ctx.reply(f'cannot stop server: {player_count} Player(s) online') return if stop_server(server_id): @@ -224,8 +194,11 @@ async def bot_help(interaction: discord.Interaction): # command not found @bot.event async def on_command_error(ctx, error): + """ + Handle command not found errors + """ if isinstance(error, commands.CommandNotFound): await ctx.send('Command not found. Use `>bot_help` to get a list of available commands.') -bot.run(os.environ['DISCORD_TOKEN']) +bot.run(os.environ['DISCORD_TOKEN']) \ No newline at end of file diff --git a/core/network.py b/core/network.py index 889d324..93d39a8 100644 --- a/core/network.py +++ b/core/network.py @@ -1,22 +1,62 @@ +""" +This module contains functions for sending HTTP requests and handling responses. +""" + import json import os import requests from requests import Response -STATUS_SUCCESS = 200 +from core.constants import STATUS_SUCCESS def is_response_successful(response: requests.Response) -> bool: + """ + Check if the HTTP response was successful. + + Args: + response (requests.Response): The HTTP response object. + + Returns: + bool: True if the response status code is 200, False otherwise. + """ return response.status_code == STATUS_SUCCESS -def get_response(path) -> Response: - response = requests.get(os.environ['SERVER_URL'] + path, - headers={'Authorization': 'Bearer ' + os.environ['CRAFTY_TOKEN']}, verify=False) +def get_response(path: str, verify: bool = False, timeout: int = 6) -> Response: + """ + Send a GET request to the specified path and return the response. + + Args: + path (str): The URL path to send the request to. + verify (bool, optional): Whether to verify the server's TLS certificate. Defaults to False. + timeout (int, optional): The timeout for the request in seconds. Defaults to 6. + + Returns: + requests.Response: The HTTP response object. + """ + response = requests.get( + os.environ['SERVER_URL'] + path, + headers={'Authorization': 'Bearer ' + os.environ['CRAFTY_TOKEN']}, + verify=verify, + timeout=timeout + ) return response -def get_json_response(path, error_message="Error while response") -> json: +def get_json_response(path: str, error_message: str = "Error while response") -> json: + """ + Send a GET request to the specified path and return the response as JSON. + + Args: + path (str): The URL path to send the request to. + error_message (str, optional): The error message to print if the response is + not successful. Defaults to "Error while response". + + Returns: + dict: The JSON response as a dictionary. Returns an empty dictionary + if the response is not successful. + """ response = get_response(path) if not is_response_successful(response): print(error_message) return {} - return response.json() \ No newline at end of file + return response.json() diff --git a/core/printing.py b/core/printing.py index 73d6566..3dd62ec 100644 --- a/core/printing.py +++ b/core/printing.py @@ -1,16 +1,28 @@ +""" +This module contains functions to print the server information as text. +""" + def print_server_info(data) -> str: + """ + Print the server information as text. + """ # Extract server names, server IPs and server ports from the API response - server_info = [(server['server_id'], server['server_name'], server['server_ip'], server['server_port']) for - server in - data['data']] + server_info = [(server.get('server_id', '-'), + server.get('server_name', ''), + server.get('server_ip', 'unknown'), + server.get('server_port', 'unknown')) for server in data['data']] # Format the server information as text server_info_text = "" - for id, name, ip, port in server_info: - server_info_text += f"```\nID: {id}\nServer: {name}\n IP: {ip}\n Port: {port}\n```\n" + for server_id, name, ip, port in server_info: + server_info_text += f"```\nID: {server_id}\nServer: {name}\n IP: { + ip}\n Port: {port}\n```\n" return server_info_text def print_server_status(data) -> str: + """ + Print the server status as text. + """ data = data['data'] cpu_usage = data['cpu'] @@ -20,8 +32,10 @@ def print_server_status(data) -> str: server_info_text: str if running: - server_info_text = ( - f"```\nWorld: {data['world_name']}\nRunning: {running}\nPlayers: {data['players']}\nVersion: {data['version']}\nCPU: {cpu_usage}%\nRAM: {mem}MB ({mem_percent}%)\n```\n") + server_info_text = ( + f"```\nWorld: {data['world_name']}\nRunning: {running}\nPlayers: { + data['players']}\nVersion: {data['version']}\nCPU: {cpu_usage}%\nRAM: { + mem}MB ({mem_percent}%)\n```\n") else: - server_info_text = f"```\nWorld: {data['world_name']}\nRunning: {running}\n```\n" - return server_info_text \ No newline at end of file + server_info_text = f"```\nWorld: {data['world_name']}\nRunning: {running}\n```\n" + return server_info_text diff --git a/core/server.py b/core/server.py index 3235629..7f2d8e8 100644 --- a/core/server.py +++ b/core/server.py @@ -1,7 +1,40 @@ +"""This module contains the Server class.""" import dataclasses +from core.constants import API_ENDPOINT +from core.network import get_json_response -@dataclasses -class Server: - server_id: int - empty_round_count: int +def stop_server(server_id) -> bool: + """ + Stop the server with the specified ID. + """ + data = get_json_response(API_ENDPOINT + str(server_id) + '/action/stop_server', + 'failed to stop server') + if data['status'] == "ok": + print('Server stopped', str(server_id)) + return True + + print('failed to stop server') + return False + +def get_player_count(server_id, ctx=None) -> int: + """ + Get the number of players online on the server with the specified server ID. + """ + data = get_json_response(API_ENDPOINT + str(server_id) + '/stats', 'failed to get server stats') + if not data: + if ctx: + ctx.reply('failed to get server stats') + return -1 + return data['data']['online'] + +def is_server_running(server_id, ctx=None) -> bool: + """ + Check if the server with the specified ID is running. + """ + data = get_json_response(API_ENDPOINT + str(server_id) + '/stats', 'failed to get server stats') + if not data: + if ctx: + ctx.reply('failed to get server stats') + return False + return data['data']['running'] diff --git a/tests/test_network.py b/tests/test_network.py index 09a7a5b..dd84b5f 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,8 +1,53 @@ +import os import unittest +from unittest.mock import patch, MagicMock + +import requests + +from core.network import is_response_successful, get_response, get_json_response class TestNetwork(unittest.TestCase): - def setUp(self): - pass - def tearDown(self): - pass \ No newline at end of file + @patch('core.network.requests.get') + def test_is_response_successful_status_200(self, mock_get): + mock_response = MagicMock() + mock_response.status_code = 200 + self.assertTrue(is_response_successful(mock_response)) + + @patch('core.network.requests.get') + def test_is_response_successful_status_not_200(self, mock_get): + mock_response = MagicMock() + mock_response.status_code = 404 + self.assertFalse(is_response_successful(mock_response)) + + @patch('core.network.requests.get') + @patch.dict(os.environ, {'SERVER_URL': 'http://example.com', 'CRAFTY_TOKEN': 'dummy_token'}) + def test_get_response_successful(self, mock_get): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_get.return_value = mock_response + response = get_response('/test') + self.assertEqual(response.status_code, 200) + + @patch('core.network.requests.get') + @patch.dict(os.environ, {'SERVER_URL': 'http://example.com', 'CRAFTY_TOKEN': 'dummy_token'}) + def test_get_response_timeout(self, mock_get): + mock_get.side_effect = requests.Timeout + with self.assertRaises(requests.Timeout): + get_response('/test', timeout=1) + + @patch('core.network.get_response') + @patch.dict(os.environ, {'SERVER_URL': 'http://example.com', 'CRAFTY_TOKEN': 'dummy_token'}) + def test_get_json_response_successful(self, mock_get_response): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {'key': 'value'} + mock_get_response.return_value = mock_response + self.assertEqual(get_json_response('/test'), {'key': 'value'}) + + @patch('core.network.get_response') + def test_get_json_response_failure(self, mock_get_response): + mock_response = MagicMock() + mock_response.status_code = 404 + mock_get_response.return_value = mock_response + self.assertEqual(get_json_response('/test'), {}) \ No newline at end of file diff --git a/tests/test_printing.py b/tests/test_printing.py new file mode 100644 index 0000000..d77e7e5 --- /dev/null +++ b/tests/test_printing.py @@ -0,0 +1,91 @@ +import unittest + +from core.printing import print_server_info, print_server_status + + +class TestPrintServerInfo(unittest.TestCase): + + def test_server_info_correct_format(self): + data = { + 'data': [ + {'server_id': 1, 'server_name': 'Server1', 'server_ip': '192.168.1.1', 'server_port': 25565}, + {'server_id': 2, 'server_name': 'Server2', 'server_ip': '192.168.1.2', 'server_port': 25566} + ] + } + expected_output = ( + "```\nID: 1\nServer: Server1\n IP: 192.168.1.1\n Port: 25565\n```\n" + "```\nID: 2\nServer: Server2\n IP: 192.168.1.2\n Port: 25566\n```\n" + ) + self.assertEqual(print_server_info(data), expected_output) + + def test_server_info_empty_data(self): + data = {'data': []} + expected_output = "" + self.assertEqual(print_server_info(data), expected_output) + + def test_server_info_missing_fields(self): + data = { + 'data': [ + {'server_id': 1, 'server_name': 'Server1', 'server_ip': '192.168.1.1'}, + {'server_id': 2, 'server_name': 'Server2', 'server_port': 25566} + ] + } + expected_output = ( + "```\nID: 1\nServer: Server1\n IP: 192.168.1.1\n Port: unknown\n```\n" + "```\nID: 2\nServer: Server2\n IP: unknown\n Port: 25566\n```\n" + ) + self.assertEqual(print_server_info(data), expected_output) + + +class TestPrintServerStatus(unittest.TestCase): + + def test_server_status_running(self): + data = { + 'data': { + 'world_name': 'World1', + 'running': True, + 'players': 5, + 'version': '1.16.5', + 'cpu': 50, + 'mem': 1024, + 'mem_percent': 25 + } + } + expected_output = ( + "```\nWorld: World1\nRunning: True\nPlayers: 5\nVersion: 1.16.5\nCPU: 50%\nRAM: 1024MB (25%)\n```\n" + ) + self.assertEqual(print_server_status(data), expected_output) + + def test_server_status_stopped(self): + data = { + 'data': { + 'world_name': 'World1', + 'running': False, + 'cpu': '', + 'mem': '', + 'mem_percent': '' + } + } + expected_output = "```\nWorld: World1\nRunning: False\n```\n" + self.assertEqual(print_server_status(data), expected_output) + + def test_server_status_missing_fields(self): + data = { + 'data': { + 'world_name': 'World1', + 'running': True, + 'players': 5, + 'version': '1.16.5', + 'cpu': 50, + 'mem': 23, + 'mem_percent': 12 + } + } + expected_output = ( + "```\nWorld: World1\nRunning: True\nPlayers: 5\nVersion: 1.16.5\nCPU: 50%\nRAM: 23MB (12%)\n```\n" + ) + self.assertEqual(print_server_status(data), expected_output) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..939978a --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,40 @@ +import unittest +from unittest.mock import patch, MagicMock + +from core.server import stop_server, get_player_count, is_server_running + + +class TestServerFunctions(unittest.TestCase): + + @patch('core.server.get_json_response') + def test_stop_server_successful(self, mock_get_json_response): + mock_get_json_response.return_value = {'status': 'ok'} + self.assertTrue(stop_server(1)) + + @patch('core.server.get_json_response') + def test_stop_server_failure(self, mock_get_json_response): + mock_get_json_response.return_value = {'status': 'error'} + self.assertFalse(stop_server(1)) + + @patch('core.server.get_json_response') + def test_get_player_count_successful(self, mock_get_json_response): + mock_get_json_response.return_value = {'data': {'online': 5}} + self.assertEqual(get_player_count(1), 5) + + @patch('core.server.get_json_response') + def test_get_player_count_failure(self, mock_get_json_response): + mock_get_json_response.return_value = {} + self.assertEqual(get_player_count(1), -1) + + @patch('core.server.get_json_response') + def test_is_server_running_successful(self, mock_get_json_response): + mock_get_json_response.return_value = {'data': {'running': True}} + self.assertTrue(is_server_running(1)) + + @patch('core.server.get_json_response') + def test_is_server_running_failure(self, mock_get_json_response): + mock_get_json_response.return_value = {} + self.assertFalse(is_server_running(1)) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file