From 6c1449439ce8c83732c0d75334dfeff03b5c28fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:49:34 +0000 Subject: [PATCH] Add Discord RPC E2E test scripts and address code review feedback Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com> Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/a41f3e83-c88d-4c57-971b-54c380891556 --- .../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 | 3 +- 4 files changed, 785 insertions(+), 1 deletion(-) create mode 100644 .skills/mcc-integration-testing/scripts/discord-rpc-e2e/fake_discord_ipc.py create mode 100644 .skills/mcc-integration-testing/scripts/discord-rpc-e2e/fake_mc_server.py create 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 new file mode 100644 index 00000000..f17a9914 --- /dev/null +++ b/.skills/mcc-integration-testing/scripts/discord-rpc-e2e/fake_discord_ipc.py @@ -0,0 +1,222 @@ +#!/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 new file mode 100644 index 00000000..b509216f --- /dev/null +++ b/.skills/mcc-integration-testing/scripts/discord-rpc-e2e/run_e2e_discord_rpc.py @@ -0,0 +1,280 @@ +#!/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 e2c5e757..5a20906e 100644 --- a/MinecraftClient/ChatBots/DiscordBridge.cs +++ b/MinecraftClient/ChatBots/DiscordBridge.cs @@ -384,7 +384,8 @@ namespace MinecraftClient.ChatBots if (string.IsNullOrEmpty(message) || string.IsNullOrWhiteSpace(message)) return; - // Relay messages from other bots when configured, but never process commands from them + // Relay messages from other bots when configured, but never process commands from them. + // Skip relay when direction is Discord-only (Discord -> MC disabled). if (e.Author.IsBot) { if (Config.Allow_Other_Bot_Messages && bridgeDirection != BridgeDirection.Discord)