Skip to content

Commit c42529c

Browse files
committed
feat: link multi-message code blocks for delete-all and language switching
Delete-all button removes every message of a block; ephemeral language dropdown re-colors every chunk. Only the bot's own messages are touched.
1 parent 9a03a81 commit c42529c

6 files changed

Lines changed: 204 additions & 33 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: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ public static void sendCode(Code code, @Nonnull CommandInteraction event, Messag
4949
return;
5050
}
5151

52-
Responses.success(event.getHook(), "Success", "The formatted message is being sent to this channel.")
52+
event.getHook().sendMessage("Your message has been formatted. If needed, you can change the language used for syntax highlighting below.")
53+
.setEphemeral(true)
54+
.setComponents(FormatCodeInteractionHandler.buildLanguageMenu(event.getUser().getIdLong(),messages.size()))
5355
.queue(success -> sendChunksInOrder(channel, messages, 0, target,event));
5456
}
5557

@@ -62,38 +64,16 @@ private static void sendChunksInOrder(MessageChannel channel, List<String> messa
6264
.setAllowedMentions(List.of());
6365

6466
if (index == messages.size() - 1) {
65-
if(index == 0){
66-
action.setComponents(buildActionRow(target, event.getUser().getIdLong()));
67-
} else {
68-
action.setComponents(buildActionRow(target));
69-
}
67+
action.setComponents(buildActionRow(target, event.getUser().getIdLong(), messages.size()));
7068
}
7169

72-
action.queue(success ->
73-
sendChunksInOrder(channel, messages, index + 1, target, event));
70+
action.queue(sent -> sendChunksInOrder(channel, messages, index + 1, target, event));
7471
}
7572

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

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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package net.discordjug.javabot.systems.user_commands.format_code;
2+
3+
import net.dv8tion.jda.api.entities.Message;
4+
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
5+
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
import java.util.function.Consumer;
9+
10+
11+
/**
12+
* Helper for acting on a block of related messages sent as a group — such as a piece of code split
13+
* across several Discord messages. Given one message of the block and the total count, it resolves
14+
* the whole block (only the bot's own messages) so a single interaction can delete or edit it.
15+
*/
16+
public class LinkedMessages {
17+
private LinkedMessages(){}
18+
19+
/**
20+
* Resolves the block ending at {@code triggerMessage} (walking back {@code total} messages) and
21+
* passes the bot's own messages among them to {@code onResolved}.
22+
*
23+
* @param channel the channel the block is in
24+
* @param triggerMessage the last message of the block (carries the component)
25+
* @param total the number of messages in the block
26+
* @param onResolved receives the bot's messages that make up the block
27+
*/
28+
static void resolve(MessageChannel channel, Message triggerMessage, int total, Consumer<List<Message>> onResolved) {
29+
if (total <= 1) {
30+
onResolved.accept(List.of(triggerMessage));
31+
return;
32+
}
33+
channel.getHistoryBefore(triggerMessage.getIdLong(), total - 1).queue(history -> {
34+
List<Message> block = new ArrayList<>(history.getRetrievedHistory());
35+
block.add(triggerMessage);
36+
onResolved.accept(onlyOwn(channel, block));
37+
});
38+
}
39+
40+
/**
41+
* Resolves the block of {@code total} messages sent after {@code anchorMessage} and passes the
42+
* bot's own messages among them to {@code onResolved}.
43+
*
44+
* @param channel the channel the block is in
45+
* @param anchorMessage the message just before the block (carries the component)
46+
* @param total the number of messages in the block
47+
* @param onResolved receives the bot's messages that make up the block
48+
*/
49+
static void resolveForward(MessageChannel channel, Message anchorMessage, int total, Consumer<List<Message>> onResolved) {
50+
channel.getHistoryAfter(anchorMessage.getIdLong(), total).queue(history ->
51+
onResolved.accept(onlyOwn(channel, history.getRetrievedHistory())));
52+
}
53+
54+
private static List<Message> onlyOwn(MessageChannel channel, List<Message> messages) {
55+
long selfId = channel.getJDA().getSelfUser().getIdLong();
56+
return messages.stream()
57+
.filter(message -> message.getAuthor().getIdLong() == selfId)
58+
.toList();
59+
}
60+
}

0 commit comments

Comments
 (0)