package me.rayzr522.jsonmessage; import com.google.common.base.Strings; import com.google.common.collect.BiMap; import com.google.common.collect.ImmutableBiMap; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.entity.Player; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Vector; /** * This is a complete JSON message builder class. To create a new JSONMessage do * {@link #create(String)} * * @author Rayzr */ @SuppressWarnings({"WeakerAccess", "unused"}) public class JSONMessage { private static final BiMap<ChatColor, String> stylesToNames; static { ImmutableBiMap.Builder<ChatColor, String> builder = ImmutableBiMap.builder(); for (final ChatColor style : ChatColor.values()) { if (!style.isFormat()) { continue; } String styleName; switch (style) { case MAGIC: styleName = "obfuscated"; break; case UNDERLINE: styleName = "underlined"; break; default: styleName = style.name().toLowerCase(); break; } builder.put(style, styleName); } stylesToNames = builder.build(); } private final List<MessagePart> parts = new ArrayList<>(); private int centeringStartIndex = -1; /** * Creates a new {@link JSONMessage} object * * @param text The text to start with */ private JSONMessage(String text) { parts.add(new MessagePart(text)); } /** * Creates a new {@link JSONMessage} object * * @param text The text to start with * @return A new {@link JSONMessage} object */ public static JSONMessage create(String text) { return new JSONMessage(text); } /** * Creates a new {@link JSONMessage} object * * @return A new {@link JSONMessage} object */ public static JSONMessage create() { return create(""); } /** * Sends an action bar message * * @param message The message to send * @param players The players you want to send it to */ public static void actionbar(String message, Player... players) { ReflectionHelper.sendPacket(ReflectionHelper.createActionbarPacket(ChatColor.translateAlternateColorCodes('&', message)), players); } /** * @return The latest {@link MessagePart} * @throws ArrayIndexOutOfBoundsException If {@code parts.size() <= 0}. */ public MessagePart last() { if (parts.size() <= 0) { throw new ArrayIndexOutOfBoundsException("No MessageParts exist!"); } return parts.get(parts.size() - 1); } /** * Converts this {@link JSONMessage} instance to actual JSON * * @return The JSON representation of this {@link JSONMessage} */ public JsonObject toJSON() { JsonObject obj = new JsonObject(); obj.addProperty("text", ""); JsonArray array = new JsonArray(); parts.stream() .map(MessagePart::toJSON) .forEach(array::add); obj.add("extra", array); return obj; } /** * Converts this {@link JSONMessage} object to a String representation of the JSON. * This is an alias of {@code toJSON().toString()}. */ @Override public String toString() { return toJSON().toString(); } /** * Converts this {@link JSONMessage} object to the legacy formatting system, which * uses formatting codes (like &6, &l, &4, etc.) * * @return This {@link JSONMessage} instance {@link JSONMessage} in legacy format */ public String toLegacy() { StringBuilder output = new StringBuilder(); parts.stream() .map(MessagePart::toLegacy) .forEach(output::append); return output.toString(); } /** * Sends this {@link JSONMessage} to all the players specified * * @param players The players you want to send this to */ public void send(Player... players) { ReflectionHelper.sendPacket(ReflectionHelper.createTextPacket(toString()), players); } /** * Sends this as a title to all the players specified * * @param fadeIn How many ticks to fade in * @param stay How many ticks to stay * @param fadeOut How many ticks to fade out * @param players The players to send this to */ public void title(int fadeIn, int stay, int fadeOut, Player... players) { ReflectionHelper.sendPacket(ReflectionHelper.createTitleTimesPacket(fadeIn, stay, fadeOut), players); ReflectionHelper.sendPacket(ReflectionHelper.createTitlePacket(toString()), players); } /** * Sends this as a subtitle to all the players specified. Must be used after sending a {@link #title(int, int, int, Player...) title}. * * @param players The players to send this to */ public void subtitle(Player... players) { ReflectionHelper.sendPacket(ReflectionHelper.createSubtitlePacket(toString()), players); } /** * Sends an action bar message * * @param players The players you want to send this to */ public void actionbar(Player... players) { actionbar(toLegacy(), players); } /** * Sets the color of the current message part. * * @param color The color to set * @return This {@link JSONMessage} instance */ public JSONMessage color(ChatColor color) { last().setColor(color); return this; } /** * Adds a style to the current message part. * * @param style The style to add * @return This {@link JSONMessage} instance */ public JSONMessage style(ChatColor style) { last().addStyle(style); return this; } /** * Makes the text run a command. * * @param command The command to run * @return This {@link JSONMessage} instance */ public JSONMessage runCommand(String command) { last().setOnClick(ClickEvent.runCommand(command)); return this; } /** * Makes the text suggest a command. * * @param command The command to suggest * @return This {@link JSONMessage} instance */ public JSONMessage suggestCommand(String command) { last().setOnClick(ClickEvent.suggestCommand(command)); return this; } /** * Opens a URL. * * @param url The url to open * @return This {@link JSONMessage} instance */ public JSONMessage openURL(String url) { last().setOnClick(ClickEvent.openURL(url)); return this; } /** * Changes the page of a book. Using this in a non-book context is useless * and will probably error. * * @param page The page to change to * @return This {@link JSONMessage} instance */ public JSONMessage changePage(int page) { last().setOnClick(ClickEvent.changePage(page)); return this; } /** * Shows text when you hover over it * * @param text The text to show * @return This {@link JSONMessage} instance */ public JSONMessage tooltip(String text) { last().setOnHover(HoverEvent.showText(text)); return this; } /** * Shows text when you hover over it * * @param message The text to show * @return This {@link JSONMessage} instance */ public JSONMessage tooltip(JSONMessage message) { last().setOnHover(HoverEvent.showText(message)); return this; } /** * Shows an achievement when you hover over it * * @param id The id of the achievement * @return This {@link JSONMessage} instance */ public JSONMessage achievement(String id) { last().setOnHover(HoverEvent.showAchievement(id)); return this; } /** * Adds another part to this {@link JSONMessage} * * @param text The text to start the next {@link MessagePart} with * @return This {@link JSONMessage} instance */ public JSONMessage then(String text) { return then(new MessagePart(text)); } /** * Adds another part to this {@link JSONMessage} * * @param nextPart The next {@link MessagePart} * @return This {@link JSONMessage} instance */ public JSONMessage then(MessagePart nextPart) { parts.add(nextPart); return this; } /** * Adds a horizontal bar to the message of the given length * * @param length The length of the horizontal bar * @return This {@link JSONMessage} instance */ public JSONMessage bar(int length) { return then(Strings.repeat("-", length)).color(ChatColor.DARK_GRAY).style(ChatColor.STRIKETHROUGH); } /** * Adds a horizontal bar to the message that's 53 characters long. This is * the default width of the player's chat window. * * @return This {@link JSONMessage} instance */ public JSONMessage bar() { return bar(53); } /** * Adds a blank line to the message * * @return This {@link JSONMessage} instance */ public JSONMessage newline() { return then("\n"); } /** * Sets the starting point to begin centering JSONMessages. * * @return This {@link JSONMessage} instance */ public JSONMessage beginCenter() { // Start with the NEXT message part. centeringStartIndex = parts.size(); return this; } /** * Ends the centering of the JSONMessage text. * * @return This {@link JSONMessage} instance */ public JSONMessage endCenter() { int current = centeringStartIndex; while (current < parts.size()) { Vector<MessagePart> currentLine = new Vector<>(); int totalLineLength = 0; for (; ; current++) { MessagePart part = current < parts.size() ? parts.get(current) : null; String raw = part == null ? null : ChatColor.stripColor(part.toLegacy()); if (current >= parts.size() || totalLineLength + raw.length() >= 53) { int padding = Math.max(0, (53 - totalLineLength) / 2); currentLine.firstElement().setText(Strings.repeat(" ", padding) + currentLine.firstElement().getText()); currentLine.lastElement().setText(currentLine.lastElement().getText() + "\n"); currentLine.clear(); break; } totalLineLength += raw.length(); currentLine.add(part); } } MessagePart last = parts.get(parts.size() - 1); last.setText(last.getText().substring(0, last.getText().length() - 1)); centeringStartIndex = -1; return this; } /////////////////////////// // BEGIN UTILITY CLASSES // /////////////////////////// /** * Represents the JSON format that all click/hover events in JSON messages must follow. * <br> * <br> * <a href="http://minecraft.gamepedia.com/Commands#Raw_JSON_text">Reference</a> * * @author Rayzr */ public static class MessageEvent { private String action; private Object value; public MessageEvent(String action, Object value) { this.action = action; this.value = value; } /** * @return A {@link JsonObject} representing the properties of this {@link MessageEvent} */ public JsonObject toJSON() { JsonObject obj = new JsonObject(); obj.addProperty("action", action); if (value instanceof JsonElement) { obj.add("value", (JsonElement) value); } else { obj.addProperty("value", value.toString()); } return obj; } /** * @return The action */ public String getAction() { return action; } /** * @param action The action to set */ public void setAction(String action) { this.action = action; } /** * @return The value */ public Object getValue() { return value; } /** * @param value The value to set */ public void setValue(Object value) { this.value = value; } } public static class ClickEvent { /** * Runs a command. * * @param command The command to run * @return The {@link MessageEvent} */ public static MessageEvent runCommand(String command) { return new MessageEvent("run_command", command); } /** * Suggests a command by putting inserting it in chat. * * @param command The command to suggest * @return The {@link MessageEvent} */ public static MessageEvent suggestCommand(String command) { return new MessageEvent("suggest_command", command); } /** * Requires web links to be enabled on the client. * * @param url The url to open * @return The {@link MessageEvent} */ public static MessageEvent openURL(String url) { return new MessageEvent("open_url", url); } /** * Only used with written books. * * @param page The page to switch to * @return The {@link MessageEvent} */ public static MessageEvent changePage(int page) { return new MessageEvent("change_page", page); } } public static class HoverEvent { /** * Shows text when you hover over it * * @param text The text to show * @return The {@link MessageEvent} */ public static MessageEvent showText(String text) { return new MessageEvent("show_text", text); } /** * Shows text when you hover over it * * @param message The {@link JSONMessage} to show * @return The {@link MessageEvent} */ public static MessageEvent showText(JSONMessage message) { JsonArray arr = new JsonArray(); arr.add(new JsonPrimitive("")); arr.add(message.toJSON()); return new MessageEvent("show_text", arr); } /** * Shows an achievement when you hover over it * * @param id The id of the achievement * @return The {@link MessageEvent} */ public static MessageEvent showAchievement(String id) { return new MessageEvent("show_achievement", id); } } private static class ReflectionHelper { private static final String version; private static Class<?> craftPlayer; private static Constructor<?> chatComponentText; private static Class<?> packetPlayOutChat; private static Class<?> packetPlayOutTitle; private static Class<?> iChatBaseComponent; private static Class<?> titleAction; private static Field connection; private static MethodHandle GET_HANDLE; private static MethodHandle SEND_PACKET; private static MethodHandle STRING_TO_CHAT; private static Object enumActionTitle; private static Object enumActionSubtitle; private static Object enumChatMessage; private static Object enumActionbarMessage; private static boolean SETUP; private static int MAJOR_VER = -1; static { String[] split = Bukkit.getServer().getClass().getPackage().getName().split("\\."); version = split[split.length - 1]; try { SETUP = true; MAJOR_VER = getVersion(); craftPlayer = getClass("{obc}.entity.CraftPlayer"); Method getHandle = craftPlayer.getMethod("getHandle"); connection = getHandle.getReturnType().getField("playerConnection"); Method sendPacket = connection.getType().getMethod("sendPacket", getClass("{nms}.Packet")); chatComponentText = getClass("{nms}.ChatComponentText").getConstructor(String.class); iChatBaseComponent = getClass("{nms}.IChatBaseComponent"); Method stringToChat; if (MAJOR_VER < 8) { stringToChat = getClass("{nms}.ChatSerializer").getMethod("a", String.class); } else { stringToChat = getClass("{nms}.IChatBaseComponent$ChatSerializer").getMethod("a", String.class); } GET_HANDLE = MethodHandles.lookup().unreflect(getHandle); SEND_PACKET = MethodHandles.lookup().unreflect(sendPacket); STRING_TO_CHAT = MethodHandles.lookup().unreflect(stringToChat); packetPlayOutChat = getClass("{nms}.PacketPlayOutChat"); packetPlayOutTitle = getClass("{nms}.PacketPlayOutTitle"); titleAction = getClass("{nms}.PacketPlayOutTitle$EnumTitleAction"); enumActionTitle = titleAction.getField("TITLE").get(null); enumActionSubtitle = titleAction.getField("SUBTITLE").get(null); if (MAJOR_VER >= 12) { Method getChatMessageType = getClass("{nms}.ChatMessageType").getMethod("a", byte.class); enumChatMessage = getChatMessageType.invoke(null, (byte) 1); enumActionbarMessage = getChatMessageType.invoke(null, (byte) 2); } } catch (Exception e) { e.printStackTrace(); SETUP = false; } } static void sendPacket(Object packet, Player... players) { if (!SETUP) { throw new IllegalStateException("ReflectionHelper is not set up!"); } if (packet == null) { return; } for (Player player : players) { try { SEND_PACKET.bindTo(connection.get(GET_HANDLE.bindTo(player).invoke())).invoke(packet); } catch (Throwable e) { System.err.println("Failed to send packet"); e.printStackTrace(); } } } private static void setType(Object object, byte type) { if (MAJOR_VER < 12) { set("b", object, type); return; } switch (type) { case 1: set("b", object, enumChatMessage); break; case 2: set("b", object, enumActionbarMessage); break; default: throw new IllegalArgumentException("type must be 1 or 2"); } } static Object createActionbarPacket(String message) { if (!SETUP) { throw new IllegalStateException("ReflectionHelper is not set up!"); } Object packet = createTextPacket(message); setType(packet, (byte) 2); return packet; } static Object createTextPacket(String message) { if (!SETUP) { throw new IllegalStateException("ReflectionHelper is not set up!"); } try { Object packet = packetPlayOutChat.newInstance(); set("a", packet, fromJson(message)); setType(packet, (byte) 1); return packet; } catch (Exception e) { e.printStackTrace(); return null; } } static Object createTitlePacket(String message) { if (!SETUP) { throw new IllegalStateException("ReflectionHelper is not set up!"); } try { return packetPlayOutTitle.getConstructor(titleAction, iChatBaseComponent).newInstance(enumActionTitle, fromJson(message)); } catch (Exception e) { e.printStackTrace(); return null; } } static Object createSubtitlePacket(String message) { if (!SETUP) { throw new IllegalStateException("ReflectionHelper is not set up!"); } try { return packetPlayOutTitle.getConstructor(titleAction, iChatBaseComponent).newInstance(enumActionSubtitle, fromJson(message)); } catch (Exception e) { e.printStackTrace(); return null; } } static Object createTitleTimesPacket(int fadeIn, int stay, int fadeOut) { if (!SETUP) { throw new IllegalStateException("ReflectionHelper is not set up!"); } try { return packetPlayOutTitle.getConstructor(int.class, int.class, int.class).newInstance(fadeIn, stay, fadeOut); } catch (Exception e) { e.printStackTrace(); return null; } } /** * Creates a ChatComponentText from plain text * * @param message The text to convert to a chat component * @return The chat component */ static Object componentText(String message) { if (!SETUP) { throw new IllegalStateException("ReflectionHelper is not set up!"); } try { return chatComponentText.newInstance(message); } catch (Exception e) { e.printStackTrace(); return null; } } /** * Attempts to convert a String representing a JSON message into a usable object * * @param json The JSON to attempt to parse * @return The object representing the text in JSON form, or <code>null</code> if something went wrong converting the String to JSON data */ static Object fromJson(String json) { if (!SETUP) { throw new IllegalStateException("ReflectionHelper is not set up!"); } if (!json.trim().startsWith("{")) { return componentText(json); } try { return STRING_TO_CHAT.invoke(json); } catch (Throwable e) { e.printStackTrace(); return null; } } /** * Returns a class with the given package and name. This method replaces <code>{nms}</code> with <code>net.minecraft.server.[version]</code> and <code>{obc}</code> with <code>org.bukkit.craft.[version]</code> * <br> * <br> * Example: * * <pre> * Class<?> entityPlayer = ReflectionHelper.getClass("{nms}.EntityPlayer"); * </pre> * * @param path The path to the {@link Class} * @return The class * @throws ClassNotFoundException If the class was not found */ static Class<?> getClass(String path) throws ClassNotFoundException { if (!SETUP) { throw new IllegalStateException("ReflectionHelper is not set up!"); } return Class.forName(path.replace("{nms}", "net.minecraft.server." + version).replace("{obc}", "org.bukkit.craftbukkit." + version)); } /** * Sets a field with the given name on an object to the value specified * * @param field The name of the field to change * @param obj The object to change the field of * @param value The new value to set */ static void set(String field, Object obj, Object value) { try { Field f = obj.getClass().getDeclaredField(field); f.setAccessible(true); f.set(obj, value); } catch (Exception e) { e.printStackTrace(); } } static int getVersion() { if (!SETUP) { throw new IllegalStateException("ReflectionHelper is not set up!"); } try { return Integer.parseInt(version.split("_")[1]); } catch (NumberFormatException e) { e.printStackTrace(); return 10; } } } /** * Defines a section of the message, and represents the format that all JSON messages must follow in Minecraft. * <br> * <br> * <a href="http://minecraft.gamepedia.com/Commands#Raw_JSON_text">Reference</a> * * @author Rayzr */ public class MessagePart { private final List<ChatColor> styles = new ArrayList<>(); private MessageEvent onClick; private MessageEvent onHover; private ChatColor color; private String text; public MessagePart(String text) { this.text = text == null ? "null" : text; } /** * Converts this {@link MessagePart} into a {@link JsonObject} * * @return The Minecraft-compatible {@link JsonObject} */ public JsonObject toJSON() { Objects.requireNonNull(text); JsonObject obj = new JsonObject(); obj.addProperty("text", text); if (color != null) { obj.addProperty("color", color.name().toLowerCase()); } for (ChatColor style : styles) { obj.addProperty(stylesToNames.get(style), true); } if (onClick != null) { obj.add("clickEvent", onClick.toJSON()); } if (onHover != null) { obj.add("hoverEvent", onHover.toJSON()); } return obj; } /** * @return This {@link MessagePart} in legacy-style color/formatting codes */ public String toLegacy() { StringBuilder output = new StringBuilder(); if (color != null) { output.append(color.toString()); } styles.stream() .map(ChatColor::toString) .forEach(output::append); return output.append(text).toString(); } /** * @return The click event bound */ public MessageEvent getOnClick() { return onClick; } /** * @param onClick The new click event to bind */ public void setOnClick(MessageEvent onClick) { this.onClick = onClick; } /** * @return The hover event bound */ public MessageEvent getOnHover() { return onHover; } /** * @param onHover The new hover event to bind */ public void setOnHover(MessageEvent onHover) { this.onHover = onHover; } /** * @return The color */ public ChatColor getColor() { return color; } /** * @param color The color to set */ public void setColor(ChatColor color) { if (!color.isColor()) { throw new IllegalArgumentException(color.name() + " is not a color!"); } this.color = color; } /** * @return The list of styles */ public List<ChatColor> getStyles() { return styles; } /** * @param style The new style to add */ public void addStyle(ChatColor style) { if (style == null) { throw new IllegalArgumentException("Style cannot be null!"); } if (!style.isFormat()) { throw new IllegalArgumentException(color.name() + " is not a style!"); } styles.add(style); } /** * @return The raw text */ public String getText() { return text; } /** * @param text The raw text to set */ public void setText(String text) { this.text = text; } } }