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(); } + } } }