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..31e027485c 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 @@ -1,15 +1,20 @@ package org.togetherjava.tjbot.commands.tags; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command; import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.utils.StringDistances; import java.time.Instant; -import java.util.Objects; +import java.util.Collection; /** * Implements the {@code /tag} command which lets the bot respond content of a tag that has been @@ -20,7 +25,7 @@ */ public final class TagCommand extends SlashCommandAdapter { private final TagSystem tagSystem; - + private static final int MAX_SUGGESTIONS = 5; static final String ID_OPTION = "id"; static final String REPLY_TO_USER_OPTION = "reply-to"; @@ -34,16 +39,16 @@ public TagCommand(TagSystem tagSystem) { this.tagSystem = tagSystem; - // TODO Think about adding an ephemeral selection menu with pagination support - // if the user calls this without id or similar - getData().addOption(OptionType.STRING, ID_OPTION, "The id of the tag to display", true) - .addOption(OptionType.USER, REPLY_TO_USER_OPTION, - "Optionally, the user who you want to reply to", false); + getData().addOptions( + new OptionData(OptionType.STRING, ID_OPTION, "The id of the tag to display", true, + true), + new OptionData(OptionType.USER, REPLY_TO_USER_OPTION, + "Optionally, the user who you want to reply to", false)); } @Override public void onSlashCommand(SlashCommandInteractionEvent event) { - String id = Objects.requireNonNull(event.getOption(ID_OPTION)).getAsString(); + String id = event.getOption(ID_OPTION).getAsString(); OptionMapping replyToUserOption = event.getOption(REPLY_TO_USER_OPTION); if (tagSystem.handleIsUnknownTag(id, event)) { @@ -62,4 +67,22 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { } message.queue(); } + + @Override + public void onAutoComplete(CommandAutoCompleteInteractionEvent event) { + AutoCompleteQuery focusedOption = event.getFocusedOption(); + + if (!focusedOption.getName().equals(ID_OPTION)) { + throw new IllegalArgumentException( + "Unexpected option, was: " + focusedOption.getName()); + } + + Collection choices = StringDistances + .closeMatches(focusedOption.getValue(), tagSystem.getAllIds(), MAX_SUGGESTIONS) + .stream() + .map(id -> new Command.Choice(id, id)) + .toList(); + + event.replyChoices(choices).queue(); + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/utils/StringDistances.java b/application/src/main/java/org/togetherjava/tjbot/commands/utils/StringDistances.java index c839a46681..b3f522bb22 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/utils/StringDistances.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/StringDistances.java @@ -1,15 +1,19 @@ package org.togetherjava.tjbot.commands.utils; -import java.util.Arrays; -import java.util.Collection; -import java.util.Comparator; -import java.util.Optional; +import java.util.*; import java.util.stream.IntStream; +import java.util.stream.Stream; /** * Utility class for computing string distances, for example the edit distance between two words. */ public class StringDistances { + /** + * Matches that are further off than this are not considered as match anymore. The value is + * between 0.0 (full match) and 1.0 (completely different). + */ + private static final double OFF_BY_PERCENTAGE_THRESHOLD = 0.5; + private StringDistances() { throw new UnsupportedOperationException("Utility class, construction not supported"); } @@ -51,6 +55,42 @@ public static Optional autocomplete(CharSequence pre .min(Comparator.comparingInt(candidate -> prefixEditDistance(prefix, candidate))); } + /** + * Gives sorted suggestion to autocomplete a prefix string from the given options. + * + * @param prefix the prefix to give matches for + * @param candidates all the possible matches + * @param limit number of matches to generate at max + * @return the matches closest to the given prefix, limited to the given limit + */ + public static Collection closeMatches(CharSequence prefix, + Collection candidates, int limit) { + if (candidates.isEmpty()) { + return List.of(); + } + + Collection scoredMatches = candidates.stream() + .map(candidate -> new MatchScore(candidate, prefixEditDistance(prefix, candidate))) + .toList(); + + Queue bestMatches = new PriorityQueue<>(); + bestMatches.addAll(scoredMatches); + + return Stream.generate(bestMatches::poll) + .limit(limit) + .takeWhile(matchScore -> isCloseEnough(matchScore, prefix)) + .map(MatchScore::candidate) + .toList(); + } + + private static boolean isCloseEnough(MatchScore matchScore, CharSequence prefix) { + if (prefix.isEmpty()) { + return true; + } + + return matchScore.score / prefix.length() <= OFF_BY_PERCENTAGE_THRESHOLD; + } + /** * Distance to receive {@code destination} from {@code source} by editing. *

@@ -141,4 +181,17 @@ private static int[][] computeLevenshteinDistanceTable(CharSequence source, return table; } + + private record MatchScore(String candidate, double score) implements Comparable { + @Override + public int compareTo(MatchScore otherMatchScore) { + int compareResult = Double.compare(this.score, otherMatchScore.score); + + if (compareResult == 0) { + return this.candidate.compareTo(otherMatchScore.candidate); + } + + return compareResult; + } + } } diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/utils/StringDistancesTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/utils/StringDistancesTest.java index 2b83957bed..4a439e1f7a 100644 --- a/application/src/test/java/org/togetherjava/tjbot/commands/utils/StringDistancesTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/commands/utils/StringDistancesTest.java @@ -2,12 +2,40 @@ import org.junit.jupiter.api.Test; +import java.util.Collection; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; final class StringDistancesTest { + @Test + void closeMatches() { + record TestCase(String name, Collection expectedSuggestions, String prefix, + Collection candidates, int limit) { + } + + List candidates = List.of("c", "c#", "c++", "emacs", "foo", "hello", "java", "js", + "key", "nvim", "py", "tag", "taz", "vi", "vim"); + final int MAX_MATCHES = 5; + + List tests = List.of( + new TestCase("no_tags", List.of(), "foo", List.of(), MAX_MATCHES), + new TestCase("no_prefix", List.of("c", "c#", "c++", "emacs", "foo"), "", candidates, + MAX_MATCHES), + new TestCase("both_empty", List.of(), "", List.of(), MAX_MATCHES), + new TestCase("withPrefix0", List.of("vi", "vim"), "v", candidates, MAX_MATCHES), + new TestCase("withPrefix1", List.of("java", "js"), "j", candidates, MAX_MATCHES), + new TestCase("withPrefix2", List.of("c", "c#", "c++"), "c", candidates, + MAX_MATCHES)); + + for (TestCase test : tests) { + assertEquals(test.expectedSuggestions, + StringDistances.closeMatches(test.prefix, test.candidates, test.limit), + "Test '%s' failed".formatted(test.name)); + } + } + @Test void editDistance() { record TestCase(String name, int expectedDistance, String source, String destination) {