Minecraft-Console-Client/MinecraftClient/Protocol/Message/ChatParser.cs
2026-04-04 00:32:07 +02:00

643 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using static MinecraftClient.Settings;
namespace MinecraftClient.Protocol.Message
{
/// <summary>
/// This class parses JSON chat data from MC 1.6+ and returns the appropriate string to be printed.
/// </summary>
static class ChatParser
{
public enum MessageType
{
CHAT,
SAY_COMMAND,
MSG_COMMAND_INCOMING,
MSG_COMMAND_OUTGOING,
TEAM_MSG_COMMAND_INCOMING,
TEAM_MSG_COMMAND_OUTGOING,
EMOTE_COMMAND,
RAW_MSG
};
public static Dictionary<int, MessageType>? ChatId2Type;
// Used to store Chat Types in 1.20.6+
public static void ReadChatType(Dictionary<int, string> data)
{
var chatTypeDictionary = ChatId2Type ?? new Dictionary<int, MessageType>();
foreach (var (chatId, chatName) in data)
{
chatTypeDictionary[chatId] = chatName switch
{
"minecraft:chat" => MessageType.CHAT,
"minecraft:emote_command" => MessageType.EMOTE_COMMAND,
"minecraft:msg_command_incoming" => MessageType.MSG_COMMAND_INCOMING,
"minecraft:msg_command_outgoing" => MessageType.MSG_COMMAND_OUTGOING,
"minecraft:say_command" => MessageType.SAY_COMMAND,
"minecraft:team_msg_command_incoming" => MessageType.TEAM_MSG_COMMAND_INCOMING,
"minecraft:team_msg_command_outgoing" => MessageType.TEAM_MSG_COMMAND_OUTGOING,
_ => MessageType.CHAT,
};
}
ChatId2Type = chatTypeDictionary;
}
public static void ReadChatType(Dictionary<string, object> registryCodec)
{
Dictionary<int, MessageType> chatTypeDictionary = ChatId2Type ?? new();
// Check if the chat type registry is in the correct format
if (!registryCodec.ContainsKey("minecraft:chat_type")) {
// If not, then we force the registry to be in the correct format
if (registryCodec.ContainsKey("chat_type")) {
foreach (var key in registryCodec.Keys.ToArray()) {
// Skip entries with a namespace already
if (key.Contains(':', StringComparison.OrdinalIgnoreCase)) continue;
// Assume all other entries are in the minecraft namespace
registryCodec["minecraft:" + key] = registryCodec[key];
registryCodec.Remove(key);
}
}
}
var chatTypeListNbt = (object[])(((Dictionary<string, object>)registryCodec["minecraft:chat_type"])["value"]);
foreach (var (chatName, chatId) in from Dictionary<string, object> chatTypeNbt in chatTypeListNbt
let chatName = (string)chatTypeNbt["name"]
let chatId = (int)chatTypeNbt["id"]
select (chatName, chatId))
{
chatTypeDictionary[chatId] = chatName switch
{
"minecraft:chat" => MessageType.CHAT,
"minecraft:emote_command" => MessageType.EMOTE_COMMAND,
"minecraft:msg_command_incoming" => MessageType.MSG_COMMAND_INCOMING,
"minecraft:msg_command_outgoing" => MessageType.MSG_COMMAND_OUTGOING,
"minecraft:say_command" => MessageType.SAY_COMMAND,
"minecraft:team_msg_command_incoming" => MessageType.TEAM_MSG_COMMAND_INCOMING,
"minecraft:team_msg_command_outgoing" => MessageType.TEAM_MSG_COMMAND_OUTGOING,
_ => MessageType.CHAT,
};
}
ChatId2Type = chatTypeDictionary;
}
/// <summary>
/// The main function to convert text from MC 1.6+ JSON to MC 1.5.2 formatted text
/// </summary>
/// <param name="json">JSON serialized text</param>
/// <param name="links">Optional container for links from JSON serialized text</param>
/// <returns>Returns the translated text</returns>
public static string ParseText(string json, List<string>? links = null)
{
return JSONData2String(Json.ParseJson(json), "", links);
}
public static string ParseText(Dictionary<string, object> nbt)
{
return NbtToString(nbt);
}
/// <summary>
/// The main function to convert text from MC 1.9+ JSON to MC 1.5.2 formatted text
/// </summary>
/// <param name="message">Message received</param>
/// <param name="links">Optional container for links from JSON serialized text</param>
/// <returns>Returns the translated text</returns>
public static string ParseSignedChat(ChatMessage message, List<string>? links = null)
{
string sender = message.isSenderJson ? ParseText(message.displayName!) : message.displayName!;
string content;
if (Config.Signature.ShowModifiedChat && message.unsignedContent is not null)
{
content = ParseText(message.unsignedContent!);
if (string.IsNullOrEmpty(content))
content = message.unsignedContent!;
}
else
{
content = message.isJson ? ParseText(message.content) : message.content;
if (string.IsNullOrEmpty(content))
content = message.content!;
}
string text;
List<string> usingData = new();
MessageType chatType;
if (message.chatTypeId == -1)
chatType = MessageType.RAW_MSG;
else if (!ChatId2Type!.TryGetValue(message.chatTypeId, out chatType))
chatType = MessageType.CHAT;
switch (chatType)
{
case MessageType.CHAT:
usingData.Add(sender);
usingData.Add(content);
text = TranslateString("chat.type.text", usingData);
break;
case MessageType.SAY_COMMAND:
usingData.Add(sender);
usingData.Add(content);
text = TranslateString("chat.type.announcement", usingData);
break;
case MessageType.MSG_COMMAND_INCOMING:
usingData.Add(sender);
usingData.Add(content);
text = TranslateString("commands.message.display.incoming", usingData);
break;
case MessageType.MSG_COMMAND_OUTGOING:
usingData.Add(sender);
usingData.Add(content);
text = TranslateString("commands.message.display.outgoing", usingData);
break;
case MessageType.TEAM_MSG_COMMAND_INCOMING:
usingData.Add(message.teamName!);
usingData.Add(sender);
usingData.Add(content);
text = TranslateString("chat.type.team.text", usingData);
break;
case MessageType.TEAM_MSG_COMMAND_OUTGOING:
usingData.Add(message.teamName!);
usingData.Add(sender);
usingData.Add(content);
text = TranslateString("chat.type.team.sent", usingData);
break;
case MessageType.EMOTE_COMMAND:
usingData.Add(sender);
usingData.Add(content);
text = TranslateString("chat.type.emote", usingData);
break;
case MessageType.RAW_MSG:
text = content;
break;
default:
goto case MessageType.CHAT;
}
return text;
}
/// <summary>
/// Get the classic color tag corresponding to a color name
/// </summary>
/// <param name="colorname">Color Name</param>
/// <returns>Color code</returns>
private static string Color2tag(string colorname)
{
return colorname.ToLower() switch
{
#pragma warning disable format // @formatter:off
/* MC 1.7+ Name || MC 1.6 Name || Classic tag */
"black" => "§0",
"dark_blue" => "§1",
"dark_green" => "§2",
"dark_aqua" or "dark_cyan" => "§3",
"dark_red" => "§4",
"dark_purple" or "dark_magenta" => "§5",
"gold" or "dark_yellow" => "§6",
"gray" => "§7",
"dark_gray" => "§8",
"blue" => "§9",
"green" => "§a",
"aqua" or "cyan" => "§b",
"red" => "§c",
"light_purple" or "magenta" => "§d",
"yellow" => "§e",
"white" => "§f",
_ => "" ,
#pragma warning restore format // @formatter:on
};
}
/// <summary>
/// Specify whether translation rules have been loaded
/// </summary>
private static bool RulesInitialized = false;
private static readonly Lock RulesInitializationLock = new();
private static Task? RulesRefreshTask = null;
/// <summary>
/// Set of translation rules for formatting text
/// </summary>
private static Dictionary<string, string> TranslationRules = new();
/// <summary>
/// Initialize translation rules.
/// Necessary for properly printing some chat messages.
/// </summary>
public static void InitTranslations()
{
lock (RulesInitializationLock)
{
if (RulesInitialized)
return;
RulesInitialized = true;
RulesRefreshTask = InitRulesAsync();
_ = ObserveInitRulesAsync(RulesRefreshTask);
}
}
/// <summary>
/// Internal rule initialization method. Looks for local rule file and refreshes it from Mojang asset servers if needed.
/// </summary>
private static async Task InitRulesAsync()
{
if (Config.Main.Advanced.Language == "en_us")
{
TranslationRules = LoadEmbeddedTranslationRules();
return;
}
//Language file in a subfolder, depending on the language setting
if (!Directory.Exists("lang"))
Directory.CreateDirectory("lang");
string languageFilePath = "lang" + Path.DirectorySeparatorChar + Config.Main.Advanced.Language + ".json";
if (TryLoadTranslationRulesFromFile(languageFilePath, out Dictionary<string, string>? translationRules))
TranslationRules = translationRules;
else TranslationRules = LoadEmbeddedTranslationRules();
if (TranslationRules.TryGetValue("Version", out string? version) &&
version == Settings.TranslationsFile_Version)
{
if (Config.Logging.DebugMessages)
ConsoleIO.WriteLineFormatted(Translations.chat_loaded, acceptnewlines: true);
return;
}
// Try downloading language file from Mojang's servers?
ConsoleIO.WriteLineFormatted(
"§8" + string.Format(Translations.chat_download, Config.Main.Advanced.Language));
using HttpClient httpClient = new();
try
{
string fetchIndex = await httpClient.GetStringAsync(TranslationsFile_Website_Index);
Match match = Regex.Match(fetchIndex,
$"minecraft/lang/{Config.Main.Advanced.Language}.json" + @""":\s\{""hash"":\s""([\d\w]{40})""");
if (match.Success && match.Groups.Count == 2)
{
string hash = match.Groups[1].Value;
string translation_file_location = TranslationsFile_Website_Download + '/' + hash[..2] + '/' + hash;
if (Config.Logging.DebugMessages)
ConsoleIO.WriteLineFormatted(
string.Format(Translations.chat_request, translation_file_location));
Dictionary<string, string>? fetchedFile =
await httpClient.GetFromJsonAsync<Dictionary<string, string>>(translation_file_location);
if (fetchedFile is not null && fetchedFile.Count > 0)
{
TranslationRules = fetchedFile;
TranslationRules["Version"] = TranslationsFile_Version;
await File.WriteAllTextAsync(languageFilePath,
JsonSerializer.Serialize(TranslationRules, typeof(Dictionary<string, string>)),
Encoding.UTF8);
ConsoleIO.WriteLineFormatted("§8" + string.Format(Translations.chat_done, languageFilePath));
return;
}
}
else
{
ConsoleIO.WriteLineFormatted("§8" + Translations.chat_fail, acceptnewlines: true);
}
}
catch (HttpRequestException)
{
ConsoleIO.WriteLineFormatted("§8" + Translations.chat_fail, acceptnewlines: true);
}
catch (IOException)
{
ConsoleIO.WriteLineFormatted("§8" + string.Format(Translations.chat_save_fail, languageFilePath),
acceptnewlines: true);
}
catch (Exception e)
{
ConsoleIO.WriteLineFormatted("§8" + Translations.chat_fail, acceptnewlines: true);
ConsoleIO.WriteLine(e.Message);
if (Config.Logging.DebugMessages && !string.IsNullOrEmpty(e.StackTrace))
ConsoleIO.WriteLine(e.StackTrace);
}
TranslationRules = LoadEmbeddedTranslationRules();
ConsoleIO.WriteLine(Translations.chat_use_default);
}
private static async Task ObserveInitRulesAsync(Task initRulesTask)
{
try
{
await initRulesTask;
}
catch (Exception e)
{
TranslationRules = LoadEmbeddedTranslationRules();
if (Config.Logging.DebugMessages)
ConsoleIO.WriteLine(e.ToString());
}
}
private static Dictionary<string, string> LoadEmbeddedTranslationRules()
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(
(byte[])MinecraftAssets.ResourceManager.GetObject("en_us.json")!)!;
}
private static bool TryLoadTranslationRulesFromFile(string languageFilePath, out Dictionary<string, string>? translationRules)
{
translationRules = null;
if (!File.Exists(languageFilePath))
return false;
try
{
translationRules =
JsonSerializer.Deserialize<Dictionary<string, string>>(File.OpenRead(languageFilePath))!;
return translationRules is not null;
}
catch (IOException)
{
return false;
}
catch (JsonException)
{
return false;
}
}
public static string? TranslateString(string rulename)
{
if (TranslationRules.TryGetValue(rulename, out string? result))
return result;
else
return null;
}
/// <summary>
/// Format text using a specific formatting rule.
/// Example : * %s %s + ["ORelio", "is doing something"] = * ORelio is doing something
/// </summary>
/// <param name="rulename">Name of the rule, chosen by the server</param>
/// <param name="using_data">Data to be used in the rule</param>
/// <returns>Returns the formatted text according to the given data</returns>
private static string TranslateString(string rulename, List<string> using_data)
{
if (!RulesInitialized)
{
InitRules();
RulesInitialized = true;
}
if (TranslationRules.ContainsKey(rulename))
{
int using_idx = 0;
string rule = TranslationRules[rulename];
StringBuilder result = new();
for (int i = 0; i < rule.Length; i++)
{
if (rule[i] == '%' && i + 1 < rule.Length)
{
//Using string or int with %s or %d
if (rule[i + 1] == 's' || rule[i + 1] == 'd')
{
if (using_data.Count > using_idx)
{
result.Append(using_data[using_idx]);
using_idx++;
i += 1;
continue;
}
}
//Using specified string or int with %1$s, %2$s...
else if (char.IsDigit(rule[i + 1])
&& i + 3 < rule.Length && rule[i + 2] == '$'
&& (rule[i + 3] == 's' || rule[i + 3] == 'd'))
{
int specified_idx = rule[i + 1] - '1';
if (using_data.Count > specified_idx)
{
result.Append(using_data[specified_idx]);
using_idx++;
i += 3;
continue;
}
}
}
result.Append(rule[i]);
}
return result.ToString();
}
else return "[" + rulename + "] " + string.Join(" ", using_data);
}
/// <summary>
/// Mapping from JSON/NBT property names to Minecraft formatting codes (without §).
/// Both "underlined" (canonical Minecraft name) and "underline" (alias) are supported.
/// </summary>
private static readonly Dictionary<string, string> FormattingCodes = new()
{
{ "obfuscated", "k" },
{ "bold", "l" },
{ "strikethrough", "m" },
{ "underlined", "n" },
{ "underline", "n" },
{ "italic", "o" },
};
/// <summary>Matches a single color code (§0§9, §a§f). Used to strip color when replacing.</summary>
private static readonly Regex ColorCodeRegex = new(@"§[0-9a-f]", RegexOptions.Compiled);
/// <summary>
/// Use a JSON Object to build the corresponding string
/// </summary>
/// <param name="data">JSON object to convert</param>
/// <param name="formatting">Inherited formatting codes from parent elements (set to "" for function init)</param>
/// <param name="links">Container for links from JSON serialized text</param>
/// <returns>returns the Minecraft-formatted string</returns>
private static string JSONData2String(System.Text.Json.Nodes.JsonNode? data, string formatting, List<string>? links)
{
string extra_result = "";
switch (data)
{
case System.Text.Json.Nodes.JsonObject obj:
if (obj.ContainsKey("color"))
{
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)
{
var clickEvent = obj["clickEvent"]!.AsObject();
if (clickEvent.ContainsKey("action")
&& clickEvent.ContainsKey("value")
&& clickEvent["action"]!.GetStringValue() == "open_url"
&& !string.IsNullOrEmpty(clickEvent["value"]!.GetStringValue()))
{
links.Add(clickEvent["value"]!.GetStringValue());
}
}
if (obj.ContainsKey("extra"))
{
foreach (var item in obj["extra"]!.AsArray())
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"))
{
// Pass "" to the leaf text node: formatting is already prepended here,
// and the default: case would add it a second time if we passed formatting.
return formatting + JSONData2String(obj["text"], "", links) + extra_result;
}
else if (obj.ContainsKey("translate"))
{
List<string> using_data = new();
if (obj.ContainsKey("using") && !obj.ContainsKey("with"))
obj["with"] = obj["using"]!.DeepClone();
if (obj.ContainsKey("with"))
{
foreach (var item in obj["with"]!.AsArray())
{
using_data.Add(JSONData2String(item, formatting, links));
}
}
return formatting +
TranslateString(JSONData2String(obj["translate"], "", links), using_data) +
extra_result;
}
else return extra_result;
case System.Text.Json.Nodes.JsonArray arr:
string result = "";
foreach (var item in arr)
{
result += JSONData2String(item, formatting, links);
}
return result;
default:
return formatting + data.GetStringValue();
}
}
private static string NbtToString(Dictionary<string, object> nbt, string formatting = "")
{
if (nbt.Count == 1 && nbt.TryGetValue("", out object? rootMessage))
{
return formatting + (rootMessage?.ToString() ?? string.Empty);
}
string message = string.Empty;
StringBuilder extraBuilder = new();
// Build formatting from color and formatting flags first
if (nbt.TryGetValue("color", out object? color))
{
formatting = ColorCodeRegex.Replace(formatting, "");
formatting += Color2tag((string)color);
}
foreach (var (key, code) in FormattingCodes)
{
if (nbt.TryGetValue(key, out object? flagValue))
{
bool isActive = flagValue switch
{
byte b => b > 0,
bool b => b,
_ => flagValue?.ToString()?.ToLower() == "true"
};
if (isActive)
formatting += "§" + code;
else
formatting = formatting.Replace("§" + code, "");
}
}
// 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<string> 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<string, object> { { "text", $"{withs[i]}" } },
string => new Dictionary<string, object> { { "text", (string)withs[i] } },
_ => (Dictionary<string, object>)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<string, object> { { "text", $"{extras[i]}" } },
string => new Dictionary<string, object> { { "text", (string)extras[i] } },
_ => (Dictionary<string, object>)extras[i]
};
extraBuilder.Append(NbtToString(extraDict, "§r" + formatting));
}
}
return formatting + message + extraBuilder.ToString();
}
}
}