diff --git a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatAndIndentCodeMessageContext.java b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatAndIndentCodeMessageContext.java index 4c3bbc7c4..f355fdc01 100644 --- a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatAndIndentCodeMessageContext.java +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatAndIndentCodeMessageContext.java @@ -30,6 +30,6 @@ public void execute(@NotNull MessageContextInteractionEvent event) { Code code = new Code(Language.JAVA, indented); - event.deferReply().queue(_ -> FormatCodeDispatcher.sendCode(code, event, event.getTarget())); + event.deferReply(true).queue(_ -> FormatCodeDispatcher.sendCode(code, event, event.getTarget())); } } diff --git a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeCommand.java b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeCommand.java index 10744eab6..ebbfecceb 100644 --- a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeCommand.java @@ -56,7 +56,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { String indentation = event.getOption("auto-indent","NULL",OptionMapping::getAsString); if (idOption == null) { - event.deferReply().queue(_ -> { + event.deferReply(true).queue(_ -> { event.getChannel().getHistory() .retrievePast(10) .queue(messages -> { @@ -78,7 +78,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { return; } long messageId = idOption.getAsLong(); - event.deferReply().queue(_ -> { + event.deferReply(true).queue(_ -> { event.getChannel().retrieveMessageById(messageId).queue( target -> sendFormattedCode(event, target, language, indentation), error -> Responses.errorWithTitle(event.getHook(), "Message Not Found", "Could not retrieve the message with ID `" + messageId + "`. Make sure the message exists and is accessible.").queue()); diff --git a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeDispatcher.java b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeDispatcher.java index 4fc1215ac..f0b04375f 100644 --- a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeDispatcher.java +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeDispatcher.java @@ -49,7 +49,9 @@ public static void sendCode(Code code, @Nonnull CommandInteraction event, Messag return; } - Responses.success(event.getHook(), "Success", "The formatted message is being sent to this channel.") + event.getHook().sendMessage("Your message has been formatted. If needed, you can change the language used for syntax highlighting below.") + .setEphemeral(true) + .setComponents(FormatCodeInteractionHandler.buildLanguageMenu(event.getUser().getIdLong(),messages.size())) .queue(success -> sendChunksInOrder(channel, messages, 0, target,event)); } @@ -62,38 +64,16 @@ private static void sendChunksInOrder(MessageChannel channel, List messa .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)); } - /** - * 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), Button.link(target.getJumpUrl(), "View Original")); } } diff --git a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeInteractionHandler.java b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeInteractionHandler.java new file mode 100644 index 000000000..56472a08e --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeInteractionHandler.java @@ -0,0 +1,133 @@ +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"); + } + + 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 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]), + messages -> messages.forEach(message -> + message.editMessage(withLanguage(message.getContentRaw(), language)).queue()), + () -> Responses.error(event.getHook(),"Could not update the code block").queue()); + } + + 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); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeMessageContext.java b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeMessageContext.java index ce1033702..835fa72cf 100644 --- a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeMessageContext.java +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeMessageContext.java @@ -27,6 +27,6 @@ public void execute(@NotNull MessageContextInteractionEvent event) { Code code = new Code(Language.JAVA, content); - event.deferReply().queue(_ -> FormatCodeDispatcher.sendCode(code, event, event.getTarget())); + event.deferReply(true).queue(_ -> FormatCodeDispatcher.sendCode(code, event, event.getTarget())); } } diff --git a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/LinkedMessages.java b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/LinkedMessages.java new file mode 100644 index 000000000..54d0e8e63 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/LinkedMessages.java @@ -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> onResolved, Runnable onError) { + if (total <= 1) { + verify(List.of(triggerMessage), total, onResolved, onError); + return; + } + triggerMessage.getChannel().getHistoryBefore(triggerMessage.getIdLong(), total - 1).queue(history -> { + List 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> onResolved, Runnable onError) { + anchorMessage.getChannel().getHistoryAfter(anchorMessage.getIdLong(), total).queue(history -> + verify(history.getRetrievedHistory(), total, onResolved, onError)); + } + + private static void verify(List messages, int total, Consumer> onResolved, Runnable onError) { + List 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 onlyOwn(List messages) { + return messages.stream() + .filter(message -> message.getAuthor().getIdLong() == message.getJDA().getSelfUser().getIdLong()) + .toList(); + } +} diff --git a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java index 41c057b3d..b91f2cb10 100644 --- a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java +++ b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java @@ -116,7 +116,7 @@ private boolean canDeleteUsingButton(Member member, String[] componentId) { } public static Button createDeleteButton(long senderId) { - return Button.secondary(createDeleteInteractionId(senderId), "\uD83D\uDDD1️"); + return Button.secondary(createDeleteInteractionId(senderId), "\uD83D\uDDD1\uFE0F"); } public static String createDeleteInteractionId(long senderId) {