-
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
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 |
|---|---|---|
|
|
@@ -33,9 +33,9 @@ class FormatCodeDispatcher { | |
| * @param target the original message the code came from, used for the channel and the | ||
| * "View Original" / delete buttons | ||
| */ | ||
| public static void sendCode(Code code, @Nonnull CommandInteraction event, Message target){ | ||
| public static void sendCode(Code code, @Nonnull CommandInteraction event, Message target) { | ||
| if (code.getContent().isBlank()) { | ||
| Responses.errorWithTitle(event.getHook(), "404 Code not found","There is no code to format in that message.").queue(); | ||
| Responses.errorWithTitle(event.getHook(), "404 Code not found", "There is no code to format in that message.").queue(); | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -44,56 +44,37 @@ public static void sendCode(Code code, @Nonnull CommandInteraction event, Messag | |
| MessageChannel channel = target.getChannel(); | ||
|
|
||
| if (messages.size() > MAX_MESSAGES) { | ||
| Responses.errorWithTitle(event.getHook(), "Output Too Large", "The formatted result is too large to send. Please provide a smaller code snippet or use a paste service instead." | ||
| Responses.errorWithTitle(event.getHook(), "Code out of Bound", "The formatted result is too large to send. Please provide a smaller code snippet or use a paste service instead." | ||
| ).queue(); | ||
| return; | ||
| } | ||
|
|
||
| Responses.success(event.getHook(), "Success", "The formatted message is being sent to this channel.") | ||
| .queue(success -> sendChunksInOrder(channel, messages, 0, target,event)); | ||
| sendChunksInOrder(channel, messages, 0, target, event, 0L); | ||
| } | ||
|
|
||
|
|
||
| private static void sendChunksInOrder(MessageChannel channel, List<String> messages, int index, Message target, @Nonnull CommandInteraction event) { | ||
| private static void sendChunksInOrder(MessageChannel channel, List<String> messages, int index, Message target, @Nonnull CommandInteraction event, long firstMessageID) { | ||
| if (index >= messages.size()) { | ||
| event.getHook().sendMessage("Your message has been set. If needed, you can change the language used for syntax highlighting below.") | ||
|
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.
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 you want to send the ephemeral response after the code block messages, I think you can do the following: // use deleteOriginal() to delete the intermediate message sent by deferReply(false)
event.getHook().deleteOriginal().queue(_ ->
event.getHook().sendMessage("Your message has been sent. If needed, you can change the language used for syntax highlighting below.")
...
.queue();
);Otherwise you would need to check the last message and not the first message I think (the goal is to check the message that is furthest from the anchor). |
||
| .setEphemeral(true) | ||
| .setComponents(FormatCodeInteractionHandler.buildLanguageMenu(event.getUser().getIdLong(), messages.size(), firstMessageID)) | ||
| .queue(); | ||
| return; | ||
| } | ||
| var action = channel.sendMessage(messages.get(index)) | ||
|
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. Would it be possible to specify the type explicitly here instead of using |
||
| .setAllowedMentions(List.of()); | ||
|
|
||
| if (index == messages.size() - 1) { | ||
| if(index == 0){ | ||
| action.setComponents(buildActionRow(target, event.getUser().getIdLong())); | ||
| } else { | ||
| action.setComponents(buildActionRow(target)); | ||
| } | ||
| action.setComponents(buildActionRow(target, event.getUser().getIdLong(), messages.size())); | ||
| } | ||
|
|
||
| action.queue(success -> | ||
| sendChunksInOrder(channel, messages, index + 1, target, event)); | ||
| action.queue(sent -> sendChunksInOrder(channel, messages, index + 1, target, event, index == 0 ? sent.getIdLong() : firstMessageID)); | ||
| } | ||
|
|
||
| /** | ||
| * Builds the action row placed on the last code-block message. | ||
| * | ||
| * @param target the original message linked by the "View Original" button | ||
| * @return an action row containing the "View Original" link button | ||
| */ | ||
| @Contract("_ -> new") | ||
| static @NotNull ActionRow buildActionRow(@NotNull Message target) { | ||
| return ActionRow.of(Button.link(target.getJumpUrl(), "View Original")); | ||
| } | ||
|
|
||
| /** | ||
| * Builds the action row placed on the file-upload message: a delete button and a "View Original" link. | ||
| * | ||
| * @param target the original message linked by the "View Original" button | ||
| * @param requesterId the id of the user permitted to delete the message | ||
| * @return an action row containing the delete and "View Original" buttons | ||
| */ | ||
| @Contract("_,_ -> new") | ||
| static @NotNull ActionRow buildActionRow(@NotNull Message target, long requesterId) { | ||
| return ActionRow.of(InteractionUtils.createDeleteButton(requesterId), | ||
| @Contract("_,_,_ -> new") | ||
| static @NotNull ActionRow buildActionRow(@NotNull Message target, long requesterId, int total) { | ||
| return ActionRow.of( | ||
| FormatCodeInteractionHandler.createDeleteAllButton(requesterId, total), | ||
|
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 make sense to add the first message ID to the delete button and verify that it is actually the first message before deleting. I think that should allow you using (almost) the same logic for finding and checking the messages. |
||
| Button.link(target.getJumpUrl(), "View Original")); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| 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.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.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"); | ||
| } | ||
|
|
||
|
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 move the delete logic together and the language change logic together? e.g. first the methods for creating and handling the delete button and then the methods for creating and handling the language menu. |
||
| 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 | ||
| * @param firstMessageID the id of first message to check update code block | ||
| * @return an action row containing the language dropdown | ||
| */ | ||
| public static ActionRow buildLanguageMenu(long requesterId, int total, long firstMessageID) { | ||
| return ActionRow.of(languageMenu(ComponentIdBuilder.build(COMPONENT_ID, requesterId, total, firstMessageID))); | ||
| } | ||
|
|
||
| @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.errorWithTitle(event, "Server Required", "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.errorWithTitle(event.getHook(), "Undefined Behavior", "Could not delete the code block").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. I don't think the "Undefined Behavior" adds much of value. I think it's better to not use a title here. |
||
| } | ||
|
|
||
| @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.errorWithTitle(event, "Server Required", "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]), messages -> { | ||
|
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. Are you sure
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. Note: This is assuming you are changing the interaction to use |
||
| if (messages.getLast().getIdLong() == Long.parseLong(id[3])) { | ||
| messages.forEach(message -> message.editMessage(withLanguage(message.getContentRaw(), language)).queue()); | ||
| } else { | ||
| Responses.errorWithTitle(event.getHook(), "Merge Conflict", "The code block could not be updated. The messages may have been deleted.").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. This isn't really a merge conflict. If the titles don't add much of value, please don't specify them. |
||
| } | ||
| }, () -> Responses.errorWithTitle(event.getHook(), "Undefined Behavior", "Could not update the code block").queue()); | ||
| } | ||
|
|
||
| private boolean canManage(Member member, long requesterId) { | ||
|
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 rename this method |
||
| 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,71 @@ | ||
| 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}, ordered from newest to oldest. | ||
| * Runs {@code onError} if the block 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, ordered from newest to oldest | ||
| * @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}, ordered from newest to oldest. | ||
| * Runs {@code onError} if the block 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, ordered from newest to oldest | ||
| * @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 think you meant to say
Code Out of Bounds(plural).