package me.chayapak1.chomens_bot.util; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import net.kyori.adventure.text.*; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.Style; import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextDecoration; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.util.HashMap; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; public class ComponentUtilities { // https://github.com/kaboomserver/extras/blob/master/src/main/java/pw/kaboom/extras/modules/player/PlayerChat.java#L49C9-L81C26 // with the hover event added public static final TextReplacementConfig URL_REPLACEMENT_CONFIG = TextReplacementConfig .builder() .match(Pattern .compile("((https?://(ww(w|\\d)\\.)?|ww(w|\\d))[-a-zA-Z0-9@:%._+~#=]{1,256}" + "\\.[a-zA-Z0-9]{2,6}\\b([-a-zA-Z0-9@:%_+.~#?&/=]*))")) .replacement((b, c) -> { if (c == null) { return null; } if (b.groupCount() < 1) { return null; } final String content = b.group(1); final String url; /* Minecraft doesn't accept "www.google.com" as a URL in click events */ if (content.contains("://")) { url = content; } else { url = "https://" + content; } return Component.text(content, NamedTextColor.BLUE) .decorate(TextDecoration.UNDERLINED) .clickEvent(ClickEvent.openUrl(url)) .hoverEvent( HoverEvent.showText( Component .text("Click here to open the URL") .color(NamedTextColor.BLUE) ) ); }) .build(); // component parsing // rewritten from chipmunkbot, a lot of stuff has changed, and also ANSI and section signs support, etc... public static final Map LANGUAGE = loadJsonStringMap("language.json"); public static final Map VOICE_CHAT_LANGUAGE = loadJsonStringMap("voiceChatLanguage.json"); public static final Map KEYBINDINGS = loadJsonStringMap("keybinds.json"); private static Map loadJsonStringMap (String name) { Map map = new HashMap<>(); InputStream is = ClassLoader.getSystemClassLoader().getResourceAsStream(name); BufferedReader reader = new BufferedReader(new InputStreamReader(is)); JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); for (Map.Entry entry : json.entrySet()) { map.put(entry.getKey(), json.get(entry.getKey()).getAsString()); } return map; } public static String getOrReturnFallback (TranslatableComponent component) { final String key = component.key(); final String minecraftKey = LANGUAGE.get(key); final String voiceChatKey = VOICE_CHAT_LANGUAGE.get(key); if (minecraftKey != null) return minecraftKey; else if (voiceChatKey != null) return voiceChatKey; else return component.fallback() != null ? component.fallback() : key; } public static String stringify (Component message) { return new ComponentParser().stringify(message, ComponentParser.ParseType.PLAIN); } public static String stringifySectionSign (Component message) { return new ComponentParser().stringify(message, ComponentParser.ParseType.SECTION_SIGNS); } public static String stringifyAnsi (Component message) { return new ComponentParser().stringify(message, ComponentParser.ParseType.ANSI); } public static String stringifyDiscordAnsi (Component message) { return new ComponentParser().stringify(message, ComponentParser.ParseType.DISCORD_ANSI); } private static class ComponentParser { public static final Pattern ARG_PATTERN = Pattern.compile("%(?:(\\d+)\\$)?([s%])"); public static final long MAX_TIME = 100; // this is actually more than we need public static final Map ANSI_MAP = new HashMap<>(); static { // map totallynotskidded™ from https://github.com/PrismarineJS/prismarine-chat/blob/master/index.js#L10 ANSI_MAP.put("0", "\u001b[38;2;0;0;0m"); ANSI_MAP.put("1", "\u001b[38;2;0;0;170m"); ANSI_MAP.put("2", "\u001b[38;2;0;170;0m"); ANSI_MAP.put("3", "\u001b[38;2;0;170;170m"); ANSI_MAP.put("4", "\u001b[38;2;170;0;0m"); ANSI_MAP.put("5", "\u001b[38;2;170;0;170m"); ANSI_MAP.put("6", "\u001b[38;2;255;170;0m"); ANSI_MAP.put("7", "\u001b[38;2;170;170;170m"); ANSI_MAP.put("8", "\u001b[38;2;85;85;85m"); ANSI_MAP.put("9", "\u001b[38;2;85;85;255m"); ANSI_MAP.put("a", "\u001b[38;2;85;255;85m"); ANSI_MAP.put("b", "\u001b[38;2;85;255;255m"); ANSI_MAP.put("c", "\u001b[38;2;255;85;85m"); ANSI_MAP.put("d", "\u001b[38;2;255;85;255m"); ANSI_MAP.put("e", "\u001b[38;2;255;255;85m"); ANSI_MAP.put("f", "\u001b[38;2;255;255;255m"); ANSI_MAP.put("l", "\u001b[1m"); ANSI_MAP.put("o", "\u001b[3m"); ANSI_MAP.put("n", "\u001b[4m"); ANSI_MAP.put("m", "\u001b[9m"); ANSI_MAP.put("k", "\u001b[6m"); ANSI_MAP.put("r", "\u001b[0m"); } public static final Map DISCORD_ANSI_MAP = new HashMap<>(); static { // map totallynotskidded™ from https://github.com/PrismarineJS/prismarine-chat/blob/master/index.js#L10 DISCORD_ANSI_MAP.put("0", "\u001b[30m"); DISCORD_ANSI_MAP.put("1", "\u001b[34m"); DISCORD_ANSI_MAP.put("2", "\u001b[32m"); DISCORD_ANSI_MAP.put("3", "\u001b[36m"); DISCORD_ANSI_MAP.put("4", "\u001b[31m"); DISCORD_ANSI_MAP.put("5", "\u001b[35m"); DISCORD_ANSI_MAP.put("6", "\u001b[33m"); DISCORD_ANSI_MAP.put("7", "\u001b[37m"); DISCORD_ANSI_MAP.put("8", "\u001b[90m"); DISCORD_ANSI_MAP.put("9", "\u001b[94m"); DISCORD_ANSI_MAP.put("a", "\u001b[92m"); DISCORD_ANSI_MAP.put("b", "\u001b[96m"); DISCORD_ANSI_MAP.put("c", "\u001b[91m"); DISCORD_ANSI_MAP.put("d", "\u001b[95m"); DISCORD_ANSI_MAP.put("e", "\u001b[93m"); DISCORD_ANSI_MAP.put("f", "\u001b[97m"); DISCORD_ANSI_MAP.put("l", "\u001b[1m"); DISCORD_ANSI_MAP.put("o", "\u001b[3m"); DISCORD_ANSI_MAP.put("n", "\u001b[4m"); DISCORD_ANSI_MAP.put("m", "\u001b[9m"); DISCORD_ANSI_MAP.put("k", "\u001b[6m"); DISCORD_ANSI_MAP.put("r", "\u001b[0m"); } private ParseType type; private long parseStartTime = System.currentTimeMillis(); private String lastStyle = ""; private boolean isSubParsing = false; private String stringify (Component message, ParseType type) { this.type = type; if (System.currentTimeMillis() > parseStartTime + MAX_TIME) return ""; try { final StringBuilder builder = new StringBuilder(); final String color = getColor(message.color()); final String style = getStyle(message.style()); final String output = stringifyPartially(message, color, style); builder.append(output); for (Component child : message.children()) { final ComponentParser parser = new ComponentParser(); parser.lastStyle = lastStyle + color + style; parser.parseStartTime = parseStartTime; parser.isSubParsing = true; builder.append(parser.stringify(child, type)); } if ( !isSubParsing && (type == ParseType.ANSI || type == ParseType.DISCORD_ANSI) ) { builder.append(DISCORD_ANSI_MAP.get("r")); } if (type == ParseType.DISCORD_ANSI) { // as of the time writing this (2024-12-28) discord doesn't support the bright colors yet return builder.toString().replace("\u001b[9", "\u001b[3"); } else { return builder.toString(); } } catch (Exception e) { LoggerUtilities.error(e); return ""; } } public String stringifyPartially (Component message, String color, String style) { return switch (message) { case TextComponent t_component -> stringifyPartially(t_component, color, style); case TranslatableComponent t_component -> stringifyPartially(t_component, color, style); case SelectorComponent t_component -> stringifyPartially(t_component, color, style); case KeybindComponent t_component -> stringifyPartially(t_component, color, style); default -> ""; }; } public String getStyle (Style textStyle) { if (textStyle == null) return ""; StringBuilder style = new StringBuilder(); for (Map.Entry decorationEntry : textStyle.decorations().entrySet()) { final TextDecoration decoration = decorationEntry.getKey(); final TextDecoration.State state = decorationEntry.getValue(); if (state == TextDecoration.State.NOT_SET || state == TextDecoration.State.FALSE) continue; if (type == ParseType.ANSI) { // right now discord doesn't care about styling switch (decoration) { case BOLD -> style.append(ANSI_MAP.get("l")); case ITALIC -> style.append(ANSI_MAP.get("o")); case OBFUSCATED -> style.append(ANSI_MAP.get("k")); case UNDERLINED -> style.append(ANSI_MAP.get("n")); case STRIKETHROUGH -> style.append(ANSI_MAP.get("m")); } } else if (type == ParseType.SECTION_SIGNS) { switch (decoration) { case BOLD -> style.append("§l"); case ITALIC -> style.append("§o"); case OBFUSCATED -> style.append("§k"); case UNDERLINED -> style.append("§n"); case STRIKETHROUGH -> style.append("§m"); } } } return style.toString(); } public String getColor (TextColor color) { if (color == null) return ""; // map totallynotskidded™ from https://github.com/PrismarineJS/prismarine-chat/blob/master/index.js#L299 String code; if (color == NamedTextColor.BLACK) code = "0"; else if (color == NamedTextColor.DARK_BLUE) code = "1"; else if (color == NamedTextColor.DARK_GREEN) code = "2"; else if (color == NamedTextColor.DARK_AQUA) code = "3"; else if (color == NamedTextColor.DARK_RED) code = "4"; else if (color == NamedTextColor.DARK_PURPLE) code = "5"; else if (color == NamedTextColor.GOLD) code = "6"; else if (color == NamedTextColor.GRAY) code = "7"; else if (color == NamedTextColor.DARK_GRAY) code = "8"; else if (color == NamedTextColor.BLUE) code = "9"; else if (color == NamedTextColor.GREEN) code = "a"; else if (color == NamedTextColor.AQUA) code = "b"; else if (color == NamedTextColor.RED) code = "c"; else if (color == NamedTextColor.LIGHT_PURPLE) code = "d"; else if (color == NamedTextColor.YELLOW) code = "e"; else if (color == NamedTextColor.WHITE) code = "f"; else { try { code = color.asHexString(); } catch (NullPointerException e) { code = ""; // mabe...,,.,.., } } if (type == ParseType.SECTION_SIGNS) { return "§" + code; } else if (type == ParseType.ANSI || type == ParseType.DISCORD_ANSI) { String ansiCode = type == ParseType.ANSI ? ANSI_MAP.get(code) : DISCORD_ANSI_MAP.get(code); if (ansiCode == null) { if (type == ParseType.DISCORD_ANSI) { // gets the closest color to the hex final int rgb = Integer.parseInt(code.substring(1), 16); final String chatColor = Character.toString(ColorUtilities.getClosestChatColor(rgb)); ansiCode = DISCORD_ANSI_MAP.get(chatColor); } else { ansiCode = "\u001b[38;2;" + color.red() + ";" + color.green() + ";" + color.blue() + "m"; } } return ansiCode; } else { return ""; } } private String getPartialResultAndSetLastColor (String originalResult, String color, String style) { if (type == ParseType.PLAIN) return originalResult; String resetCode; if (type == ParseType.ANSI || type == ParseType.DISCORD_ANSI) resetCode = ANSI_MAP.get("r"); else resetCode = "§r"; final String result = lastStyle + color + style + originalResult + (lastStyle.isEmpty() ? resetCode : ""); lastStyle = color + style; return result; } private String stringifyPartially (String message, String color, String style) { if (type == ParseType.PLAIN) return message; final boolean isAllAnsi = type == ParseType.ANSI || type == ParseType.DISCORD_ANSI; String replacedContent = message; // processes section signs // not processing invalid codes is INTENTIONAL and it is a FEATURE if (isAllAnsi && replacedContent.contains("§")) { // is try-catch a great idea for these? try { replacedContent = Pattern .compile("(§.)") .matcher(message) .replaceAll(m -> { final String code = m.group(0).substring(1); if (!code.equals("r")) return type == ParseType.ANSI ? ANSI_MAP.get(code) : DISCORD_ANSI_MAP.get(code); else return color; }); } catch (Exception ignored) {} } return getPartialResultAndSetLastColor(replacedContent, color, style); } private String stringifyPartially (TextComponent message, String color, String style) { return stringifyPartially(message.content(), color, style); } private String stringifyPartially (TranslatableComponent message, String color, String style) { final String format = getOrReturnFallback(message); // totallynotskidded™️from HBot (and changed a bit) Matcher matcher = ARG_PATTERN.matcher(format); StringBuilder sb = new StringBuilder(); // not checking if arguments length equals input format length // is INTENTIONAL and is a FEATURE int i = 0; while (matcher.find()) { parseStartTime++; if (matcher.group().equals("%%")) { matcher.appendReplacement(sb, "%"); } else { final String idxStr = matcher.group(1); try { int idx = idxStr == null ? i++ : (Integer.parseInt(idxStr) - 1); if (idx >= 0 && idx < message.arguments().size()) { final ComponentParser parser = new ComponentParser(); parser.lastStyle = lastStyle + color + style; parser.parseStartTime = parseStartTime; parser.isSubParsing = true; matcher.appendReplacement( sb, Matcher.quoteReplacement( parser.stringify( message.arguments() .get(idx) .asComponent(), type ) + lastStyle + color + style // IMPORTANT!!!! ) ); } else { matcher.appendReplacement(sb, ""); } } catch (NumberFormatException ignored) {} // is this a good idea? } } matcher.appendTail(sb); return getPartialResultAndSetLastColor(sb.toString(), color, style); } // on the client side, this acts just like TextComponent // and does NOT process any players stuff private String stringifyPartially (SelectorComponent message, String color, String style) { return stringifyPartially(message.pattern(), style, color); } public String stringifyPartially (KeybindComponent message, String color, String style) { final String keybind = message.keybind(); // FIXME: this isn't the correct way to parse keybinds final Component component = KEYBINDINGS.containsKey(keybind) ? Component.translatable(KEYBINDINGS.get(keybind)) : Component.text(keybind); return stringifyPartially(component, color, style); } public enum ParseType { PLAIN, SECTION_SIGNS, ANSI, DISCORD_ANSI } } }