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
This commit is contained in:
copilot-swe-agent[bot] 2026-03-23 00:49:34 +00:00
parent 5a9786cb0b
commit 6c1449439c
4 changed files with 785 additions and 1 deletions

View file

@ -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("<II", header)
data = b""
while len(data) < length:
chunk = conn.recv(length - len(data))
if not chunk:
return None, None
data += chunk
try:
payload = json.loads(data.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
payload = data
return opcode, payload
def send_message(conn, opcode, payload):
"""Send a Discord IPC message."""
data = json.dumps(payload).encode("utf-8")
header = struct.pack("<II", opcode, len(data))
conn.sendall(header + data)
def handle_client(conn, addr):
"""Handle a single Discord RPC client connection."""
log(f"Client connected: {addr}")
try:
while True:
opcode, payload = read_message(conn)
if opcode is None:
log("Client disconnected")
break
if opcode == OP_HANDSHAKE:
client_id = payload.get("client_id", "unknown")
log(f"HANDSHAKE received - client_id: {client_id}, version: {payload.get('v')}")
# Send READY response
ready_response = {
"cmd": "DISPATCH",
"evt": "READY",
"data": {
"v": 1,
"config": {
"cdn_host": "cdn.discordapp.com",
"api_endpoint": "https://discord.com/api",
"environment": "production"
},
"user": {
"id": "123456789012345678",
"username": "TestUser",
"discriminator": "0",
"avatar": None,
"global_name": "TestUser"
}
},
"nonce": None
}
send_message(conn, OP_FRAME, ready_response)
log("Sent READY response (user: TestUser)")
elif opcode == OP_FRAME:
cmd = payload.get("cmd", "UNKNOWN")
nonce = payload.get("nonce")
if cmd == "SET_ACTIVITY":
activity = payload.get("args", {}).get("activity", {})
log(f"*** SET_ACTIVITY received ***")
log(f" Details : {activity.get('details', 'N/A')}")
log(f" State : {activity.get('state', 'N/A')}")
assets = activity.get("assets", {})
if assets:
log(f" Large : {assets.get('large_image', 'N/A')} ({assets.get('large_text', '')})")
log(f" Small : {assets.get('small_image', 'N/A')} ({assets.get('small_text', '')})")
timestamps = activity.get("timestamps", {})
if timestamps:
log(f" Start : {timestamps.get('start', 'N/A')}")
party = activity.get("party", {})
if party:
log(f" Party : {party.get('id', '')} size={party.get('size', [])}")
all_presences.append(activity)
presence_received.set()
# Send success response
response = {
"cmd": "SET_ACTIVITY",
"evt": None,
"data": activity,
"nonce": nonce
}
send_message(conn, OP_FRAME, response)
log("Sent SET_ACTIVITY acknowledgement")
else:
log(f"FRAME received - cmd: {cmd}, payload: {json.dumps(payload, indent=2)[:200]}")
# Generic ack
response = {
"cmd": cmd,
"evt": None,
"data": {},
"nonce": nonce
}
send_message(conn, OP_FRAME, response)
elif opcode == OP_CLOSE:
log(f"CLOSE received: {payload}")
break
elif opcode == OP_PING:
log("PING received, sending PONG")
send_message(conn, OP_PONG, payload)
else:
log(f"Unknown opcode {opcode}: {payload}")
except Exception as e:
log(f"Error handling client: {e}")
finally:
conn.close()
log("Client connection closed")
def main():
global SOCKET_PATH
SOCKET_PATH = get_socket_path()
# Clean up old socket
if os.path.exists(SOCKET_PATH):
os.unlink(SOCKET_PATH)
# Clear log
with open(LOG_FILE, "w") as f:
f.write("")
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(SOCKET_PATH)
sock.listen(5)
# Ensure the socket is world-readable/writable
os.chmod(SOCKET_PATH, 0o777)
log(f"Fake Discord IPC listening on: {SOCKET_PATH}")
log("Waiting for RPC client 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 os.path.exists(SOCKET_PATH):
os.unlink(SOCKET_PATH)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
Fake Minecraft Server for testing MCC.
Handles:
- Server List Ping (status)
- Offline-mode Login (no encryption)
- Completes login phase but does NOT send JoinGame
- Sends keep-alive packets to maintain connection
Uses protocol 763 (Minecraft 1.20.1) to avoid the Configuration phase.
The key insight: 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 without needing complex NBT registry data.
"""
import json
import os
import socket
import struct
import sys
import time
import threading
import uuid
HOST = "127.0.0.1"
PORT = 25565
PROTOCOL_VERSION = 763 # 1.20.1
MC_VERSION = "1.20.1"
LOG_FILE = "/tmp/e2e-test/fake_mc_server.log"
client_joined = threading.Event()
def log(msg):
timestamp = time.strftime("%H:%M:%S")
line = f"[{timestamp}] [FakeMCServer] {msg}"
print(line, flush=True)
with open(LOG_FILE, "a") as f:
f.write(line + "\n")
# -- VarInt encoding/decoding --
def write_varint(value):
"""Encode a VarInt."""
result = bytearray()
value &= 0xFFFFFFFF # handle negative values
while True:
byte = value & 0x7F
value >>= 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()

View file

@ -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())

View file

@ -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)