From 0a463f2380c568e2e73542890750d3e581fa2cc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 23:50:40 +0000 Subject: [PATCH 1/7] Initial plan From a2d032b5497a2e06d432e295e78031bb972d02e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:03:55 +0000 Subject: [PATCH 2/7] Add Discord Rich Presence ChatBot integration - Add DiscordRichPresence NuGet package (v1.143.0) - Create DiscordRpc.cs ChatBot with configurable presence display - Wire config in Settings.cs ChatBotConfig class - Register bot in McClient.cs RegisterBots() - Add translation strings to Translations.resx and Designer - Add config comments to ConfigComments.resx and Designer - Support placeholders: {server_host}, {server_port}, {username}, {health}, {max_health}, {food}, {dimension}, {gamemode}, {x}, {y}, {z}, {player_count}, {protocol} Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com> Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/43388021-4264-47e6-8403-7a1bca66d86f --- MinecraftClient/ChatBots/DiscordRpc.cs | 309 ++++++++++++++++++ MinecraftClient/McClient.cs | 1 + MinecraftClient/MinecraftClient.csproj | 1 + .../ConfigComments/ConfigComments.Designer.cs | 99 ++++++ .../ConfigComments/ConfigComments.resx | 33 ++ .../Translations/Translations.Designer.cs | 72 ++++ .../Resources/Translations/Translations.resx | 24 ++ MinecraftClient/Settings.cs | 7 + 8 files changed, 546 insertions(+) create mode 100644 MinecraftClient/ChatBots/DiscordRpc.cs diff --git a/MinecraftClient/ChatBots/DiscordRpc.cs b/MinecraftClient/ChatBots/DiscordRpc.cs new file mode 100644 index 00000000..0e17b910 --- /dev/null +++ b/MinecraftClient/ChatBots/DiscordRpc.cs @@ -0,0 +1,309 @@ +using System; +using System.Diagnostics; +using DiscordRPC; +using DiscordRPC.Logging; +using MinecraftClient.Mapping; +using MinecraftClient.Scripting; +using Tomlet.Attributes; + +namespace MinecraftClient.ChatBots +{ + /// + /// Displays a Discord Rich Presence status showing the player's + /// current Minecraft session information (server, health, dimension, etc.). + /// Requires a Discord Application ID from https://discord.com/developers/applications + /// + public class DiscordRpc : ChatBot + { + public static Configs Config = new(); + + [TomlDoNotInlineObject] + public class Configs + { + [NonSerialized] + private const string BotName = "DiscordRpc"; + + public bool Enabled = false; + + [TomlInlineComment("$ChatBot.DiscordRpc.ApplicationId$")] + public string ApplicationId = string.Empty; + + [TomlInlineComment("$ChatBot.DiscordRpc.PresenceDetails$")] + public string PresenceDetails = "Playing on {server_host}:{server_port}"; + + [TomlInlineComment("$ChatBot.DiscordRpc.PresenceState$")] + public string PresenceState = "{dimension} - HP: {health}/{max_health}"; + + [TomlInlineComment("$ChatBot.DiscordRpc.LargeImageKey$")] + public string LargeImageKey = "mcc_icon"; + + [TomlInlineComment("$ChatBot.DiscordRpc.LargeImageText$")] + public string LargeImageText = "Minecraft Console Client"; + + [TomlInlineComment("$ChatBot.DiscordRpc.SmallImageKey$")] + public string SmallImageKey = string.Empty; + + [TomlInlineComment("$ChatBot.DiscordRpc.SmallImageText$")] + public string SmallImageText = string.Empty; + + [TomlInlineComment("$ChatBot.DiscordRpc.ShowElapsedTime$")] + public bool ShowElapsedTime = true; + + [TomlInlineComment("$ChatBot.DiscordRpc.ShowPlayerCount$")] + public bool ShowPlayerCount = true; + + [TomlInlineComment("$ChatBot.DiscordRpc.UpdateIntervalSeconds$")] + public int UpdateIntervalSeconds = 10; + + public void OnSettingUpdate() + { + ApplicationId ??= string.Empty; + PresenceDetails ??= string.Empty; + PresenceState ??= string.Empty; + LargeImageKey ??= string.Empty; + LargeImageText ??= string.Empty; + SmallImageKey ??= string.Empty; + SmallImageText ??= string.Empty; + + if (UpdateIntervalSeconds < 1) + { + UpdateIntervalSeconds = 10; + LogToConsole(BotName, Translations.bot_DiscordRpc_invalid_interval); + } + } + } + + private DiscordRpcClient? rpcClient; + private int tickCounter; + private int updateIntervalTicks; + private Timestamps? sessionTimestamps; + private float lastHealth; + + public override void Initialize() + { + if (string.IsNullOrWhiteSpace(Config.ApplicationId)) + { + LogToConsole(Translations.bot_DiscordRpc_missing_app_id); + UnloadBot(); + return; + } + + try + { + rpcClient = new DiscordRpcClient(Config.ApplicationId.Trim()) + { + Logger = Settings.Config.Logging.DebugMessages + ? new ConsoleLogger(LogLevel.Trace) + : new ConsoleLogger(LogLevel.None) + }; + + rpcClient.OnReady += (_, e) => + { + LogToConsole(string.Format(Translations.bot_DiscordRpc_connected, e.User.Username)); + }; + + rpcClient.OnConnectionFailed += (_, e) => + { + LogToConsole(string.Format(Translations.bot_DiscordRpc_connection_failed, e.FailedPipe)); + }; + + 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) + { + LogToConsole(string.Format(Translations.bot_DiscordRpc_init_error, e.Message)); + LogDebugToConsole(e.StackTrace ?? string.Empty); + UnloadBot(); + } + } + + public override void OnUnload() + { + if (rpcClient is { IsDisposed: false }) + { + rpcClient.ClearPresence(); + rpcClient.Dispose(); + } + + rpcClient = null; + } + + public override void AfterGameJoined() + { + if (Config.ShowElapsedTime) + sessionTimestamps = Timestamps.Now; + + SetPresence(); + } + + public override void Update() + { + tickCounter++; + if (tickCounter < updateIntervalTicks) + return; + + tickCounter = 0; + SetPresence(); + } + + public override void OnHealthUpdate(float health, int food) + { + lastHealth = health; + } + + public override bool OnDisconnect(DisconnectReason reason, string message) + { + if (rpcClient is { IsDisposed: false }) + rpcClient.ClearPresence(); + + return false; + } + + private void SetPresence() + { + if (rpcClient is null or { IsDisposed: true }) + return; + + try + { + string details = ReplacePlaceholders(Config.PresenceDetails); + string state = ReplacePlaceholders(Config.PresenceState); + + var presence = new RichPresence + { + Details = TruncateForDiscord(details, 128), + State = TruncateForDiscord(state, 128) + }; + + // Assets (images) + var assets = new Assets(); + bool hasAssets = false; + + if (!string.IsNullOrWhiteSpace(Config.LargeImageKey)) + { + assets.LargeImageKey = Config.LargeImageKey.Trim(); + assets.LargeImageText = TruncateForDiscord( + ReplacePlaceholders(Config.LargeImageText), 128); + hasAssets = true; + } + + if (!string.IsNullOrWhiteSpace(Config.SmallImageKey)) + { + assets.SmallImageKey = Config.SmallImageKey.Trim(); + assets.SmallImageText = TruncateForDiscord( + ReplacePlaceholders(Config.SmallImageText), 128); + hasAssets = true; + } + + if (hasAssets) + presence.Assets = assets; + + // Timestamps + if (Config.ShowElapsedTime && sessionTimestamps is not null) + presence.Timestamps = sessionTimestamps; + + // Player count as party + if (Config.ShowPlayerCount) + { + string[] onlinePlayers = GetOnlinePlayers(); + int playerCount = onlinePlayers.Length; + if (playerCount > 0) + { + presence.Party = new Party + { + ID = $"mcc_{GetServerHost()}_{GetServerPort()}", + Size = playerCount, + Max = Math.Max(playerCount, playerCount) + }; + } + } + + rpcClient.SetPresence(presence); + } + catch (Exception e) + { + LogDebugToConsole(string.Format(Translations.bot_DiscordRpc_update_error, e.Message)); + } + } + + private string ReplacePlaceholders(string template) + { + if (string.IsNullOrEmpty(template)) + return string.Empty; + + string serverHost = GetServerHost(); + int serverPort = GetServerPort(); + string username = GetUsername(); + float health = Handler.GetHealth(); + int food = Handler.GetSaturation(); + Location location = GetCurrentLocation(); + string[] onlinePlayers = GetOnlinePlayers(); + int gamemode = GetGamemode(); + int protocolVersion = GetProtocolVersion(); + + string dimensionName = "Unknown"; + try + { + var dim = World.GetDimension(); + dimensionName = dim.Name ?? "Unknown"; + + // Clean up the dimension name for display + if (dimensionName.StartsWith("minecraft:")) + dimensionName = dimensionName["minecraft:".Length..]; + + dimensionName = dimensionName switch + { + "overworld" => "Overworld", + "the_nether" => "The Nether", + "the_end" => "The End", + _ => dimensionName + }; + } + catch + { + // World may not be available + } + + string gamemodeStr = gamemode switch + { + 0 => "Survival", + 1 => "Creative", + 2 => "Adventure", + 3 => "Spectator", + _ => "Unknown" + }; + + return template + .Replace("{server_host}", serverHost) + .Replace("{server_port}", serverPort.ToString()) + .Replace("{username}", username) + .Replace("{health}", ((int)Math.Ceiling(health)).ToString()) + .Replace("{max_health}", "20") + .Replace("{food}", food.ToString()) + .Replace("{dimension}", dimensionName) + .Replace("{gamemode}", gamemodeStr) + .Replace("{x}", ((int)location.X).ToString()) + .Replace("{y}", ((int)location.Y).ToString()) + .Replace("{z}", ((int)location.Z).ToString()) + .Replace("{player_count}", onlinePlayers.Length.ToString()) + .Replace("{protocol}", protocolVersion.ToString()); + } + + private static string TruncateForDiscord(string value, int maxLength) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + return value.Length <= maxLength ? value : value[..(maxLength - 3)] + "..."; + } + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index b5ee2278..d0f86a2e 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -430,6 +430,7 @@ namespace MinecraftClient if (Config.ChatBot.ScriptScheduler.Enabled) { BotLoad(new ScriptScheduler()); } if (Config.ChatBot.TelegramBridge.Enabled) { BotLoad(new TelegramBridge()); } if (Config.ChatBot.ItemsCollector.Enabled) { BotLoad(new ItemsCollector()); } + if (Config.ChatBot.DiscordRpc.Enabled) { BotLoad(new DiscordRpc()); } if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("MCC_FILE_INPUT"))) BotLoad(new FileInputBot()); } diff --git a/MinecraftClient/MinecraftClient.csproj b/MinecraftClient/MinecraftClient.csproj index 6b0e4198..a25750c2 100644 --- a/MinecraftClient/MinecraftClient.csproj +++ b/MinecraftClient/MinecraftClient.csproj @@ -31,6 +31,7 @@ + diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs index 4dcf11cd..c01667d5 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs @@ -898,6 +898,105 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to Show a Discord Rich Presence status with your current Minecraft session info.. + /// + internal static string ChatBot_DiscordRpc { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your Discord Application ID.. + /// + internal static string ChatBot_DiscordRpc_ApplicationId { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.ApplicationId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The top line of the Rich Presence display. Supports placeholders.. + /// + internal static string ChatBot_DiscordRpc_PresenceDetails { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.PresenceDetails", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The second line of the Rich Presence display. Supports placeholders.. + /// + internal static string ChatBot_DiscordRpc_PresenceState { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.PresenceState", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The key of the large image asset uploaded to your Discord application.. + /// + internal static string ChatBot_DiscordRpc_LargeImageKey { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.LargeImageKey", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tooltip text for the large image. Supports placeholders.. + /// + internal static string ChatBot_DiscordRpc_LargeImageText { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.LargeImageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The key of the small image asset uploaded to your Discord application (leave empty to hide).. + /// + internal static string ChatBot_DiscordRpc_SmallImageKey { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.SmallImageKey", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tooltip text for the small image. Supports placeholders.. + /// + internal static string ChatBot_DiscordRpc_SmallImageText { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.SmallImageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show elapsed session time in the Discord presence.. + /// + internal static string ChatBot_DiscordRpc_ShowElapsedTime { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.ShowElapsedTime", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show the online player count as a party size in the Discord presence.. + /// + internal static string ChatBot_DiscordRpc_ShowPlayerCount { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.ShowPlayerCount", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to How often (in seconds) to refresh the Discord presence. Minimum: 1. + /// + internal static string ChatBot_DiscordRpc_UpdateIntervalSeconds { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.UpdateIntervalSeconds", resourceCulture); + } + } + /// /// Looks up a localized string similar to Relay messages between players and servers, like a mail plugin ///This bot can store messages when the recipients are offline, and send them when they join the server diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index 53be0a22..0581b79c 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -828,6 +828,39 @@ If the connection to the Minecraft game server is blocked by the firewall, set E A Chat Bot that collects items on the ground + + Show a Discord Rich Presence status with your current Minecraft session info.\nRequires a Discord Application ID from https://discord.com/developers/applications\nYou can customize what is shown using placeholders: {server_host}, {server_port}, {username}, {health}, {max_health}, {food}, {dimension}, {gamemode}, {x}, {y}, {z}, {player_count}, {protocol} + + + Your Discord Application ID. Create one at https://discord.com/developers/applications + + + The top line of the Rich Presence display. Supports placeholders. + + + The second line of the Rich Presence display. Supports placeholders. + + + The key of the large image asset uploaded to your Discord application. + + + Tooltip text for the large image. Supports placeholders. + + + The key of the small image asset uploaded to your Discord application (leave empty to hide). + + + Tooltip text for the small image. Supports placeholders. + + + Show elapsed session time in the Discord presence. + + + Show the online player count as a party size in the Discord presence. + + + How often (in seconds) to refresh the Discord presence. Minimum: 1 + Remotely control the client using Web Sockets.\n# This is useful if you want to implement an application that can remotely and asynchronously execute procedures in MCC.\n# Example implementation written in JavaScript: https://github.com/milutinke/MCC.js.git\n# The protocol specification will be available in the documentation soon. diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index af3da4ab..d1f31606 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -1095,6 +1095,69 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to Please provide a valid Discord Application ID!. + /// + internal static string bot_DiscordRpc_missing_app_id { + get { + return ResourceManager.GetString("bot.DiscordRpc.missing_app_id", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connected to Discord as {0}. + /// + internal static string bot_DiscordRpc_connected { + get { + return ResourceManager.GetString("bot.DiscordRpc.connected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to connect to Discord (pipe {0}). Is Discord running?. + /// + internal static string bot_DiscordRpc_connection_failed { + get { + return ResourceManager.GetString("bot.DiscordRpc.connection_failed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Discord Rich Presence initialized successfully.. + /// + internal static string bot_DiscordRpc_initialized { + get { + return ResourceManager.GetString("bot.DiscordRpc.initialized", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to initialize Discord Rich Presence: {0}. + /// + internal static string bot_DiscordRpc_init_error { + get { + return ResourceManager.GetString("bot.DiscordRpc.init_error", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error updating Discord presence: {0}. + /// + internal static string bot_DiscordRpc_update_error { + get { + return ResourceManager.GetString("bot.DiscordRpc.update_error", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid update interval, must be at least 1 second. Using default of 10 seconds.. + /// + internal static string bot_DiscordRpc_invalid_interval { + get { + return ResourceManager.GetString("bot.DiscordRpc.invalid_interval", resourceCulture); + } + } + /// /// Looks up a localized string similar to The bot is already farming!. /// @@ -2089,6 +2152,15 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to DiscordRpc. + /// + internal static string botname_DiscordRpc { + get { + return ResourceManager.GetString("botname.DiscordRpc", resourceCulture); + } + } + /// /// Looks up a localized string similar to Farmer. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 26117d75..b16e7620 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -463,6 +463,27 @@ cooldown: {6} An unknown error has occured! + + Please provide a valid Discord Application ID! Get one at https://discord.com/developers/applications + + + Connected to Discord as {0} + + + Failed to connect to Discord (pipe {0}). Is Discord running? + + + Discord Rich Presence initialized successfully. + + + Failed to initialize Discord Rich Presence: {0} + + + Error updating Discord presence: {0} + + + Invalid update interval, must be at least 1 second. Using default of 10 seconds. + The bot is already farming! @@ -770,6 +791,9 @@ Add the ID of this chat to "Authorized_Chat_Ids" field in the configuration file DiscordBridge + + DiscordRpc + Farmer diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index 0fc34523..0309dc17 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -1466,6 +1466,13 @@ namespace MinecraftClient ChatBots.ItemsCollector.Config.OnSettingUpdate(); } } + + [TomlPrecedingComment("$ChatBot.DiscordRpc$")] + public ChatBots.DiscordRpc.Configs DiscordRpc + { + get { return ChatBots.DiscordRpc.Config; } + set { ChatBots.DiscordRpc.Config = value; ChatBots.DiscordRpc.Config.OnSettingUpdate(); } + } } } From 8743129e72371f31ba9880468441291f7b3afd80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:08:08 +0000 Subject: [PATCH 3/7] Address code review: fix party max, rename food variable, add ShowServerAddress option Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com> Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/43388021-4264-47e6-8403-7a1bca66d86f --- MinecraftClient/ChatBots/DiscordRpc.cs | 14 +++++++++----- .../ConfigComments/ConfigComments.Designer.cs | 9 +++++++++ .../Resources/ConfigComments/ConfigComments.resx | 3 +++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/MinecraftClient/ChatBots/DiscordRpc.cs b/MinecraftClient/ChatBots/DiscordRpc.cs index 0e17b910..3daa3ef4 100644 --- a/MinecraftClient/ChatBots/DiscordRpc.cs +++ b/MinecraftClient/ChatBots/DiscordRpc.cs @@ -46,6 +46,9 @@ namespace MinecraftClient.ChatBots [TomlInlineComment("$ChatBot.DiscordRpc.SmallImageText$")] public string SmallImageText = string.Empty; + [TomlInlineComment("$ChatBot.DiscordRpc.ShowServerAddress$")] + public bool ShowServerAddress = true; + [TomlInlineComment("$ChatBot.DiscordRpc.ShowElapsedTime$")] public bool ShowElapsedTime = true; @@ -222,7 +225,7 @@ namespace MinecraftClient.ChatBots { ID = $"mcc_{GetServerHost()}_{GetServerPort()}", Size = playerCount, - Max = Math.Max(playerCount, playerCount) + Max = playerCount }; } } @@ -240,11 +243,12 @@ namespace MinecraftClient.ChatBots if (string.IsNullOrEmpty(template)) return string.Empty; - string serverHost = GetServerHost(); + string serverHost = Config.ShowServerAddress ? GetServerHost() : "Hidden"; int serverPort = GetServerPort(); + string serverPortStr = Config.ShowServerAddress ? serverPort.ToString() : "****"; string username = GetUsername(); float health = Handler.GetHealth(); - int food = Handler.GetSaturation(); + int foodLevel = Handler.GetSaturation(); Location location = GetCurrentLocation(); string[] onlinePlayers = GetOnlinePlayers(); int gamemode = GetGamemode(); @@ -284,11 +288,11 @@ namespace MinecraftClient.ChatBots return template .Replace("{server_host}", serverHost) - .Replace("{server_port}", serverPort.ToString()) + .Replace("{server_port}", serverPortStr) .Replace("{username}", username) .Replace("{health}", ((int)Math.Ceiling(health)).ToString()) .Replace("{max_health}", "20") - .Replace("{food}", food.ToString()) + .Replace("{food}", foodLevel.ToString()) .Replace("{dimension}", dimensionName) .Replace("{gamemode}", gamemodeStr) .Replace("{x}", ((int)location.X).ToString()) diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs index c01667d5..0caddf01 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs @@ -970,6 +970,15 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to Show the server address (host and port) in the Discord presence.. + /// + internal static string ChatBot_DiscordRpc_ShowServerAddress { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.ShowServerAddress", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show elapsed session time in the Discord presence.. /// diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index 0581b79c..2039e499 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -852,6 +852,9 @@ If the connection to the Minecraft game server is blocked by the firewall, set E Tooltip text for the small image. Supports placeholders. + + Show the server address (host and port) in the Discord presence. When disabled, {server_host} and {server_port} are masked for privacy. + Show elapsed session time in the Discord presence. From 02686de79c85e5f9362ae2e910f51116ef662e97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:13:03 +0000 Subject: [PATCH 4/7] Add granular privacy controls: ShowCoordinates, ShowHealth, ShowDimension, ShowGamemode Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com> Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/43388021-4264-47e6-8403-7a1bca66d86f --- MinecraftClient/ChatBots/DiscordRpc.cs | 90 ++++++++++++------- .../ConfigComments/ConfigComments.Designer.cs | 36 ++++++++ .../ConfigComments/ConfigComments.resx | 14 ++- 3 files changed, 106 insertions(+), 34 deletions(-) diff --git a/MinecraftClient/ChatBots/DiscordRpc.cs b/MinecraftClient/ChatBots/DiscordRpc.cs index 3daa3ef4..6e5c60cb 100644 --- a/MinecraftClient/ChatBots/DiscordRpc.cs +++ b/MinecraftClient/ChatBots/DiscordRpc.cs @@ -49,6 +49,18 @@ namespace MinecraftClient.ChatBots [TomlInlineComment("$ChatBot.DiscordRpc.ShowServerAddress$")] public bool ShowServerAddress = true; + [TomlInlineComment("$ChatBot.DiscordRpc.ShowCoordinates$")] + public bool ShowCoordinates = true; + + [TomlInlineComment("$ChatBot.DiscordRpc.ShowHealth$")] + public bool ShowHealth = true; + + [TomlInlineComment("$ChatBot.DiscordRpc.ShowDimension$")] + public bool ShowDimension = true; + + [TomlInlineComment("$ChatBot.DiscordRpc.ShowGamemode$")] + public bool ShowGamemode = true; + [TomlInlineComment("$ChatBot.DiscordRpc.ShowElapsedTime$")] public bool ShowElapsedTime = true; @@ -254,50 +266,62 @@ namespace MinecraftClient.ChatBots int gamemode = GetGamemode(); int protocolVersion = GetProtocolVersion(); - string dimensionName = "Unknown"; - try + string healthStr = Config.ShowHealth ? ((int)Math.Ceiling(health)).ToString() : "?"; + string maxHealthStr = Config.ShowHealth ? "20" : "?"; + string foodStr = Config.ShowHealth ? foodLevel.ToString() : "?"; + string xStr = Config.ShowCoordinates ? ((int)location.X).ToString() : "?"; + string yStr = Config.ShowCoordinates ? ((int)location.Y).ToString() : "?"; + string zStr = Config.ShowCoordinates ? ((int)location.Z).ToString() : "?"; + + string dimensionName = Config.ShowDimension ? "Unknown" : "Hidden"; + if (Config.ShowDimension) { - var dim = World.GetDimension(); - dimensionName = dim.Name ?? "Unknown"; - - // Clean up the dimension name for display - if (dimensionName.StartsWith("minecraft:")) - dimensionName = dimensionName["minecraft:".Length..]; - - dimensionName = dimensionName switch + try { - "overworld" => "Overworld", - "the_nether" => "The Nether", - "the_end" => "The End", - _ => dimensionName - }; - } - catch - { - // World may not be available + var dim = World.GetDimension(); + dimensionName = dim.Name ?? "Unknown"; + + // Clean up the dimension name for display + if (dimensionName.StartsWith("minecraft:")) + dimensionName = dimensionName["minecraft:".Length..]; + + dimensionName = dimensionName switch + { + "overworld" => "Overworld", + "the_nether" => "The Nether", + "the_end" => "The End", + _ => dimensionName + }; + } + catch + { + // World may not be available + } } - string gamemodeStr = gamemode switch - { - 0 => "Survival", - 1 => "Creative", - 2 => "Adventure", - 3 => "Spectator", - _ => "Unknown" - }; + string gamemodeStr = Config.ShowGamemode + ? gamemode switch + { + 0 => "Survival", + 1 => "Creative", + 2 => "Adventure", + 3 => "Spectator", + _ => "Unknown" + } + : "Hidden"; return template .Replace("{server_host}", serverHost) .Replace("{server_port}", serverPortStr) .Replace("{username}", username) - .Replace("{health}", ((int)Math.Ceiling(health)).ToString()) - .Replace("{max_health}", "20") - .Replace("{food}", foodLevel.ToString()) + .Replace("{health}", healthStr) + .Replace("{max_health}", maxHealthStr) + .Replace("{food}", foodStr) .Replace("{dimension}", dimensionName) .Replace("{gamemode}", gamemodeStr) - .Replace("{x}", ((int)location.X).ToString()) - .Replace("{y}", ((int)location.Y).ToString()) - .Replace("{z}", ((int)location.Z).ToString()) + .Replace("{x}", xStr) + .Replace("{y}", yStr) + .Replace("{z}", zStr) .Replace("{player_count}", onlinePlayers.Length.ToString()) .Replace("{protocol}", protocolVersion.ToString()); } diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs index 0caddf01..cd3a09a5 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs @@ -979,6 +979,42 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to Show the player coordinates in the Discord presence.. + /// + internal static string ChatBot_DiscordRpc_ShowCoordinates { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.ShowCoordinates", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show health and food level in the Discord presence.. + /// + internal static string ChatBot_DiscordRpc_ShowHealth { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.ShowHealth", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show the current dimension in the Discord presence.. + /// + internal static string ChatBot_DiscordRpc_ShowDimension { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.ShowDimension", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show the current gamemode in the Discord presence.. + /// + internal static string ChatBot_DiscordRpc_ShowGamemode { + get { + return ResourceManager.GetString("ChatBot.DiscordRpc.ShowGamemode", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show elapsed session time in the Discord presence.. /// diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index 2039e499..b68844fa 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -853,7 +853,19 @@ If the connection to the Minecraft game server is blocked by the firewall, set E Tooltip text for the small image. Supports placeholders. - Show the server address (host and port) in the Discord presence. When disabled, {server_host} and {server_port} are masked for privacy. + Show the server address (host and port) in the Discord presence. When disabled, {server_host} and {server_port} are masked. + + + Show the player coordinates in the Discord presence. When disabled, {x}, {y}, {z} are masked. + + + Show health and food level in the Discord presence. When disabled, {health}, {max_health}, {food} are masked. + + + Show the current dimension in the Discord presence. When disabled, {dimension} is masked. + + + Show the current gamemode in the Discord presence. When disabled, {gamemode} is masked. Show elapsed session time in the Discord presence. From 5a9786cb0bb23ebdc80b253f7f0c89468d61878c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:41:40 +0000 Subject: [PATCH 5/7] Add Discord RPC setup tutorial, Discord Bridge AllowOtherBotMessages config, and enhance integration tests 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 --- .../references/command-matrix.md | 38 ++++++++ .../scripts/run_full_spectrum_test.sh | 87 +++++++++++++++++++ MinecraftClient/ChatBots/DiscordBridge.cs | 17 +++- .../ConfigComments/ConfigComments.Designer.cs | 15 +++- .../ConfigComments/ConfigComments.resx | 11 ++- 5 files changed, 164 insertions(+), 4 deletions(-) diff --git a/.skills/mcc-integration-testing/references/command-matrix.md b/.skills/mcc-integration-testing/references/command-matrix.md index 9880f2f8..31077374 100644 --- a/.skills/mcc-integration-testing/references/command-matrix.md +++ b/.skills/mcc-integration-testing/references/command-matrix.md @@ -9,9 +9,19 @@ This skill uses a fixed set of stable commands for local offline integration tes - `inventory player list` - `/gamemode creative` - `inventory creativegive 36 Diamond 16` +- `inventory creativegive 37 IronSword 1` +- `inventory creativegive 38 GoldenApple 8` +- `inventory creativeclear 38` - `entity` - `/time query daytime` +- `look up` +- `look down` +- `look east` +- `/gamemode survival` +- `respawn` +- `/tp CursorBot 0 -60 0` - `smoke_test_from_mcc_full_spectrum` +- `integration_test_chat_response` Notes: - Lines starting with `/` are sent to the server as chat/commands. @@ -24,6 +34,11 @@ Notes: - `gamerule logAdminCommands true` - `time set day` - `weather clear` +- `say Hello from the server console` +- `msg CursorBot This is a private whisper` +- `effect give CursorBot minecraft:speed 30 1` +- `effect give CursorBot minecraft:regeneration 10 1` +- `kill CursorBot` ## Representative entity coverage @@ -34,6 +49,21 @@ Notes: - `execute as CursorBot at @s run summon minecraft:villager ~-2 ~ ~` - `execute as CursorBot at @s run summon minecraft:allay ~-4 ~ ~` - `execute as CursorBot at @s run summon minecraft:armor_stand ~ ~ ~2` +- `execute as CursorBot at @s run summon minecraft:item_display ~-6 ~ ~ {item:{id:"minecraft:diamond",count:1}}` +- `execute as CursorBot at @s run summon minecraft:spider ~10 ~ ~` +- `execute as CursorBot at @s run summon minecraft:pig ~-8 ~ ~` + +## Block placement coverage + +- `execute as CursorBot at @s run fill ~1 ~ ~1 ~3 ~2 ~3 minecraft:stone` +- `execute as CursorBot at @s run setblock ~5 ~ ~5 minecraft:chest` +- `execute as CursorBot at @s run setblock ~5 ~1 ~5 minecraft:furnace` +- `execute as CursorBot at @s run setblock ~6 ~ ~5 minecraft:crafting_table` + +## Dimension change coverage + +- `execute in minecraft:the_nether run tp CursorBot 0 64 0` +- `execute in minecraft:overworld run tp CursorBot 0 -60 0` ## Representative particle coverage @@ -41,13 +71,21 @@ Notes: - `execute as CursorBot at @s run particle minecraft:end_rod ~ ~1 ~ 0.5 0.5 0.5 0.01 20 force` - `execute as CursorBot at @s run particle minecraft:explosion ~ ~1 ~ 0 0 0 0 1 force` - `execute as CursorBot at @s run particle minecraft:totem_of_undying ~ ~1 ~ 0.5 0.5 0.5 0.1 20 force` +- `execute as CursorBot at @s run particle minecraft:flame ~ ~1 ~ 0.2 0.2 0.2 0.02 30 force` +- `execute as CursorBot at @s run particle minecraft:heart ~ ~2 ~ 0.3 0.3 0.3 0 5 force` ## Representative sound coverage - `execute as CursorBot at @s run playsound minecraft:entity.lightning_bolt.thunder master CursorBot ~ ~ ~ 1 1 0` - `execute as CursorBot at @s run playsound minecraft:block.note_block.bell master CursorBot ~ ~ ~ 1 1 0` +- `execute as CursorBot at @s run playsound minecraft:entity.experience_orb.pickup master CursorBot ~ ~ ~ 1 1 0` ## Explosion coverage - `execute as CursorBot at @s run summon minecraft:tnt ~3 ~ ~` - `execute as CursorBot at @s run summon minecraft:tnt ~6 ~ ~` + +## Kill and respawn cycle + +- `kill CursorBot` (via RCON, requires survival mode) +- `respawn` (via MCC command after death) diff --git a/.skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh b/.skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh index 1ae6e983..02bc4f9f 100755 --- a/.skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh +++ b/.skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh @@ -155,6 +155,7 @@ run_server_command "time set day" run_server_command "weather clear" sleep 2 +# ── Phase 1: Basic status and info commands ── run_mcc_command "health" run_mcc_command "list" run_mcc_command "inventory player list" @@ -165,6 +166,34 @@ run_mcc_command "entity" run_mcc_command "/time query daytime" run_mcc_command "smoke_test_from_mcc_full_spectrum" +# ── Phase 2: Movement and look commands ── +run_mcc_command "/tp CursorBot 0 -60 0" +sleep 3 +run_mcc_command "look up" +sleep 1 +run_mcc_command "look down" +sleep 1 +run_mcc_command "look east" +sleep 1 + +# ── Phase 3: Advanced inventory operations ── +run_mcc_command "inventory creativegive 37 IronSword 1" +run_mcc_command "inventory creativegive 38 GoldenApple 8" +run_mcc_command "inventory player list" +run_mcc_command "inventory creativeclear 38" +run_mcc_command "inventory player list" + +# ── Phase 4: Block placement and interaction ── +run_server_command "execute as CursorBot at @s run fill ~1 ~ ~1 ~3 ~2 ~3 minecraft:stone" +sleep 2 +run_server_command "execute as CursorBot at @s run setblock ~5 ~ ~5 minecraft:chest" +sleep 1 +run_server_command "execute as CursorBot at @s run setblock ~5 ~1 ~5 minecraft:furnace" +sleep 1 +run_server_command "execute as CursorBot at @s run setblock ~6 ~ ~5 minecraft:crafting_table" +sleep 1 + +# ── Phase 5: Entity spawning (expanded coverage) ── run_server_command "execute as CursorBot at @s run summon minecraft:cow ~2 ~ ~" run_server_command "execute as CursorBot at @s run summon minecraft:zombie ~4 ~ ~" run_server_command "execute as CursorBot at @s run summon minecraft:creeper ~6 ~ ~" @@ -172,41 +201,99 @@ run_server_command "execute as CursorBot at @s run summon minecraft:skeleton ~8 run_server_command "execute as CursorBot at @s run summon minecraft:villager ~-2 ~ ~" run_server_command "execute as CursorBot at @s run summon minecraft:allay ~-4 ~ ~" run_server_command "execute as CursorBot at @s run summon minecraft:armor_stand ~ ~ ~2" +run_server_command "execute as CursorBot at @s run summon minecraft:item_display ~-6 ~ ~ {item:{id:\"minecraft:diamond\",count:1}}" +run_server_command "execute as CursorBot at @s run summon minecraft:spider ~10 ~ ~" +run_server_command "execute as CursorBot at @s run summon minecraft:pig ~-8 ~ ~" sleep 2 run_mcc_command "entity" +# ── Phase 6: Effects and environment ── +run_server_command "effect give CursorBot minecraft:speed 30 1" +sleep 2 +run_mcc_command "health" +run_server_command "effect give CursorBot minecraft:regeneration 10 1" +sleep 2 +run_mcc_command "health" + +# ── Phase 7: Gamemode cycling ── +run_mcc_command "/gamemode survival" +sleep 2 +run_mcc_command "health" +run_mcc_command "/gamemode creative" +sleep 2 + +# ── Phase 8: Dimension change (nether) ── +run_server_command "execute in minecraft:the_nether run tp CursorBot 0 64 0" +sleep 4 +run_mcc_command "health" +run_server_command "execute in minecraft:overworld run tp CursorBot 0 -60 0" +sleep 4 + +# ── Phase 9: Server chat and whisper ── +run_server_command "say Hello from the server console" +sleep 2 +run_server_command "msg CursorBot This is a private whisper" +sleep 2 +run_mcc_command "integration_test_chat_response" + +# ── Phase 10: Particles, sounds, and explosions ── run_server_command "execute as CursorBot at @s run particle minecraft:happy_villager ~ ~1 ~ 0.5 0.5 0.5 0 12 force" run_server_command "execute as CursorBot at @s run particle minecraft:end_rod ~ ~1 ~ 0.5 0.5 0.5 0.01 20 force" run_server_command "execute as CursorBot at @s run particle minecraft:explosion ~ ~1 ~ 0 0 0 0 1 force" run_server_command "execute as CursorBot at @s run particle minecraft:totem_of_undying ~ ~1 ~ 0.5 0.5 0.5 0.1 20 force" +run_server_command "execute as CursorBot at @s run particle minecraft:flame ~ ~1 ~ 0.2 0.2 0.2 0.02 30 force" +run_server_command "execute as CursorBot at @s run particle minecraft:heart ~ ~2 ~ 0.3 0.3 0.3 0 5 force" run_server_command "execute as CursorBot at @s run playsound minecraft:entity.lightning_bolt.thunder master CursorBot ~ ~ ~ 1 1 0" run_server_command "execute as CursorBot at @s run playsound minecraft:block.note_block.bell master CursorBot ~ ~ ~ 1 1 0" +run_server_command "execute as CursorBot at @s run playsound minecraft:entity.experience_orb.pickup master CursorBot ~ ~ ~ 1 1 0" run_server_command "execute as CursorBot at @s run summon minecraft:tnt ~3 ~ ~" sleep 2 run_server_command "execute as CursorBot at @s run summon minecraft:tnt ~6 ~ ~" +# ── Phase 11: Kill and respawn cycle ── +run_mcc_command "/gamemode survival" +sleep 2 +run_server_command "kill CursorBot" +sleep 4 +run_mcc_command "respawn" +sleep 4 +run_mcc_command "health" +run_mcc_command "/gamemode creative" +sleep 2 + sleep 6 capture_server_logs +# ── Assertions: MCC log ── assert_contains "$MCC_LOG" "Server was successfully joined." "MCC never joined the server" assert_contains "$MCC_LOG" "[FileInput] > inventory player list" "Inventory command was not executed" assert_contains "$MCC_LOG" "[FileInput] > entity" "Entity command was not executed" assert_contains "$MCC_LOG" "[FileInput] > /gamemode creative" "Creative mode command was not executed from MCC" assert_contains "$MCC_LOG" "Requested Diamond x16 in slot #36" "Creative inventory give did not succeed" assert_contains "$MCC_LOG" "smoke_test_from_mcc_full_spectrum" "Client-originated chat was not observed" +assert_contains "$MCC_LOG" "[FileInput] > look up" "Look command was not executed" +assert_contains "$MCC_LOG" "[FileInput] > /gamemode survival" "Survival mode switch was not executed" +assert_contains "$MCC_LOG" "[FileInput] > respawn" "Respawn command was not executed" +assert_contains "$MCC_LOG" "[FileInput] > health" "Health command was not executed" +assert_contains "$MCC_LOG" "integration_test_chat_response" "Chat response test message was not observed" assert_not_contains "$MCC_LOG" "Please enable InventoryHandling" "Inventory handling is still disabled" assert_not_contains "$MCC_LOG" "Please enable EntityHandling" "Entity handling is still disabled" assert_not_contains "$MCC_LOG" "You must be in Creative gamemode" "Creative mode was not active when creativegive ran" assert_not_contains "$MCC_LOG" "Failed to load settings" "MCC failed to reload its config" +assert_not_contains "$MCC_LOG" "NullReferenceException" "A NullReferenceException occurred during the test" +# ── Assertions: Server log ── assert_contains "$SERVER_FILE_LOG" "CursorBot joined the game" "Server never saw CursorBot join" assert_contains "$SERVER_FILE_LOG" "smoke_test_from_mcc_full_spectrum" "Server never received the client chat message" assert_contains "$SERVER_FILE_LOG" "Displaying particle minecraft:happy_villager" "Particle events were not recorded on the server" assert_contains "$SERVER_FILE_LOG" "Played sound minecraft:block.note_block.bell to CursorBot" "Sound events were not recorded on the server" assert_contains "$SERVER_FILE_LOG" "Summoned new Primed TNT" "TNT summon did not occur on the server" +assert_contains "$SERVER_FILE_LOG" "integration_test_chat_response" "Server never received the chat response test message" +assert_contains "$SERVER_FILE_LOG" "Hello from the server console" "Server say command was not logged" +assert_contains "$SERVER_FILE_LOG" "Killed CursorBot" "Server kill command did not execute" assert_not_contains "$SERVER_FILE_LOG" "Sending unknown packet 'clientbound/minecraft:disconnect'" "Server hit the disconnect packet regression during the test" cat < - /// Looks up a localized string similar to Automatically farms crops for you (plants, breaks and bonemeals them). + /// Looks up a localized string similar to When enabled, messages from other Discord bots in the channel will be relayed to Minecraft chat.. + /// + internal static string ChatBot_DiscordBridge_AllowOtherBotMessages { + get { + return ResourceManager.GetString("ChatBot.DiscordBridge.AllowOtherBotMessages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Automatically farms cropsfor you (plants, breaks and bonemeals them). ///Crop types available: Beetroot, Carrot, Melon, Netherwart, Pumpkin, Potato, Wheat. ///Usage: "/farmer start" command and "/farmer stop" command. ///NOTE: This a newly added bot, it is not perfect and was only tested in 1.19.2, there are some minor issues like not being able to bonemeal carrots/potatoes sometimes. @@ -899,7 +908,9 @@ namespace MinecraftClient { } /// - /// Looks up a localized string similar to Show a Discord Rich Presence status with your current Minecraft session info.. + /// Looks up a localized string similar to Show a Discord Rich Presence status with your current Minecraft session info. + ///Setup: + ///1. Go to https://discord.com/developers/applications and log in with your Discord account. [rest of string was truncated]";. /// internal static string ChatBot_DiscordRpc { get { diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index b68844fa..9c6152df 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -390,6 +390,9 @@ For Discord message formatting, check the following: https://mccteam.github.io/r Your Discord Bot token. + + When enabled, messages from other Discord bots in the channel will be relayed to Minecraft chat. The bridge always ignores its own messages to prevent loops. + Automatically farms crops for you (plants, breaks and bonemeals them). Crop types available: Beetroot, Carrot, Melon, Netherwart, Pumpkin, Potato, Wheat. @@ -829,7 +832,13 @@ If the connection to the Minecraft game server is blocked by the firewall, set E A Chat Bot that collects items on the ground - Show a Discord Rich Presence status with your current Minecraft session info.\nRequires a Discord Application ID from https://discord.com/developers/applications\nYou can customize what is shown using placeholders: {server_host}, {server_port}, {username}, {health}, {max_health}, {food}, {dimension}, {gamemode}, {x}, {y}, {z}, {player_count}, {protocol} + Show a Discord Rich Presence status with your current Minecraft session info. +Setup: +1. Go to https://discord.com/developers/applications and log in with your Discord account. +2. Click "New Application", give it a name (e.g. "MCC") and confirm. +3. On the application page, copy the "Application ID" and paste it in the "ApplicationId" field below. +4. (Optional) Go to "Rich Presence" -> "Art Assets" to upload custom images for LargeImageKey/SmallImageKey. +Note: This does NOT require a Bot Token, only an Application ID. Discord must be running on the same machine as MCC. Your Discord Application ID. Create one at https://discord.com/developers/applications 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 6/7] 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) 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 7/7] 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