import asyncio import sys import discord from discord import app_commands from datetime import datetime from pathlib import Path from mcstatus import JavaServer from secrets import TOKEN BOT_STATUS_CHANNEL = "bot-status" SCRIPTS_DIR = Path(__file__).parent class GPackBot(discord.Client): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.tree = app_commands.CommandTree(self) self._serve_active = False async def _wait_for_tcp_listen(self, host: str, port: int, timeout_s: float) -> bool: """ Returns True when `host:port` accepts TCP connections. Used to confirm `simple_serve.py` successfully bound before reporting success to Discord. """ deadline = asyncio.get_running_loop().time() + timeout_s last_err: Exception | None = None while asyncio.get_running_loop().time() < deadline: try: reader, writer = await asyncio.open_connection(host=host, port=port) writer.close() await writer.wait_closed() return True except Exception as e: last_err = e await asyncio.sleep(0.2) # Avoid leaking potentially sensitive details; just return False. _ = last_err return False async def setup_hook(self): self.tree.command(name="start", description="Start the server")(self.cmd_start) self.tree.command(name="stop", description="Stop the server")(self.cmd_stop) self.tree.command(name="status", description="Check server status")(self.cmd_status) self.tree.command(name="serve", description="Serve ModPack webpage")(self.cmd_serve) async def on_ready(self): print(f"GPack Bot {self.user} (ID: {self.user.id}) @{datetime.now().strftime('%I:%M%p')}") for guild in self.guilds: self.tree.copy_global_to(guild=guild) await self.tree.sync(guild=guild) self.tree.clear_commands(guild=None) await self.tree.sync() timestamp = datetime.now().strftime("%H:%M") channel = discord.utils.get(self.get_all_channels(), name=BOT_STATUS_CHANNEL) if isinstance(channel, discord.TextChannel): await channel.send(f"Server in Standby! - {timestamp}") async def close(self): timestamp = datetime.now().strftime("%H:%M") channel = discord.utils.get(self.get_all_channels(), name=BOT_STATUS_CHANNEL) if isinstance(channel, discord.TextChannel): await channel.send(f"Server Disabled - {timestamp}") await super().close() async def run_script(self, script: str) -> tuple[int, str, str]: proc = await asyncio.create_subprocess_exec( SCRIPTS_DIR / script, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate() return proc.returncode or 0, stdout.decode(), stderr.decode() async def cmd_start(self, interaction: discord.Interaction): await interaction.response.defer() proc = await asyncio.create_subprocess_shell( "ss -tuln | grep 443", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, _ = await proc.communicate() status = "Online" if stdout.strip() else "Offline" if status == "Offline": code, stdout, stderr = await self.run_script("start.sh") if code == 0: await interaction.followup.send("Server started.") else: await interaction.followup.send(f"Start failed (exit {code}): {stderr or stdout}") else: await interaction.followup.send("Barryslab is running, can't turn on rn") async def cmd_stop(self, interaction: discord.Interaction): await interaction.response.defer() code, stdout, stderr = await self.run_script("stop.sh") if code == 0: await interaction.followup.send("Server stopped.") else: await interaction.followup.send(f"Stop failed (exit {code}): {stderr or stdout}") async def cmd_status(self, interaction: discord.Interaction): proc = await asyncio.create_subprocess_shell( "ss -tuln | grep 25565", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, _ = await proc.communicate() status = "Online" if stdout.strip() else "Offline" if status == "Offline": await interaction.response.send_message(f"Server is {status}.") else: server = JavaServer.lookup("barrys.cloud") status = server.status() await interaction.response.send_message(f"Server has {status.players.online} player(s) online and replied in {status.latency} ms") async def cmd_serve(self, interaction: discord.Interaction): if self._serve_active: await interaction.response.send_message("Serve is already running.", ephemeral=True) return await interaction.response.defer() self._serve_active = True proc = None try: proc = await asyncio.create_subprocess_exec( sys.executable, str(SCRIPTS_DIR / "simple_serve.py"), str(SCRIPTS_DIR), stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) # Confirm the HTTP server actually bound successfully on port 80. # If binding fails (e.g. permissions), the process will exit and no port will listen. bound = await self._wait_for_tcp_listen("127.0.0.1", 80, timeout_s=5) if not bound: # Stop the child process and return the stderr so we can see the real reason. try: proc.terminate() await asyncio.wait_for(proc.wait(), timeout=5) except Exception: try: proc.kill() await proc.wait() except Exception: pass _, stderr = await proc.communicate() err_text = (stderr.decode(errors="replace").strip() if stderr else "").splitlines() err_text = err_text[0] if err_text else "unknown error" await interaction.followup.send( f"Serve failed to start (port 80 not listening): {err_text}", ephemeral=True, ) return await interaction.followup.send( "HTTP server started for 4 minutes. Go to http://barrys.cloud to download the modpack." ) await asyncio.sleep(240) if proc.returncode is None: proc.terminate() try: await asyncio.wait_for(proc.wait(), timeout=15) except asyncio.TimeoutError: proc.kill() await proc.wait() await interaction.followup.send("Serve window ended.") except Exception as e: await interaction.followup.send(f"Serve failed: {e}") finally: self._serve_active = False if proc is not None and proc.returncode is None: proc.terminate() try: await asyncio.wait_for(proc.wait(), timeout=15) except asyncio.TimeoutError: proc.kill() await proc.wait() def main(): print("Hello from gpackbot!") bot = GPackBot(intents=discord.Intents.default()) bot.run(TOKEN) if __name__ == "__main__": main()