Skip to content

Commit

Permalink
Command Option Autocomplete Functionality (#48)
Browse files Browse the repository at this point in the history
* autocomplete

* fixes

* fix lowercasing

* better examples
  • Loading branch information
acikek committed Dec 26, 2022
1 parent 92c737e commit 1c3a58e
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 78 deletions.
Expand Up @@ -62,17 +62,18 @@ public void onEnable() {
DenizenCore.commandRegistry.registerCommand(DiscordModalCommand.class);
DenizenCore.commandRegistry.registerCommand(DiscordReactCommand.class);
// Events
ScriptEvent.registerScriptEvent(DiscordApplicationCommandScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordButtonClickedScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordChannelCreateScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordChannelDeleteScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordCommandAutocompleteScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordMessageDeletedScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordMessageModifiedScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordMessageReactionAddScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordMessageReactionRemoveScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordMessageReceivedScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordModalSubmittedScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordSelectionUsedScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordApplicationCommandScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordThreadArchivedScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordThreadRevealedScriptEvent.class);
ScriptEvent.registerScriptEvent(DiscordUserJoinsScriptEvent.class);
Expand Down
Expand Up @@ -15,6 +15,7 @@
import net.dv8tion.jda.api.events.guild.member.GuildMemberRoleRemoveEvent;
import net.dv8tion.jda.api.events.guild.member.update.GuildMemberUpdateNicknameEvent;
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
Expand All @@ -29,6 +30,7 @@
import net.dv8tion.jda.api.events.thread.ThreadRevealedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;

import javax.annotation.Nonnull;
import java.util.function.Consumer;
Expand Down Expand Up @@ -134,6 +136,11 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
autoHandle(event, DiscordApplicationCommandScriptEvent.instance);
}

@Override
public void onCommandAutoCompleteInteraction(CommandAutoCompleteInteractionEvent event) {
autoHandle(event, DiscordCommandAutocompleteScriptEvent.instance);
}

@Override
public void onButtonInteraction(ButtonInteractionEvent event) {
autoHandle(event, DiscordButtonClickedScriptEvent.instance);
Expand Down
Expand Up @@ -62,6 +62,7 @@ public DiscordCommandCommand() {
// The "options" argument controls the command parameters. It is a MapTag of ordered MapTags that can sometimes hold ordered MapTags. It is recommended to use <@link command definemap> or a data script key when creating commands.
// All option MapTags must have "type", "name", and "description" keys, with an optional "required" key (defaulting to true). The "type" key can be one of: STRING, INTEGER, BOOLEAN, USER, CHANNEL, ROLE, MENTIONABLE, NUMBER, ATTACHMENT.
// Additionally, the option map can include a "choices" key, which is a MapTag of ordered MapTags that have a "name" (what displays to the user) and a "value" (what gets passed to the client).
// Instead of choices, the option map can also include an "autocomplete" key controlling whether dynamic suggestions can be provided to the client (defaulting to false). See <@link event on discord command autocomplete>.
//
// Editing application command permissions has been moved to the "Integrations" section in the server settings.
// Read more about it here: <@link url https://discord.com/blog/slash-commands-permissions-discord-apps-bots>
Expand Down Expand Up @@ -185,6 +186,8 @@ public static void autoExecute(ScriptEntry scriptEntry,
ElementTag optionName = option.getElement("name");
ElementTag optionDescription = option.getElement("description");
ElementTag optionIsRequired = option.getElement("required");
ElementTag optionIsAutocomplete = option.getElement("autocomplete");
boolean isAutocomplete = optionIsAutocomplete != null && optionIsAutocomplete.asBoolean();
MapTag optionChoices = option.getObjectAs("choices", MapTag.class, scriptEntry.context);
if (optionName == null) {
Debug.echoError(scriptEntry, "Command options must specify a name!");
Expand All @@ -196,6 +199,11 @@ else if (optionDescription == null) {
scriptEntry.setFinished(true);
return;
}
if (isAutocomplete && optionChoices != null) {
Debug.echoError(scriptEntry, "Command options cannot be autocompletable and have choices!");
scriptEntry.setFinished(true);
return;
}
if (optionType == OptionType.SUB_COMMAND) {
((SlashCommandData) data).addSubcommands(new SubcommandData(optionName.asString(), optionDescription.asString()));
}
Expand All @@ -208,7 +216,7 @@ else if (optionType == OptionType.SUB_COMMAND_GROUP) {
}
*/
else {
OptionData optionData = new OptionData(optionType, optionName.asString(), optionDescription.asString(), optionIsRequired == null || optionIsRequired.asBoolean());
OptionData optionData = new OptionData(optionType, optionName.asString(), optionDescription.asString(), optionIsRequired == null || optionIsRequired.asBoolean(), isAutocomplete);
if (optionChoices != null) {
if (!optionType.canSupportChoices()) {
Debug.echoError(scriptEntry, "Command options with choices must be STRING, INTEGER, or NUMBER!");
Expand Down
@@ -1,20 +1,9 @@
package com.denizenscript.ddiscordbot.events;

import com.denizenscript.ddiscordbot.DiscordScriptEvent;
import com.denizenscript.ddiscordbot.objects.DiscordChannelTag;
import com.denizenscript.ddiscordbot.objects.DiscordCommandTag;
import com.denizenscript.ddiscordbot.objects.DiscordGroupTag;
import com.denizenscript.ddiscordbot.objects.DiscordInteractionTag;
import com.denizenscript.ddiscordbot.objects.DiscordRoleTag;
import com.denizenscript.ddiscordbot.objects.DiscordUserTag;
import com.denizenscript.denizencore.objects.ObjectTag;
import com.denizenscript.denizencore.objects.core.ElementTag;
import com.denizenscript.denizencore.objects.core.MapTag;
import com.denizenscript.denizencore.utilities.CoreUtilities;
import net.dv8tion.jda.api.events.interaction.command.GenericCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
import net.dv8tion.jda.api.interactions.commands.CommandInteractionPayload;

public class DiscordApplicationCommandScriptEvent extends DiscordScriptEvent {
public class DiscordApplicationCommandScriptEvent extends DiscordCommandInteractionScriptEvent {

// <--[event]
// @Events
Expand Down Expand Up @@ -46,78 +35,19 @@ public class DiscordApplicationCommandScriptEvent extends DiscordScriptEvent {
public DiscordApplicationCommandScriptEvent() {
instance = this;
registerCouldMatcher("discord application|slash|message|user command");
registerSwitches("channel", "group", "name");
}

public GenericCommandInteractionEvent getEvent() {
return (GenericCommandInteractionEvent) event;
@Override
public CommandInteractionPayload getPayload() {
return ((GenericCommandInteractionEvent) event).getInteraction();
}

@Override
public boolean matches(ScriptPath path) {
if (!tryChannel(path, getEvent().getChannel())) {
return false;
}
if (!tryGuild(path, getEvent().isFromGuild() ? getEvent().getGuild() : null)) {
return false;
}
String type = path.eventArgLowerAt(1);
if (!type.equals("application") && !runGenericCheck(type, getEvent().getCommandType().name())) {
return false;
}
if (!runGenericSwitchCheck(path, "name", CoreUtilities.replace(getEvent().getName(), " ", "_"))) {
if (!type.equals("application") && !runGenericCheck(type, getPayload().getCommandType().name())) {
return false;
}
return super.matches(path);
}

@Override
public ObjectTag getContext(String name) {
switch (name) {
case "channel":
return new DiscordChannelTag(botID, getEvent().getChannel());
case "group":
if (getEvent().isFromGuild()) {
return new DiscordGroupTag(botID, getEvent().getGuild());
}
break;
case "interaction":
return DiscordInteractionTag.getOrCreate(botID, getEvent().getInteraction());
case "command":
return new DiscordCommandTag(botID, getEvent().isFromGuild() ? getEvent().getGuild().getIdLong() : 0, getEvent().getCommandIdLong());
case "options": {
MapTag options = new MapTag();
for (OptionMapping mapping : getEvent().getOptions()) {
ObjectTag result;
switch (mapping.getType()) {
case STRING:
case SUB_COMMAND:
case SUB_COMMAND_GROUP:
result = new ElementTag(mapping.getAsString()); break;
case BOOLEAN: result = new ElementTag(mapping.getAsBoolean()); break;
case INTEGER: result = new ElementTag(mapping.getAsLong()); break;
case NUMBER: result = new ElementTag(mapping.getAsDouble()); break;
case ATTACHMENT: result = new ElementTag(mapping.getAsAttachment().getUrl()); break;
case CHANNEL: result = new DiscordChannelTag(botID, mapping.getAsChannel()); break;
case MENTIONABLE: {
String mention = mapping.getAsMentionable().getAsMention();
if (mention.startsWith("<@&")) {
result = new DiscordRoleTag(botID, getEvent().getGuild().getIdLong(), mapping.getAsMentionable().getIdLong());
}
else {
result = new DiscordUserTag(botID, mapping.getAsMentionable().getIdLong());
}
break;
}
case ROLE: result = new DiscordRoleTag(botID, mapping.getAsRole()); break;
case USER: result = new DiscordUserTag(botID, mapping.getAsUser()); break;
default: result = new ElementTag(botID, null);
}
options.putObject(mapping.getName(), result);
}
return options;
}
}
return super.getContext(name);
}
}
@@ -0,0 +1,126 @@
package com.denizenscript.ddiscordbot.events;

import com.denizenscript.denizencore.objects.ObjectTag;
import com.denizenscript.denizencore.objects.core.ElementTag;
import com.denizenscript.denizencore.objects.core.ListTag;
import com.denizenscript.denizencore.objects.core.MapTag;
import com.denizenscript.denizencore.utilities.CoreUtilities;
import com.denizenscript.denizencore.utilities.debugging.Debug;
import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.Command;
import net.dv8tion.jda.api.interactions.commands.CommandInteractionPayload;

import java.util.ArrayList;
import java.util.List;

public class DiscordCommandAutocompleteScriptEvent extends DiscordCommandInteractionScriptEvent {

// <--[event]
// @Events
// discord command autocomplete
//
// @Switch for:<bot> to only process the event for a specified Discord bot.
// @Switch channel:<channel_id> to only process the event when it occurs in a specified Discord channel.
// @Switch group:<group_id> to only process the event for a specified Discord group.
// @Switch name:<command_name> to only process the event for a specified Discord application command. Spaces are replaced with underscores.
// @Switch option:<option_name> to only process the event for a specified autocompletable option.
//
// @Triggers when a Discord user queries a slash command option that can be autocompleted.
//
// @Plugin dDiscordBot
//
// @Group Discord
//
// @Context
// <context.bot> returns the relevant DiscordBotTag.
// <context.channel> returns the DiscordChannelTag.
// <context.group> returns the DiscordGroupTag.
// <context.interaction> returns the DiscordInteractionTag.
// <context.command> returns the DiscordCommandTag.
// <context.options> returns the supplied options as a MapTag.
// <context.focused_option> returns the name of the focused option.
//
// @Determine
// "CHOICES:" + ListTag to suggest values to the Discord client. Up to 25 suggestions are allowed to be sent. Each entry can be an ElementTag which controls both the value and display of the choice or a MapTag with "name" and "value" keys to control both separately.
//
// @Example
// # Suggests fruits that are only longer in length than the current input.
// on discord command autocomplete name:eat option:fruit:
// - define length <context.options.get[fruit].length>
// - define fruits <list[lime|apple|orange|blueberry|dragonfruit]>
// - determine choices:<[fruits].filter_tag[<[filter_value].length.is_more_than[<[length]>]>]>
//
// @Example
// # Suggests the 25 best-matching selections from some dataset against the current input.
// on discord command autocomplete:
// - define value <context.options.get[<context.focused_option>]>
// - determine choices:<server.flag[dataset].sort_by_number[difference[<[value]>]].first[25].if_null[<list>]>
//
// -->

public static DiscordCommandAutocompleteScriptEvent instance;

public DiscordCommandAutocompleteScriptEvent() {
instance = this;
registerCouldMatcher("discord command autocomplete");
registerSwitches("option");
}

public CommandAutoCompleteInteractionEvent getAutocompleteEvent() {
return (CommandAutoCompleteInteractionEvent) event;
}

@Override
public CommandInteractionPayload getPayload() {
return getAutocompleteEvent().getInteraction();
}

@Override
public boolean matches(ScriptPath path) {
if (!runGenericSwitchCheck(path, "option", getAutocompleteEvent().getFocusedOption().getName())) {
return false;
}
return super.matches(path);
}

@Override
public ObjectTag getContext(String name) {
switch (name) {
case "focused_option":
return new ElementTag(getAutocompleteEvent().getFocusedOption().getName(), true);
}
return super.getContext(name);
}

public Command.Choice getChoiceSuggestion(ObjectTag objectTag, ScriptPath path) {
if (objectTag.canBeType(MapTag.class)) {
MapTag map = objectTag.asType(MapTag.class, getTagContext(path));
String name = map.getElement("name").asString();
String value = map.getElement("value").asString();
return new Command.Choice(name, value);
}
String value = objectTag.toString();
return new Command.Choice(value, value);
}

@Override
public boolean applyDetermination(ScriptPath path, ObjectTag determinationObj) {
if (determinationObj instanceof ElementTag) {
String determination = ((ElementTag) determinationObj).asString();
if (CoreUtilities.toLowerCase(determination).startsWith("choices:")) {
ListTag list = ListTag.valueOf(determination.substring("choices:".length()), getTagContext(path));
if (list.size() > 25) {
Debug.echoError("Cannot suggest more than 25 choices!");
return false;
}
List<Command.Choice> choices = new ArrayList<>();
for (ObjectTag objectTag : list.objectForms) {
choices.add(getChoiceSuggestion(objectTag, path));
}
getAutocompleteEvent().replyChoices(choices).queue();
return true;
}
}
return super.applyDetermination(path, determinationObj);
}
}

0 comments on commit 1c3a58e

Please sign in to comment.