import discord from discord.ext import commands, tasks from discord import app_commands, ui, File from mcstatus import JavaServer, BedrockServer import aiosqlite import os import re from datetime import datetime from PIL import Image, ImageDraw, ImageFont import io import asyncio import base64 from utils.emoji import SUCCESS, ERROR, WARNING_UNICODE, CLOCK, REFRESH, JAVA_COFFEE # --- Updated Asset Paths --- ASSETS_DIR = "assets" FONT_PATH = os.path.join(ASSETS_DIR, "fonts", "minecraft.ttf") BACKGROUND_PATH = os.path.join(ASSETS_DIR, "background", "background.png") DB_PATH = "db/minecraft.db" # --- Other Constants --- REFRESH_COOLDOWN_SECONDS = 30 CANVAS_WIDTH = 800 CANVAS_HEIGHT = 125 # --- Emojis (imported from utils.emoji) --- EMOJI_STATUS_ONLINE = "๐ŸŸข" EMOJI_STATUS_OFFLINE = "๐Ÿ”ด" EMOJI_PLAYERS = "๐Ÿ‘ฅ" EMOJI_INFO = "๐Ÿ’ป" EMOJI_SUCCESS = SUCCESS EMOJI_ERROR = ERROR EMOJI_WARNING = WARNING_UNICODE EMOJI_CLOCK = CLOCK EMOJI_REFRESH = REFRESH EMOJI_JAVA = JAVA_COFFEE EMOJI_BEDROCK = "๐Ÿ“ฑ" EMOJI_PROXY = "๐Ÿ”Œ" class SetupModal(ui.Modal, title="Minecraft Server Setup"): server_ip = ui.TextInput(label="Enter Server IP Address", placeholder="e.g., play.hypixel.net", required=True) server_port = ui.TextInput(label="Enter Server Port (Optional)", placeholder="Leave blank for default ports", required=False) def __init__(self, cog): super().__init__(timeout=None) self.cog = cog async def on_submit(self, interaction: discord.Interaction): await interaction.response.defer(ephemeral=True) ip, port_str = self.server_ip.value.strip(), self.server_port.value.strip() port = int(port_str) if port_str.isdigit() else None detected_type, status, _ = await self.cog.auto_detect_server(ip, port) if not detected_type or not status or not status['online']: await interaction.followup.send(f"{EMOJI_ERROR} Could not reach or identify the server. Check IP/Port and try again.", ephemeral=True) return embed, file, view = await self.cog.generate_response(interaction.guild, detected_type, ip, port, interaction.user.id) sent_message = await interaction.channel.send(embed=embed, file=file, view=view) await self.cog.save_status_message(interaction.guild.id, interaction.user.id, interaction.channel.id, sent_message.id, detected_type, ip, port) await interaction.followup.send(f"{EMOJI_SUCCESS} Auto-updating status for `{ip}` set up!", ephemeral=True) class MinecraftView(ui.View): def __init__(self, bot, server_type, ip, port, user_id): super().__init__(timeout=None) self.bot, self.server_type, self.ip, self.port, self.user_id = bot, server_type, ip, port, user_id self.last_refresh = None @ui.button(label="Refresh", style=discord.ButtonStyle.secondary, custom_id="refresh_button", emoji=EMOJI_REFRESH) async def refresh_button(self, interaction: discord.Interaction, button: discord.ui.Button): if interaction.user.id != self.user_id: return await interaction.response.send_message(f"{EMOJI_ERROR} You can't refresh this panel.", ephemeral=True) now = datetime.utcnow() if self.last_refresh and (now - self.last_refresh).total_seconds() < REFRESH_COOLDOWN_SECONDS: return await interaction.response.send_message(f"{EMOJI_CLOCK} On cooldown. Wait `{REFRESH_COOLDOWN_SECONDS}` seconds.", ephemeral=True) await interaction.response.defer(ephemeral=True) cog = self.bot.get_cog("Minecraft") embed, file, view = await cog.generate_response(interaction.guild, self.server_type, self.ip, self.port, self.user_id) await interaction.message.edit(embed=embed, attachments=[file] if file else [], view=view) await interaction.followup.send(f"{EMOJI_SUCCESS} Status refreshed.", ephemeral=True) self.last_refresh = now class Minecraft(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) os.makedirs(os.path.dirname(FONT_PATH), exist_ok=True) os.makedirs(os.path.dirname(BACKGROUND_PATH), exist_ok=True) self.bot.loop.create_task(self.init_db()) self.refresh_all_statuses.start() async def init_db(self): async with aiosqlite.connect(DB_PATH) as db: await db.execute(""" CREATE TABLE IF NOT EXISTS mc_status_messages ( guild_id INTEGER PRIMARY KEY, user_id INTEGER, channel_id INTEGER, message_id INTEGER, server_type TEXT, server_ip TEXT, server_port INTEGER )""") await db.commit() def clean_motd(self, motd: str) -> str: return re.sub(r'ยง.', '', motd).strip() async def auto_detect_server(self, ip: str, port: int = None): try: java_port = port or 25565 server = JavaServer(ip, java_port) status = await asyncio.wait_for(server.async_status(tries=1), timeout=5) version_name = status.version.name.lower() detected_type = 'velocity' if 'velocity' in version_name else 'bungeecord' if 'bungee' in version_name else 'java' favicon = getattr(status, 'favicon', None) motd_raw = status.description full_text = "".join(part.get('text', '') for part in motd_raw.get('extra', [])) or motd_raw.get('text', '') if isinstance(motd_raw, dict) else str(motd_raw) status_data = { "motd": self.clean_motd(full_text) or "A Minecraft Server", "players_online": status.players.online, "players_max": status.players.max, "players_sample": status.players.sample, "version": status.version.name, "online": True} return detected_type, status_data, favicon except Exception: pass try: bedrock_port = port or 19132 server = BedrockServer(ip, bedrock_port) status = await asyncio.wait_for(server.async_status(tries=1), timeout=5) status_data = { "motd": self.clean_motd(status.motd) or "A Minecraft Server", "players_online": status.players.online, "players_max": status.players.max, "players_sample": None, "version": status.version.name, "online": True} return 'bedrock', status_data, None except Exception: pass return None, {"online": False}, None async def create_status_image(self, status_data, ip, port, server_type, server_icon_b64): try: bg = Image.open(BACKGROUND_PATH).convert("RGBA") img = Image.new("RGBA", (CANVAS_WIDTH, CANVAS_HEIGHT)) for i in range(0, img.width, bg.width): for j in range(0, img.height, bg.height): img.paste(bg, (i, j)) draw = ImageDraw.Draw(img) font_large, font_medium, font_small = ImageFont.truetype(FONT_PATH, 24), ImageFont.truetype(FONT_PATH, 20), ImageFont.truetype(FONT_PATH, 16) if server_icon_b64: icon_data = base64.b64decode(server_icon_b64.split(',')[-1]) icon_img = Image.open(io.BytesIO(icon_data)).convert("RGBA").resize((80, 80), Image.Resampling.LANCZOS) img.paste(icon_img, (20, (CANVAS_HEIGHT - 80) // 2), icon_img) default_port = 25565 if server_type != 'bedrock' else 19132 full_ip = f"{ip}:{port}" if port and port != default_port else ip draw.text((120, 15), f"IP: {full_ip}", font=font_medium, fill=(255, 255, 255)) player_text = f"{status_data.get('players_online', 0)}/{status_data.get('players_max', 0)}" bbox = draw.textbbox((0, 0), player_text, font=font_medium) draw.text((CANVAS_WIDTH - (bbox[2] - bbox[0]) - 20, 15), player_text, font=font_medium, fill=(255, 255, 255)) motd = (status_data.get('motd', 'Offline') or "A Minecraft Server").split('')[0] bbox = draw.textbbox((0,0), motd, font=font_large) draw.text(((CANVAS_WIDTH - (bbox[2] - bbox[0])) // 2, 60), motd, font=font_large, fill=(0, 255, 255)) power_text = "Powered by STACY" bbox = draw.textbbox((0,0), power_text, font=font_small) draw.text((CANVAS_WIDTH - (bbox[2] - bbox[0]) - 15, CANVAS_HEIGHT - 25), power_text, font=font_small, fill=(170, 170, 170)) buffer = io.BytesIO() img.save(buffer, "PNG") buffer.seek(0) return File(buffer, filename="status.png") except FileNotFoundError: return None except Exception: return None async def generate_response(self, guild, server_type, ip, port, user_id): _, status, favicon = await self.auto_detect_server(ip, port) if not status: status = {'online': False} file = await self.create_status_image(status, ip, port, server_type, favicon) is_online = status.get("online", False) embed = discord.Embed(title=f"{EMOJI_STATUS_ONLINE if is_online else EMOJI_STATUS_OFFLINE} Minecraft Server Status", color=discord.Color.dark_green() if is_online else discord.Color.red()) default_port = 25565 if server_type != 'bedrock' else 19132 full_ip = f"{ip}:{port}" if port and port != default_port else ip type_emoji_map = {'java': EMOJI_JAVA, 'bedrock': EMOJI_BEDROCK, 'velocity': EMOJI_PROXY, 'bungeecord': EMOJI_PROXY} type_emoji = type_emoji_map.get(server_type, EMOJI_INFO) info_value = (f"**IP:** `{full_ip}`\n" f"**Type:** {type_emoji} {server_type.capitalize()}\n" f"**Version:** `{status.get('version', 'Unknown')}`\n") embed.add_field(name=f"{EMOJI_INFO} Server Information", value=info_value, inline=False) players_online = status.get('players_online', 0) players_max = status.get('players_max', 0) player_list_str = f"**Online:** `{players_online}/{players_max}`\n" player_sample = status.get('players_sample') if player_sample: player_names = [player.name for player in player_sample] player_list_str += "\n".join(f"`{i+1}.` {name}" for i, name in enumerate(player_names[:10])) if len(player_names) > 10: player_list_str += f"*...and {len(player_names) - 10} more*" elif is_online: player_list_str += "*Player list is hidden or unavailable.*" embed.add_field(name=f"{EMOJI_PLAYERS} Players", value=player_list_str, inline=False) if file: embed.set_image(url="attachment://status.png") embed.set_footer(text="Last updated") embed.timestamp = datetime.utcnow() view = MinecraftView(self.bot, server_type, ip, port, user_id) return embed, file, view async def save_status_message(self, guild_id, user_id, channel_id, message_id, server_type, ip, port): async with aiosqlite.connect(DB_PATH) as db: await db.execute(""" INSERT INTO mc_status_messages (guild_id, user_id, channel_id, message_id, server_type, server_ip, server_port) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(guild_id) DO UPDATE SET user_id=excluded.user_id, channel_id=excluded.channel_id, message_id=excluded.message_id, server_type=excluded.server_type, server_ip=excluded.server_ip, server_port=excluded.server_port """, (guild_id, user_id, channel_id, message_id, server_type, ip, port)) await db.commit() async def delete_status_message(self, guild_id): async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute("SELECT channel_id, message_id FROM mc_status_messages WHERE guild_id = ?", (guild_id,)) if row := await cursor.fetchone(): try: channel = await self.bot.fetch_channel(row[0]); msg = await channel.fetch_message(row[1]); await msg.delete() except (discord.NotFound, discord.Forbidden): pass await db.execute("DELETE FROM mc_status_messages WHERE guild_id = ?", (guild_id,)); await db.commit() return True return False @tasks.loop(minutes=2) async def refresh_all_statuses(self): await self.bot.wait_until_ready() async with aiosqlite.connect(DB_PATH) as db: async with db.execute("SELECT guild_id, user_id, channel_id, message_id, server_type, server_ip, server_port FROM mc_status_messages") as cursor: async for row in cursor: try: guild = self.bot.get_guild(row[0]) if not guild: continue channel = guild.get_channel(row[2]) if not channel: continue message = await channel.fetch_message(row[3]) embed, file, view = await self.generate_response(guild, row[4], row[5], row[6], row[1]) await message.edit(embed=embed, attachments=[file] if file else [], view=view) await asyncio.sleep(2) except discord.NotFound: await self.delete_status_message(row[0]) except Exception as e: print(f"Auto-refresh error for guild {row[0]}: {e}") minecraft = app_commands.Group(name="minecraft", description="Commands for Minecraft server status.") @minecraft.command(name="setup", description="Set up the auto-updating Minecraft server status.") @app_commands.checks.has_permissions(manage_guild=True) async def setup_slash(self, interaction: discord.Interaction): async with aiosqlite.connect(DB_PATH) as db: if await (await db.execute("SELECT 1 FROM mc_status_messages WHERE guild_id = ?", (interaction.guild.id,))).fetchone(): return await interaction.response.send_message(f"{EMOJI_WARNING} Setup already exists. Use `/minecraft reset`.", ephemeral=True) await interaction.response.send_modal(SetupModal(self)) @minecraft.command(name="reset", description="Remove the Minecraft server status setup.") @app_commands.checks.has_permissions(manage_guild=True) async def reset_slash(self, interaction: discord.Interaction): await interaction.response.defer(ephemeral=True) removed = await self.delete_status_message(interaction.guild.id) await interaction.followup.send(f"{EMOJI_SUCCESS} Minecraft status panel removed." if removed else f"{EMOJI_WARNING} No setup found.", ephemeral=True) @minecraft.command(name="status", description="Get a one-time status of the configured server.") async def status_slash(self, interaction: discord.Interaction): await interaction.response.defer(ephemeral=False) async with aiosqlite.connect(DB_PATH) as db: row = await (await db.execute("SELECT server_type, server_ip, server_port, user_id FROM mc_status_messages WHERE guild_id = ?", (interaction.guild.id,))).fetchone() if not row: return await interaction.followup.send(f"{EMOJI_WARNING} No server configured. Use `/minecraft setup`.", ephemeral=True) embed, file, _ = await self.generate_response(interaction.guild, row[0], row[1], row[2], row[3]) await interaction.followup.send(embed=embed, file=file, ephemeral=False) async def setup(bot: commands.Bot): await bot.add_cog(Minecraft(bot))