Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
* <p>
* 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() {
Copy link
Member

@Zabuzard Zabuzard Oct 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the method is not supposed to return a nullable.

its a mistake to call the method before intiialization, not allowed. the method should fail-fast if its called before.

should probably also be added to its javadoc. thanks

return componentIdGenerator;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";

Expand All @@ -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;

Expand All @@ -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<String> 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<String> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -317,7 +318,8 @@ private Optional<String> 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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<String> closestMatches = new PriorityQueue<>(
Comparator.comparingInt(candidate -> StringDistances.editDistance(id, candidate)));

closestMatches.addAll(getAllIds());

List<String> 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<List<String>> batches = ListUtils.partition(suggestions, 5);

for (List<String> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ public MessageReceivedEvent createMessageReceiveEvent(MessageCreateData message,
* strings or files.
* <p>
* An example would be
*
*
* <pre>
* {
* &#64;code
Expand Down