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