Skip to content

Commit 2b7ce2f

Browse files
authored
Added auto-completion for tag command id option (#598)
1 parent 6847ad3 commit 2b7ce2f

File tree

3 files changed

+116
-12
lines changed

3 files changed

+116
-12
lines changed

application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
package org.togetherjava.tjbot.commands.tags;
22

33
import net.dv8tion.jda.api.EmbedBuilder;
4+
import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent;
45
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
6+
import net.dv8tion.jda.api.interactions.AutoCompleteQuery;
7+
import net.dv8tion.jda.api.interactions.commands.Command;
58
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
69
import net.dv8tion.jda.api.interactions.commands.OptionType;
10+
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
711
import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction;
812

913
import org.togetherjava.tjbot.commands.CommandVisibility;
1014
import org.togetherjava.tjbot.commands.SlashCommandAdapter;
15+
import org.togetherjava.tjbot.commands.utils.StringDistances;
1116

1217
import java.time.Instant;
13-
import java.util.Objects;
18+
import java.util.Collection;
1419

1520
/**
1621
* Implements the {@code /tag} command which lets the bot respond content of a tag that has been
@@ -21,7 +26,7 @@
2126
*/
2227
public final class TagCommand extends SlashCommandAdapter {
2328
private final TagSystem tagSystem;
24-
29+
private static final int MAX_SUGGESTIONS = 5;
2530
static final String ID_OPTION = "id";
2631
static final String REPLY_TO_USER_OPTION = "reply-to";
2732

@@ -35,16 +40,16 @@ public TagCommand(TagSystem tagSystem) {
3540

3641
this.tagSystem = tagSystem;
3742

38-
// TODO Think about adding an ephemeral selection menu with pagination support
39-
// if the user calls this without id or similar
40-
getData().addOption(OptionType.STRING, ID_OPTION, "The id of the tag to display", true)
41-
.addOption(OptionType.USER, REPLY_TO_USER_OPTION,
42-
"Optionally, the user who you want to reply to", false);
43+
getData().addOptions(
44+
new OptionData(OptionType.STRING, ID_OPTION, "The id of the tag to display", true,
45+
true),
46+
new OptionData(OptionType.USER, REPLY_TO_USER_OPTION,
47+
"Optionally, the user who you want to reply to", false));
4348
}
4449

4550
@Override
4651
public void onSlashCommand(SlashCommandInteractionEvent event) {
47-
String id = Objects.requireNonNull(event.getOption(ID_OPTION)).getAsString();
52+
String id = event.getOption(ID_OPTION).getAsString();
4853
OptionMapping replyToUserOption = event.getOption(REPLY_TO_USER_OPTION);
4954

5055
if (tagSystem.handleIsUnknownTag(id, event)) {
@@ -63,4 +68,22 @@ public void onSlashCommand(SlashCommandInteractionEvent event) {
6368
}
6469
message.queue();
6570
}
71+
72+
@Override
73+
public void onAutoComplete(CommandAutoCompleteInteractionEvent event) {
74+
AutoCompleteQuery focusedOption = event.getFocusedOption();
75+
76+
if (!focusedOption.getName().equals(ID_OPTION)) {
77+
throw new IllegalArgumentException(
78+
"Unexpected option, was: " + focusedOption.getName());
79+
}
80+
81+
Collection<Command.Choice> choices = StringDistances
82+
.closeMatches(focusedOption.getValue(), tagSystem.getAllIds(), MAX_SUGGESTIONS)
83+
.stream()
84+
.map(id -> new Command.Choice(id, id))
85+
.toList();
86+
87+
event.replyChoices(choices).queue();
88+
}
6689
}

application/src/main/java/org/togetherjava/tjbot/commands/utils/StringDistances.java

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package org.togetherjava.tjbot.commands.utils;
22

3-
import java.util.Arrays;
4-
import java.util.Collection;
5-
import java.util.Comparator;
6-
import java.util.Optional;
3+
import java.util.*;
74
import java.util.stream.IntStream;
5+
import java.util.stream.Stream;
86

97
/**
108
* Utility class for computing string distances, for example the edit distance between two words.
119
*/
1210
public class StringDistances {
11+
/**
12+
* Matches that are further off than this are not considered as match anymore. The value is
13+
* between 0.0 (full match) and 1.0 (completely different).
14+
*/
15+
private static final double OFF_BY_PERCENTAGE_THRESHOLD = 0.5;
16+
1317
private StringDistances() {
1418
throw new UnsupportedOperationException("Utility class, construction not supported");
1519
}
@@ -51,6 +55,42 @@ public static <S extends CharSequence> Optional<S> autocomplete(CharSequence pre
5155
.min(Comparator.comparingInt(candidate -> prefixEditDistance(prefix, candidate)));
5256
}
5357

58+
/**
59+
* Gives sorted suggestion to autocomplete a prefix string from the given options.
60+
*
61+
* @param prefix the prefix to give matches for
62+
* @param candidates all the possible matches
63+
* @param limit number of matches to generate at max
64+
* @return the matches closest to the given prefix, limited to the given limit
65+
*/
66+
public static Collection<String> closeMatches(CharSequence prefix,
67+
Collection<String> candidates, int limit) {
68+
if (candidates.isEmpty()) {
69+
return List.of();
70+
}
71+
72+
Collection<MatchScore> scoredMatches = candidates.stream()
73+
.map(candidate -> new MatchScore(candidate, prefixEditDistance(prefix, candidate)))
74+
.toList();
75+
76+
Queue<MatchScore> bestMatches = new PriorityQueue<>();
77+
bestMatches.addAll(scoredMatches);
78+
79+
return Stream.generate(bestMatches::poll)
80+
.limit(limit)
81+
.takeWhile(matchScore -> isCloseEnough(matchScore, prefix))
82+
.map(MatchScore::candidate)
83+
.toList();
84+
}
85+
86+
private static boolean isCloseEnough(MatchScore matchScore, CharSequence prefix) {
87+
if (prefix.isEmpty()) {
88+
return true;
89+
}
90+
91+
return matchScore.score / prefix.length() <= OFF_BY_PERCENTAGE_THRESHOLD;
92+
}
93+
5494
/**
5595
* Distance to receive {@code destination} from {@code source} by editing.
5696
* <p>
@@ -141,4 +181,17 @@ private static int[][] computeLevenshteinDistanceTable(CharSequence source,
141181

142182
return table;
143183
}
184+
185+
private record MatchScore(String candidate, double score) implements Comparable<MatchScore> {
186+
@Override
187+
public int compareTo(MatchScore otherMatchScore) {
188+
int compareResult = Double.compare(this.score, otherMatchScore.score);
189+
190+
if (compareResult == 0) {
191+
return this.candidate.compareTo(otherMatchScore.candidate);
192+
}
193+
194+
return compareResult;
195+
}
196+
}
144197
}

application/src/test/java/org/togetherjava/tjbot/commands/utils/StringDistancesTest.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,40 @@
22

33
import org.junit.jupiter.api.Test;
44

5+
import java.util.Collection;
56
import java.util.List;
67

78
import static org.junit.jupiter.api.Assertions.assertEquals;
89

910
final class StringDistancesTest {
1011

12+
@Test
13+
void closeMatches() {
14+
record TestCase(String name, Collection<String> expectedSuggestions, String prefix,
15+
Collection<String> candidates, int limit) {
16+
}
17+
18+
List<String> candidates = List.of("c", "c#", "c++", "emacs", "foo", "hello", "java", "js",
19+
"key", "nvim", "py", "tag", "taz", "vi", "vim");
20+
final int MAX_MATCHES = 5;
21+
22+
List<TestCase> tests = List.of(
23+
new TestCase("no_tags", List.of(), "foo", List.of(), MAX_MATCHES),
24+
new TestCase("no_prefix", List.of("c", "c#", "c++", "emacs", "foo"), "", candidates,
25+
MAX_MATCHES),
26+
new TestCase("both_empty", List.of(), "", List.of(), MAX_MATCHES),
27+
new TestCase("withPrefix0", List.of("vi", "vim"), "v", candidates, MAX_MATCHES),
28+
new TestCase("withPrefix1", List.of("java", "js"), "j", candidates, MAX_MATCHES),
29+
new TestCase("withPrefix2", List.of("c", "c#", "c++"), "c", candidates,
30+
MAX_MATCHES));
31+
32+
for (TestCase test : tests) {
33+
assertEquals(test.expectedSuggestions,
34+
StringDistances.closeMatches(test.prefix, test.candidates, test.limit),
35+
"Test '%s' failed".formatted(test.name));
36+
}
37+
}
38+
1139
@Test
1240
void editDistance() {
1341
record TestCase(String name, int expectedDistance, String source, String destination) {

0 commit comments

Comments
 (0)