192 lines
7.7 KiB
Python
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()
|
|
|