From ffece65dcc96d655d03f92b3f48968a24e255936 Mon Sep 17 00:00:00 2001 From: seailz Date: Sat, 30 Mar 2024 22:24:43 +0000 Subject: [PATCH 1/3] feat(json handling): added a new decoding system for turning JSON payloads from Discord into Java objects --- pom.xml | 12 +- .../com/seailz/discordjar/DiscordJar.java | 3 + .../discordjar/decoding/DiscordObject.java | 17 ++ .../decoding/DiscordObjectParser.java | 193 ++++++++++++++++++ .../annotations/DiscordJarParameter.java | 20 ++ .../annotations/DiscordObjectConstructor.java | 14 ++ ...DiscordObjectCustomAssignationsMethod.java | 19 ++ .../annotations/DiscordObjectParameter.java | 25 +++ .../data/DiscordObjectInformation.java | 16 ++ .../DiscordObjectParameterInformation.java | 160 +++++++++++++++ 10 files changed, 473 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/seailz/discordjar/decoding/DiscordObject.java create mode 100644 src/main/java/com/seailz/discordjar/decoding/DiscordObjectParser.java create mode 100644 src/main/java/com/seailz/discordjar/decoding/annotations/DiscordJarParameter.java create mode 100644 src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectConstructor.java create mode 100644 src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectCustomAssignationsMethod.java create mode 100644 src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectParameter.java create mode 100644 src/main/java/com/seailz/discordjar/decoding/data/DiscordObjectInformation.java create mode 100644 src/main/java/com/seailz/discordjar/decoding/data/DiscordObjectParameterInformation.java diff --git a/pom.xml b/pom.xml index a7cdf302..acac7d5b 100644 --- a/pom.xml +++ b/pom.xml @@ -65,12 +65,6 @@ jitpack.io https://jitpack.io - - - dv8tion - m2-dv8tion - https://m2.dv8tion.net/releases - @@ -146,5 +140,11 @@ xsalsa20poly1305 v0.10.1 + + org.projectlombok + lombok + 1.18.32 + provided + diff --git a/src/main/java/com/seailz/discordjar/DiscordJar.java b/src/main/java/com/seailz/discordjar/DiscordJar.java index 10ff9979..e6ae0e99 100644 --- a/src/main/java/com/seailz/discordjar/DiscordJar.java +++ b/src/main/java/com/seailz/discordjar/DiscordJar.java @@ -18,6 +18,7 @@ import com.seailz.discordjar.command.listeners.slash.SlashCommandListener; import com.seailz.discordjar.command.listeners.slash.SlashSubCommand; import com.seailz.discordjar.command.listeners.slash.SubCommandListener; +import com.seailz.discordjar.decoding.DiscordObjectParser; import com.seailz.discordjar.events.DiscordListener; import com.seailz.discordjar.events.EventDispatcher; import com.seailz.discordjar.gateway.Gateway; @@ -90,6 +91,7 @@ public class DiscordJar { * Stores the logger */ private final Logger logger; + private final DiscordObjectParser parser; /** * Intents the bot will use when connecting to the gateway */ @@ -224,6 +226,7 @@ public DiscordJar(String token, EnumSet intents, APIVersion version, boo new URLS(release, version); logger = Logger.getLogger("DISCORD.JAR"); this.commandDispatcher = new CommandDispatcher(); + this.parser = new DiscordObjectParser(this); this.queuedRequests = new ArrayList<>(); this.buckets = new ArrayList<>(); this.voiceStates = new HashMap<>(); diff --git a/src/main/java/com/seailz/discordjar/decoding/DiscordObject.java b/src/main/java/com/seailz/discordjar/decoding/DiscordObject.java new file mode 100644 index 00000000..895b6b85 --- /dev/null +++ b/src/main/java/com/seailz/discordjar/decoding/DiscordObject.java @@ -0,0 +1,17 @@ +package com.seailz.discordjar.decoding; + +import com.seailz.discordjar.decoding.annotations.DiscordJarParameter; +import com.seailz.discordjar.decoding.annotations.DiscordObjectConstructor; +import com.seailz.discordjar.decoding.annotations.DiscordObjectParameter; + +/** + * Marks an object that can be decoded by {@link DiscordObjectParser}. + *

Each one of your fields must be marked with {@link DiscordObjectParameter DiscordObjectParameter}, + * and can be marked with {@link DiscordJarParameter DiscordJarParameter} in order to have + * its value set default to the existing DiscordJar instance. + *

+ * You must have a constructor that matches your fields, even down to the order. That constructor + * must be marked with {@link DiscordObjectConstructor DiscordObjectConstructor}. + */ +public abstract class DiscordObject { +} diff --git a/src/main/java/com/seailz/discordjar/decoding/DiscordObjectParser.java b/src/main/java/com/seailz/discordjar/decoding/DiscordObjectParser.java new file mode 100644 index 00000000..fabc9141 --- /dev/null +++ b/src/main/java/com/seailz/discordjar/decoding/DiscordObjectParser.java @@ -0,0 +1,193 @@ +package com.seailz.discordjar.decoding; + +import com.seailz.discordjar.DiscordJar; +import com.seailz.discordjar.decoding.annotations.DiscordJarParameter; +import com.seailz.discordjar.decoding.annotations.DiscordObjectConstructor; +import com.seailz.discordjar.decoding.annotations.DiscordObjectCustomAssignationsMethod; +import com.seailz.discordjar.decoding.annotations.DiscordObjectParameter; +import com.seailz.discordjar.decoding.data.DiscordObjectInformation; +import com.seailz.discordjar.decoding.data.DiscordObjectParameterInformation; +import lombok.Getter; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; + +import java.lang.reflect.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.logging.Logger; + +/** + * Parses objects from JSON into Java Objects ({@link DiscordObject}). Designed using reflection + * to improve the developer experience of working on discord.jar and decrease + * the amount of repeated code. + *

+ * Each DiscordJar instance uses its own parser as if a parameter is marked as {@link DiscordObjectParameter}, + * the parser needs to provide a DiscordJar instance. + */ +public class DiscordObjectParser { + + @Getter + private DiscordJar discordJar; + + public DiscordObjectParser(DiscordJar discordJar) { + this.discordJar = discordJar; + } + + /** + * Since reflection isn't instant, there's a cache to prevent un-needed delays. + */ + private HashMap, DiscordObjectInformation> objectInformationCache = new HashMap<>(); + + public T decompileObject(JSONObject data, Class type) { + long start = System.currentTimeMillis(); + DiscordObjectInformation objInfo = objectInformationCache.get(type); + + if (objInfo == null) { + objInfo = discoverObject(type); + } + System.out.println("Took " + (System.currentTimeMillis() - start) + "ms to discover object info"); + + List parameterValues = new ArrayList<>(); + start = System.currentTimeMillis(); + HashMap customAssignations = invokeCustomAssignationsMethod(objInfo, data); + System.out.println("Took " + (System.currentTimeMillis() - start) + "ms to discover custom assignations"); + + start = System.currentTimeMillis(); + for (DiscordObjectParameterInformation param : objInfo.parameterList()) { + if (customAssignations != null && customAssignations.containsKey(param.determineKey())) { + parameterValues.add(customAssignations.get(param.determineKey())); + continue; + } + parameterValues.add(param.determineValue(data, this)); + } + System.out.println("Took " + (System.currentTimeMillis() - start) + "ms to discover parameter values"); + + try { + start = System.currentTimeMillis(); + if (objInfo.constructor() == null) { + Logger.getLogger("discord.jar") + .severe("[discord.jar - object decoding] Unable to find valid constructor for " + + type.getName() + " - returning null, please contact discord.jar's developers."); + return null; + } + + System.out.println(Arrays.toString(parameterValues.toArray())); + System.out.println("Took " + (System.currentTimeMillis() - start) + "ms to check constructor"); + + start = System.currentTimeMillis(); + T obj = (T) objInfo.constructor() + .newInstance(parameterValues.toArray()); + System.out.println("Took " + (System.currentTimeMillis() - start) + "ms to create new instance of object"); + return obj; + } catch (Exception e) { + Logger.getLogger("discord.jar") + .severe("[discord.jar - object decoding] Unable to decode object for " + type.getName() + ", printing stacktrace," + + " returning null. Please contact discord.jar's developers."); + e.printStackTrace(); + } + + return null; + } + + private DiscordObjectInformation discoverObject(Class clazz) { + long start = System.currentTimeMillis(); + List parameterList = new ArrayList<>(); + + for (Field field : clazz.getDeclaredFields()) { + // If an annotation isn't present, we don't really care. + if (!field.isAnnotationPresent(DiscordObjectParameter.class)) continue; + DiscordObjectParameter param = field.getAnnotation(DiscordObjectParameter.class); + + DiscordObjectParameterInformation info = new DiscordObjectParameterInformation( + field.getName(), + field, + field.getType(), + field.isAnnotationPresent(DiscordJarParameter.class), + param + ); + + parameterList.add(info); + } + + System.out.println("Time to discover object: " + (System.currentTimeMillis() - start) + "ms"); + + DiscordObjectInformation info = new DiscordObjectInformation(parameterList, discoverCustomAssignationsMethod(clazz), discoverConstructor(clazz)); + objectInformationCache.remove(clazz); + objectInformationCache.put(clazz, info); + + return info; + } + + /** + * Finds a constructor marked with {@link DiscordObjectConstructor} within a class. + * Ideally this shouldn't return null, but don't rule it out. + */ + @Nullable + private Constructor discoverConstructor(Class clazz) { + for (Constructor constructor : clazz.getConstructors()) { + if (!constructor.isAnnotationPresent(DiscordObjectConstructor.class)) continue; + return (Constructor) constructor; + } + return null; + } + + /** + * discord.jar allows objects to come up with their own method for decompiling certain parameters. + *
To do this, an object would define a static method that takes a {@link JSONObject}, returns a + * {@link HashMap HashMap} of JSON keys to values and is annotated with + * {@link DiscordObjectCustomAssignationsMethod}. + */ + private Method discoverCustomAssignationsMethod(Class clazz) { + for (Method method : clazz.getMethods()) { + if (!method.isAnnotationPresent(DiscordObjectCustomAssignationsMethod.class)) continue; + + if (!Modifier.isStatic(method.getModifiers())) { + Logger.getLogger("discord.jar").warning("[discord.jar - object decoding] Defined custom assignations method" + + " for " + clazz.getName() + " was not static. Ignoring custom assignations - please contact the discord.jar" + + " developers."); + return null; + } + if (!method.getReturnType().equals(HashMap.class)) { + Logger.getLogger("discord.jar").warning("[discord.jar - object decoding] Defined custom assignations method" + + " for " + clazz.getName() + " doesn't return a HashMap. Ignoring custom assignations - please contact the discord.jar" + + " developers."); + return null; + } + if (!Arrays.equals(method.getParameterTypes(), new Class[]{JSONObject.class})) { + Logger.getLogger("discord.jar").warning("[discord.jar - object decoding] Defined custom assignations method" + + " for " + clazz.getName() + " doesn't take a JSONObject as input. Ignoring custom assignations - please contact the discord.jar" + + " developers."); + return null; + } + + return method; + } + return null; + } + + private HashMap invokeCustomAssignationsMethod(DiscordObjectInformation info, JSONObject data) { + if (info.customAssignations() == null) return null; + Object obj = null; + try { + obj = info.customAssignations().invoke(null, data); + } catch (Exception e) { + Logger.getLogger("discord.jar").warning("[discord.jar - object decoding] Error while finding custom " + + "assignations in the process of decoding an object - please contact the discord.jar developers with " + + "the stacktrace below."); + e.printStackTrace(); + return null; + } + + try { + return (HashMap) obj; + } catch (Exception e) { + Logger.getLogger("discord.jar").warning("[discord.jar - object decoding] Error while finding custom " + + "assignations in the process of decoding an object - couldn't cast to HashMap - please " + + "contact the discord.jar developers with the stacktrace below."); + e.printStackTrace(); + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordJarParameter.java b/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordJarParameter.java new file mode 100644 index 00000000..ccc48c3d --- /dev/null +++ b/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordJarParameter.java @@ -0,0 +1,20 @@ +package com.seailz.discordjar.decoding.annotations; + +import com.seailz.discordjar.decoding.annotations.DiscordObjectParameter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * If used on a parameter, this won't be treated like a normal object parameter and instead will always + * have its value set to the current DiscordJar instance. + *

+ * This must be used in conjunction with {@link DiscordObjectParameter} and cannot be used on it's own. + * {@link DiscordObjectParameter} + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DiscordJarParameter { +} diff --git a/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectConstructor.java b/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectConstructor.java new file mode 100644 index 00000000..7b17f196 --- /dev/null +++ b/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectConstructor.java @@ -0,0 +1,14 @@ +package com.seailz.discordjar.decoding.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a constructor for a Discord object + */ +@Target(ElementType.CONSTRUCTOR) +@Retention(RetentionPolicy.RUNTIME) +public @interface DiscordObjectConstructor { +} diff --git a/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectCustomAssignationsMethod.java b/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectCustomAssignationsMethod.java new file mode 100644 index 00000000..18cfe31d --- /dev/null +++ b/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectCustomAssignationsMethod.java @@ -0,0 +1,19 @@ +package com.seailz.discordjar.decoding.annotations; + +import com.seailz.discordjar.decoding.DiscordObjectParser; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotate a method with this to define custom decompilation methods for certain values while using + * {@link DiscordObjectParser DiscordObjectParser}. + *

The method must be static and should take in a {@link org.json.JSONObject} and return a {@link java.util.HashMap HashMap} of JSON + * keys to the values. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DiscordObjectCustomAssignationsMethod { +} diff --git a/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectParameter.java b/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectParameter.java new file mode 100644 index 00000000..fb4c1cc3 --- /dev/null +++ b/src/main/java/com/seailz/discordjar/decoding/annotations/DiscordObjectParameter.java @@ -0,0 +1,25 @@ +package com.seailz.discordjar.decoding.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DiscordObjectParameter { + + boolean nullable() default true; + + /** + * Is there a possibility for this parameter to be excluded from an object? + */ + boolean excludable() default true; + + /** + * Allows you to override the default JSON key of a parameter. + *
If this is not set, a key is assumed by setting the parameter name to full lowercase. + */ + String overrideKey() default ""; + +} diff --git a/src/main/java/com/seailz/discordjar/decoding/data/DiscordObjectInformation.java b/src/main/java/com/seailz/discordjar/decoding/data/DiscordObjectInformation.java new file mode 100644 index 00000000..0f0dfa67 --- /dev/null +++ b/src/main/java/com/seailz/discordjar/decoding/data/DiscordObjectInformation.java @@ -0,0 +1,16 @@ +package com.seailz.discordjar.decoding.data; + +import com.seailz.discordjar.decoding.DiscordObject; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.List; + +/** + * Contains information about a {@link DiscordObject}. This class is generally used for caching purposes, + * as reflection can take some time to complete and doing that every time you want to use a Discord object would + * be too resource intensive. + */ +public record DiscordObjectInformation(List parameterList, Method customAssignations, Constructor constructor) { + +} diff --git a/src/main/java/com/seailz/discordjar/decoding/data/DiscordObjectParameterInformation.java b/src/main/java/com/seailz/discordjar/decoding/data/DiscordObjectParameterInformation.java new file mode 100644 index 00000000..166226db --- /dev/null +++ b/src/main/java/com/seailz/discordjar/decoding/data/DiscordObjectParameterInformation.java @@ -0,0 +1,160 @@ +package com.seailz.discordjar.decoding.data; + +import com.seailz.discordjar.decoding.DiscordObject; +import com.seailz.discordjar.decoding.DiscordObjectParser; +import com.seailz.discordjar.decoding.annotations.DiscordObjectParameter; +import org.jetbrains.annotations.CheckReturnValue; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.logging.Logger; + +public record DiscordObjectParameterInformation(String name, Field field, Class type, boolean discordJar, DiscordObjectParameter parameter) { + + @Nullable + public String determineKey() { + if (discordJar) return null; + + return !parameter.overrideKey().isEmpty() ? + parameter.overrideKey() : name.toLowerCase(); + } + + // TODO: HashMap implementation + @Nullable + @CheckReturnValue + public Object determineValue(JSONObject obj, DiscordObjectParser parserInstance) { + if (discordJar) return parserInstance.getDiscordJar(); + + if (!obj.has(determineKey())) { + if (!parameter().excludable()) { + // A parameter marked as required has not been included. We'll warn, but still return null. + // This attempt is to cause the least collateral damage. + Logger.getLogger("DiscordJarObjectDecoder") + .warning("[discord.jar - object decoder] A parameter marked as not excludable was not" + + " included in the input data. Possibly a key error? Attempting to proceed - please inform the " + + "discord.jar developers."); + } + return null; + } + if (obj.get(determineKey()) == JSONObject.NULL || obj.get(determineKey()) == null) { + if (!parameter().nullable()) { + // A parameter marked as not nullable was null. We'll warn, but still return null. + // This attempt is to cause the least collateral damage. + Logger.getLogger("DiscordJarObjectDecoder") + .warning("[discord.jar - object decoder] A parameter marked as not nullable was null" + + " in the input data. Attempting to proceed - please inform " + + "discord.jar developers."); + } + + return null; + } + + Class realType = getArrayElementType(); + boolean array = realType != null; + if (!array) realType = type; + + if (DiscordObject.class.isAssignableFrom(realType)) { + Object data = obj.get(determineKey()); + Class clazz = (Class) realType; + + if (array && data instanceof JSONArray dataArray) { + System.out.println("array"); + if (type.isArray()) { + System.out.println("Type is array"); + // The type of the list is an array so we need to treat it as such + Object[] resultArray = (Object[]) Array.newInstance(clazz, dataArray.length()); + + int i = 0; + for (Object dataJson : dataArray) { + resultArray[i] = parserInstance.decompileObject((JSONObject) dataJson, clazz); + i++; + } + + return resultArray; + } + + // The type of the list is a typical List class rather than an array + List returnList = new ArrayList<>(); + + for (Object dataJson : dataArray) { + returnList.add(parserInstance.decompileObject((JSONObject) dataJson, clazz)); + } + + System.out.println(returnList); + + return returnList; + } + + if (!(data instanceof JSONObject dataJson)) { + Logger.getLogger("DiscordJarObjectDecoder") + .severe("[discord.jar - object decoder] A parameter marked as an object was decoded as" + + " a non-object. Returning null and attempting to proceed - please inform discord.jar " + + "developers."); + return null; + } + return parserInstance.decompileObject(dataJson, clazz); + } + + if (array) { + if (!(obj.get(determineKey()) instanceof JSONArray dataArray)) { + Logger.getLogger("discord.jar").warning("[discord.jar - object decoder] Value for parameter " + + determineKey() + " was marked as an array while provided data was " + obj.get(determineKey())); + return null; + } + + if (type.isArray()) { + Object[] returnArray = (Object[]) Array.newInstance(realType, dataArray.length()); + + int i = 0; + for (Object dataJson : dataArray) { + returnArray[i] = dataJson; + i++; + } + + return returnArray; + } + + return dataArray.toList(); + } + + // TODO: lists for non-objects + + return obj.get(determineKey()); + } + + /** + * Checks if the type for this parameter is a list of some sort (ArrayList, Array, etc), and if so, + * returns the type that the array takes. If not, returns null. + */ + @Nullable + private Class getArrayElementType() { + if (!type.isArray() && !(type.isAssignableFrom(List.class))) return null; + return getElementType(field); + } + + public static Class getElementType(Field field) { + if (field.getType().isArray()) { + return field.getType().getComponentType(); + } else if (Collection.class.isAssignableFrom(field.getType())) { + Type type = field.getGenericType(); + if (type instanceof ParameterizedType pt) { + for (Type actualTypeArgument : pt.getActualTypeArguments()) { + return (Class) actualTypeArgument; + } + } + return null; + } else { + return null; + } + } + + +} From 6d2cde51e304e3c166b82408b7003f0f4770b198 Mon Sep 17 00:00:00 2001 From: seailz Date: Sat, 30 Mar 2024 22:30:20 +0000 Subject: [PATCH 2/3] feat(decoding): turned DiscordObject.java into an interface instead of an abstract class so that it can be used in records --- src/main/java/com/seailz/discordjar/decoding/DiscordObject.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/seailz/discordjar/decoding/DiscordObject.java b/src/main/java/com/seailz/discordjar/decoding/DiscordObject.java index 895b6b85..85830a25 100644 --- a/src/main/java/com/seailz/discordjar/decoding/DiscordObject.java +++ b/src/main/java/com/seailz/discordjar/decoding/DiscordObject.java @@ -13,5 +13,5 @@ * You must have a constructor that matches your fields, even down to the order. That constructor * must be marked with {@link DiscordObjectConstructor DiscordObjectConstructor}. */ -public abstract class DiscordObject { +public interface DiscordObject { } From 110855efa6853e8426eddf11bacf1a4c63f95732 Mon Sep 17 00:00:00 2001 From: seailz Date: Sat, 30 Mar 2024 22:34:38 +0000 Subject: [PATCH 3/3] feat(decoding): added getter for DiscordObjectParser in DiscordJar.java --- src/main/java/com/seailz/discordjar/DiscordJar.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/seailz/discordjar/DiscordJar.java b/src/main/java/com/seailz/discordjar/DiscordJar.java index e6ae0e99..1cf56120 100644 --- a/src/main/java/com/seailz/discordjar/DiscordJar.java +++ b/src/main/java/com/seailz/discordjar/DiscordJar.java @@ -1574,4 +1574,8 @@ public Long getAverageGatewayPing() { public APIVersion getApiVersion() { return apiVersion; } + + public DiscordObjectParser getParser() { + return parser; + } }