From 3fb7625b39a5ddba6b7c2104cb89eb42ed2d6d23 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 24 Mar 2026 20:18:53 +0000
Subject: [PATCH] Add chat formatting code propagation (bold, italic,
underline, strikethrough, obfuscated)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Re-implements PR #2851 against the current System.Text.Json.Nodes codebase.
Changes:
- ChatParser.cs: Add FormattingCodes dict, update JSONData2String to propagate
formatting codes alongside colors, update NbtToString for the NBT chat path
(used in MC 1.20.4+), fix root-string shortcut to preserve formatting prefix
- DiscordBridge.cs: Add GetDiscordText() converting § codes to Discord Markdown,
handle unclosed formatting codes with end-of-string matching
End-to-end tested against Minecraft 1.21.11 (MC 26.1) offline server.
All 8 formatting types verified: bold(§l), italic(§o), strikethrough(§m),
underline(§n), combined, nested, reset-via-false, obfuscated(§k).
Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/65b0d230-287c-4ed8-9b3c-a5ea25411804
---
MinecraftClient/ChatBots/DiscordBridge.cs | 18 +-
.../Protocol/Message/ChatParser.cs | 183 ++++++++++--------
2 files changed, 123 insertions(+), 78 deletions(-)
diff --git a/MinecraftClient/ChatBots/DiscordBridge.cs b/MinecraftClient/ChatBots/DiscordBridge.cs
index 0af5c08d..fa13f84e 100644
--- a/MinecraftClient/ChatBots/DiscordBridge.cs
+++ b/MinecraftClient/ChatBots/DiscordBridge.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Brigadier.NET.Builder;
using DSharpPlus;
@@ -222,7 +223,22 @@ namespace MinecraftClient.ChatBots
SendMessage(messageBuilder);
return;
}
- else SendMessage(message);
+ else SendMessage(GetDiscordText(message));
+ }
+
+ ///
+ /// Converts Minecraft § formatting codes to Discord Markdown equivalents
+ /// and strips remaining § codes.
+ /// Handles both properly closed formatting (§l...§r) and unclosed formatting (§l... end).
+ ///
+ private static string GetDiscordText(string text)
+ {
+ text = Regex.Replace(text, @"§l(.*?)(?:§r|$)", "**$1**");
+ text = Regex.Replace(text, @"§m(.*?)(?:§r|$)", "~~$1~~");
+ text = Regex.Replace(text, @"§n(.*?)(?:§r|$)", "__$1__");
+ text = Regex.Replace(text, @"§o(.*?)(?:§r|$)", "*$1*");
+ text = Regex.Replace(text, @"§.", "");
+ return text;
}
public void SendMessage(string message)
diff --git a/MinecraftClient/Protocol/Message/ChatParser.cs b/MinecraftClient/Protocol/Message/ChatParser.cs
index 28311400..bc12da5b 100644
--- a/MinecraftClient/Protocol/Message/ChatParser.cs
+++ b/MinecraftClient/Protocol/Message/ChatParser.cs
@@ -429,14 +429,31 @@ namespace MinecraftClient.Protocol.Message
else return "[" + rulename + "] " + string.Join(" ", using_data);
}
+ ///
+ /// Mapping from JSON/NBT property names to Minecraft formatting codes (without §).
+ /// Both "underlined" (canonical Minecraft name) and "underline" (alias) are supported.
+ ///
+ private static readonly Dictionary FormattingCodes = new()
+ {
+ { "obfuscated", "k" },
+ { "bold", "l" },
+ { "strikethrough", "m" },
+ { "underlined", "n" },
+ { "underline", "n" },
+ { "italic", "o" },
+ };
+
+ /// Matches a single color code (§0–§9, §a–§f). Used to strip color when replacing.
+ private static readonly Regex ColorCodeRegex = new(@"§[0-9a-f]", RegexOptions.Compiled);
+
///
/// Use a JSON Object to build the corresponding string
///
/// JSON object to convert
- /// Allow parent color code to affect child elements (set to "" for function init)
+ /// Inherited formatting codes from parent elements (set to "" for function init)
/// Container for links from JSON serialized text
/// returns the Minecraft-formatted string
- private static string JSONData2String(System.Text.Json.Nodes.JsonNode? data, string colorcode, List? links)
+ private static string JSONData2String(System.Text.Json.Nodes.JsonNode? data, string formatting, List? links)
{
string extra_result = "";
switch (data)
@@ -444,7 +461,20 @@ namespace MinecraftClient.Protocol.Message
case System.Text.Json.Nodes.JsonObject obj:
if (obj.ContainsKey("color"))
{
- colorcode = Color2tag(JSONData2String(obj["color"], "", links));
+ formatting = ColorCodeRegex.Replace(formatting, "");
+ formatting += Color2tag(JSONData2String(obj["color"], "", links));
+ }
+
+ foreach (var (key, code) in FormattingCodes)
+ {
+ if (obj.ContainsKey(key))
+ {
+ string val = obj[key]!.GetStringValue();
+ if (val == "true")
+ formatting += "§" + code;
+ else if (val == "false")
+ formatting = formatting.Replace("§" + code, "");
+ }
}
if (obj.ContainsKey("clickEvent") && links is not null)
@@ -462,12 +492,16 @@ namespace MinecraftClient.Protocol.Message
if (obj.ContainsKey("extra"))
{
foreach (var item in obj["extra"]!.AsArray())
- extra_result = extra_result + JSONData2String(item, colorcode, links) + "§r";
+ extra_result += JSONData2String(item, "§r" + formatting, links);
}
+ // Strip any formatting codes that appear before the last §r, since §r resets all
+ // prior formatting. The greedy .* matches up to the last §r in the string.
+ formatting = Regex.Replace(formatting, ".*(§r.*)", "$1");
+
if (obj.ContainsKey("text"))
{
- return colorcode + JSONData2String(obj["text"], colorcode, links) + extra_result;
+ return formatting + JSONData2String(obj["text"], formatting, links) + extra_result;
}
else if (obj.ContainsKey("translate"))
{
@@ -478,11 +512,11 @@ namespace MinecraftClient.Protocol.Message
{
foreach (var item in obj["with"]!.AsArray())
{
- using_data.Add(JSONData2String(item, colorcode, links));
+ using_data.Add(JSONData2String(item, formatting, links));
}
}
- return colorcode +
+ return formatting +
TranslateString(JSONData2String(obj["translate"], "", links), using_data) +
extra_result;
}
@@ -492,98 +526,93 @@ namespace MinecraftClient.Protocol.Message
string result = "";
foreach (var item in arr)
{
- result += JSONData2String(item, colorcode, links);
+ result += JSONData2String(item, formatting, links);
}
return result;
default:
- return colorcode + data.GetStringValue();
+ return formatting + data.GetStringValue();
}
}
- private static string NbtToString(Dictionary nbt)
+ private static string NbtToString(Dictionary nbt, string formatting = "")
{
if (nbt.Count == 1 && nbt.TryGetValue("", out object? rootMessage))
{
- return rootMessage?.ToString() ?? string.Empty;
+ return formatting + (rootMessage?.ToString() ?? string.Empty);
}
string message = string.Empty;
- string colorCode = string.Empty;
StringBuilder extraBuilder = new();
- foreach (var kvp in nbt)
+
+ // Build formatting from color and formatting flags first
+ if (nbt.TryGetValue("color", out object? color))
{
- string key = kvp.Key;
- object value = kvp.Value;
+ formatting = ColorCodeRegex.Replace(formatting, "");
+ formatting += Color2tag((string)color);
+ }
- switch (key)
+ foreach (var (key, code) in FormattingCodes)
+ {
+ if (nbt.TryGetValue(key, out object? flagValue))
{
- case "text":
+ bool isActive = flagValue switch
{
- message = value?.ToString() ?? string.Empty;
- }
- break;
- case "extra":
- {
- object[] extras = (object[])value;
- for (var i = 0; i < extras.Length; i++)
- {
- var extraDict = extras[i] switch
- {
- int => new Dictionary { { "text", $"{extras[i]}" } },
- string => new Dictionary
- {
- { "text", (string)extras[i] }
- },
- _ => (Dictionary)extras[i]
- };
-
- extraBuilder.Append(NbtToString(extraDict) + "§r");
- }
- }
- break;
- case "translate":
- {
- if (nbt.TryGetValue("translate", out object? translate))
- {
- var translateKey = (string)translate;
- List translateString = new();
- if (nbt.TryGetValue("with", out object? withComponent))
- {
- var withs = (object[])withComponent;
- for (var i = 0; i < withs.Length; i++)
- {
- var withDict = withs[i] switch
- {
- int => new Dictionary { { "text", $"{withs[i]}" } },
- string => new Dictionary
- {
- { "text", (string)withs[i] }
- },
- _ => (Dictionary)withs[i]
- };
-
- translateString.Add(NbtToString(withDict));
- }
- }
-
- message = TranslateString(translateKey, translateString);
- }
- }
- break;
- case "color":
- {
- if (nbt.TryGetValue("color", out object? color))
- {
- colorCode = Color2tag((string)color);
- }
- }
- break;
+ byte b => b > 0,
+ bool b => b,
+ _ => flagValue?.ToString()?.ToLower() == "true"
+ };
+ if (isActive)
+ formatting += "§" + code;
+ else
+ formatting = formatting.Replace("§" + code, "");
}
}
- return colorCode + message + extraBuilder.ToString();
+ // Process text
+ if (nbt.TryGetValue("text", out object? textValue))
+ message = textValue?.ToString() ?? string.Empty;
+
+ // Process translate
+ if (nbt.TryGetValue("translate", out object? translate))
+ {
+ var translateKey = (string)translate;
+ List translateString = new();
+ if (nbt.TryGetValue("with", out object? withComponent))
+ {
+ var withs = (object[])withComponent;
+ for (var i = 0; i < withs.Length; i++)
+ {
+ var withDict = withs[i] switch
+ {
+ int => new Dictionary { { "text", $"{withs[i]}" } },
+ string => new Dictionary { { "text", (string)withs[i] } },
+ _ => (Dictionary)withs[i]
+ };
+ translateString.Add(NbtToString(withDict, formatting));
+ }
+ }
+ message = TranslateString(translateKey, translateString);
+ }
+
+ // Process extras, each starting with a reset then inheriting the current formatting
+ if (nbt.TryGetValue("extra", out object? extraValue))
+ {
+ object[] extras = (object[])extraValue;
+ for (var i = 0; i < extras.Length; i++)
+ {
+ var extraDict = extras[i] switch
+ {
+ int => new Dictionary { { "text", $"{extras[i]}" } },
+ string => new Dictionary { { "text", (string)extras[i] } },
+ _ => (Dictionary)extras[i]
+ };
+ extraBuilder.Append(NbtToString(extraDict, "§r" + formatting));
+ }
+ }
+
+ return formatting + message + extraBuilder.ToString();
}
}
}
\ No newline at end of file