-
Notifications
You must be signed in to change notification settings - Fork 25
Fix formatting of code longer than Discord's message limit #549
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
7679c4b
d2f25e6
1cdeef7
50e0594
f5c3037
6e47314
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,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<String> toDiscordChunks() { | ||
| List<String> 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<String> toDiscordMessages() { | ||
| return toDiscordChunks() | ||
| .stream() | ||
| .map(chunk -> String.format("```%s\n%s\n```", language.getDiscordName(), chunk)) | ||
| .toList(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /** | ||
| * <h3>This class represents the "Format and Indent Code" Message Context command.</h3> | ||
|
|
@@ -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())) | ||
|
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. The action row including the buttons for deleting and the URL for jumping back are no longer present because you removed them here. Please add them to all messages.
Author
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. There is a reason for that. The buttons only affect the message they are attached to, so adding them to each message would only allow deletion of that specific message. Adding buttons to every message would also introduce a visual break inside the code block, which could hurt readability, as shown in the screenshots. If you'd prefer the buttons to appear only on the last message while still affecting the entire code block, I can look into implementing that. I'm not familiar with that approach yet, but I'm happy to investigate it.
Here is with buttons:
as you can see it's hard to read through indentation.
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 it is a single message, both buttons should definitely be there. |
||
| .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()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 net.dv8tion.jda.api.utils.FileUpload; | ||
| import org.jetbrains.annotations.Contract; | ||
| import org.jetbrains.annotations.NotNull; | ||
|
|
||
| import javax.annotation.Nonnull; | ||
| import java.nio.charset.StandardCharsets; | ||
| 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 { | ||
|
|
||
| /** | ||
| * 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.error(event, "There is no code to format in that message.").queue(); | ||
| return; | ||
| } | ||
|
|
||
| List<String> 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( | ||
| code.getContent().getBytes(StandardCharsets.UTF_8), | ||
| "code." + code.getLanguage().getDiscordName() | ||
| ); | ||
|
|
||
| MessageChannel channel = target.getChannel(); | ||
|
|
||
| event.replyFiles(file) | ||
|
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. What is the reason to reply with a file vs just using At least if it's only a single chunk, there shouldn't be any file (but I think it's also better without a file with multiple messages). Make sure to update the Javadoc accordingly.
Author
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 tried dropping the file and replying with the first chunk as suggested, but it hits two problems: Sending the chunks as plain messages with no reply leaves the interaction unacknowledged → "This application did not respond." Replying with the file avoids both: it acknowledges the interaction, and since the reply is a file (not a chunk) the code-block messages stay uniform with no break — plus it's a clean, copyable full version that reads nicely on desktop. The file seemed like the most useful thing to put there. So, my preference would actually be to keep the file for both the single- and multi-chunk cases — it gives a consistent experience either way. But if you'd rather it was gone, I'm happy to remove it for the single-chunk case (just reply with the one block directly), or if you have a different approach in mind, I'll gladly go with that. |
||
| .setAllowedMentions(List.of()) | ||
| .setComponents(buildActionRow(target, event.getUser().getIdLong())) | ||
| .queue(success -> sendChunksInOrder(channel, messages, 0, target,event)); | ||
| } | ||
|
|
||
|
|
||
| private static void sendChunksInOrder(MessageChannel channel, List<String> messages, int index, Message target, @Nonnull CommandInteraction event) { | ||
| if (index >= messages.size()) { | ||
| 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. If you remove the file upload, you can do something like this: String content = messages.get(index);
MessageRequest<?> action = index == 0 ? event.reply(content) : channel.sendMessage(content);
action.setAllowedMentions(List.of()); |
||
| .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")); | ||
| } | ||
| } | ||



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
getContent()andsetLanguageare unused. If so, please remove them.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.
getContent()is still present.Uh oh!
There was an error while loading. Please reload this page.
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.
getContent()in now being used inFormatCodeDispatcherat line 34 and 44.