Last active
January 25, 2022 19:08
-
-
Save DV8FromTheWorld/9449f1fc5cd352d317684dee4fc5d60d to your computer and use it in GitHub Desktop.
JDA SlashCommandConverter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Copyright 2021 Austin Keener | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
//implementation "net.sf.trove4j:trove4j:3.0.3" | |
import gnu.trove.set.TLongSet; | |
import gnu.trove.set.hash.TLongHashSet; | |
import net.dv8tion.jda.api.entities.*; | |
import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; | |
import net.dv8tion.jda.api.events.message.MessageReceivedEvent; | |
import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent; | |
import net.dv8tion.jda.api.events.message.priv.PrivateMessageReceivedEvent; | |
import net.dv8tion.jda.api.interactions.InteractionHook; | |
import net.dv8tion.jda.api.interactions.commands.OptionType; | |
import net.dv8tion.jda.api.interactions.components.ActionRow; | |
import net.dv8tion.jda.api.requests.RestAction; | |
import net.dv8tion.jda.api.requests.restaction.MessageAction; | |
import net.dv8tion.jda.api.requests.restaction.WebhookMessageAction; | |
import net.dv8tion.jda.api.utils.AllowedMentions; | |
import net.dv8tion.jda.internal.entities.ReceivedMessage; | |
import java.lang.reflect.*; | |
import java.util.*; | |
import java.util.concurrent.atomic.AtomicReference; | |
import java.util.function.Function; | |
import java.util.stream.Collectors; | |
public class SlashCommandConverter { | |
/** | |
* Converts a SlashCommandEvent to a MessageReceivedEvent similar to what a bot would receive today | |
* as if the SlashCommand had actually been a content command / message. | |
* | |
* @param originalEvent | |
* The original SlashCommandEvent to convert | |
* | |
* @param commandConverter | |
* A function which converts the provided SlashCommandEvent to the String content expected by the bot's parser. | |
* | |
* @return A simulated MessageReceivedEvent | |
* | |
* @throws InvocationTargetException | |
* @throws NoSuchMethodException | |
* @throws InstantiationException | |
* @throws IllegalAccessException | |
*/ | |
public static MessageReceivedEvent convertToMessageReceived(SlashCommandEvent originalEvent, Function<SlashCommandEvent, String> commandConverter) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { | |
//Immediately defer the reply so that we can just use sendMessage as expected. | |
//This is a POC impl. We may need to do something else here for commands that reply to private channels and don't | |
// end up sending a message to the channel via event.getChannel().sendMessage or event.getMessage().getChannel().sendMessage | |
originalEvent.deferReply().queue(); | |
Message fakeMessage = makeFakeMessage(originalEvent, commandConverter); | |
return new MessageReceivedEvent(originalEvent.getJDA(), originalEvent.getResponseNumber(), fakeMessage); | |
} | |
/** | |
* Converts a SlashCommandEvent to a GuildMessageReceivedEvent similar to what a bot would receive today | |
* as if the SlashCommand had actually been a content command / message. | |
* | |
* @param originalEvent | |
* The original SlashCommandEvent to convert | |
* | |
* @param commandConverter | |
* A function which converts the provided SlashCommandEvent to the String content expected by the bot's parser. | |
* | |
* @return A simulated GuildMessageReceivedEvent | |
* | |
* @throws InvocationTargetException | |
* @throws NoSuchMethodException | |
* @throws InstantiationException | |
* @throws IllegalAccessException | |
*/ | |
public static GuildMessageReceivedEvent convertToGuildMessageReceived(SlashCommandEvent originalEvent, Function<SlashCommandEvent, String> commandConverter) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { | |
//Immediately defer the reply so that we can just use sendMessage as expected. | |
//This is a POC impl. We may need to do something else here for commands that reply to private channels and don't | |
// end up sending a message to the channel via event.getChannel().sendMessage or event.getMessage().getChannel().sendMessage | |
originalEvent.deferReply().queue(); | |
Message fakeMessage = makeFakeMessage(originalEvent, commandConverter); | |
return new GuildMessageReceivedEvent(originalEvent.getJDA(), originalEvent.getResponseNumber(), fakeMessage); | |
} | |
/** | |
* Converts a SlashCommandEvent to a PrivateMessageReceivedEvent similar to what a bot would receive today | |
* as if the SlashCommand had actually been a content command / message. | |
* | |
* @param originalEvent | |
* The original SlashCommandEvent to convert | |
*the | |
* @param commandConverter | |
* A function which converts the provided SlashCommandEvent to the String content expected by the bot's parser. | |
* | |
* @return A simulated PrivateMessageReceivedEvent | |
* | |
* @throws InvocationTargetException | |
* @throws NoSuchMethodException | |
* @throws InstantiationException | |
* @throws IllegalAccessException | |
*/ | |
public static PrivateMessageReceivedEvent convertToPrivateMessageReceived(SlashCommandEvent originalEvent, Function<SlashCommandEvent, String> commandConverter) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { | |
//Immediately defer the reply so that we can just use sendMessage as expected. | |
//This is a POC impl. We may need to do something else here for commands that reply to private channels and don't | |
// end up sending a message to the channel via event.getChannel().sendMessage or event.getMessage().getChannel().sendMessage | |
originalEvent.deferReply().queue(); | |
Message fakeMessage = makeFakeMessage(originalEvent, commandConverter); | |
return new PrivateMessageReceivedEvent(originalEvent.getJDA(), originalEvent.getResponseNumber(), fakeMessage); | |
} | |
private static Message makeFakeMessage(SlashCommandEvent originalEvent, Function<SlashCommandEvent, String> commandConverter) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException { | |
String content = commandConverter.apply(originalEvent); | |
TLongSet mentionedUsers = new TLongHashSet(); | |
originalEvent.getOptionsByType(OptionType.USER) | |
.forEach(option -> mentionedUsers.add(option.getAsLong())); | |
//Add the current bot to the mentioned users as many bots will be putting a mention reference at the beginning of | |
// the message content as they use the BotMention as the prefix. | |
//Note, this just ensures that the `getMentionedUsers/etc` code will resolve the user. It doesn't actually make | |
// the bot user available via getMentionedUsers/etc unless the mention is actually in the message content. | |
mentionedUsers.add(originalEvent.getJDA().getSelfUser().getIdLong()); | |
TLongSet mentionedRoles = new TLongHashSet(); | |
originalEvent.getOptionsByType(OptionType.ROLE) | |
.forEach(option -> mentionedRoles.add(option.getAsLong())); | |
//Technically none of these have to be defined, they're simply here for visual explanation | |
List<MessageReaction> reactions = Collections.emptyList(); | |
List<Message.Attachment> attachments = Collections.emptyList(); | |
List<MessageEmbed> embeds = Collections.emptyList(); | |
List<MessageSticker> stickers = Collections.emptyList(); | |
List<ActionRow> components = Collections.emptyList(); | |
//Create a "MessageChannel" proxy that selectively sends some method calls to the actual MessageChannel | |
// and others to the InteractionHook. | |
MessageChannel channel = makeMessageChannelProxy(originalEvent); | |
return new ReceivedMessage(0L, channel, MessageType.DEFAULT, null, | |
false, false, mentionedUsers, mentionedRoles, false, false, | |
content, null, originalEvent.getUser(), originalEvent.getMember(), null, null, | |
reactions, attachments, embeds, stickers, components, 0); | |
} | |
private static MessageChannel makeMessageChannelProxy(SlashCommandEvent originalEvent) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { | |
InteractionHook hook = originalEvent.getHook(); | |
MessageChannel channel = originalEvent.getChannel(); | |
//If we continue to redirect the sendMessage/File/Embeds methods after the initial one has been redirected | |
// then additional message will be sent as if they are replying to the initial message sent as a response. | |
//That is just how Interactions work; Follow up messages sent to the InteractionHook appear as messages that | |
// reply to the initial Interaction response message. | |
//As such, if we want to support how legacy command look today, then we need to not use the InteractionHook | |
// after the initial response, so we have a flag for knowing when to stop redirecting. | |
AtomicReference<Boolean> hasRedirectedAlready = new AtomicReference<>(false); | |
List<String> redirectMethods = Arrays.asList( | |
"sendFile", | |
"sendMessage", | |
"sendMessageEmbed", //Deprecated. Dunno I feel like supporting it. | |
"sendMessageEmbeds", | |
"sendMessageFormat" | |
); | |
InvocationHandler methodHandler = (self, method, args) -> { | |
//Allow through to the MessageChannel if it isn't a method we need to redirect to the InteractionHook | |
// or if we've already redirected the initial response. | |
if (!redirectMethods.contains(method.getName()) || hasRedirectedAlready.get()) { | |
return method.invoke(channel, args); | |
} | |
if (method.getName().equals("sendMessageEmbed")) { | |
throw new RuntimeException("MessageChannel#sendMessageEmbed is deprecated. Use sendMessageEmbeds(MessageEmbed, MessageEmbed...) instead."); | |
} | |
hasRedirectedAlready.set(true); | |
Method hookMethod; | |
//Hook implements sendMessage(String) while MessageChannel implements sendMessage(CharSequence) | |
if (method.getName().equals("sendMessage") && method.getParameterTypes()[0] == CharSequence.class) { | |
hookMethod = hook.getClass().getMethod("sendMessage", String.class); | |
} | |
else { | |
hookMethod = hook.getClass().getMethod(method.getName(), method.getParameterTypes()); | |
} | |
WebhookMessageAction<?> action = (WebhookMessageAction<?>) hookMethod.invoke(hook, args); | |
//All of the MessageChannel methods return MessageAction, not WebhookMessageAction; As such, we need to | |
// mask the WebhookMessageAction as a MessageAction to satisfy the type checking. | |
return makeMessageActionProxy(action, channel); | |
}; | |
//Determine what type of MessageChannel we need to proxy | |
Class<? extends MessageChannel> clazz = null; | |
switch (originalEvent.getChannelType()) { | |
case TEXT: | |
clazz = TextChannel.class; | |
break; | |
case PRIVATE: | |
clazz = PrivateChannel.class; | |
break; | |
default: | |
throw new RuntimeException("Not setup to handle SlashCommand conversion for ChannelType: " + originalEvent.getChannelType().name()); | |
} | |
Object proxy = Proxy.newProxyInstance(clazz.getClassLoader(), new Class<?>[] { clazz }, methodHandler); | |
return (MessageChannel) proxy; | |
} | |
private static MessageAction makeMessageActionProxy(final WebhookMessageAction<?> action, MessageChannel channel) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException, NoSuchFieldException { | |
//Because the interfaces between MessageAction and WebhookMessageAction are pretty different, we have to deal with | |
// their differences. One big difference is that MessageAction uses PUT style setters (full overwrite) while | |
// WebhookMessageAction uses PATCH style mutators (addX). To handle this, we instead need to "save" the state of | |
// the internal content provided to MessageAction and only apply it to the WebhookMessageAction on finalization. | |
AtomicReference<Boolean> hasFinalized = new AtomicReference<>(false); | |
//Grab the values already set into the WebhookMessageAction when the MessageChannel#sendMessage/Embeds/Files methods were called | |
StringBuilder existingContent = getPrivateValue(action, "content"); | |
List<MessageEmbed> existingEmbeds = getPrivateValue(action, "embeds"); | |
List<ActionRow> existingComponents = getPrivateValue(action, "components"); | |
//Use those initial values as the initial values for our "tracked state" that is unused in finalization | |
AtomicReference<String> content = new AtomicReference<>(existingContent.toString()); | |
AtomicReference<List<MessageEmbed>> embeds = new AtomicReference<>(existingEmbeds); | |
AtomicReference<List<ActionRow>> actionRows = new AtomicReference<>(existingComponents); | |
//Methods that we will redirect from MessageAction to WebookMessageAction | |
List<String> redirectMethods = new ArrayList<>( | |
Collections.singletonList("addFile") | |
); | |
//Also redirect all AllowMentions and RestAction methods to WebhookMessageAction | |
List<String> restActionMethods = Arrays.stream(RestAction.class.getMethods()).map(Method::getName).collect(Collectors.toList()); | |
redirectMethods.addAll(Arrays.stream(AllowedMentions.class.getMethods()).map(Method::getName).collect(Collectors.toList())); | |
redirectMethods.addAll(restActionMethods); | |
//These methods are also redirected in some way to WebhookMessageAction, but they must be handled manually. | |
List<String> captureAndHandle = Arrays.asList( | |
"append", | |
"apply", | |
// "clearFiles", //This is really annoying to support, so not doing it until someone tells me they need it | |
"embed", //deprecated | |
"content", | |
"getChannel", | |
"isEdit", | |
// These methods are probably only used for edits | |
// "isEmpty", | |
// "reset", | |
// "retainFiles", | |
// "retailFilesById", | |
"setActionRow", | |
"setActionRows", | |
"setEmbeds", | |
"tts" | |
); | |
//These don't really make sense outside the context of an edit.. which doesn't make sense for the initial message. | |
List<String> captureAndIgnore = Arrays.asList( | |
"nonce", | |
"override" | |
); | |
InvocationHandler methodHandler = (self, method, args) -> | |
{ | |
String methodName = method.getName(); | |
//If a RestAction method is called, likely that means that we are triggering the RestAction (like queue) | |
// and thus are about to all finalizeData internally to build the JSON to send to Discord. | |
//As such, go ahead and update the WebhookMessageAction with the actual finalized data. | |
if (restActionMethods.contains(methodName) && !hasFinalized.get()) { | |
hasFinalized.set(true); | |
String _content = content.get(); | |
List<MessageEmbed> _embeds = embeds.get(); | |
List<ActionRow> _actionRows = actionRows.get(); | |
if (!_content.isEmpty()) { | |
action.setContent(_content); | |
} | |
if (!_embeds.isEmpty()) { | |
action.addEmbeds(_embeds); | |
} | |
if (!_actionRows.isEmpty()) { | |
action.addActionRows(_actionRows); | |
} | |
} | |
if (redirectMethods.contains(methodName)) { | |
//Redirect the method from MessageAction to WebhookMessageAction. For these methods, their types are the same. | |
Method redirectedMethod = WebhookMessageAction.class.getMethod(methodName, method.getParameterTypes()); | |
redirectedMethod.invoke(action, args); | |
//Return the MessageAction proxy, not the WebhookMessageAction instance that would be returned by the | |
// above invocation. | |
return self; | |
} | |
if (captureAndIgnore.contains(methodName)) { | |
System.out.printf("Encountered call to MessageAction#%s when mapping to WebhookMessageAction. However, this method isn't applicable, so it is being ignored.\n", method.getName()); | |
return self; | |
} | |
if (!captureAndHandle.contains(methodName)) { | |
throw new RuntimeException(String.format("MessageChannel#%s is not supported by the WebhookMessageAction converter. Inform DV8 is this is a problem.", methodName)); | |
} | |
switch (methodName) { | |
case "append": | |
content.getAndUpdate(current -> current + (String) args[0]); | |
return self; | |
case "apply": | |
//TODO consider mapping this stuff like we do below. Currently, if we don't map it out, the content, embed, and component stuff might be wrong when overriding an applied message. | |
action.applyMessage((Message) args[0]); | |
return self; | |
case "content": | |
content.set((String) args[0]); | |
return self; | |
case "embed": | |
embeds.set(Arrays.asList((MessageEmbed) args[0])); | |
return self; | |
case "getChannel": | |
return channel; | |
case "isEdit": | |
return false; | |
case "setActionRow": | |
actionRows.set(Arrays.asList((ActionRow) args[0])); | |
return self; | |
case "setActionRows": | |
actionRows.set(Arrays.asList((ActionRow[]) args)); | |
return self; | |
case "setEmbeds": | |
embeds.set(Arrays.asList((MessageEmbed[]) args)); | |
return self; | |
case "tts": | |
action.setTTS((Boolean) args[0]); | |
return self; | |
default: | |
throw new RuntimeException("MessageAction -> WebhookMessageAction proxy didn't implement support for method when it said it would: " + methodName); | |
} | |
}; | |
//Setup the proxy factor to generated a MessageActionImpl proxy | |
Object proxy = Proxy.newProxyInstance(MessageAction.class.getClassLoader(), new Class<?>[] { MessageAction.class }, methodHandler); | |
return (MessageAction) proxy; | |
} | |
private static <T> T getPrivateValue(Object instance, String fieldName) throws NoSuchFieldException, IllegalAccessException { | |
Field field = instance.getClass().getDeclaredField(fieldName); | |
field.setAccessible(true); | |
return (T) field.get(instance); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; | |
import net.dv8tion.jda.api.interactions.commands.OptionType; | |
import java.util.function.Function; | |
public class CommandConverters { | |
public static Function<SlashCommandEvent, String> spaceSeparated(String prefix) { | |
return spaceSeparated(prefix, true, true); | |
} | |
public static Function<SlashCommandEvent, String> spaceSeparated(String prefix, boolean useRawMentions, boolean quoteStrings) { | |
return (event) -> { | |
StringBuilder builder = new StringBuilder(); | |
builder.append(prefix).append(event.getName()); | |
if (event.getSubcommandGroup() != null) { | |
builder.append(" ").append(event.getSubcommandGroup()); | |
} | |
if (event.getSubcommandName() != null) { | |
builder.append(" ").append(event.getSubcommandName()); | |
} | |
event.getOptions().forEach(option -> { | |
builder.append(" "); | |
if (option.getType() == OptionType.USER | |
|| option.getType() == OptionType.ROLE | |
|| option.getType() == OptionType.CHANNEL | |
|| option.getType() == OptionType.MENTIONABLE) { | |
if (useRawMentions) { | |
builder.append(option.getAsMentionable().getAsMention()); | |
} | |
else { | |
builder.append(option.getAsString()); | |
} | |
} | |
else if (option.getType() == OptionType.STRING) { | |
if (quoteStrings) { | |
builder.append('"').append(option.getAsString()).append('"'); | |
} | |
else { | |
builder.append(option.getAsString()); | |
} | |
} | |
else { | |
//This includes OptionType.INTEGER and OptionType.BOOLEAN | |
builder.append(option.getAsString()); | |
} | |
}); | |
return builder.toString(); | |
}; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment