From 7679c4bbddd8c7e8f367b3340373b06a3f6d51f7 Mon Sep 17 00:00:00 2001 From: Neil Tomar Date: Sat, 20 Jun 2026 19:25:00 +0530 Subject: [PATCH] fix formatting of code longer than Discord's message limit --- .../user_commands/format_code/Code.java | 69 +++++++++++++++++++ .../FormatAndIndentCodeMessageContext.java | 53 ++++++++++++-- .../format_code/FormatCodeCommand.java | 43 +++++++++--- .../format_code/FormatCodeMessageContext.java | 63 +++++++++++++++-- .../user_commands/format_code/Language.java | 49 +++++++++++++ 5 files changed, 258 insertions(+), 19 deletions(-) create mode 100644 src/main/java/net/discordjug/javabot/systems/user_commands/format_code/Code.java create mode 100644 src/main/java/net/discordjug/javabot/systems/user_commands/format_code/Language.java 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..9982b1de9 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/Code.java @@ -0,0 +1,69 @@ +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 Language language; + private final String content; + + public Code(Language language, String content) { + this.language = language; + this.content = content; + } + + public String getContent() { + return content; + } + + public Language getLanguage() { + return language; + } + + public void setLanguage(Language language) { + this.language = 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. + */ + public 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; + } + + /** Wraps each chunk in a language-tagged Discord code block. */ + 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..9cfd906a2 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,15 +1,19 @@ package net.discordjug.javabot.systems.user_commands.format_code; - +import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.IndentationHelper; +import net.discordjug.javabot.util.Responses; import net.discordjug.javabot.util.StringUtils; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; 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 net.dv8tion.jda.api.utils.FileUpload; import org.jetbrains.annotations.NotNull; import xyz.dynxsty.dih4jda.interactions.commands.application.ContextCommand; +import javax.annotation.Nonnull; +import java.nio.charset.StandardCharsets; import java.util.List; /** @@ -27,9 +31,48 @@ 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)) + String indented = IndentationHelper.formatIndentation( + StringUtils.standardSanitizer().compute(event.getTarget().getContentRaw()), + IndentationHelper.IndentationType.TABS); + + if (indented.isBlank()) { + event.reply("There is no code to format in that message.").setEphemeral(true).queue(); + return; + } + + Code code = new Code(Language.JAVA, indented); + List messages = code.toDiscordMessages(); + + // Reply with the full code as a file (acknowledges the interaction), then post + // the readable code-block chunks in order. + FileUpload file = FileUpload.fromData(indented.getBytes(StandardCharsets.UTF_8), + "code." + code.getLanguage().getDiscordName()); + MessageChannel channel = event.getChannel(); + event.replyFiles(file) + .setAllowedMentions(List.of()) + .queue( + success -> sendChunksInOrder(channel, messages, 0, event), + error -> { + ExceptionLogger.capture(error, getClass().getSimpleName()); + Responses.error(event.getHook(), "The message could not be converted into a formatted code block.") + .queue(); + } + ); + } + + private void sendChunksInOrder(MessageChannel channel, List messages, int index, @Nonnull MessageContextInteractionEvent event) { + if (index >= messages.size()) { + return; + } + channel.sendMessage(messages.get(index)) .setAllowedMentions(List.of()) - .setComponents(FormatCodeCommand.buildActionRow(event.getTarget(), event.getUser().getIdLong())) - .queue(); + .queue( + success -> sendChunksInOrder(channel, messages, index + 1, event), + error -> { + ExceptionLogger.capture(error, getClass().getSimpleName()); + Responses.error(event.getHook(), "The message could not be converted into a formatted code block.") + .queue(); + } + ); } } 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..fdaf6b49a 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 @@ -1,5 +1,6 @@ package net.discordjug.javabot.systems.user_commands.format_code; +import net.dv8tion.jda.api.interactions.InteractionHook; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; import net.discordjug.javabot.util.*; import net.dv8tion.jda.api.components.actionrow.ActionRow; @@ -77,10 +78,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { .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, format, indentation); } else { Responses.error(event.getHook(), "Could not find message; please specify a message id.").queue(); } @@ -92,11 +90,38 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { } 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(), + target -> sendFormattedCode(event, target, format, indentation), e -> Responses.error(event.getHook(), "Could not retrieve message with id: " + messageId).queue()); } } -} + + private void sendFormattedCode(SlashCommandInteractionEvent event, Message target, String format, String indentation) { + String content = IndentationHelper.formatIndentation( + StringUtils.standardSanitizer().compute(target.getContentRaw()), + IndentationHelper.IndentationType.valueOf(indentation)); + + if (content.isBlank()) { + Responses.error(event.getHook(), "There is no code to format in that message.").queue(); + return; + } + + Code code = new Code(Language.fromString(format), content); + sendChunksInOrder(event.getHook(), code.toDiscordMessages(), 0); + } + + private void sendChunksInOrder(InteractionHook hook, List messages, int index) { + if (index >= messages.size()) { + return; + } + var action = hook.sendMessage(messages.get(index)).setAllowedMentions(List.of()); + + action.queue( + success -> sendChunksInOrder(hook, messages, index + 1), + error -> { + ExceptionLogger.capture(error, getClass().getSimpleName()); + Responses.error(hook, "The message could not be converted into a formatted code block.") + .queue(); + } + ); + } +} \ No newline at end of file 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..1f1736884 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,13 +1,18 @@ package net.discordjug.javabot.systems.user_commands.format_code; -import xyz.dynxsty.dih4jda.interactions.commands.application.ContextCommand; +import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.Responses; import net.discordjug.javabot.util.StringUtils; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.utils.FileUpload; +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 javax.annotation.Nonnull; +import java.nio.charset.StandardCharsets; import java.util.List; /** @@ -25,9 +30,57 @@ public FormatCodeMessageContext() { @Override public void execute(@NotNull MessageContextInteractionEvent event) { - event.replyFormat("```java\n%s\n```", StringUtils.standardSanitizer().compute(event.getTarget().getContentRaw())) + String sanitized = StringUtils.standardSanitizer().compute(event.getTarget().getContentRaw()); + + if (sanitized.isBlank()) { + event.reply("There is no code to format in that message.") + .setEphemeral(true) + .queue(); + return; + } + + // Currently we always format as Java. A language dropdown will be added in the future. + Code code = new Code(Language.JAVA, sanitized); + List messages = code.toDiscordMessages(); + + // The reply both acknowledges the interaction and hands users the full, + // un-split code as a downloadable file (so chunking never loses anything). + FileUpload file = FileUpload.fromData( + sanitized.getBytes(StandardCharsets.UTF_8), + "code." + code.getLanguage().getDiscordName() + ); + + MessageChannel channel = event.getChannel(); + + event.replyFiles(file) .setAllowedMentions(List.of()) - .setComponents(FormatCodeCommand.buildActionRow(event.getTarget(), event.getUser().getIdLong())) - .queue(); + .queue( + success -> sendChunksInOrder(channel, messages, 0, event), + error -> { + ExceptionLogger.capture(error, getClass().getSimpleName()); + Responses.error(event.getHook(), "The message could not be converted into a formatted code block.") + .queue(); + } + ); + } + + /** + * Sends the code-block chunks one at a time — each in the success callback of + * the previous — so Discord keeps them in order. + */ + private void sendChunksInOrder(MessageChannel channel, List messages, int index,@Nonnull MessageContextInteractionEvent event) { + if (index >= messages.size()) { + return; + } + channel.sendMessage(messages.get(index)) + .setAllowedMentions(List.of()) // never ping people from pasted code + .queue( + success -> sendChunksInOrder(channel, messages, index + 1, event), + error -> { + ExceptionLogger.capture(error, getClass().getSimpleName()); + Responses.error(event.getHook(), "The message could not be converted into a formatted code block.") + .queue(); + } + ); } } 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..ea2265fc9 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/Language.java @@ -0,0 +1,49 @@ +package net.discordjug.javabot.systems.user_commands.format_code; + +public enum Language { + C("c"), + CPP("cpp"), + CSHARP("csharp"), + CSS("css"), + D("d"), + GO("go"), + HTML("html"), + JAVA("java"), + JAVASCRIPT("js"), + KOTLIN("kotlin"), + PHP("php"), + PYTHON("python"), + RUBY("ruby"), + RUST("rust"), + SQL("sql"), + SWIFT("swift"), + TYPESCRIPT("typescript"), + XML("xml"), + UNKNOWN("txt"); + + private final String discordName; + + Language(String discordName) { + this.discordName = discordName; + } + + 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) { + for (Language language : values()) { + if (language.discordName.equalsIgnoreCase(name)) { + return language; + } + } + return UNKNOWN; + } +} \ No newline at end of file