Skip to content

Commit cf9421d

Browse files
authored
Merge pull request #550 from Neil-Tomar/feat/format-code-message-linking
feat: delete-all button and language dropdown for multi-message code blocks
2 parents 9a03a81 + 16fb9d0 commit cf9421d

7 files changed

Lines changed: 231 additions & 35 deletions

File tree

src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatAndIndentCodeMessageContext.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,6 @@ public void execute(@NotNull MessageContextInteractionEvent event) {
3030

3131
Code code = new Code(Language.JAVA, indented);
3232

33-
event.deferReply().queue(_ -> FormatCodeDispatcher.sendCode(code, event, event.getTarget()));
33+
event.deferReply(true).queue(_ -> FormatCodeDispatcher.sendCode(code, event, event.getTarget()));
3434
}
3535
}

src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeCommand.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) {
5656
String indentation = event.getOption("auto-indent","NULL",OptionMapping::getAsString);
5757

5858
if (idOption == null) {
59-
event.deferReply().queue(_ -> {
59+
event.deferReply(true).queue(_ -> {
6060
event.getChannel().getHistory()
6161
.retrievePast(10)
6262
.queue(messages -> {
@@ -78,7 +78,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) {
7878
return;
7979
}
8080
long messageId = idOption.getAsLong();
81-
event.deferReply().queue(_ -> {
81+
event.deferReply(true).queue(_ -> {
8282
event.getChannel().retrieveMessageById(messageId).queue(
8383
target -> sendFormattedCode(event, target, language, indentation),
8484
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());

src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeDispatcher.java

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import net.dv8tion.jda.api.entities.Message;
77
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
88
import net.dv8tion.jda.api.interactions.commands.CommandInteraction;
9+
import net.dv8tion.jda.api.requests.restaction.MessageCreateAction;
910
import org.jetbrains.annotations.Contract;
1011
import org.jetbrains.annotations.NotNull;
1112

@@ -33,9 +34,9 @@ class FormatCodeDispatcher {
3334
* @param target the original message the code came from, used for the channel and the
3435
* "View Original" / delete buttons
3536
*/
36-
public static void sendCode(Code code, @Nonnull CommandInteraction event, Message target){
37+
public static void sendCode(Code code, @Nonnull CommandInteraction event, Message target) {
3738
if (code.getContent().isBlank()) {
38-
Responses.errorWithTitle(event.getHook(), "404 Code not found","There is no code to format in that message.").queue();
39+
Responses.errorWithTitle(event.getHook(), "404 Code not found", "There is no code to format in that message.").queue();
3940
return;
4041
}
4142

@@ -44,56 +45,50 @@ public static void sendCode(Code code, @Nonnull CommandInteraction event, Messag
4445
MessageChannel channel = target.getChannel();
4546

4647
if (messages.size() > MAX_MESSAGES) {
47-
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."
48+
Responses.errorWithTitle(event.getHook(), "Code out of Bounds", "The formatted result is too large to send. Please provide a smaller code snippet or use a paste service instead."
4849
).queue();
4950
return;
5051
}
5152

52-
Responses.success(event.getHook(), "Success", "The formatted message is being sent to this channel.")
53-
.queue(success -> sendChunksInOrder(channel, messages, 0, target,event));
53+
sendChunksInOrder(channel, messages, 0, target, event, 0L);
5454
}
5555

5656

57-
private static void sendChunksInOrder(MessageChannel channel, List<String> messages, int index, Message target, @Nonnull CommandInteraction event) {
57+
private static void sendChunksInOrder(MessageChannel channel, List<String> messages, int index, Message target, @Nonnull CommandInteraction event, long firstMessageID) {
5858
if (index >= messages.size()) {
59+
event.getHook().deleteOriginal().queue(_ ->
60+
event.getHook().sendMessage("Your message has been sent. If needed, you can change the language used for syntax highlighting below.")
61+
.setEphemeral(true)
62+
.setComponents(FormatCodeInteractionHandler.buildLanguageMenu(event.getUser().getIdLong(), messages.size(), firstMessageID))
63+
.queue()
64+
);
5965
return;
6066
}
61-
var action = channel.sendMessage(messages.get(index))
67+
MessageCreateAction action = channel.sendMessage(messages.get(index))
6268
.setAllowedMentions(List.of());
6369

6470
if (index == messages.size() - 1) {
65-
if(index == 0){
66-
action.setComponents(buildActionRow(target, event.getUser().getIdLong()));
71+
if(messages.size() >1) {
72+
action.setComponents(buildMultiMessageActionRow(target, event.getUser().getIdLong(), messages.size(), firstMessageID));
6773
} else {
68-
action.setComponents(buildActionRow(target));
74+
action.setComponents(buildActionRow(target,event.getUser().getIdLong()));
6975
}
7076
}
7177

72-
action.queue(success ->
73-
sendChunksInOrder(channel, messages, index + 1, target, event));
78+
action.queue(sent -> sendChunksInOrder(channel, messages, index + 1, target, event, index == 0 ? sent.getIdLong() : firstMessageID));
7479
}
7580

76-
/**
77-
* Builds the action row placed on the last code-block message.
78-
*
79-
* @param target the original message linked by the "View Original" button
80-
* @return an action row containing the "View Original" link button
81-
*/
82-
@Contract("_ -> new")
83-
static @NotNull ActionRow buildActionRow(@NotNull Message target) {
84-
return ActionRow.of(Button.link(target.getJumpUrl(), "View Original"));
81+
@Contract("_,_,_,_ -> new")
82+
static @NotNull ActionRow buildMultiMessageActionRow(@NotNull Message target, long requesterId, int total, long firstMessageID) {
83+
return ActionRow.of(
84+
FormatCodeInteractionHandler.createDeleteAllButton(requesterId, total, firstMessageID ),
85+
Button.link(target.getJumpUrl(), "View Original"));
8586
}
8687

87-
/**
88-
* Builds the action row placed on the file-upload message: a delete button and a "View Original" link.
89-
*
90-
* @param target the original message linked by the "View Original" button
91-
* @param requesterId the id of the user permitted to delete the message
92-
* @return an action row containing the delete and "View Original" buttons
93-
*/
94-
@Contract("_,_ -> new")
88+
@Contract("_,_-> new")
9589
static @NotNull ActionRow buildActionRow(@NotNull Message target, long requesterId) {
96-
return ActionRow.of(InteractionUtils.createDeleteButton(requesterId),
90+
return ActionRow.of(
91+
InteractionUtils.createDeleteButton(requesterId),
9792
Button.link(target.getJumpUrl(), "View Original"));
9893
}
9994
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package net.discordjug.javabot.systems.user_commands.format_code;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import net.discordjug.javabot.annotations.AutoDetectableComponentHandler;
5+
import net.discordjug.javabot.data.config.BotConfig;
6+
import net.discordjug.javabot.util.Checks;
7+
import net.discordjug.javabot.util.Responses;
8+
import net.dv8tion.jda.api.components.actionrow.ActionRow;
9+
import net.dv8tion.jda.api.components.buttons.Button;
10+
import net.dv8tion.jda.api.components.selections.SelectOption;
11+
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
12+
import net.dv8tion.jda.api.entities.Member;
13+
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
14+
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
15+
import net.dv8tion.jda.api.interactions.components.ComponentInteraction;
16+
import org.jspecify.annotations.NonNull;
17+
import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler;
18+
import xyz.dynxsty.dih4jda.interactions.components.StringSelectMenuHandler;
19+
import xyz.dynxsty.dih4jda.util.ComponentIdBuilder;
20+
21+
import java.util.Arrays;
22+
import java.util.List;
23+
24+
/**
25+
* Handles the interactive components on formatted code blocks: the delete-all button and the
26+
* language-selection dropdown. Both act on every message of a (possibly multi-message) block,
27+
* which is resolved via {@link LinkedMessages}.
28+
*/
29+
@AutoDetectableComponentHandler(FormatCodeInteractionHandler.COMPONENT_ID)
30+
@RequiredArgsConstructor
31+
public class FormatCodeInteractionHandler implements ButtonHandler, StringSelectMenuHandler {
32+
static final String COMPONENT_ID = "format-code";
33+
private final BotConfig botConfig;
34+
35+
/**
36+
* Builds the delete-all button placed on the last message of a code block.
37+
*
38+
* @param requesterID the id of the user allowed to delete the block
39+
* @param total the number of messages making up the block
40+
* @param firstMessageID the id of first message to check update code block
41+
* @return the delete-all button
42+
*/
43+
public static Button createDeleteAllButton(long requesterID, int total, long firstMessageID) {
44+
return Button.secondary(ComponentIdBuilder.build(COMPONENT_ID, requesterID, total,firstMessageID), "\uD83D\uDDD1\uFE0F");
45+
}
46+
47+
@Override
48+
public void handleButton(ButtonInteractionEvent event, @NonNull Button button) {
49+
if (!isValid(event)) {
50+
return;
51+
}
52+
String[] id = ComponentIdBuilder.split(event.getComponentId());
53+
54+
event.deferEdit().queue();
55+
56+
LinkedMessages.resolveBefore(event.getMessage(), Integer.parseInt(id[2]), true,
57+
messages -> {
58+
if (messages.getLast().getIdLong() == Long.parseLong(id[3])) {
59+
event.getChannel().purgeMessages(messages);
60+
} else {
61+
Responses.error(event.getHook(), "The code block could not be deleted. The messages may have been deleted.").queue();
62+
}
63+
}, () -> Responses.error(event.getHook(), "Could not delete the code block").queue());
64+
}
65+
66+
/**
67+
* Builds the language-selection dropdown row for a code block.
68+
*
69+
* @param requesterId the id of the user allowed to change the language
70+
* @param total the number of messages making up the block
71+
* @param firstMessageID the id of first message to check update code block
72+
* @return an action row containing the language dropdown
73+
*/
74+
public static ActionRow buildLanguageMenu(long requesterId, int total, long firstMessageID) {
75+
return ActionRow.of(languageMenu(ComponentIdBuilder.build(COMPONENT_ID, requesterId, total, firstMessageID)));
76+
}
77+
78+
@Override
79+
public void handleStringSelectMenu(@NonNull StringSelectInteractionEvent event, @NonNull List<String> values) {
80+
if (!isValid(event)) {
81+
return;
82+
}
83+
84+
String[] id = ComponentIdBuilder.split(event.getComponentId());
85+
Language language = Language.fromString(values.getFirst());
86+
87+
event.deferEdit().queue();
88+
89+
LinkedMessages.resolveBefore(event.getMessage(), Integer.parseInt(id[2]), false,
90+
messages -> {
91+
if (messages.getLast().getIdLong() == Long.parseLong(id[3])) {
92+
messages.forEach(message -> message.editMessage(withLanguage(message.getContentRaw(), language)).queue());
93+
} else {
94+
Responses.error(event.getHook(), "The code block could not be updated. The messages may have been deleted.").queue();
95+
}
96+
}, () -> Responses.error(event.getHook(), "Could not update the code block").queue());
97+
}
98+
99+
private static StringSelectMenu languageMenu(String customId) {
100+
return StringSelectMenu.create(customId)
101+
.setPlaceholder("Change language")
102+
.addOptions(Arrays.stream(Language.values())
103+
.filter(language -> language != Language.UNKNOWN)
104+
.map(language -> SelectOption.of(language.getDisplayName(), language.name()))
105+
.toList())
106+
.build();
107+
}
108+
109+
private boolean isValid(ComponentInteraction event) {
110+
String[] id = ComponentIdBuilder.split(event.getComponentId());
111+
long requesterId = Long.parseLong(id[1]);
112+
113+
Member member = event.getMember();
114+
if (member == null) {
115+
Responses.errorWithTitle(event, "Server Required", "This may only be used inside a server.").queue();
116+
return false;
117+
}
118+
if (member.getIdLong() != requesterId && !Checks.hasStaffRole(botConfig, member)) {
119+
Responses.errorWithTitle(event, "Access Denied", "You are not authorized to perform this action.").queue();
120+
return false;
121+
}
122+
123+
return true;
124+
}
125+
126+
/**
127+
* Re-wraps a code-block message in a different language by swapping the tag on its opening fence,
128+
* leaving the code itself untouched.
129+
*
130+
* @param content the raw message content, expected to start with a fenced code block
131+
* @param language the language to switch to
132+
* @return the message content with its opening fence set to {@code language}
133+
*/
134+
private static String withLanguage(String content, Language language) {
135+
int firstLineEnd = content.indexOf('\n');
136+
return firstLineEnd < 0
137+
? content
138+
: "```" + language.getDiscordName() + content.substring(firstLineEnd);
139+
}
140+
}

src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeMessageContext.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,6 @@ public void execute(@NotNull MessageContextInteractionEvent event) {
2727

2828
Code code = new Code(Language.JAVA, content);
2929

30-
event.deferReply().queue(_ -> FormatCodeDispatcher.sendCode(code, event, event.getTarget()));
30+
event.deferReply(true).queue(_ -> FormatCodeDispatcher.sendCode(code, event, event.getTarget()));
3131
}
3232
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package net.discordjug.javabot.systems.user_commands.format_code;
2+
3+
import net.dv8tion.jda.api.entities.Message;
4+
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
import java.util.function.Consumer;
8+
9+
10+
/**
11+
* Helper for acting on a block of related messages sent as a group — such as a piece of code split
12+
* across several Discord messages. Given one message of the block and the total count, it resolves
13+
* the whole block (only the bot's own messages) so a single interaction can delete or edit it.
14+
*/
15+
public class LinkedMessages {
16+
private LinkedMessages() {
17+
}
18+
19+
/**
20+
* Resolves a block of messages ending at {@code triggerMessage} and passes the
21+
* bot's own messages to {@code onResolved}, ordered from newest to oldest.
22+
* If {@code inclusive} is {@code true}, {@code triggerMessage} is included in
23+
* the resolved block; otherwise, only the preceding {@code total} messages are
24+
* considered. When {@code inclusive} is {@code true}, {@code total} must be at
25+
* least 2: {@code triggerMessage} counts as one of the {@code total} messages
26+
* and the remaining {@code total - 1} are fetched from the channel history, so
27+
* a single-message inclusive block is not supported. Runs {@code onError} if
28+
* the block can't be safely resolved.
29+
*
30+
* @param triggerMessage the message marking the end of the block
31+
* @param total the number of messages to resolve; must be at least 2 when {@code inclusive} is {@code true}
32+
* @param inclusive whether {@code triggerMessage} should be included in the resolved block
33+
* @param onResolved receives the bot's messages, ordered from newest to oldest
34+
* @param onError runs if the block can't be safely resolved
35+
*/
36+
static void resolveBefore(Message triggerMessage, int total, boolean inclusive,Consumer<List<Message>> onResolved, Runnable onError) {
37+
triggerMessage.getChannel().getHistoryBefore(triggerMessage.getIdLong(), inclusive?total-1 :total).queue(history -> {
38+
List<Message> block = new ArrayList<>(history.getRetrievedHistory());
39+
if (inclusive){
40+
block.addFirst(triggerMessage);
41+
}
42+
verify(block, total, onResolved, onError);
43+
});
44+
}
45+
46+
private static void verify(List<Message> messages, int total, Consumer<List<Message>> onResolved, Runnable onError) {
47+
List<Message> own = onlyOwn(messages);
48+
boolean allCodeBlocks = own.stream().allMatch(message -> message.getContentRaw().startsWith("```"));
49+
if (own.size() != total || !allCodeBlocks) {
50+
onError.run();
51+
return;
52+
}
53+
onResolved.accept(own);
54+
}
55+
56+
private static List<Message> onlyOwn(List<Message> messages) {
57+
return messages.stream()
58+
.filter(message -> message.getAuthor().getIdLong() == message.getJDA().getSelfUser().getIdLong())
59+
.toList();
60+
}
61+
}

src/main/java/net/discordjug/javabot/util/InteractionUtils.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ private boolean canDeleteUsingButton(Member member, String[] componentId) {
116116
}
117117

118118
public static Button createDeleteButton(long senderId) {
119-
return Button.secondary(createDeleteInteractionId(senderId), "\uD83D\uDDD1");
119+
return Button.secondary(createDeleteInteractionId(senderId), "\uD83D\uDDD1\uFE0F");
120120
}
121121

122122
public static String createDeleteInteractionId(long senderId) {

0 commit comments

Comments
 (0)