diff --git a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/Code.java b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/Code.java new file mode 100644 index 000000000..a894649e9 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/Code.java @@ -0,0 +1,78 @@ +package net.discordjug.javabot.systems.user_commands.format_code; + +import java.util.ArrayList; +import java.util.List; + +/** + * Holds a piece of code and its {@link Language}, and turns it into + * Discord-friendly representations that respect Discord's 2000-character limit. + */ +public class Code { + + /** + * Maximum characters per chunk. Discord's hard limit per message is 2000; + * the remaining headroom covers the surrounding ```language fences. + */ + private static final int MAX_SIZE = 1980; + + private final Language language; + private final String content; + + /** + * Creates a code block for the given language and content. + * + * @param language the language the code is written in, used for syntax highlighting + * @param content the raw, already-sanitized code to format + */ + public Code(Language language, String content) { + this.language = language; + this.content = content; + } + + public String getContent() { + return content; + } + + public Language getLanguage() { + return language; + } + + /** + * Splits {@link #content} into pieces that each fit within {@link #MAX_SIZE}, + * breaking on newlines where possible so lines are not cut in half. + * + * @return the content split into chunks that each fit within the limit + */ + private List toDiscordChunks() { + List chunks = new ArrayList<>(); + String remaining = content; + + while (remaining.length() > MAX_SIZE) { + int split = remaining.lastIndexOf('\n', MAX_SIZE); + if (split <= 0) { + // No newline in range (or only at the very start) -> hard cut, + // guaranteeing progress so this can never infinite-loop. + chunks.add(remaining.substring(0, MAX_SIZE)); + remaining = remaining.substring(MAX_SIZE); + } else { + chunks.add(remaining.substring(0, split)); + remaining = remaining.substring(split + 1); // +1 consumes the '\n' + } + } + chunks.add(remaining); + return chunks; + } + + /** + * Splits the content into chunks that each fit within Discord's character limit and wraps + * every chunk in a language-tagged code block. + * + * @return the formatted code-block messages, one per Discord message + */ + public List toDiscordMessages() { + return toDiscordChunks() + .stream() + .map(chunk -> String.format("```%s\n%s\n```", language.getDiscordName(), chunk)) + .toList(); + } +} 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 d544c7d63..ca92222fc 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 @@ -1,16 +1,13 @@ package net.discordjug.javabot.systems.user_commands.format_code; - import net.discordjug.javabot.util.IndentationHelper; import net.discordjug.javabot.util.StringUtils; import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; import net.dv8tion.jda.api.interactions.InteractionContextType; import net.dv8tion.jda.api.interactions.commands.build.Commands; - import org.jetbrains.annotations.NotNull; import xyz.dynxsty.dih4jda.interactions.commands.application.ContextCommand; -import java.util.List; /** *

This class represents the "Format and Indent Code" Message Context command.

@@ -27,9 +24,12 @@ public FormatAndIndentCodeMessageContext() { @Override public void execute(@NotNull MessageContextInteractionEvent event) { - event.replyFormat("```java\n%s\n```", IndentationHelper.formatIndentation(StringUtils.standardSanitizer().compute(event.getTarget().getContentRaw()), IndentationHelper.IndentationType.TABS)) - .setAllowedMentions(List.of()) - .setComponents(FormatCodeCommand.buildActionRow(event.getTarget(), event.getUser().getIdLong())) - .queue(); + String indented = IndentationHelper.formatIndentation( + StringUtils.standardSanitizer().compute(event.getTarget().getContentRaw()), + IndentationHelper.IndentationType.TABS); + + Code code = new Code(Language.JAVA, indented); + + 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 9f88bc1b6..45fb674e2 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 @@ -2,8 +2,6 @@ import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; import net.discordjug.javabot.util.*; -import net.dv8tion.jda.api.components.actionrow.ActionRow; -import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.InteractionContextType; @@ -11,11 +9,8 @@ import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.interactions.commands.build.OptionData; -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.NotNull; -import java.util.Collections; -import java.util.List; +import org.jetbrains.annotations.NotNull; /** *

This class represents the /format-code command.

@@ -29,25 +24,7 @@ public FormatCodeCommand() { .setContexts(InteractionContextType.GUILD) .addOptions( new OptionData(OptionType.STRING, "message-id", "Message to be formatted, last message used if left blank.", false), - new OptionData(OptionType.STRING, "format", "The language used to format the code, defaults to Java if left blank.", false) - .addChoice("C", "c") - .addChoice("C#", "csharp") - .addChoice("C++", "cpp") - .addChoice("CSS", "css") - .addChoice("D", "d") - .addChoice("Go", "go") - .addChoice("HTML", "html") - .addChoice("Java", "java") - .addChoice("JavaScript", "js") - .addChoice("Kotlin", "kotlin") - .addChoice("PHP", "php") - .addChoice("Python", "python") - .addChoice("Ruby", "ruby") - .addChoice("Rust", "rust") - .addChoice("SQL", "sql") - .addChoice("Swift", "swift") - .addChoice("TypeScript", "typescript") - .addChoice("XML", "xml"), + formatOption(), new OptionData(OptionType.STRING,"auto-indent","The type of indentation applied to the message, does not automatically indent if left blank.",false) .addChoice("Four Spaces","FOUR_SPACES") .addChoice("Two Spaces","TWO_SPACES") @@ -56,47 +33,62 @@ public FormatCodeCommand() { ); } - @Contract("_ -> new") - static @NotNull ActionRow buildActionRow(@NotNull Message target, long requesterId) { - return ActionRow.of(InteractionUtils.createDeleteButton(requesterId), - Button.link(target.getJumpUrl(), "View Original")); + /** + * Builds the {@code format} option, generating one choice per {@link Language} (excluding + * {@link Language#UNKNOWN}) so the enum stays the single source of truth for the language list. + * + * @return the configured {@code format} option + */ + private static OptionData formatOption() { + OptionData option = new OptionData(OptionType.STRING, "format", "The language used to format the code, defaults to Java if left blank.", false); + for (Language language : Language.values()) { + if (language != Language.UNKNOWN) { // UNKNOWN is the fallback, not a real choice + option.addChoice(language.getDisplayName(), language.name()); // value = enum name so valueOf() reverses it + } + } + return option; } @Override public void execute(@NotNull SlashCommandInteractionEvent event) { OptionMapping idOption = event.getOption("message-id"); - String format = event.getOption("format", "java", OptionMapping::getAsString); + Language language = event.getOption("format", Language.JAVA, o -> Language.fromString(o.getAsString())); String indentation = event.getOption("auto-indent","NULL",OptionMapping::getAsString); - event.deferReply().queue(); + if (idOption == null) { event.getChannel().getHistory() .retrievePast(10) .queue(messages -> { - Collections.reverse(messages); Message target = messages.stream() .filter(m -> !m.getAuthor().isBot()).findFirst() .orElse(null); if (target != null) { - event.getHook().sendMessageFormat("```%s\n%s\n```", format, IndentationHelper.formatIndentation(StringUtils.standardSanitizer().compute(target.getContentRaw()),IndentationHelper.IndentationType.valueOf(indentation))) - .setAllowedMentions(List.of()) - .setComponents(buildActionRow(target, event.getUser().getIdLong())) - .queue(); + sendFormattedCode(event, target, language, indentation); } else { - Responses.error(event.getHook(), "Could not find message; please specify a message id.").queue(); + Responses.errorWithTitle(event, "Message Not Found", "No recent user message could be found. Please specify a message ID.") + .queue(); } }); } else { if (Checks.isInvalidLongInput(idOption)) { - Responses.error(event.getHook(), "Please provide a valid message id!").queue(); + Responses.errorWithTitle(event, "Invalid Message ID", "Please provide a valid Discord message ID.") + .queue(); return; } long messageId = idOption.getAsLong(); event.getChannel().retrieveMessageById(messageId).queue( - target -> event.getHook().sendMessageFormat("```%s\n%s\n```", format, IndentationHelper.formatIndentation(StringUtils.standardSanitizer().compute(target.getContentRaw()), IndentationHelper.IndentationType.valueOf(indentation))) - .setAllowedMentions(List.of()) - .setComponents(buildActionRow(target, event.getUser().getIdLong())) - .queue(), - e -> Responses.error(event.getHook(), "Could not retrieve message with id: " + messageId).queue()); + target -> sendFormattedCode(event, target, language, indentation), + error -> Responses.errorWithTitle(event, "Message Not Found", "Could not retrieve the message with ID `" + messageId + "`. Make sure the message exists and is accessible.").queue()); } } + + private void sendFormattedCode(SlashCommandInteractionEvent event, Message target, Language language, String indentation) { + String content = IndentationHelper.formatIndentation( + StringUtils.standardSanitizer().compute(target.getContentRaw()), + IndentationHelper.IndentationType.valueOf(indentation)); + + Code code = new Code(language,content); + + FormatCodeDispatcher.sendCode(code, event, target); + } } 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 new file mode 100644 index 000000000..38fbcb6a6 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeDispatcher.java @@ -0,0 +1,99 @@ +package net.discordjug.javabot.systems.user_commands.format_code; + +import net.discordjug.javabot.util.*; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.interactions.commands.CommandInteraction; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +import javax.annotation.Nonnull; +import java.util.List; + +/** + * Shared sending logic for the code-formatting commands. Replies with the full code as a + * downloadable file, then posts it as one or more ordered code-block messages that each respect + * Discord's 2000-character limit. + */ +class FormatCodeDispatcher { + + /** + * The maximum number of code-block messages to post inline; longer code results as an ERROR. + */ + private static final int MAX_MESSAGES = 5; + + /** + * Acknowledges the interaction by replying with the full code as a file, then posts the code as + * ordered code-block messages. Replies with an error instead if there is nothing to format. + * + * @param code the code to send + * @param event the interaction to reply to + * @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){ + if (code.getContent().isBlank()) { + Responses.errorWithTitle(event, "404 Code not found","There is no code to format in that message.").queue(); + return; + } + + List messages = code.toDiscordMessages(); + + 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." + ).queue(); + return; + } + + Responses.success(event, "Success", "The formatted message is being sent to this channel.") + .queue(success -> sendChunksInOrder(channel, messages, 0, target,event)); + } + + + private static void sendChunksInOrder(MessageChannel channel, List messages, int index, Message target, @Nonnull CommandInteraction event) { + if (index >= messages.size()) { + return; + } + var action = channel.sendMessage(messages.get(index)) + .setAllowedMentions(List.of()); + + if (index == messages.size() - 1) { + if(index == 0){ + action.setComponents(buildActionRow(target, event.getUser().getIdLong())); + } else { + action.setComponents(buildActionRow(target)); + } + } + + action.queue(success -> + 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), + Button.link(target.getJumpUrl(), "View Original")); + } +} 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 e0777670d..07afbe0d9 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 @@ -1,14 +1,12 @@ package net.discordjug.javabot.systems.user_commands.format_code; -import xyz.dynxsty.dih4jda.interactions.commands.application.ContextCommand; import net.discordjug.javabot.util.StringUtils; +import xyz.dynxsty.dih4jda.interactions.commands.application.ContextCommand; import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; import net.dv8tion.jda.api.interactions.InteractionContextType; import net.dv8tion.jda.api.interactions.commands.build.Commands; - import org.jetbrains.annotations.NotNull; -import java.util.List; /** *

This class represents the "Format Code" Message Context command.

@@ -25,9 +23,10 @@ public FormatCodeMessageContext() { @Override public void execute(@NotNull MessageContextInteractionEvent event) { - event.replyFormat("```java\n%s\n```", StringUtils.standardSanitizer().compute(event.getTarget().getContentRaw())) - .setAllowedMentions(List.of()) - .setComponents(FormatCodeCommand.buildActionRow(event.getTarget(), event.getUser().getIdLong())) - .queue(); + String content = StringUtils.standardSanitizer().compute(event.getTarget().getContentRaw()); + + Code code = new Code(Language.JAVA, content); + + FormatCodeDispatcher.sendCode(code, event, event.getTarget()); } } diff --git a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/Language.java b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/Language.java new file mode 100644 index 000000000..5a5d71067 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/Language.java @@ -0,0 +1,125 @@ +package net.discordjug.javabot.systems.user_commands.format_code; + +/** + * The programming languages supported by the code-formatting commands. Each constant maps a + * human-readable {@link #displayName} to the {@link #discordName} tag Discord uses for + * syntax highlighting inside code blocks. + */ +public enum Language { + /** + * The C programming language. + */ + C("C", "c"), + /** + * The C++ programming language. + */ + CPP("C++", "cpp"), + /** + * The C# programming language. + */ + CSHARP("C#", "csharp"), + /** + * Cascading Style Sheets. + */ + CSS("CSS", "css"), + /** + * The D programming language. + */ + D("D", "d"), + /** + * The Go programming language. + */ + GO("Go", "go"), + /** + * HyperText Markup Language. + */ + HTML("HTML", "html"), + /** + * The Java programming language. + */ + JAVA("Java", "java"), + /** + * The JavaScript programming language. + */ + JAVASCRIPT("JavaScript", "javascript"), + /** + * The Kotlin programming language. + */ + KOTLIN("Kotlin", "kotlin"), + /** + * The PHP programming language. + */ + PHP("PHP", "php"), + /** + * The Python programming language. + */ + PYTHON("Python", "python"), + /** + * The Ruby programming language. + */ + RUBY("Ruby", "ruby"), + /** + * The Rust programming language. + */ + RUST("Rust", "rust"), + /** + * Structured Query Language. + */ + SQL("SQL", "sql"), + /** + * The Swift programming language. + */ + SWIFT("Swift", "swift"), + /** + * The TypeScript programming language. + */ + TYPESCRIPT("TypeScript", "typescript"), + /** + * Extensible Markup Language. + */ + XML("XML", "xml"), + /** + * A structured data format. + */ + JSON("JSON", "json"), + /** + * Simple plain text. + */ + TXT("TXT", "txt"), + /** + * Fallback used when the language is unrecognised. + */ + UNKNOWN("Unknown", ""); + + private final String displayName; + private final String discordName; + + Language(String displayName, String discordName) { + this.displayName = displayName; + this.discordName = discordName; + } + + public String getDisplayName() { + return displayName; + } + + public String getDiscordName() { + return discordName; + } + + + /** + * Resolves a language from a string (e.g. the value of the /format-code "format" + * option) by matching its Discord code-fence name, falling back to {@link #UNKNOWN}. + * + * @param name the code-fence name to look up (case-insensitive) + * @return the matching language, or {@link #UNKNOWN} if none matches + */ + public static Language fromString(String name) { + try { + return Language.valueOf(name.toUpperCase()); + } catch (IllegalArgumentException e) { + return UNKNOWN; + } + } +} \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/util/Responses.java b/src/main/java/net/discordjug/javabot/util/Responses.java index b48de0085..93f1a579f 100644 --- a/src/main/java/net/discordjug/javabot/util/Responses.java +++ b/src/main/java/net/discordjug/javabot/util/Responses.java @@ -62,6 +62,11 @@ private Responses() { return reply(event, title, String.format(message, args), Type.ERROR.getColor(), true); } + @CheckReturnValue + public static @NotNull WebhookMessageCreateAction errorWithTitle(InteractionHook hook, String title, String message, Object... args) { + return reply(hook, title, String.format(message, args), Type.ERROR.getColor(), true); + } + @CheckReturnValue public static @NotNull WebhookMessageCreateAction error(InteractionHook hook, String message, Object... args) { return reply(hook, "An Error Occurred", String.format(message, args), Type.ERROR.getColor(), true); diff --git a/src/test/java/net/discordjug/javabot/util/IndentationHelperTest.java b/src/test/java/net/discordjug/javabot/util/IndentationHelperTest.java index bfbbd351f..30a993f4d 100644 --- a/src/test/java/net/discordjug/javabot/util/IndentationHelperTest.java +++ b/src/test/java/net/discordjug/javabot/util/IndentationHelperTest.java @@ -22,10 +22,15 @@ public void testFormatIndentation() throws IOException { formatted = StringResourceCache.load("/Formatted Strings.txt").split("----"); for (int i = 0, k = 0; i < unformatted.length; i++) { - assertEquals(formatted[k++], IndentationHelper.formatIndentation(unformatted[i], IndentationHelper.IndentationType.FOUR_SPACES), "Method failed to format a text with four spaces correctly"); - assertEquals(formatted[k++], IndentationHelper.formatIndentation(unformatted[i], IndentationHelper.IndentationType.TWO_SPACES), "Method failed to format a text with two spaces correctly"); - assertEquals(formatted[k++], IndentationHelper.formatIndentation(unformatted[i], IndentationHelper.IndentationType.TABS), "Method failed to format a text with tabs correctly."); - assertEquals(formatted[k++], IndentationHelper.formatIndentation(unformatted[i], IndentationHelper.IndentationType.NULL), "Method returned a String not matching the input"); + assertEquals(normalizeLineEndings(formatted[k++]), normalizeLineEndings(IndentationHelper.formatIndentation(unformatted[i], IndentationHelper.IndentationType.FOUR_SPACES)), "Method failed to format a text with four spaces correctly"); + assertEquals(normalizeLineEndings(formatted[k++]), normalizeLineEndings(IndentationHelper.formatIndentation(unformatted[i], IndentationHelper.IndentationType.TWO_SPACES)), "Method failed to format a text with two spaces correctly"); + assertEquals(normalizeLineEndings(formatted[k++]), normalizeLineEndings(IndentationHelper.formatIndentation(unformatted[i], IndentationHelper.IndentationType.TABS)), "Method failed to format a text with tabs correctly."); + assertEquals(normalizeLineEndings(formatted[k++]), normalizeLineEndings(IndentationHelper.formatIndentation(unformatted[i], IndentationHelper.IndentationType.NULL)), "Method returned a String not matching the input"); } } + + private static String normalizeLineEndings(String text) { + return text.replace("\r\n", "\n") + .replace("\r", "\n"); + } }