diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/BotCommandAdapter.java b/application/src/main/java/org/togetherjava/tjbot/commands/BotCommandAdapter.java index 3a5e5af1f3..066f652539 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/BotCommandAdapter.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/BotCommandAdapter.java @@ -6,6 +6,7 @@ import net.dv8tion.jda.api.interactions.commands.Command; import net.dv8tion.jda.api.interactions.commands.build.CommandData; import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; import org.togetherjava.tjbot.commands.componentids.ComponentId; import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.commands.componentids.Lifespan; @@ -134,4 +135,16 @@ protected final String generateComponentId(Lifespan lifespan, String... args) { .generate(new ComponentId(UserInteractorPrefix.getPrefixedNameFromInstance(this), Arrays.asList(args)), lifespan); } + + /** + * Gets the generator used to create component IDs. + *

+ * In general, prefer using {@link #generateComponentId(Lifespan, String...)} and + * {@link #generateComponentId(String...)} instead of interacting with the generator directly. + * + * @return the generator + */ + protected final @Nullable ComponentIdGenerator getComponentIdGenerator() { + return componentIdGenerator; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java index 2e00048a18..51d934ab28 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java @@ -2,14 +2,18 @@ import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import javax.annotation.Nullable; import java.time.Instant; +import java.util.List; import java.util.Objects; +import java.util.Optional; /** * Implements the {@code /tag} command which lets the bot respond content of a tag that has been @@ -21,6 +25,7 @@ public final class TagCommand extends SlashCommandAdapter { private final TagSystem tagSystem; + static final String COMMAND_NAME = "tag"; static final String ID_OPTION = "id"; static final String REPLY_TO_USER_OPTION = "reply-to"; @@ -30,7 +35,7 @@ public final class TagCommand extends SlashCommandAdapter { * @param tagSystem the system providing the actual tag data */ public TagCommand(TagSystem tagSystem) { - super("tag", "Display a tags content", CommandVisibility.GUILD); + super(COMMAND_NAME, "Display a tags content", CommandVisibility.GUILD); this.tagSystem = tagSystem; @@ -46,20 +51,34 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { String id = Objects.requireNonNull(event.getOption(ID_OPTION)).getAsString(); OptionMapping replyToUserOption = event.getOption(REPLY_TO_USER_OPTION); - if (tagSystem.handleIsUnknownTag(id, event)) { + if (tagSystem.handleIsUnknownTag(id, event, + Objects.requireNonNull(getComponentIdGenerator()), replyToUserOption)) { return; } - ReplyCallbackAction message = event - .replyEmbeds(new EmbedBuilder().setDescription(tagSystem.getTag(id).orElseThrow()) - .setFooter(event.getUser().getName() + " • used " + event.getCommandString()) + sendTagReply(event, event.getUser().getName(), id, event.getCommandString(), + replyToUserOption == null ? null : replyToUserOption.getAsUser().getAsMention()); + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + sendTagReply(event, event.getUser().getName(), args.get(0), null, args.get(1)); + } + + /** + * Sends the reply for a successfull /tag use (i.e. the given tag exists). + */ + private void sendTagReply(IReplyCallback event, String invokerUserName, String tag, + @Nullable String commandString, @Nullable String replyTargetUser) { + Optional commandStringOpt = Optional.ofNullable(commandString); + + event + .replyEmbeds(new EmbedBuilder().setDescription(tagSystem.getTag(tag).orElseThrow()) + .setFooter(invokerUserName + commandStringOpt.map(s -> " • used " + s).orElse("")) .setTimestamp(Instant.now()) .setColor(TagSystem.AMBIENT_COLOR) - .build()); - - if (replyToUserOption != null) { - message = message.setContent(replyToUserOption.getAsUser().getAsMention()); - } - message.queue(); + .build()) + .setContent(replyTargetUser) + .queue(); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java index d72a4e1b14..9b5a06345d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java @@ -152,7 +152,8 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { private void rawTag(SlashCommandInteractionEvent event) { String id = Objects.requireNonNull(event.getOption(ID_OPTION)).getAsString(); - if (tagSystem.handleIsUnknownTag(id, event)) { + if (tagSystem.handleIsUnknownTag(id, event, + Objects.requireNonNull(getComponentIdGenerator()), null)) { return; } @@ -317,7 +318,8 @@ private Optional getTagContent(Subcommand subcommand, String id) { private boolean isWrongTagStatusAndHandle(TagStatus requiredTagStatus, String id, IReplyCallback event) { if (requiredTagStatus == TagStatus.EXISTS) { - return tagSystem.handleIsUnknownTag(id, event); + return tagSystem.handleIsUnknownTag(id, event, + Objects.requireNonNull(getComponentIdGenerator()), null); } else if (requiredTagStatus == TagStatus.NOT_EXISTS) { if (tagSystem.hasTag(id)) { event.reply("The tag with id '%s' already exists.".formatted(id)) diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagSystem.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagSystem.java index 5d7d0cce41..bfaaa3aa91 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagSystem.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagSystem.java @@ -2,23 +2,37 @@ import net.dv8tion.jda.api.entities.emoji.Emoji; import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +import org.apache.commons.collections4.ListUtils; +import org.togetherjava.tjbot.commands.UserInteractorPrefix; +import org.togetherjava.tjbot.commands.componentids.ComponentId; +import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator; +import org.togetherjava.tjbot.commands.componentids.Lifespan; import org.togetherjava.tjbot.commands.utils.StringDistances; import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.db.generated.tables.Tags; import org.togetherjava.tjbot.db.generated.tables.records.TagsRecord; -import java.awt.Color; -import java.util.Optional; -import java.util.Set; +import javax.annotation.Nullable; +import java.awt.*; +import java.util.List; +import java.util.*; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * The core of the tag system. Provides methods to read and create tags, directly tied to the * underlying database. */ public final class TagSystem { + /** + * The amount of tags suggested when the user types an incorrect tag name. + */ + private static final int TAG_SUGGESTIONS_AMOUNT = 5; + /** * The ambient color to use for tag system related messages. */ @@ -54,22 +68,56 @@ static Button createDeleteButton(String componentId) { * * @param id the id of the tag to check * @param event the event to send messages with + * @param componentIdGenerator used to generate buttons with tag suggestions + * @param replyTargetUser the user that was originally meant to be replied to when the tag + * command was invoked * @return whether the given tag is unknown to the system */ - boolean handleIsUnknownTag(String id, IReplyCallback event) { + boolean handleIsUnknownTag(String id, IReplyCallback event, + ComponentIdGenerator componentIdGenerator, @Nullable OptionMapping replyTargetUser) { if (hasTag(id)) { return false; } - String suggestionText = StringDistances.closestMatch(id, getAllIds()) - .map(", did you perhaps mean '%s'?"::formatted) - .orElse("."); - event.reply("Could not find any tag with id '%s'%s".formatted(id, suggestionText)) - .setEphemeral(true) - .queue(); + Queue closestMatches = new PriorityQueue<>( + Comparator.comparingInt(candidate -> StringDistances.editDistance(id, candidate))); + + closestMatches.addAll(getAllIds()); + + List suggestions = + Stream.generate(closestMatches::poll).limit(TAG_SUGGESTIONS_AMOUNT).toList(); + ReplyCallbackAction action = + event + .reply("Could not find any tag with id '%s'%s".formatted(id, + suggestions.isEmpty() ? "." + : ", did you perhaps mean any of the following?")) + .setEphemeral(true); + List> batches = ListUtils.partition(suggestions, 5); + + for (List batch : batches) { + action.addActionRow(batch.stream() + .map(suggestion -> createSuggestionButton(suggestion, componentIdGenerator, + replyTargetUser)) + .toList()); + } + return true; } + /** + * Creates a button for a suggestion for + * {@link #handleIsUnknownTag(String, IReplyCallback, ComponentIdGenerator, OptionMapping)}. + */ + private Button createSuggestionButton(String suggestion, + ComponentIdGenerator componentIdGenerator, @Nullable OptionMapping userToReplyTo) { + return Button.secondary(componentIdGenerator.generate(new ComponentId( + UserInteractorPrefix.getPrefixedNameFromClass(TagCommand.class, + TagCommand.COMMAND_NAME), + Arrays.asList(suggestion, + userToReplyTo != null ? userToReplyTo.getAsUser().getAsMention() : null)), + Lifespan.REGULAR), suggestion); + } + /** * Checks if the given tag is known to the tag system. * diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagCommandTest.java index eced581e77..62ee40ea96 100644 --- a/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagCommandTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagCommandTest.java @@ -7,6 +7,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.togetherjava.tjbot.commands.SlashCommand; +import org.togetherjava.tjbot.commands.componentids.ComponentId; +import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator; +import org.togetherjava.tjbot.commands.componentids.Lifespan; import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.db.generated.tables.Tags; import org.togetherjava.tjbot.jda.JdaTester; @@ -27,6 +30,8 @@ void setUp() { system = spy(new TagSystem(database)); jdaTester = new JdaTester(); command = new TagCommand(system); + + command.acceptComponentIdGenerator((componentId, lifespan) -> "foo"); } private SlashCommandInteractionEvent triggerSlashCommand(String id, @@ -64,8 +69,8 @@ void canNotFindTagSuggestDifferentTag() { SlashCommandInteractionEvent event = triggerSlashCommand("second", null); // THEN responds that the tag could not be found and instead suggests using the other tag - verify(event) - .reply("Could not find any tag with id 'second', did you perhaps mean 'first'?"); + verify(event).reply( + "Could not find any tag with id 'second', did you perhaps mean any of the following?"); } @Test diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagSystemTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagSystemTest.java index 9c441b023d..cd4a370ff0 100644 --- a/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagSystemTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/commands/tags/TagSystemTest.java @@ -52,10 +52,12 @@ void handleIsUnknownTag() { SlashCommandInteractionEvent event = jdaTester.createSlashCommandInteractionEvent(new TagCommand(system)).build(); - assertFalse(system.handleIsUnknownTag("known", event)); + assertFalse( + system.handleIsUnknownTag("known", event, (componentId, lifespan) -> "foo", null)); verify(event, never()).reply(anyString()); - assertTrue(system.handleIsUnknownTag("unknown", event)); + assertTrue(system.handleIsUnknownTag("unknown", event, (componentId, lifespan) -> "foo", + null)); verify(event).reply(anyString()); } diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java index 7194e189de..1acfebfb2c 100644 --- a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java +++ b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java @@ -571,7 +571,7 @@ public MessageReceivedEvent createMessageReceiveEvent(MessageCreateData message, * strings or files. *

* An example would be - * + * *

      * {
      *     @code