GPack/discord_bot.py
2026-03-26 13:17:41 +01:00

192 lines
7.7 KiB
Python

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()