fix(matrixbot): replace mcrcon with a thread-safe RCON client
mcrcon implements its read timeout with signal.SIGALRM, which only works on the main thread. The Minecraft commands call it from a worker thread via loop.run_in_executor, so it raised "signal only works in main thread of the main interpreter" on every invocation. The replacement uses socket.settimeout(), which has no such restriction. This code was already deployed on LXC 151 but had never been committed, so any matrixbot deploy would have reverted it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+26
-15
@@ -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"↺ <strong>{username}</strong> was already whitelisted."
|
||||
if already
|
||||
else f"✅ <strong>{username}</strong> 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"<strong>Minecraft</strong><br>"
|
||||
f"You have been whitelisted on the SMP!<br>"
|
||||
f"Server: <strong>minecraft.lotusguild.org</strong><br>"
|
||||
f"Username: <strong>{username}</strong>"
|
||||
f'<font color="#980000"><strong>✿ Minecraft — Lotus SMP ✿</strong></font><br>'
|
||||
f"<blockquote>"
|
||||
f"{status_line}<br>"
|
||||
f'<strong>Server:</strong> <code>minecraft.lotusguild.org</code><br>'
|
||||
f"<sup><em>fresh world · hard mode · no takebacks</em></sup>"
|
||||
f"</blockquote>"
|
||||
)
|
||||
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).")
|
||||
|
||||
|
||||
@@ -2,4 +2,3 @@ matrix-nio[e2e]
|
||||
python-dotenv>=1.2.2
|
||||
aiohttp
|
||||
markdown
|
||||
mcrcon
|
||||
|
||||
@@ -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("<ii", pkt_id, pkt_type) + payload.encode("utf-8") + b"\x00\x00"
|
||||
sock.sendall(struct.pack("<i", len(body)) + body)
|
||||
|
||||
def read_packet(sock):
|
||||
def recv_exact(n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise RconError("Connection closed by server")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
length = struct.unpack("<i", recv_exact(4))[0]
|
||||
data = recv_exact(length)
|
||||
pkt_id, _pkt_type = struct.unpack("<ii", data[:8])
|
||||
return pkt_id, data[8:-2].decode("utf-8", "replace")
|
||||
|
||||
with socket.create_connection((host, port), timeout=timeout) as sock:
|
||||
sock.settimeout(timeout)
|
||||
send_packet(sock, 1, 3, password)
|
||||
auth_id, _ = read_packet(sock)
|
||||
if auth_id == -1:
|
||||
raise RconError("RCON authentication failed (wrong password)")
|
||||
|
||||
send_packet(sock, 2, 2, command)
|
||||
_, response = read_packet(sock)
|
||||
return response
|
||||
|
||||
Reference in New Issue
Block a user