package com.supaham.commons.bukkit.text; import com.google.common.base.Preconditions; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.stream.JsonWriter; import com.supaham.commons.Enums; import com.supaham.commons.bukkit.Colors; import com.supaham.commons.bukkit.utils.ChatColorUtils; import com.supaham.commons.bukkit.utils.ReflectionUtils; import com.supaham.commons.bukkit.utils.ReflectionUtils.PackageType; import org.apache.commons.lang.Validate; import org.bukkit.Achievement; import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.Statistic; import org.bukkit.command.CommandSender; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import java.io.StringWriter; import java.util.ArrayList; import java.util.Collections; import java.util.List; import javax.annotation.Nonnull; /** * Represents a Minecraft chat message that can be fancified with hover & click events, colors, * styles and more. * <p/> * This class uses reflection for some methods such as achievements (Objects, not names) and * sending the packet to a player. * * @since 0.2.4 */ public class FancyMessage { private final List<MessagePart> messageParts; private String jsonString; private boolean dirty; public static FancyMessage of(JsonElement serialized) { if (serialized.isJsonPrimitive()) { return new FancyMessage(serialized.getAsString()); } else if (!serialized.isJsonObject()) { if (serialized.isJsonArray()) { JsonArray array = serialized.getAsJsonArray(); FancyMessage fancyMessage = null; for (JsonElement jsonElement : array) { FancyMessage curMessage = of(jsonElement); if (fancyMessage == null) { fancyMessage = curMessage; } else { fancyMessage.append(curMessage); } } return fancyMessage; } else { throw new JsonParseException("Invalid JSON chat syntax: " + serialized.toString()); } } else { JsonObject jsonObject = serialized.getAsJsonObject(); FancyMessage result = new FancyMessage(); if (jsonObject.has("text")) { result = new FancyMessage(jsonObject.get("text").getAsString()); } else if (jsonObject.has("translate")) { // TODO } else if (jsonObject.has("score")) { // TODO } else { if (!jsonObject.has("selector")) { throw new JsonParseException("Invalid JSON chat syntax: " + serialized.toString()); } // TODO } if (jsonObject.has("extra")) { JsonArray jsonarray2 = jsonObject.getAsJsonArray("extra"); if (jsonarray2.size() <= 0) { throw new JsonParseException("Unexpected empty array of components."); } for (int j = 0; j < jsonarray2.size(); ++j) { result.append(of(jsonarray2.get(j))); } } if (jsonObject.has("color")) { result.color(Enums.findFuzzyByValue(ChatColor.class, jsonObject.get("color").getAsString())); } for (ChatColor color : ChatColorUtils.STYLES) { if (jsonObject.has(color.name().toLowerCase())) { result.style(color); } else if (color == ChatColor.MAGIC && jsonObject.has("obfuscated")) { result.style(ChatColor.MAGIC); } else if (color == ChatColor.UNDERLINE && jsonObject.has("underlined")) { result.style(ChatColor.UNDERLINE); } } if (jsonObject.has("clickEvent")) { JsonObject clickEvent = jsonObject.getAsJsonObject("clickEvent"); if (!clickEvent.has("action") || !clickEvent.has("value")) { throw new JsonParseException("clickEvent must have an action and value: " + serialized); } result.onClick(clickEvent.get("action").getAsString(), clickEvent.get("value").getAsString()); } else if (jsonObject.has("hoverEvent")) { JsonObject hoverEvent = jsonObject.getAsJsonObject("hoverEvent"); if (!hoverEvent.has("action") || !hoverEvent.has("value")) { throw new JsonParseException("hoverEvent must have an action and value: " + serialized); } result.onHover(hoverEvent.get("action").getAsString(), hoverEvent.get("value").getAsString()); } return result; } } public FancyMessage(@Nonnull final Object object) { this(Preconditions.checkNotNull(object, "object cannot be null.").toString()); } public FancyMessage(final String firstPartText) { messageParts = new ArrayList<>(); messageParts.add(new MessagePart(firstPartText)); jsonString = null; dirty = true; // true to speed up append(MessagePart) } public FancyMessage() { messageParts = new ArrayList<>(); messageParts.add(new MessagePart()); jsonString = null; dirty = false; } /** * Sets the text of the current {@link MessagePart}. * * @param text text to set * * @return this instance of FancyMessage, for chaining. * * @throws IllegalStateException thrown if the text is already set for the message part. * @see #append(String) */ public FancyMessage text(Colors text) throws IllegalStateException { return text(Preconditions.checkNotNull(text, "text cannot be null.").toString()); } /** * Sets the text of the current {@link MessagePart}. * * @param text text to set * * @return this instance of FancyMessage, for chaining. * * @throws IllegalStateException thrown if the text is already set for the message part. * @see #append(String) */ public FancyMessage text(String text) throws IllegalStateException { if (hasText()) { throw new IllegalStateException("text for this message part is already set"); } latest().text(text); dirty = true; return this; } /** * Safely appends text to this FancyMessage using {@link SafeFancyMessage#from(String)}. If the * text is already set for this MessagePart it will clone the {@link MessagePart} to keep styles * and colors. * <p/> * <b>Note:</b> this method calls {@link ChatColor#translateAlternateColorCodes(char, String)} on * the given string, where the {@code char} is &, replacing all ampersands with §. * * @param text text to append * * @return this instance of FancyMessage, for chaining. */ public FancyMessage safeAppend(String text) { append(SafeFancyMessage.from(ChatColor.translateAlternateColorCodes('&', text))); return this; } /** * Appends text to this FancyMessage. If the text is already set for this MessagePart it will * clone the {@link MessagePart} to keep styles and colors. * * @param text text to append * * @return this instance of FancyMessage, for chaining. */ public FancyMessage append(String text) { MessagePart latest = latest(); if (latest.hasText()) { latest = latest.clone(); messageParts.add(latest); } latest.text = text; dirty = true; return this; } public FancyMessage append(MessagePart part) { this.messageParts.add(part); dirty = true; return this; } public FancyMessage append(FancyMessage fancyMessage) { this.messageParts.addAll(fancyMessage.messageParts); return this; } public FancyMessage add(int index, MessagePart part) throws IllegalArgumentException { Validate.isTrue(index < messageParts.size()); this.messageParts.add(index, part); dirty = true; return this; } /** * Sets the {@link ChatColor} of this MessagePart. * * @param color color to set * * @return this instance of FancyMessage, for chaining. * * @throws IllegalArgumentException thrown if {@code color} is not a color */ public FancyMessage color(final ChatColor color) throws IllegalArgumentException { latest().color(color); dirty = true; return this; } /** * Applies {@link ChatColor} styles to this MessagePart. * * @param styles array of styles to apply * * @return this instance of FancyMessage, for chaining. * * @throws IllegalArgumentException thrown if {@code styles} contains a color */ public FancyMessage style(ChatColor... styles) throws IllegalArgumentException { latest().style(styles); dirty = true; return this; } /** * Opens a file (client-side) on click event. * * @param path the path of the file to open * * @return this instance of FancyMessage, for chaining. */ public FancyMessage file(final String path) { latest().file(path); dirty = true; return this; } /** * Opens a URL on click event. * * @param url url to open * * @return this instance of FancyMessage, for chaining. */ public FancyMessage link(final String url) { latest().link(url); dirty = true; return this; } /** * Suggests a command on click event. This opens the player's chat box and inserts the given * String. * * @param command command to suggest * * @return this instance of FancyMessage, for chaining. */ public FancyMessage suggest(final String command) { latest().suggest(command); dirty = true; return this; } /** * Executes a command on click event. It should be noted that the commands executed are run by * the * player, * thus appearing in their recent chat history. * * @param command command to execute * * @return this instance of FancyMessage, for chaining. */ public FancyMessage command(final String command) { latest().command(command); dirty = true; return this; } /** * Displays an achievement on hover event. * * @param name name of the achievement to display * * @return this instance of FancyMessage, for chaining. */ public FancyMessage achievementTooltip(final String name) { latest().achievementTooltip("achievement." + name); dirty = true; return this; } /** * Displays an {@link Achievement} on hover event. * * @param which achievement to display * * @return this instance of FancyMessage, for chaining. */ public FancyMessage achievementTooltip(final Achievement which) { latest().achievementTooltip(which); dirty = true; return this; } /** * Displays a statistic on hover event. * * @param which statistic to display * * @return this instance of FancyMessage, for chaining. */ public FancyMessage statisticTooltip(final Statistic which) { latest().statisticTooltip(which); dirty = true; return this; } /** * Displays a statistic that requires a {@link Material} parameter on hover event. * * @param statistic statistic to display * @param material material to pass to the {@code statistic} * * @return this instance of FancyMessage, for chaining. */ public FancyMessage statisticTooltip(final Statistic statistic, Material material) { latest().statisticTooltip(statistic, material); dirty = true; return this; } /** * Displays a statistic that requires a {@link EntityType} parameter on hover event. * * @param statistic statistic to display * @param entity entity to pass to the {@code statistic} * * @return this instance of FancyMessage, for chaining. */ public FancyMessage statisticTooltip(final Statistic statistic, EntityType entity) { latest().statisticTooltip(statistic, entity); dirty = true; return this; } /** * Displays an Item written in json on hover event. * * @param itemJSON item's json to display * * @return this instance of FancyMessage, for chaining. */ public FancyMessage itemTooltip(final String itemJSON) { latest().itemTooltip(itemJSON); dirty = true; return this; } /** * Displays an {@link ItemStack} on hover event. * * @param itemStack * * @return this instance of FancyMessage, for chaining. */ public FancyMessage itemTooltip(final ItemStack itemStack) { latest().itemTooltip(itemStack); dirty = true; return this; } /** * Displays a tooltip on hover event. * * @param text text to display * * @return this instance of FancyMessage, for chaining. */ public FancyMessage tooltip(final Colors text) { return tooltip(Preconditions.checkNotNull(text, "text cannot be null.").toString()); } /** * Displays a tooltip on hover event. * * @param text text to display * * @return this instance of FancyMessage, for chaining. */ public FancyMessage tooltip(final String text) { latest().tooltip(text); dirty = true; return this; } /** * Displays a list of lines on hover event. * * @param lines lines to display * * @return this instance of FancyMessage, for chaining. */ public FancyMessage tooltip(final List<String> lines) { latest().tooltip(lines); dirty = true; return this; } /** * Displays a list of lines on hover event. * * @param lines lines to display * * @return this instance of FancyMessage, for chaining. */ public FancyMessage tooltip(final Colors... lines) { for (Colors line : Preconditions.checkNotNull(lines, "lines cannot be null.")) { tooltip(Preconditions.checkNotNull(line, "line element cannot be null.")); } return this; } /** * Displays a list of lines on hover event. * * @param lines lines to display * * @return this instance of FancyMessage, for chaining. */ public FancyMessage tooltip(final String... lines) { latest().tooltip(lines); dirty = true; return this; } /** * Creates a new MessagePart and appends an {@link Object} to it. * * @param obj object to append (toString() is called) * * @return this instance of FancyMessage, for chaining. * * @throws IllegalStateException thrown if the latest MessagePart doesn't have text */ public FancyMessage then(final Object obj) throws IllegalStateException { if (!latest().hasText()) { return this; } messageParts.add(new MessagePart(obj.toString())); dirty = true; return this; } /** * Creates a new MessagePart. * * @return this instance of FancyMessage, for chaining. */ public FancyMessage then() { if (!latest().hasText()) { return this; } messageParts.add(new MessagePart()); dirty = true; return this; } /** * Creates a click event. * * @param name name of the event * @param data data to pass to the event */ public void onClick(final String name, final String data) { latest().onClick(name, data); dirty = true; } /** * Creates a hover event. * * @param name name of the event * @param data data to pass to the event */ public void onHover(final String name, final String data) { latest().onHover(name, data); dirty = true; } /** * Checks whether the current MessagePart has text. * * @return whether the current MessagePart has text */ public boolean hasText() { return latest().hasText(); } /** * Converts this FancyMessage to a JSON String. * * @return JSON of this fancy message */ public String toJSONString(Object... args) { if (args.length == 0 && !dirty && jsonString != null) { return jsonString; } StringWriter string = new StringWriter(); JsonWriter json = new JsonWriter(string); try { if (messageParts.size() == 1) { latest().writeJson(json); } else { json.beginObject().name("text").value("").name("extra").beginArray(); for (final MessagePart part : messageParts) { part.writeJson(json); } json.endArray().endObject(); json.close(); } } catch (Exception e) { throw new RuntimeException("invalid message", e); } if (args.length == 0) { // Only cache the JSON if no arguments were given. jsonString = string.toString(); dirty = false; } return String.format(jsonString, args); } /** * Returns a human readable string of this fancy message. * * @return readable string */ public String toReadableString(Object... args) { StringBuilder stringBuilder = new StringBuilder(); for (MessagePart messagePart : this.messageParts) { if (messagePart.getColor() != null) { stringBuilder.append(messagePart.getColor()); } for (ChatColor color : messagePart.getStyles()) { stringBuilder.append(color); } stringBuilder.append(messagePart.getText()); } return String.format(stringBuilder.toString(), args); } private MessagePart latest() { return messageParts.get(messageParts.size() - 1); } public List<MessagePart> getMessageParts() { return Collections.unmodifiableList(messageParts); } public MessagePart removePart(int index) { MessagePart removed = this.messageParts.remove(index); if (removed != null) { this.dirty = true; } return removed; } /** * Applies a {@link MessagePart}'s click event data to this {@link FancyMessage}. * * @param messagePart mesasge part to copy * * @return this instance of FancyMessage, for chaining. */ public FancyMessage applyClickEvent(MessagePart messagePart) { messagePart.applyClickEvent(this); return this; } /** * Applies a {@link MessagePart}'s hover event data to this {@link FancyMessage}. * * @param messagePart mesasge part to copy * * @return this instance of FancyMessage, for chaining. */ public FancyMessage applyHoverEvent(MessagePart messagePart) { messagePart.applyHoverEvent(this); return this; } protected static Class<?> nmsIChatBaseComponent = PackageType.MINECRAFT_SERVER .getClassSafe("IChatBaseComponent"); protected static Class<?> nmsPacketPlayOutChat = PackageType.MINECRAFT_SERVER .getClassSafe("PacketPlayOutChat"); protected static Class<?> nmsChatSerializer; static { if (ReflectionUtils.isServer18OrHigher()) { nmsChatSerializer = PackageType.MINECRAFT_SERVER .getClassSafe("IChatBaseComponent$ChatSerializer"); } else { nmsChatSerializer = PackageType.MINECRAFT_SERVER.getClassSafe("ChatSerializer"); } } /** * Sends this FancyMessage to a {@link CommandSender} by calling {@link * #toReadableString(Object...)} for {@link CommandSender#sendMessage(String)}. * * @param commandSender command sender to send message to * * @see #send(Iterable, Object...) */ public void send(@Nonnull CommandSender commandSender, Object... args) { Preconditions.checkNotNull(commandSender, "command sender cannot be null."); if (commandSender instanceof Player) { send(((Player) commandSender)); } else { commandSender.sendMessage(toReadableString(args)); } } /** * Sends this FancyMessage to a {@link Player}. * * @param player player to send message to * * @see #send(Iterable, Object...) */ public void send(@Nonnull Player player, Object... args) { Preconditions.checkNotNull(player, "player cannot be null."); try { Object packet = nmsPacketPlayOutChat.getConstructor(nmsIChatBaseComponent) .newInstance(getNMSChatObject(args)); ReflectionUtils.sendPacket(player, packet); } catch (Exception e) { e.printStackTrace(); } } /** * Sends this FancyMessage to a {@link Player}s. * * @param players players to send this message to * * @see #send(Player, Object...) */ public void send(@Nonnull final Iterable<Player> players, Object... args) { Preconditions.checkNotNull(players, "players cannot be null."); try { Object packet = nmsPacketPlayOutChat.getConstructor(nmsIChatBaseComponent) .newInstance(getNMSChatObject(args)); for (final Player player : players) { ReflectionUtils.sendPacket(player, packet); } } catch (Exception e) { e.printStackTrace(); } } public Object getNMSChatObject(Object... args) { try { return ReflectionUtils.getMethod(nmsChatSerializer, "a", String.class) .invoke(null, toJSONString(args)); } catch (Exception e) { e.printStackTrace(); return null; } } }