diff --git a/bot/assets/death.png b/bot/assets/death.png new file mode 100644 index 0000000..91d2e77 Binary files /dev/null and b/bot/assets/death.png differ diff --git a/bot/assets/end.png b/bot/assets/end.png new file mode 100644 index 0000000..0ee8550 Binary files /dev/null and b/bot/assets/end.png differ diff --git a/bot/assets/fight.png b/bot/assets/fight.png new file mode 100644 index 0000000..558c597 Binary files /dev/null and b/bot/assets/fight.png differ diff --git a/bot/assets/init.png b/bot/assets/init.png new file mode 100644 index 0000000..5d50a81 Binary files /dev/null and b/bot/assets/init.png differ diff --git a/bot/assets/safe.png b/bot/assets/safe.png new file mode 100644 index 0000000..b94cefc Binary files /dev/null and b/bot/assets/safe.png differ diff --git a/bot/bot.db b/bot/bot.db new file mode 100644 index 0000000..fd1cfc9 Binary files /dev/null and b/bot/bot.db differ diff --git a/bot/cogs/minecraft.py b/bot/cogs/minecraft.py index c0de23f..1715cb1 100644 --- a/bot/cogs/minecraft.py +++ b/bot/cogs/minecraft.py @@ -1,9 +1,11 @@ import discord from discord.ext import commands -from mcrcon import MCRcon import enum from transitions import Machine -from cogs.spawner import containers +from cogs.spawner import get_server +import utils + +color = discord.Color.from_rgb(181, 24, 60) class Whitelist: "A workflow machine for managing Whitelist states" @@ -11,25 +13,6 @@ class Whitelist: Off = None toggle = None -class RCON(MCRcon): - def __init__(self, ip: str, secret: str, port: int = 31066): - super().__init__(ip, secret, port) - self.connect() - - def whitelist(self): - self.whitelist.toggle() - cmds = "/whitelist {}".format(self.whitelist.current_state.id) - print(cmds) - - def sendcmd(self, cmds) -> None: - if isinstance(cmds, str): - return self.command(str) - if isinstance(cmds, list): - return [self.command(cmd) for cmd in cmds] - - def __del__(self): - self.disconnect() - class States(enum.Enum): NOTHING = 0 INIT = 1 @@ -41,7 +24,6 @@ class States(enum.Enum): class Minecraft(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.servers = dict() transitions = [ ['init_game', States.NOTHING, States.INIT], @@ -68,21 +50,12 @@ class Minecraft(commands.Cog): server_name: str Server on which the Session should be initialized """ - server_name = server_name.title() - - c = None - for container in containers: - if server_name == container.name: - c = container - break + c = get_server(server_name) if not c: await ctx.send("---The server doesn't run---") return - conn = RCON(str(c.ip), c.rcon_pass, c.rcon_port) - self.servers[server_name] = conn - cmds = [ "/effect give @a minecraft:resistance infinite 255 true", "/effect give @a minecraft:saturation infinite 4 true", @@ -92,9 +65,29 @@ class Minecraft(commands.Cog): "/worldborder set 5", "/whitelist off" ] + c.rcon.rconnect() + c.rcon.sendcmd(cmds) - conn.sendcmd(cmds) - await ctx.send("init Border Wars Game") + embed = discord.Embed( + title=f"Border Wars @ {server_name.title()}", + description=''' + Session initialized + Get ready to get a good armorset. + + **Explanation** + This phase last as long as the moderator wants. + Every player is immune to damage and can't die by hunger. + Look out for a good direction to scout. + + Happy Border Wars! + ''', + color=color, + timestamp=utils.now() + ) + file = discord.File("../assets/init.png", filename="init.png") + embed.set_thumbnail(url="attachment://init.png") + + await ctx.send(file=file, embed=embed) @commands.hybrid_command(name='safe') async def safe(self, ctx: commands.Context, server_name: str): @@ -108,25 +101,12 @@ class Minecraft(commands.Cog): server_name: str Server on which the Session should be initialized """ - server_name = server_name.title() + c = get_server(server_name) - c = None - for container in containers: - if server_name == container.name: - c = container - break - if not c: await ctx.send("---The server doesn't run---") return - - conn = self.servers.get(server_name) - - if not conn: - await ctx.send("---Border Wars Session not Initialized---") - return - - + cmds = [ '''/title @a subtitle ["",{"text":"bei ","color":"blue"},{"text":"BORDER WARS!","bold":true,"color":"red"}]''', '''/title @a title {"text":"Viel Glück!","bold":true,"color":"blue"}''', @@ -137,9 +117,29 @@ class Minecraft(commands.Cog): "/effect clear @a" ] - conn.sendcmd(cmds) - await ctx.send("Switched to Safe Phase") - + c.rcon.sendcmd(cmds) + + embed = discord.Embed( + title=f"Border Wars @ {server_name.title()}", + description=''' + Switched to Safe Phase + Hide from other players and scout for resources. + + **Explanation** + This phase lasts 30 minutes, after that the fighting phase begins. + Every player who dies will keep there inventory. + Get a good armorset and be ready to fight. + + Happy Border Wars! + ''', + color=color, + timestamp=utils.now() + ) + file = discord.File("../assets/safe.png", filename="safe.png") + embed.set_thumbnail(url="attachment://safe.png") + + await ctx.send(file=file, embed=embed) + @commands.hybrid_command(name='fight') async def fight(self, ctx: commands.Context, server_name: str): """ @@ -152,25 +152,12 @@ class Minecraft(commands.Cog): server_name: str Server on which the Session should be initialized """ - server_name = server_name.title() - - c = None - for container in containers: - if server_name == container.name: - c = container - break + c = get_server(server_name) if not c: await ctx.send("---The server doesn't run---") return - conn = self.servers.get(server_name) - - if not conn: - await ctx.send("---Border Wars Session not Initialized---") - return - - cmds = [ '''/title @a subtitle {"text":"ÜBERLEBEN!","bold":true,"color":"red"}''', '''/title @a title {"text":"Möge der beste","color":"blue"}''', @@ -179,8 +166,28 @@ class Minecraft(commands.Cog): "/gamerule keepInventory false" ] - conn.sendcmd(cmds) - await ctx.send("Switched to Fight Phase") + c.rcon.sendcmd(cmds) + + embed = discord.Embed( + title=f"Border Wars @ {server_name.title()}", + description=''' + Switched to Fight Phase + Now the real fun begins. + + **Explanation** + This phase lasts 60 minutes, after that the game ends. + Every player can die now and therefor will lose. + Look out for other players. + + Happy Border Wars! + ''', + color=color, + timestamp=utils.now() + ) + file = discord.File("../assets/fight.png", filename="fight.png") + embed.set_thumbnail(url="attachment://fight.png") + + await ctx.send(file=file, embed=embed) @commands.hybrid_command(name='death') async def death(self, ctx: commands.Context, server_name: str): @@ -194,33 +201,40 @@ class Minecraft(commands.Cog): server_name: str Server on which the Session should be initialized """ - server_name = server_name.title() + c = get_server(server_name) - c = None - for container in containers: - if server_name == container.name: - c = container - break - if not c: await ctx.send("---The server doesn't run---") return - conn = self.servers.get(server_name) - - if not conn: - await ctx.send("---Border Wars Session not Initialized---") - return - - cmds = [ '''/title @a title ["",{"text":"Sudden ","color":"dark_blue"},{"text":"DEATH!","bold":true,"color":"red"}]''', "/playsound minecraft:entity.ender_dragon.growl ambient @a 0 64 0 80", "/worldborder set 5 600" ] - conn.sendcmd(cmds) - await ctx.send("Switched to Sudden Death Phase") + c.rcon.sendcmd(cmds) + + embed = discord.Embed( + title=f"Border Wars @ {server_name.title()}", + description=''' + Switched to Sudden Death + Only one can be the Winner. + + **Explanation** + This phase lasts as long as only one player survives. + The worldborder will now shrink 'till zero zero. + Good Luck. + + Happy Border Wars! + ''', + color=color, + timestamp=utils.now() + ) + file = discord.File("../assets/death.png", filename="death.png") + embed.set_thumbnail(url="attachment://death.png") + + await ctx.send(file=file, embed=embed) @commands.hybrid_command(name='end') async def end(self, ctx: commands.Context, server_name: str, playername: str): @@ -236,24 +250,11 @@ class Minecraft(commands.Cog): playername: str Player which is announced as the Winner """ - server_name = server_name.title() - - c = None - for container in containers: - if server_name == container.name: - c = container - break + c = get_server(server_name) if not c: await ctx.send("---The server doesn't run---") return - - conn = self.servers.get(server_name) - - if not conn: - await ctx.send("---Border Wars Session not Initialized---") - return - cmds = [ "/worldborder center 0 0", @@ -264,8 +265,20 @@ class Minecraft(commands.Cog): "/playsound minecraft:entity.ender_dragon_death ambient @a 0 64 0 80", ] - conn.sendcmd(cmds) - await ctx.send("Ended Border Wars Session") + c.rcon.sendcmd(cmds) + + embed = discord.Embed( + title=f"Border Wars @ {server_name.title()}", + description=f''' + *Game Over* + Congratulations **{playername}**. + + Hope you had fun @ **Border Wars!** + ''', + color=color, + timestamp=utils.now() + ) + file = discord.File("../assets/end.png", filename="end.png") + embed.set_thumbnail(url="attachment://end.png") - - + await ctx.send(file=file, embed=embed) diff --git a/bot/cogs/spawner.py b/bot/cogs/spawner.py index c779b33..9c35089 100644 --- a/bot/cogs/spawner.py +++ b/bot/cogs/spawner.py @@ -4,35 +4,65 @@ import docker import random import socket from contextlib import closing -from datetime import datetime -import pytz from dataclasses import dataclass from ipaddress import IPv4Address import secrets +from mcrcon import MCRcon import asyncio +from models.users import * +import utils + +class RCON(MCRcon): + def __init__(self, ip: str, secret: str, port: int): + super().__init__(ip, secret, port) + + def rconnect(self): + self.connect() + + def whitelist(self): + pass + + def sendcmd(self, cmds) -> None: + if isinstance(cmds, str): + return self.command(str) + if isinstance(cmds, list): + return [self.command(cmd) for cmd in cmds] + + def __del__(self): + self.disconnect() @dataclass class Server: container: None name: str - ip: IPv4Address + rcon: RCON port: int players: int - rcon_pass: str - rcon_port: int # Global List of all running Containers containers = list() +def get_server(name): + name = name.title() + for container in containers: + if container.name == name: + return container + return None color = discord.Color.from_rgb(13, 183, 237) def seed_generator(): + ''' + Generates a random minecraft seed + ''' seed = random.randrange(1_000_000_000, 100_000_000_000_000) if random.randrange(0,2) == 0: seed *= -1 return str(seed) def find_free_port(): + ''' + Returns the next available IPv4 Port + ''' with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.bind(('', 0)) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -75,6 +105,13 @@ class Spawner(commands.Cog): enable_hardcore: bool Enables Hardcore Minecraft ''' + try: + User.get(User.username == ctx.author.id) + except: + await ctx.send(f"{ctx.author.name} isn't registered!") + return + + # Send user a Waiting screen to avoid confusion embed = discord.Embed( title="Starting Server", description=f''' @@ -83,17 +120,21 @@ class Spawner(commands.Cog): This could take up to **5 minutes** ''', color=color, - timestamp=datetime.now(pytz.timezone('Europe/Berlin')) + timestamp=utils.now() ) file = discord.File("../assets/clock.png", filename="clock.png") embed.set_thumbnail(url="attachment://clock.png") start = await ctx.send(file=file, embed=embed) + # Set up needed variables + # Server Stuff port = find_free_port() server_name = server_name.title() + # Rcon Stuff passwd = secrets.token_hex(32) rcon_port = find_free_port() + # Image Enviroment env = { "EULA": "true", "TYPE": "FABRIC", @@ -120,7 +161,6 @@ class Spawner(commands.Cog): "MAX_MEMORY": "2G", "USE_AIKAR_FLAGS": "true", - #"MODS_FILE": "/extras/mods.txt", "OPS_FILE": "https://git.cyperpunk.de/Garde-Studios/Uno-MC/raw/branch/main/ops.json", "SYNC_SKIP_NEWER_IN_DESTINATION": "false", "MAX_PLAYERS": max_players, @@ -138,6 +178,7 @@ class Spawner(commands.Cog): } + # Decide between seed or world url if no ones given seed would be randomly generated if not seed and not world_url: seed = seed_generator() if seed: @@ -145,6 +186,7 @@ class Spawner(commands.Cog): if world_url: env["WORLD"] = world_url + # setting up the container container = self.client.containers.run( image='itzg/minecraft-server:latest', environment=env, @@ -157,14 +199,18 @@ class Spawner(commands.Cog): volumes={'mods.txt': {'bind': '/extras/mods.txt', 'mode': 'ro'}} ) + # Connect Container to the appropiate network net = self.client.networks.get('bot_rcon') net.connect(container) - ip = self.client.containers.get(server_name).attrs['NetworkSettings']['Networks']['bot_rcon']['IPAddress'] - server = Server(container, server_name, IPv4Address(ip), port, max_players, passwd, rcon_port) + # save container info + ip = self.client.containers.get(server_name).attrs['NetworkSettings']['Networks']['bot_rcon']['IPAddress'] + rcon = RCON(ip, passwd, rcon_port) + server = Server(container, server_name, rcon, port, max_players) containers.append(server) - + + # Send user the confirmation embed = discord.Embed( title="Success", description=f''' @@ -174,7 +220,7 @@ class Spawner(commands.Cog): garde-studios.de:{port} ''', color=color, - timestamp=datetime.now(pytz.timezone('Europe/Berlin')) + timestamp=utils.now() ) file = discord.File("../assets/docker.png", filename="docker.png") embed.set_thumbnail(url="attachment://docker.png") @@ -191,12 +237,17 @@ class Spawner(commands.Cog): ctx: commands.Context The context of the command invocation ''' + try: + User.get(User.username == ctx.author.id) + except: + await ctx.send(f"{ctx.author.name} isn't registered!") + return embed = discord.Embed( title="Currently Running Servers", description="List of all currently running Minecraft Servers", color=color, - timestamp=datetime.now(pytz.timezone('Europe/Berlin')) + timestamp=utils.now() ) for container in containers: @@ -223,6 +274,11 @@ class Spawner(commands.Cog): server_name: str Name of the server that should be removed ''' + try: + User.get(User.username == ctx.author.id) + except: + await ctx.send(f"{ctx.author.name} isn't registered!") + return server_name = server_name.title() @@ -247,7 +303,7 @@ class Spawner(commands.Cog): title="Killed", description=f"{server_name} killed!", color=color, - timestamp=datetime.now(pytz.timezone('Europe/Berlin')) + timestamp=utils.now() ) file = discord.File("../assets/rip.png", filename="rip.png") diff --git a/bot/cogs/user_management.py b/bot/cogs/user_management.py index 5283d20..8d4ba66 100644 --- a/bot/cogs/user_management.py +++ b/bot/cogs/user_management.py @@ -1,12 +1,165 @@ +import discord from discord.ext import commands -from sqlalchemy import create_engine -from sqlalchemy.orm import DeclarativeBase +from models.users import * +from mojang import API +import utils -#engine = create_engine("sqlite://user.sqlite", echo=True) -#connection = engine.connect() - -class User(DeclarativeBase): - pass +color = discord.Color.from_rgb(79, 227, 119) class UserManager(commands.Cog): - pass + def __init__(self, bot: commands.Bot): + self.bot = bot + + def gen_user_info(self, user_name: str, user_id: int): + ''' + Generates user output Embed + + Parameters + ---------- + user_name: str + given user name + user_id: int + discord user id to access database info + ''' + user = User.get(User.username == user_id) + embed = discord.Embed( + title=user_name if not user.is_admin else f'{user_name} (Admin)', + description=f''' + Name: {user.mc_name} + UUID: {user.mc_uuid} + Registered since {user.registration_date.strftime("%d.%m.%Y, %H:%M:%S")} + ''', + color=color, + timestamp=utils.now() + ) + return embed + + @commands.hybrid_command(name='info') + async def info(self, ctx: commands.Context): + ''' + Registers Users to internal Database + and links there minecraft username to there discord account. + + Parameters + ---------- + ctx: commands.Context + The context of the command invocation + ''' + try: + user = User.get(User.username == ctx.author.id) + await ctx.send(embed=self.gen_user_info(ctx.author.name, ctx.author.id)) + except: + await ctx.send(f"{ctx.author.name} isn't registered") + + @commands.hybrid_command(name='register') + async def register(self, ctx: commands.Context, minecraft_name: str): + ''' + Registers Users to internal Database + and links there minecraft username to there discord account. + + Parameters + ---------- + ctx: commands.Context + The context of the command invocation + minecraft_name: str + The minecraft user name to link with + ''' + # Get minecraft uuid + api = API() + uuid = api.get_uuid(minecraft_name) + if not uuid: + await ctx.send("Username doesn't exist") + return + + # build user + try: + user = User( + username=ctx.author.id, + mc_name=minecraft_name, + mc_uuid=uuid, + is_admin=False if not ctx.author.id == 418848241036165160 else True + ) + user.save() + except IntegrityError: + await ctx.send("You're already registered") + return + + await ctx.send(embed=self.gen_user_info(ctx.author.name, ctx.author.id)) + + @commands.hybrid_command(name='delete') + async def delete(self, ctx: commands.Context): + ''' + Registers Users to internal Database + and links there minecraft username to there discord account. + + Parameters + ---------- + ctx: commands.Context + The context of the command invocation + ''' + try: + user = User.get(User.username == ctx.author.id) + user.delete_instance() + await ctx.send(f"Purged {ctx.author.name} from database!") + except: + await ctx.send(f"{ctx.author.name} isn't registered!") + + @commands.hybrid_command(name='op') + async def op(self, ctx: commands.Context, member: discord.Member): + ''' + Toggels if User is an Admin + + Parameters + ---------- + ctx: commands.Context + The context of the command invocation + ''' + user = User.get(User.username == ctx.author.id) + if not user.is_admin: + await ctx.send("You're not allowed to use this command") + return + + if member.id == 418848241036165160: + await ctx.send(f"{member.name} is always an admin") + return + + user = User.get(User.username == member.id) + user.is_admin = not user.is_admin + user.save() + await ctx.send(embed=self.gen_user_info(member.name, member.id)) + + + @commands.hybrid_command(name='all') + async def all(self, ctx: commands.Context): + ''' + Returns User info + + Parameters + ---------- + ctx: commands.Context + The context of the command invocation + ''' + try: + user = User.get(User.username == ctx.author.id) + if not user.is_admin: + await ctx.send("You're not allowed to use this command") + return + except: + await ctx.send(f"{ctx.author.name} isn't registered") + return + + + embed = discord.Embed( + title="All Users", + description="Registered Users in Database", + color=color, + timestamp=utils.now() + ) + + rows = User.select() + for row in rows: + member = await ctx.guild.fetch_member(row.username) + member = member if not row.is_admin else f'{member} (Admin)' + reg_date = row.registration_date.strftime("%d.%m.%Y, %H:%M:%S") + embed.add_field(name=member, value="{}\n{}\n{}\n".format(row.mc_name, row.mc_uuid, reg_date)) + await ctx.send(embed=embed) diff --git a/bot/models/base.py b/bot/models/base.py new file mode 100644 index 0000000..ecd1b90 --- /dev/null +++ b/bot/models/base.py @@ -0,0 +1,10 @@ +from peewee import * +from playhouse.postgres_ext import * +import os +import datetime + +db = SqliteDatabase('bot.db') + +class BaseModel(Model): + class Meta: + database = db diff --git a/bot/models/containers.py b/bot/models/containers.py new file mode 100644 index 0000000..45de73a --- /dev/null +++ b/bot/models/containers.py @@ -0,0 +1,13 @@ +from peewee import * +from models.base import BaseModel +import datetime + +class Container(BaseModel): + # Container + name = + # players = + port = + + # RCON + rcon_port = + rcon_pwd = diff --git a/bot/models/users.py b/bot/models/users.py new file mode 100644 index 0000000..df0b1b9 --- /dev/null +++ b/bot/models/users.py @@ -0,0 +1,12 @@ +from peewee import * +from models.base import BaseModel +import datetime + +class User(BaseModel): + username = CharField(unique=True) + mc_name = CharField(unique=True) + mc_uuid = CharField(unique=True) + is_admin = BooleanField(default=False) + registration_date = DateTimeField(default=datetime.datetime.now()) + +User.create_table() diff --git a/bot/requirements.txt b/bot/requirements.txt index a6dbfe8..193aae9 100644 --- a/bot/requirements.txt +++ b/bot/requirements.txt @@ -11,13 +11,18 @@ docker==7.1.0 frozenlist==1.4.1 greenlet==3.1.1 idna==3.10 +Jinja2==3.1.4 +MarkupSafe==3.0.1 mcrcon==0.7.0 +mojang==1.1.0 multidict==6.1.0 +peewee==3.17.7 +psycopg2-binary==2.9.9 +Pygments==2.18.0 python-dotenv==1.0.1 pytz==2024.2 requests==2.32.3 six==1.16.0 -SQLAlchemy==2.0.35 transitions==0.9.2 typing_extensions==4.12.2 urllib3==2.2.3 diff --git a/bot/utils.py b/bot/utils.py new file mode 100644 index 0000000..78235ce --- /dev/null +++ b/bot/utils.py @@ -0,0 +1,7 @@ +import datetime +import pytz + +now = lambda: datetime.datetime.now(pytz.timezone('Europe/Berlin')) + +if __name__ == '__main__': + print(now())