Skip to content

added auto-complete for tag command #598

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Oct 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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";

Expand All @@ -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)) {
Expand All @@ -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<Command.Choice> choices = StringDistances
.closeMatches(focusedOption.getValue(), tagSystem.getAllIds(), MAX_SUGGESTIONS)
.stream()
.map(id -> new Command.Choice(id, id))
.toList();

event.replyChoices(choices).queue();
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
Expand Down Expand Up @@ -51,6 +55,42 @@ public static <S extends CharSequence> Optional<S> 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<String> closeMatches(CharSequence prefix,
Collection<String> candidates, int limit) {
if (candidates.isEmpty()) {
return List.of();
}

Collection<MatchScore> scoredMatches = candidates.stream()
.map(candidate -> new MatchScore(candidate, prefixEditDistance(prefix, candidate)))
.toList();

Queue<MatchScore> 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.
* <p>
Expand Down Expand Up @@ -141,4 +181,17 @@ private static int[][] computeLevenshteinDistanceTable(CharSequence source,

return table;
}

private record MatchScore(String candidate, double score) implements Comparable<MatchScore> {
@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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> expectedSuggestions, String prefix,
Collection<String> candidates, int limit) {
}

List<String> candidates = List.of("c", "c#", "c++", "emacs", "foo", "hello", "java", "js",
"key", "nvim", "py", "tag", "taz", "vi", "vim");
final int MAX_MATCHES = 5;

List<TestCase> 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) {
Expand Down