-
Notifications
You must be signed in to change notification settings - Fork 25
feat: delete-all button and language dropdown for multi-message code blocks #550
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
base: main
Are you sure you want to change the base?
Changes from all commits
c42529c
50e3fdf
87a9552
eb35bdf
43bd07e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| package net.discordjug.javabot.systems.user_commands.format_code; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; | ||
| import net.discordjug.javabot.data.config.BotConfig; | ||
| import net.discordjug.javabot.util.Checks; | ||
| import net.discordjug.javabot.util.Responses; | ||
| import net.dv8tion.jda.api.components.Component; | ||
| import net.dv8tion.jda.api.components.actionrow.ActionRow; | ||
| import net.dv8tion.jda.api.components.buttons.Button; | ||
| import net.dv8tion.jda.api.components.selections.SelectOption; | ||
| import net.dv8tion.jda.api.components.selections.StringSelectMenu; | ||
| import net.dv8tion.jda.api.entities.Member; | ||
| import net.dv8tion.jda.api.entities.Message; | ||
|
|
||
| import java.util.ArrayList; | ||
|
|
||
| import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; | ||
| import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; | ||
| import org.jspecify.annotations.NonNull; | ||
| import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler; | ||
| import xyz.dynxsty.dih4jda.interactions.components.StringSelectMenuHandler; | ||
| import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; | ||
|
|
||
| import java.util.Arrays; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * Handles the interactive components on formatted code blocks: the delete-all button and the | ||
| * language-selection dropdown. Both act on every message of a (possibly multi-message) block, | ||
| * which is resolved via {@link LinkedMessages}. | ||
| */ | ||
| @AutoDetectableComponentHandler(FormatCodeInteractionHandler.COMPONENT_ID) | ||
| @RequiredArgsConstructor | ||
| public class FormatCodeInteractionHandler implements ButtonHandler, StringSelectMenuHandler { | ||
| static final String COMPONENT_ID = "format-code"; | ||
| private final BotConfig botConfig; | ||
|
|
||
| /** | ||
| * Builds the delete-all button placed on the last message of a code block. | ||
| * | ||
| * @param requesterID the id of the user allowed to delete the block | ||
| * @param total the number of messages making up the block | ||
| * @return the delete-all button | ||
| */ | ||
| public static Button createDeleteAllButton(long requesterID, int total) { | ||
| return Button.secondary(ComponentIdBuilder.build(COMPONENT_ID, requesterID, total), "\uD83D\uDDD1\uFE0F"); | ||
| } | ||
|
|
||
| private static StringSelectMenu languageMenu(String customId) { | ||
| return StringSelectMenu.create(customId) | ||
| .setPlaceholder("Change language") | ||
| .addOptions(Arrays.stream(Language.values()) | ||
| .filter(language -> language != Language.UNKNOWN) | ||
| .map(language -> SelectOption.of(language.getDisplayName(), language.name())) | ||
| .toList()) | ||
| .build(); | ||
| } | ||
|
|
||
| /** | ||
| * Builds the language-selection dropdown row for a code block. | ||
| * | ||
| * @param requesterId the id of the user allowed to change the language | ||
| * @param total the number of messages making up the block | ||
| * @return an action row containing the language dropdown | ||
| */ | ||
| public static ActionRow buildLanguageMenu(long requesterId, int total) { | ||
| return ActionRow.of(languageMenu(ComponentIdBuilder.build(COMPONENT_ID, requesterId, total))); | ||
| } | ||
|
|
||
| @Override | ||
| public void handleButton(ButtonInteractionEvent event, @NonNull Button button) { | ||
| String[] id = ComponentIdBuilder.split(event.getComponentId()); | ||
| long requesterId = Long.parseLong(id[1]); | ||
|
|
||
|
|
||
| Member member = event.getMember(); | ||
| if (member == null) { | ||
| Responses.error(event, "This button may only be used inside a server.").queue(); | ||
| return; | ||
| } | ||
| if (!canManage(member, requesterId)) { | ||
| Responses.errorWithTitle(event, "Access Denied", "You are not authorized to perform this action.").queue(); | ||
| return; | ||
| } | ||
|
|
||
| event.deferEdit().queue(); | ||
|
|
||
| LinkedMessages.resolveBefore(event.getMessage(), Integer.parseInt(id[2]), | ||
| messages -> event.getChannel().purgeMessages(messages), | ||
| () -> Responses.error(event.getHook(), "Could not delete the code block").queue()); | ||
| } | ||
|
|
||
| @Override | ||
| public void handleStringSelectMenu(@NonNull StringSelectInteractionEvent event, @NonNull List<String> values) { | ||
| String[] id = ComponentIdBuilder.split(event.getComponentId()); | ||
| long requesterId = Long.parseLong(id[1]); | ||
|
|
||
| Member member = event.getMember(); | ||
| if (member == null) { | ||
| Responses.error(event, "This menu may only be used inside a server.").queue(); | ||
| return; | ||
| } | ||
| if (!canManage(member, requesterId)) { | ||
| Responses.errorWithTitle(event, "Access Denied", "You are not authorized to perform this action.").queue(); | ||
| return; | ||
| } | ||
|
|
||
| Language language = Language.fromString(values.getFirst()); | ||
| event.deferEdit().queue(); | ||
|
|
||
| LinkedMessages.resolveAfter(event.getMessage(), Integer.parseInt(id[2]), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you please document that |
||
| messages -> { | ||
| if (ownsBlock(messages, event.getUser().getIdLong())) { | ||
| messages.forEach(message -> | ||
| message.editMessage(withLanguage(message.getContentRaw(), language)).queue()); | ||
| } else { | ||
| Responses.error(event.getHook(),"Could not update the code block — it may have changed or no longer be yours.").queue(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "no longer be yours" is a bit confusing in my opinion. What about "The code block could not be updated. The messages may have been deleted." Also, I assume you are not able to delete it because the original message is ephemeral? Is this correct (this is perfectly fine)? |
||
| } | ||
| }, () -> Responses.error(event.getHook(), "Could not update the code block — it may have changed or no longer be yours.").queue()); | ||
| } | ||
|
|
||
| private static boolean ownsBlock(List<Message> messages, long userId) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you please change this name to something more descriptive like
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this method requires a certain order of the messages, please document this in a Javadoc. |
||
| Message last = messages.getFirst(); | ||
| boolean lastIsUsers = buttonsOf(last).stream().anyMatch(button -> isDeleteButtonOf(button, userId)); | ||
| boolean noOtherHasButton = messages.size() <= 1 ; | ||
| for (Message message : messages.subList(0,messages.size()-1)){ | ||
| for(Button button: buttonsOf(message)){ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Formatted code block messages shouldn't have any componens except for the last message. I think the following would be easier: for (int i = 1; i < messages.size(); i++) {
if (!message.getComponents().isEmpty()) {
return false;
}
}
return messages.size() >= 1 && hasDeleteButtonOf(messages.getFirst(), userId) |
||
| if(isDeleteButton(button)){ | ||
| noOtherHasButton = true; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here you are basically requiring that there is a delete button but you are excluding the chronologically first message from the list using |
||
| } | ||
| } | ||
| } | ||
| return lastIsUsers && noOtherHasButton; | ||
| } | ||
|
|
||
| private static List<Button> buttonsOf(Message message) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think you'll need a |
||
| for (Component component: message.getComponents()) { | ||
| if(component instanceof ActionRow row) { | ||
| return row.getButtons(); | ||
| } | ||
| } | ||
| return new ArrayList<>(); | ||
| } | ||
|
|
||
| private static boolean isDeleteButton(Button button) { | ||
| return button.getCustomId() != null && button.getCustomId().startsWith(COMPONENT_ID + ":"); | ||
| } | ||
|
|
||
| private static boolean isDeleteButtonOf(Button button, long userId) { | ||
| return isDeleteButton(button) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it would be easier to use
|
||
| && Long.parseLong(ComponentIdBuilder.split(button.getCustomId())[1]) == userId; | ||
| } | ||
|
|
||
| private boolean canManage(Member member, long requesterId) { | ||
| return member.getIdLong() == requesterId | ||
| || Checks.hasStaffRole(botConfig, member) | ||
| || member.isOwner() ; | ||
| } | ||
|
|
||
| /** | ||
| * Re-wraps a code-block message in a different language by swapping the tag on its opening fence, | ||
| * leaving the code itself untouched. | ||
| * | ||
| * @param content the raw message content, expected to start with a fenced code block | ||
| * @param language the language to switch to | ||
| * @return the message content with its opening fence set to {@code language} | ||
| */ | ||
| private static String withLanguage(String content, Language language) { | ||
| int firstLineEnd = content.indexOf('\n'); | ||
| return firstLineEnd < 0 | ||
| ? content | ||
| : "```" + language.getDiscordName() + content.substring(firstLineEnd); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| package net.discordjug.javabot.systems.user_commands.format_code; | ||
|
|
||
| import net.dv8tion.jda.api.entities.Message; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.function.Consumer; | ||
|
|
||
|
|
||
| /** | ||
| * Helper for acting on a block of related messages sent as a group — such as a piece of code split | ||
| * across several Discord messages. Given one message of the block and the total count, it resolves | ||
| * the whole block (only the bot's own messages) so a single interaction can delete or edit it. | ||
| */ | ||
| public class LinkedMessages { | ||
| private LinkedMessages(){} | ||
|
|
||
| /** | ||
| * Resolves the block ending at {@code triggerMessage} (walking back {@code total} messages) and | ||
| * passes the bot's own messages to {@code onResolved}, or runs {@code onError} if it can't be | ||
| * safely resolved. | ||
| * | ||
| * @param triggerMessage the last message of the block (carries the component) | ||
| * @param total the number of messages in the block | ||
| * @param onResolved receives the bot's messages that make up the block | ||
| * @param onError runs if the block can't be safely resolved | ||
| */ | ||
| static void resolveBefore(Message triggerMessage, int total, Consumer<List<Message>> onResolved, Runnable onError) { | ||
| if (total <= 1) { | ||
| verify(List.of(triggerMessage), total, onResolved, onError); | ||
| return; | ||
| } | ||
| triggerMessage.getChannel().getHistoryBefore(triggerMessage.getIdLong(), total - 1).queue(history -> { | ||
| List<Message> block = new ArrayList<>(history.getRetrievedHistory()); | ||
| block.add(triggerMessage); | ||
| verify(block, total, onResolved, onError); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the block of {@code total} messages sent after {@code anchorMessage} and passes the | ||
| * bot's own messages to {@code onResolved}, or runs {@code onError} if it can't be safely resolved. | ||
| * | ||
| * @param anchorMessage the message just before the block (carries the component) | ||
| * @param total the number of messages in the block | ||
| * @param onResolved receives the bot's messages that make up the block | ||
| * @param onError runs if the block can't be safely resolved | ||
| */ | ||
| static void resolveAfter(Message anchorMessage, int total, Consumer<List<Message>> onResolved, Runnable onError) { | ||
| anchorMessage.getChannel().getHistoryAfter(anchorMessage.getIdLong(), total).queue(history -> | ||
| verify(history.getRetrievedHistory(), total, onResolved, onError)); | ||
| } | ||
|
|
||
| private static void verify(List<Message> messages, int total, Consumer<List<Message>> onResolved, Runnable onError) { | ||
| List<Message> own = onlyOwn(messages); | ||
| boolean allCodeBlocks = own.stream().allMatch(message -> message.getContentRaw().startsWith("```")); | ||
| if (own.size() != total || !allCodeBlocks) { | ||
| onError.run(); | ||
| return; | ||
| } | ||
| onResolved.accept(own); | ||
| } | ||
|
|
||
| private static List<Message> onlyOwn(List<Message> messages) { | ||
| return messages.stream() | ||
| .filter(message -> message.getAuthor().getIdLong() == message.getJDA().getSelfUser().getIdLong()) | ||
| .toList(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed it's possible to do the following:
In this case, the language of the code block of user B is changed. Could you prevent this in one of the following ways?
LinkedMessages.resolveAfter, check that the last message contains the delete button with the delete button with the correct requestor (= current user) and that no other messages have a delete buttonresolveBeforeand add an additional argument containing the message ID of the first code block message to the ID here. If this doesn't match, it is a failure.I think the first approach is easier to do so I recommend that one.