From 221b82643da526ffc5bea8791d9ce2a54519a45b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 01:05:48 +0000 Subject: [PATCH] Fix DiscordRpc to send presence only after game join, apply C# best practices, remove temp E2E scripts Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com> Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/80d151a5-aee7-4ae6-8497-9e7487d25136 --- .../discord-rpc-e2e/fake_discord_ipc.py | 222 -------------- .../scripts/discord-rpc-e2e/fake_mc_server.py | 281 ------------------ .../discord-rpc-e2e/run_e2e_discord_rpc.py | 280 ----------------- MinecraftClient/ChatBots/DiscordBridge.cs | 2 +- MinecraftClient/ChatBots/DiscordRpc.cs | 61 ++-- 5 files changed, 29 insertions(+), 817 deletions(-) delete mode 100644 .skills/mcc-integration-testing/scripts/discord-rpc-e2e/fake_discord_ipc.py delete mode 100644 .skills/mcc-integration-testing/scripts/discord-rpc-e2e/fake_mc_server.py delete mode 100644 .skills/mcc-integration-testing/scripts/discord-rpc-e2e/run_e2e_discord_rpc.py diff --git a/.skills/mcc-integration-testing/scripts/discord-rpc-e2e/fake_discord_ipc.py b/.skills/mcc-integration-testing/scripts/discord-rpc-e2e/fake_discord_ipc.py deleted file mode 100644 index f17a9914..00000000 --- a/.skills/mcc-integration-testing/scripts/discord-rpc-e2e/fake_discord_ipc.py +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env python3 -""" -Fake Discord IPC server. -Listens on a Unix domain socket at the Discord IPC path and handles -the discord-rpc-csharp handshake + SET_ACTIVITY commands. -Logs all received presence updates to stdout and a log file. -""" -import json -import os -import socket -import struct -import sys -import time -import threading - -# Discord IPC opcodes -OP_HANDSHAKE = 0 -OP_FRAME = 1 -OP_CLOSE = 2 -OP_PING = 3 -OP_PONG = 4 - -LOG_FILE = "/tmp/e2e-test/discord_rpc.log" -SOCKET_PATH = None - -presence_received = threading.Event() -all_presences = [] - - -def log(msg): - timestamp = time.strftime("%H:%M:%S") - line = f"[{timestamp}] [FakeDiscordIPC] {msg}" - print(line, flush=True) - with open(LOG_FILE, "a") as f: - f.write(line + "\n") - - -def get_socket_path(): - """Determine where discord-rpc-csharp will look for the IPC socket.""" - runtime_dir = os.environ.get("XDG_RUNTIME_DIR") - if runtime_dir: - return os.path.join(runtime_dir, "discord-ipc-0") - - tmpdir = os.environ.get("TMPDIR", "/tmp") - return os.path.join(tmpdir, "discord-ipc-0") - - -def read_message(conn): - """Read a Discord IPC message: 4-byte LE opcode + 4-byte LE length + JSON.""" - header = b"" - while len(header) < 8: - chunk = conn.recv(8 - len(header)) - if not chunk: - return None, None - header += chunk - - opcode, length = struct.unpack(">= 7 - if value != 0: - byte |= 0x80 - result.append(byte) - if value == 0: - break - return bytes(result) - - -def read_varint(data, offset=0): - """Decode a VarInt, return (value, new_offset).""" - result = 0 - shift = 0 - while True: - byte = data[offset] - offset += 1 - result |= (byte & 0x7F) << shift - if (byte & 0x80) == 0: - break - shift += 7 - if shift >= 35: - raise ValueError("VarInt too large") - # Sign extension for negative values - if result & (1 << 31): - result -= 1 << 32 - return result, offset - - -def read_varint_from_socket(sock): - """Read a VarInt from socket one byte at a time.""" - result = 0 - shift = 0 - while True: - byte_data = sock.recv(1) - if not byte_data: - raise ConnectionError("Connection closed") - byte = byte_data[0] - result |= (byte & 0x7F) << shift - if (byte & 0x80) == 0: - break - shift += 7 - if shift >= 35: - raise ValueError("VarInt too large") - if result & (1 << 31): - result -= 1 << 32 - return result - - -def write_string(s): - """Encode a Minecraft string (VarInt length + UTF-8 bytes).""" - encoded = s.encode("utf-8") - return write_varint(len(encoded)) + encoded - - -def read_string(data, offset): - """Decode a Minecraft string.""" - length, offset = read_varint(data, offset) - s = data[offset:offset + length].decode("utf-8") - return s, offset + length - - -def make_packet(packet_id, payload=b""): - """Wrap data into a Minecraft packet: [length VarInt][packet_id VarInt][payload].""" - pid = write_varint(packet_id) - packet_data = pid + payload - return write_varint(len(packet_data)) + packet_data - - -def read_packet(sock): - """Read a full Minecraft packet from socket. Returns (packet_id, payload_bytes).""" - length = read_varint_from_socket(sock) - if length <= 0: - return None, b"" - data = b"" - while len(data) < length: - chunk = sock.recv(length - len(data)) - if not chunk: - raise ConnectionError("Connection closed mid-packet") - data += chunk - packet_id, offset = read_varint(data, 0) - return packet_id, data[offset:] - - -# -- Packet builders -- - -def build_status_response(): - """Build Status Response (0x00) packet.""" - status = { - "version": {"name": MC_VERSION, "protocol": PROTOCOL_VERSION}, - "players": {"max": 20, "online": 1, "sample": []}, - "description": {"text": "MCC Discord RPC Test Server"}, - "enforcesSecureChat": False, - "previewsChat": False - } - return make_packet(0x00, write_string(json.dumps(status))) - - -def build_ping_response(payload_bytes): - """Build Ping Response (0x01) packet.""" - return make_packet(0x01, payload_bytes) - - -def build_login_success(username): - """Build Login Success (0x02) packet for 1.20.1.""" - player_uuid = uuid.uuid3(uuid.NAMESPACE_DNS, f"OfflinePlayer:{username}") - uuid_bytes = player_uuid.bytes - name_bytes = write_string(username) - num_properties = write_varint(0) # No properties - return make_packet(0x02, uuid_bytes + name_bytes + num_properties) - - -def build_keep_alive(keep_alive_id=0): - """Build Keep Alive (0x24 for 1.20.1) packet.""" - return make_packet(0x24, struct.pack(">q", keep_alive_id)) - - -def handle_client(conn, addr): - """Handle a single MCC client connection.""" - log(f"Client connected from {addr}") - state = "handshake" # handshake -> status or login -> play - username = None - - try: - while True: - packet_id, payload = read_packet(conn) - if packet_id is None: - break - - if state == "handshake": - if packet_id == 0x00: - # Handshake packet - proto, off = read_varint(payload, 0) - host, off = read_string(payload, off) - port = struct.unpack(">H", payload[off:off+2])[0] - off += 2 - next_state, off = read_varint(payload, off) - log(f"Handshake: protocol={proto}, host={host}, port={port}, next_state={next_state}") - - if next_state == 1: - state = "status" - elif next_state == 2: - state = "login" - - elif state == "status": - if packet_id == 0x00: - # Status Request - log("Status Request received, sending response") - conn.sendall(build_status_response()) - elif packet_id == 0x01: - # Ping - log("Ping received, sending Pong") - conn.sendall(build_ping_response(payload)) - break # Status connection is done - - elif state == "login": - if packet_id == 0x00: - # Login Start - username, off = read_string(payload, 0) - log(f"Login Start: username={username}") - - # Send Login Success (offline mode - no encryption) - log(f"Sending Login Success for {username}") - conn.sendall(build_login_success(username)) - - state = "play" - - # Signal that client joined (login phase complete) - client_joined.set() - log(f"*** {username} has logged in successfully! ***") - - # Keep the connection alive without sending JoinGame - # MCC's DiscordRpc bot initializes during the login phase, - # before JoinGame is processed. We keep the connection open - # so the async RPC send can complete. - log("Keeping connection alive (not sending JoinGame to avoid NBT complexity)...") - - # Start keep-alive loop - ka_thread = threading.Thread( - target=keep_alive_loop, args=(conn,), daemon=True - ) - ka_thread.start() - - elif state == "play": - # Just absorb play-state packets from the client silently - pass - - except (ConnectionError, ConnectionResetError, BrokenPipeError) as e: - log(f"Client disconnected: {e}") - except Exception as e: - log(f"Error: {e}") - finally: - conn.close() - if username: - log(f"{username} disconnected") - - -def keep_alive_loop(conn): - """Send keep-alive packets every 10 seconds.""" - ka_id = 0 - try: - while True: - time.sleep(10) - ka_id += 1 - conn.sendall(build_keep_alive(ka_id)) - except Exception: - pass - - -def main(): - with open(LOG_FILE, "w") as f: - f.write("") - - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((HOST, PORT)) - sock.listen(5) - - log(f"Fake MC Server listening on {HOST}:{PORT} (protocol {PROTOCOL_VERSION}, {MC_VERSION})") - log("Waiting for MCC connections...") - - try: - while True: - conn, addr = sock.accept() - t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True) - t.start() - except KeyboardInterrupt: - log("Shutting down...") - finally: - sock.close() - - -if __name__ == "__main__": - main() diff --git a/.skills/mcc-integration-testing/scripts/discord-rpc-e2e/run_e2e_discord_rpc.py b/.skills/mcc-integration-testing/scripts/discord-rpc-e2e/run_e2e_discord_rpc.py deleted file mode 100644 index b509216f..00000000 --- a/.skills/mcc-integration-testing/scripts/discord-rpc-e2e/run_e2e_discord_rpc.py +++ /dev/null @@ -1,280 +0,0 @@ -#!/usr/bin/env python3 -""" -End-to-end test orchestrator for Discord RPC ChatBot. -Starts all components, waits for MCC to connect, and verifies RPC presence is sent. -""" -import os -import signal -import subprocess -import sys -import time - -MCC_DIR = "/home/runner/work/Minecraft-Console-Client/Minecraft-Console-Client" -TEST_DIR = "/tmp/e2e-test" -MC_LOG = f"{TEST_DIR}/fake_mc_server.log" -RPC_LOG = f"{TEST_DIR}/discord_rpc.log" -MCC_LOG = f"{TEST_DIR}/mcc_output.log" - - -def log(msg): - print(f"\033[1;36m[E2E-TEST]\033[0m {msg}", flush=True) - - -def wait_for_log(log_file, marker, timeout=30, label=""): - """Wait for a specific string to appear in a log file.""" - start = time.time() - while time.time() - start < timeout: - try: - with open(log_file, "r") as f: - content = f.read() - if marker in content: - return True - except FileNotFoundError: - pass - time.sleep(0.5) - log(f"TIMEOUT waiting for '{marker}' in {label or log_file}") - return False - - -def read_log(log_file): - try: - with open(log_file, "r") as f: - return f.read() - except FileNotFoundError: - return "" - - -def main(): - os.makedirs(TEST_DIR, exist_ok=True) - - pids = [] - - # Clean up old files - for f in [MC_LOG, RPC_LOG, MCC_LOG]: - if os.path.exists(f): - os.remove(f) - - # Remove any leftover mcc_input.txt - input_file = os.path.join(MCC_DIR, "mcc_input.txt") - if os.path.exists(input_file): - os.remove(input_file) - - try: - # -- Step 1: Start fake Discord IPC -- - log("Starting fake Discord IPC server...") - discord_proc = subprocess.Popen( - [sys.executable, f"{TEST_DIR}/fake_discord_ipc.py"], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - env={**os.environ, "XDG_RUNTIME_DIR": "/tmp"} - ) - pids.append(discord_proc.pid) - time.sleep(1) - - if not wait_for_log(RPC_LOG, "Fake Discord IPC listening", timeout=5, label="Discord IPC"): - log("FAIL: Discord IPC server did not start") - return 1 - log(" OK: Discord IPC server running") - - # -- Step 2: Start fake Minecraft server -- - log("Starting fake Minecraft server...") - mc_proc = subprocess.Popen( - [sys.executable, f"{TEST_DIR}/fake_mc_server.py"], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT - ) - pids.append(mc_proc.pid) - time.sleep(1) - - if not wait_for_log(MC_LOG, "Fake MC Server listening", timeout=5, label="MC Server"): - log("FAIL: MC server did not start") - return 1 - log(" OK: Minecraft server running on 127.0.0.1:25565") - - # -- Step 3: Create MCC configuration -- - log("Creating MCC configuration...") - ini_path = os.path.join(MCC_DIR, "MinecraftClient.ini") - with open(ini_path, "w") as f: - f.write(""" -[Main] -[Main.General] -Account = { Login = "RpcTestBot", Password = "-" } -ServerIP = "127.0.0.1:25565" -MinecraftVersion = "1.20.1" - -[Main.Advanced] -Language = "en_us" -EnableSentry = false - -[Logging] -DebugMessages = true - -[ChatBot] -[ChatBot.DiscordRpc] -Enabled = true -ApplicationId = "123456789012345678" -PresenceDetails = "Playing on {server_host}:{server_port}" -PresenceState = "{dimension} - HP: {health}/{max_health}" -LargeImageKey = "mcc_icon" -LargeImageText = "Minecraft Console Client" -SmallImageKey = "" -SmallImageText = "" -ShowServerAddress = true -ShowCoordinates = true -ShowHealth = true -ShowDimension = true -ShowGamemode = true -ShowElapsedTime = true -ShowPlayerCount = true -UpdateIntervalSeconds = 5 -""") - log(" OK: MinecraftClient.ini created") - - # -- Step 4: Start MCC -- - log("Starting MCC...") - mcc_env = { - **os.environ, - "MCC_FILE_INPUT": "1", - "XDG_RUNTIME_DIR": "/tmp" # So DiscordRPC library finds our fake IPC socket - } - - mcc_proc = subprocess.Popen( - ["dotnet", "run", "--project", "MinecraftClient", "-c", "Release", - "--no-build", "--", "RpcTestBot", "-", "127.0.0.1:25565"], - cwd=MCC_DIR, - stdout=open(MCC_LOG, "w"), - stderr=subprocess.STDOUT, - env=mcc_env - ) - pids.append(mcc_proc.pid) - - # -- Step 5: Wait for MCC to join the server -- - log("Waiting for MCC to connect to server...") - mc_joined = wait_for_log(MC_LOG, "has logged in successfully", timeout=30, label="MC join") - - if mc_joined: - log(" OK: MCC connected to fake Minecraft server") - else: - log(" WARN: Could not confirm MC join in server log") - # Check MCC log for more info - mcc_content = read_log(MCC_LOG) - if "Server was successfully joined" in mcc_content: - log(" OK: MCC reports successful join in its own log") - mc_joined = True - - # -- Step 6: Wait for Discord RPC presence -- - log("Waiting for Discord RPC presence update...") - # Give MCC time to initialize the Discord RPC bot and set presence - rpc_activity = wait_for_log(RPC_LOG, "SET_ACTIVITY received", timeout=30, label="RPC activity") - - if rpc_activity: - log(" OK: Discord RPC presence received!") - else: - log(" INFO: Checking if RPC client attempted connection...") - rpc_content = read_log(RPC_LOG) - if "HANDSHAKE received" in rpc_content: - log(" OK: RPC handshake succeeded, waiting longer for activity...") - rpc_activity = wait_for_log(RPC_LOG, "SET_ACTIVITY received", timeout=20, label="RPC activity (extended)") - elif "Client connected" in rpc_content: - log(" PARTIAL: RPC client connected but no activity sent yet") - - # -- Step 7: Collect and display results -- - log("") - log("=" * 60) - log("END-TO-END TEST RESULTS") - log("=" * 60) - - # Check all criteria - mc_server_ok = "Fake MC Server listening" in read_log(MC_LOG) - discord_ipc_ok = "Fake Discord IPC listening" in read_log(RPC_LOG) - mcc_content = read_log(MCC_LOG) - rpc_content = read_log(RPC_LOG) - - mcc_connected = "has logged in successfully" in read_log(MC_LOG) or "Server was successfully joined" in mcc_content - rpc_handshake = "HANDSHAKE received" in rpc_content - rpc_ready = "Sent READY response" in rpc_content - rpc_presence = "SET_ACTIVITY received" in rpc_content - - # Extract presence details from RPC log - presence_details = "" - presence_state = "" - for line in rpc_content.split("\n"): - if "Details :" in line: - presence_details = line.split("Details :")[1].strip() - if "State :" in line: - presence_state = line.split("State :")[1].strip() - - results = [ - ("Fake MC Server started", mc_server_ok), - ("Fake Discord IPC started", discord_ipc_ok), - ("MCC connected to server", mcc_connected), - ("RPC handshake completed", rpc_handshake), - ("RPC READY sent to client", rpc_ready), - ("RPC presence set", rpc_presence), - ] - - all_pass = True - for label, ok in results: - status = "\033[1;32mPASS\033[0m" if ok else "\033[1;31mFAIL\033[0m" - log(f" [{status}] {label}") - if not ok: - all_pass = False - - if presence_details: - log(f"\n Presence Details: {presence_details}") - if presence_state: - log(f" Presence State : {presence_state}") - - log("") - - # Print relevant logs - log("--- MCC Output (last 30 lines) ---") - mcc_lines = mcc_content.strip().split("\n") - for line in mcc_lines[-30:]: - log(f" {line}") - - log("") - log("--- Discord RPC Log ---") - rpc_lines = rpc_content.strip().split("\n") - for line in rpc_lines: - log(f" {line}") - - log("") - log("--- MC Server Log ---") - mc_content = read_log(MC_LOG) - mc_lines = mc_content.strip().split("\n") - for line in mc_lines: - log(f" {line}") - - log("") - - if all_pass: - log("\033[1;32m*** ALL TESTS PASSED - Discord RPC integration is fully working! ***\033[0m") - return 0 - else: - log("\033[1;33m*** SOME TESTS DID NOT PASS ***\033[0m") - return 1 - - finally: - # Clean up all processes - log("\nCleaning up processes...") - for pid in pids: - try: - os.kill(pid, signal.SIGTERM) - except ProcessLookupError: - pass - - time.sleep(1) - - for pid in pids: - try: - os.kill(pid, signal.SIGKILL) - except ProcessLookupError: - pass - - # Clean up config - ini_path = os.path.join(MCC_DIR, "MinecraftClient.ini") - if os.path.exists(ini_path): - os.remove(ini_path) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/MinecraftClient/ChatBots/DiscordBridge.cs b/MinecraftClient/ChatBots/DiscordBridge.cs index 5a20906e..9f5905de 100644 --- a/MinecraftClient/ChatBots/DiscordBridge.cs +++ b/MinecraftClient/ChatBots/DiscordBridge.cs @@ -381,7 +381,7 @@ namespace MinecraftClient.ChatBots string message = e.Message.Content.Trim(); - if (string.IsNullOrEmpty(message) || string.IsNullOrWhiteSpace(message)) + if (string.IsNullOrWhiteSpace(message)) return; // Relay messages from other bots when configured, but never process commands from them. diff --git a/MinecraftClient/ChatBots/DiscordRpc.cs b/MinecraftClient/ChatBots/DiscordRpc.cs index 6e5c60cb..02a72049 100644 --- a/MinecraftClient/ChatBots/DiscordRpc.cs +++ b/MinecraftClient/ChatBots/DiscordRpc.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using DiscordRPC; using DiscordRPC.Logging; using MinecraftClient.Mapping; @@ -88,11 +87,11 @@ namespace MinecraftClient.ChatBots } } - private DiscordRpcClient? rpcClient; - private int tickCounter; - private int updateIntervalTicks; - private Timestamps? sessionTimestamps; - private float lastHealth; + private DiscordRpcClient? _rpcClient; + private int _tickCounter; + private int _updateIntervalTicks; + private Timestamps? _sessionTimestamps; + private float _lastHealth; public override void Initialize() { @@ -105,32 +104,26 @@ namespace MinecraftClient.ChatBots try { - rpcClient = new DiscordRpcClient(Config.ApplicationId.Trim()) + _rpcClient = new DiscordRpcClient(Config.ApplicationId.Trim()) { Logger = Settings.Config.Logging.DebugMessages ? new ConsoleLogger(LogLevel.Trace) : new ConsoleLogger(LogLevel.None) }; - rpcClient.OnReady += (_, e) => + _rpcClient.OnReady += (_, e) => { LogToConsole(string.Format(Translations.bot_DiscordRpc_connected, e.User.Username)); }; - rpcClient.OnConnectionFailed += (_, e) => + _rpcClient.OnConnectionFailed += (_, e) => { LogToConsole(string.Format(Translations.bot_DiscordRpc_connection_failed, e.FailedPipe)); }; - rpcClient.Initialize(); - updateIntervalTicks = Settings.DoubleToTick(Config.UpdateIntervalSeconds); + _rpcClient.Initialize(); + _updateIntervalTicks = Settings.DoubleToTick(Config.UpdateIntervalSeconds); - if (Config.ShowElapsedTime) - sessionTimestamps = Timestamps.Now; - - lastHealth = Handler.GetHealth(); - - SetPresence(); LogToConsole(Translations.bot_DiscordRpc_initialized); } catch (Exception e) @@ -143,49 +136,51 @@ namespace MinecraftClient.ChatBots public override void OnUnload() { - if (rpcClient is { IsDisposed: false }) + if (_rpcClient is { IsDisposed: false }) { - rpcClient.ClearPresence(); - rpcClient.Dispose(); + _rpcClient.ClearPresence(); + _rpcClient.Dispose(); } - rpcClient = null; + _rpcClient = null; } public override void AfterGameJoined() { if (Config.ShowElapsedTime) - sessionTimestamps = Timestamps.Now; + _sessionTimestamps = Timestamps.Now; + _lastHealth = Handler.GetHealth(); + _tickCounter = 0; SetPresence(); } public override void Update() { - tickCounter++; - if (tickCounter < updateIntervalTicks) + _tickCounter++; + if (_tickCounter < _updateIntervalTicks) return; - tickCounter = 0; + _tickCounter = 0; SetPresence(); } public override void OnHealthUpdate(float health, int food) { - lastHealth = health; + _lastHealth = health; } public override bool OnDisconnect(DisconnectReason reason, string message) { - if (rpcClient is { IsDisposed: false }) - rpcClient.ClearPresence(); + if (_rpcClient is { IsDisposed: false }) + _rpcClient.ClearPresence(); return false; } private void SetPresence() { - if (rpcClient is null or { IsDisposed: true }) + if (_rpcClient is null or { IsDisposed: true }) return; try @@ -223,8 +218,8 @@ namespace MinecraftClient.ChatBots presence.Assets = assets; // Timestamps - if (Config.ShowElapsedTime && sessionTimestamps is not null) - presence.Timestamps = sessionTimestamps; + if (Config.ShowElapsedTime && _sessionTimestamps is not null) + presence.Timestamps = _sessionTimestamps; // Player count as party if (Config.ShowPlayerCount) @@ -242,7 +237,7 @@ namespace MinecraftClient.ChatBots } } - rpcClient.SetPresence(presence); + _rpcClient.SetPresence(presence); } catch (Exception e) { @@ -282,7 +277,7 @@ namespace MinecraftClient.ChatBots dimensionName = dim.Name ?? "Unknown"; // Clean up the dimension name for display - if (dimensionName.StartsWith("minecraft:")) + if (dimensionName.StartsWith("minecraft:", StringComparison.Ordinal)) dimensionName = dimensionName["minecraft:".Length..]; dimensionName = dimensionName switch