diff --git a/matrixbot/commands.py b/matrixbot/commands.py index 3ada0ee..3db7bed 100644 --- a/matrixbot/commands.py +++ b/matrixbot/commands.py @@ -13,7 +13,7 @@ import aiohttp from nio import AsyncClient -from utils import send_text, send_html, send_reaction, edit_html, sanitize_input +from utils import send_text, send_html, send_reaction, edit_html, sanitize_input, rcon_command, RconError from wordle import handle_wordle, wordle_stats as _wordle_stats from welcome import clean_stale_dm_messages from config import ( @@ -1317,29 +1317,40 @@ async def cmd_minecraft(client: AsyncClient, room_id: str, sender: str, args: st await send_text(client, room_id, f"Whitelisting {username}...") try: - from mcrcon import MCRcon - def _rcon(): - with MCRcon(MINECRAFT_RCON_HOST, MINECRAFT_RCON_PASSWORD, port=MINECRAFT_RCON_PORT, timeout=3) as mcr: - return mcr.command(f"whitelist add {username}") + return rcon_command( + MINECRAFT_RCON_HOST, + MINECRAFT_RCON_PASSWORD, + f"whitelist add {username}", + port=MINECRAFT_RCON_PORT, + timeout=RCON_TIMEOUT, + ) loop = asyncio.get_running_loop() response = await asyncio.wait_for(loop.run_in_executor(None, _rcon), timeout=RCON_TIMEOUT) logger.info(f"RCON response: {response}") - plain = f"Minecraft\nYou have been whitelisted on the SMP!\nServer: minecraft.lotusguild.org\nUsername: {username}" + already = "already whitelisted" in response.lower() + status_line = ( + f"↺ {username} was already whitelisted." + if already + else f"✅ {username} is whitelisted and ready to join." + ) + plain = ( + f"✿ Minecraft — Lotus SMP ✿\n" + f"{'Already whitelisted' if already else 'Whitelisted'}: {username}\n" + f"Server: minecraft.lotusguild.org" + ) html = ( - f"Minecraft
" - f"You have been whitelisted on the SMP!
" - f"Server: minecraft.lotusguild.org
" - f"Username: {username}" + f'✿ Minecraft — Lotus SMP ✿
' + f"
" + f"{status_line}
" + f'Server: minecraft.lotusguild.org
' + f"fresh world · hard mode · no takebacks" + f"
" ) await send_html(client, room_id, plain, html) - except ImportError: - await send_text(client, room_id, "mcrcon is not installed. Ask an admin to install it.") - except asyncio.TimeoutError: - await send_text(client, room_id, "Minecraft server timed out. It may be offline.") - except Exception as e: + except (asyncio.TimeoutError, RconError, OSError) as e: logger.error(f"RCON error: {e}", exc_info=True) await send_text(client, room_id, "Failed to whitelist. The server may be offline (let jared know).") diff --git a/matrixbot/requirements.txt b/matrixbot/requirements.txt index 1d79d03..a4f80f9 100644 --- a/matrixbot/requirements.txt +++ b/matrixbot/requirements.txt @@ -2,4 +2,3 @@ matrix-nio[e2e] python-dotenv>=1.2.2 aiohttp markdown -mcrcon diff --git a/matrixbot/utils.py b/matrixbot/utils.py index cb047cb..a7040a2 100644 --- a/matrixbot/utils.py +++ b/matrixbot/utils.py @@ -155,3 +155,50 @@ def sanitize_input(text: str, max_length: int = MAX_INPUT_LENGTH) -> str: text = text.strip()[:max_length] text = "".join(char for char in text if char.isprintable()) return text + + +class RconError(Exception): + pass + + +def rcon_command(host: str, password: str, command: str, port: int = 25575, timeout: float = 5.0) -> str: + """Minimal Source RCON client (Minecraft protocol) using socket timeouts. + + Deliberately avoids the `mcrcon` package: it implements its read timeout + via signal.SIGALRM, which only works on the main thread — this is always + called from a worker thread (via loop.run_in_executor), so that library + raises "signal only works in main thread of the main interpreter" every + time. socket.settimeout() has no such restriction. + """ + import socket + import struct + + def send_packet(sock, pkt_id, pkt_type, payload): + body = struct.pack("