Skip to content

Commit cefe923

Browse files
authored
Add code format command (#622)
* First draft of format command * adding onMessageDeleted to message receiver * some stuff * moved code formatting responsibility over to the message handler * Draft of final UX * Generify, support multiple commands * signature after rebase, ignore bots * some logging * Caffeine cache * cant do much about that duplication * Improved code extraction, polished design, javadoc * fixed bug where newlines are not matched * Removed code-section feature (not needed) * Improved tokens * Got rid of line-wise lexing, improved patterns * Improved matching to not solely rely on regex * Improved lexer to use rolling window on string view * Improved formatter interface * Improved actual formatter engine, rules and queue * patch multi line comments * Adjusted tokenqueue to actual needs * javadoc * extract code tests, got rid of regex * Expanded list of tokens * unit tests for matching * Lexer tests * unit tests for tokenqueue * formatting tests and bugfixes * got rid of example duplication * Added java as default language to always have syntax highlighting * CR Tais
1 parent 691d1ca commit cefe923

33 files changed

+2166
-985
lines changed

application/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dependencies {
4444

4545
implementation project(':database')
4646
implementation project(':utils')
47+
implementation project(':formatter')
4748

4849
implementation 'net.dv8tion:JDA:5.0.0-alpha.20'
4950

application/src/main/java/org/togetherjava/tjbot/commands/Features.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.togetherjava.tjbot.commands.basic.RoleSelectCommand;
77
import org.togetherjava.tjbot.commands.basic.SuggestionsUpDownVoter;
88
import org.togetherjava.tjbot.commands.basic.VcActivityCommand;
9+
import org.togetherjava.tjbot.commands.code.CodeMessageHandler;
910
import org.togetherjava.tjbot.commands.filesharing.FileSharingMessageListener;
1011
import org.togetherjava.tjbot.commands.help.*;
1112
import org.togetherjava.tjbot.commands.mathcommands.TeXCommand;
@@ -94,6 +95,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
9495
features.add(new MediaOnlyChannelListener(config));
9596
features.add(new FileSharingMessageListener(config));
9697
features.add(new BlacklistedAttachmentListener(config, modAuditLogWriter));
98+
features.add(new CodeMessageHandler());
9799

98100
// Event receivers
99101
features.add(new RejoinModerationRoleListener(actionsStore, config));

application/src/main/java/org/togetherjava/tjbot/commands/MessageReceiver.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.togetherjava.tjbot.commands;
22

3+
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
34
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
45
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
56

@@ -46,4 +47,13 @@ public interface MessageReceiver extends Feature {
4647
* message that was edited
4748
*/
4849
void onMessageUpdated(MessageUpdateEvent event);
50+
51+
/**
52+
* Triggered by the core system whenever an existing message was deleted in a text channel of a
53+
* guild the bot has been added to.
54+
*
55+
* @param event the event that triggered this, containing information about the corresponding
56+
* message that was deleted
57+
*/
58+
void onMessageDeleted(MessageDeleteEvent event);
4959
}

application/src/main/java/org/togetherjava/tjbot/commands/MessageReceiverAdapter.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.togetherjava.tjbot.commands;
22

3+
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
34
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
45
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
56

@@ -43,4 +44,10 @@ public void onMessageReceived(MessageReceivedEvent event) {
4344
public void onMessageUpdated(MessageUpdateEvent event) {
4445
// Adapter does not react by default, subclasses may change this behavior
4546
}
47+
48+
@SuppressWarnings("NoopMethodInAbstractClass")
49+
@Override
50+
public void onMessageDeleted(MessageDeleteEvent event) {
51+
// Adapter does not react by default, subclasses may change this behavior
52+
}
4653
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package org.togetherjava.tjbot.commands.code;
2+
3+
import net.dv8tion.jda.api.entities.MessageEmbed;
4+
5+
import org.togetherjava.tjbot.commands.utils.CodeFence;
6+
7+
/**
8+
* Actions that can be executed on code, such as running it.
9+
*/
10+
interface CodeAction {
11+
/**
12+
* The name of the action, displayed to the user for applying it.
13+
*
14+
* @return the label of the action
15+
*/
16+
String getLabel();
17+
18+
/**
19+
* Applies the action to the given code and returns a message.
20+
*
21+
* @param codeFence the code to apply the action to
22+
* @return the message to send to the user
23+
*/
24+
MessageEmbed apply(CodeFence codeFence);
25+
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package org.togetherjava.tjbot.commands.code;
2+
3+
import com.github.benmanes.caffeine.cache.Cache;
4+
import com.github.benmanes.caffeine.cache.Caffeine;
5+
import net.dv8tion.jda.api.entities.Message;
6+
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
7+
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
8+
import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent;
9+
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
10+
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
11+
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
12+
import net.dv8tion.jda.api.interactions.components.buttons.Button;
13+
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
14+
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
15+
import net.dv8tion.jda.internal.requests.CompletedRestAction;
16+
import org.slf4j.Logger;
17+
import org.slf4j.LoggerFactory;
18+
19+
import org.togetherjava.tjbot.commands.MessageReceiverAdapter;
20+
import org.togetherjava.tjbot.commands.UserInteractionType;
21+
import org.togetherjava.tjbot.commands.UserInteractor;
22+
import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator;
23+
import org.togetherjava.tjbot.commands.componentids.ComponentIdInteractor;
24+
import org.togetherjava.tjbot.commands.utils.CodeFence;
25+
import org.togetherjava.tjbot.commands.utils.MessageUtils;
26+
27+
import javax.annotation.Nullable;
28+
29+
import java.awt.Color;
30+
import java.util.LinkedHashMap;
31+
import java.util.List;
32+
import java.util.Map;
33+
import java.util.Optional;
34+
import java.util.function.Function;
35+
import java.util.regex.Pattern;
36+
import java.util.stream.Collectors;
37+
38+
/**
39+
* Handler that detects code in messages and offers code actions to the user, such as formatting
40+
* their code.
41+
* <p>
42+
* Code actions are automatically updated whenever the code in the original message is edited or
43+
* deleted.
44+
*/
45+
public final class CodeMessageHandler extends MessageReceiverAdapter implements UserInteractor {
46+
private static final Logger logger = LoggerFactory.getLogger(CodeMessageHandler.class);
47+
48+
static final Color AMBIENT_COLOR = Color.decode("#FDFD96");
49+
50+
private final ComponentIdInteractor componentIdInteractor;
51+
private final Map<String, CodeAction> labelToCodeAction;
52+
53+
/**
54+
* Memorizes the ID of the bots code-reply message that a message belongs to. That way, the
55+
* code-reply can be retrieved and managed easily when the original message is edited or
56+
* deleted. Losing this cache, for example during bot-restart, effectively disables this
57+
* update-feature for old messages.
58+
* <p>
59+
* The feature is secondary though, which is why its kept in RAM and not in the DB.
60+
*/
61+
private final Cache<Long, Long> originalMessageToCodeReply =
62+
Caffeine.newBuilder().maximumSize(2_000).build();
63+
64+
/**
65+
* Creates a new instance.
66+
*/
67+
public CodeMessageHandler() {
68+
super(Pattern.compile(".*"));
69+
70+
componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName());
71+
72+
List<CodeAction> codeActions = List.of(new FormatCodeCommand());
73+
74+
labelToCodeAction = codeActions.stream()
75+
.collect(Collectors.toMap(CodeAction::getLabel, Function.identity(), (x, y) -> y,
76+
LinkedHashMap::new));
77+
}
78+
79+
@Override
80+
public String getName() {
81+
return "code-actions";
82+
}
83+
84+
@Override
85+
public UserInteractionType getInteractionType() {
86+
return UserInteractionType.OTHER;
87+
}
88+
89+
@Override
90+
public void onSelectMenuSelection(SelectMenuInteractionEvent event, List<String> args) {
91+
throw new UnsupportedOperationException("Not used");
92+
}
93+
94+
@Override
95+
public void onModalSubmitted(ModalInteractionEvent event, List<String> args) {
96+
throw new UnsupportedOperationException("Not used");
97+
}
98+
99+
@Override
100+
public void acceptComponentIdGenerator(ComponentIdGenerator generator) {
101+
componentIdInteractor.acceptComponentIdGenerator(generator);
102+
}
103+
104+
@Override
105+
public void onMessageReceived(MessageReceivedEvent event) {
106+
if (event.isWebhookMessage() || event.getAuthor().isBot()) {
107+
return;
108+
}
109+
110+
Message originalMessage = event.getMessage();
111+
String content = originalMessage.getContentRaw();
112+
113+
Optional<CodeFence> maybeCode = MessageUtils.extractCode(content);
114+
if (maybeCode.isEmpty()) {
115+
// There is no code in the message, ignore it
116+
return;
117+
}
118+
119+
// Suggest code actions and remember the message <-> reply
120+
originalMessage.reply(createCodeReplyMessage(originalMessage.getIdLong()))
121+
.onSuccess(replyMessage -> originalMessageToCodeReply.put(originalMessage.getIdLong(),
122+
replyMessage.getIdLong()))
123+
.queue();
124+
}
125+
126+
private MessageCreateData createCodeReplyMessage(long originalMessageId) {
127+
return new MessageCreateBuilder().setContent("Detected code, here are some useful tools:")
128+
.setActionRow(createButtons(originalMessageId, null))
129+
.build();
130+
}
131+
132+
private List<Button> createButtons(long originalMessageId,
133+
@Nullable CodeAction currentlyActiveAction) {
134+
return labelToCodeAction.values().stream().map(action -> {
135+
Button button = createButtonForAction(action, originalMessageId);
136+
return action == currentlyActiveAction ? button.asDisabled() : button;
137+
}).toList();
138+
}
139+
140+
private Button createButtonForAction(CodeAction action, long originalMessageId) {
141+
return Button.primary(
142+
componentIdInteractor.generateComponentId(Long.toString(originalMessageId)),
143+
action.getLabel());
144+
}
145+
146+
@Override
147+
public void onButtonClick(ButtonInteractionEvent event, List<String> args) {
148+
long originalMessageId = Long.parseLong(args.get(0));
149+
CodeAction codeAction = getActionOfEvent(event);
150+
151+
event.deferEdit().queue();
152+
153+
// User decided for an action, apply it to the code
154+
event.getChannel()
155+
.retrieveMessageById(originalMessageId)
156+
.mapToResult()
157+
.flatMap(originalMessage -> {
158+
if (originalMessage.isFailure()) {
159+
return event.getHook()
160+
.sendMessage(
161+
"Sorry, I am unable to locate the original message that contained the code, was it deleted?")
162+
.setEphemeral(true);
163+
}
164+
165+
// If the bot got restarted in the meantime, it forgot about the message
166+
// since we have the context here, we can restore that information
167+
originalMessageToCodeReply.put(originalMessageId, event.getMessageIdLong());
168+
169+
Optional<CodeFence> maybeCode =
170+
MessageUtils.extractCode(originalMessage.get().getContentRaw());
171+
if (maybeCode.isEmpty()) {
172+
return event.getHook()
173+
.sendMessage(
174+
"Sorry, I am unable to locate any code in the original message, was it removed?")
175+
.setEphemeral(true);
176+
}
177+
178+
// Apply the selected action
179+
return event.getHook()
180+
.editOriginalEmbeds(codeAction.apply(maybeCode.orElseThrow()))
181+
.setActionRow(createButtons(originalMessageId, codeAction));
182+
})
183+
.queue();
184+
}
185+
186+
private CodeAction getActionOfEvent(ButtonInteractionEvent event) {
187+
return labelToCodeAction.get(event.getButton().getLabel());
188+
}
189+
190+
@Override
191+
public void onMessageUpdated(MessageUpdateEvent event) {
192+
long originalMessageId = event.getMessageIdLong();
193+
194+
Long codeReplyMessageId = originalMessageToCodeReply.getIfPresent(originalMessageId);
195+
if (codeReplyMessageId == null) {
196+
// Some unrelated non-code message was edited
197+
return;
198+
}
199+
200+
// Edit the code reply as well by re-applying the current action
201+
String content = event.getMessage().getContentRaw();
202+
203+
Optional<CodeFence> maybeCode = MessageUtils.extractCode(content);
204+
if (maybeCode.isEmpty()) {
205+
// The original message had code, but now the code was removed
206+
return;
207+
}
208+
209+
event.getChannel().retrieveMessageById(codeReplyMessageId).flatMap(codeReplyMessage -> {
210+
Optional<CodeAction> maybeCodeAction = getCurrentActionFromCodeReply(codeReplyMessage);
211+
if (maybeCodeAction.isEmpty()) {
212+
// The user did not decide on an action yet, nothing to update
213+
return new CompletedRestAction<>(event.getJDA(), null);
214+
}
215+
216+
// Re-apply the current action
217+
return codeReplyMessage
218+
.editMessageEmbeds(maybeCodeAction.orElseThrow().apply(maybeCode.orElseThrow()));
219+
}).queue(any -> {
220+
}, failure -> logger.warn(
221+
"Attempted to update a code-reply-message ({}), but failed. The original code-message was {}",
222+
codeReplyMessageId, originalMessageId, failure));
223+
}
224+
225+
private Optional<CodeAction> getCurrentActionFromCodeReply(Message codeReplyMessage) {
226+
// The disabled action is the currently applied action
227+
return codeReplyMessage.getButtons()
228+
.stream()
229+
.filter(Button::isDisabled)
230+
.map(Button::getLabel)
231+
.map(labelToCodeAction::get)
232+
.findAny();
233+
}
234+
235+
@Override
236+
public void onMessageDeleted(MessageDeleteEvent event) {
237+
long originalMessageId = event.getMessageIdLong();
238+
239+
Long codeReplyMessageId = originalMessageToCodeReply.getIfPresent(originalMessageId);
240+
if (codeReplyMessageId == null) {
241+
// Some unrelated non-code message was deleted
242+
return;
243+
}
244+
245+
// Delete the code reply as well
246+
originalMessageToCodeReply.invalidate(codeReplyMessageId);
247+
248+
event.getChannel().deleteMessageById(codeReplyMessageId).queue(any -> {
249+
}, failure -> logger.warn(
250+
"Attempted to delete a code-reply-message ({}), but failed. The original code-message was {}",
251+
codeReplyMessageId, originalMessageId, failure));
252+
}
253+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package org.togetherjava.tjbot.commands.code;
2+
3+
import net.dv8tion.jda.api.EmbedBuilder;
4+
import net.dv8tion.jda.api.entities.MessageEmbed;
5+
6+
import org.togetherjava.tjbot.commands.utils.CodeFence;
7+
import org.togetherjava.tjbot.formatter.Formatter;
8+
9+
/**
10+
* Formats the given code.
11+
* <p>
12+
* While it will attempt formatting for any language, best results are achieved for Java code.
13+
*/
14+
final class FormatCodeCommand implements CodeAction {
15+
private final Formatter formatter = new Formatter();
16+
17+
@Override
18+
public String getLabel() {
19+
return "Format";
20+
}
21+
22+
@Override
23+
public MessageEmbed apply(CodeFence codeFence) {
24+
String formattedCode = formatCode(codeFence.code());
25+
// Any syntax highlighting is better than none
26+
String language = codeFence.language() == null ? "java" : codeFence.language();
27+
28+
CodeFence formattedCodeFence = new CodeFence(language, formattedCode);
29+
30+
return new EmbedBuilder().setTitle("Formatted code")
31+
.setDescription(formattedCodeFence.toMarkdown())
32+
.setColor(CodeMessageHandler.AMBIENT_COLOR)
33+
.build();
34+
}
35+
36+
private String formatCode(CharSequence code) {
37+
return formatter.format(code);
38+
}
39+
}

formatter/src/main/java/org/togetherjava/tjbot/formatter/util/package-info.java renamed to application/src/main/java/org/togetherjava/tjbot/commands/code/package-info.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/**
2-
* Contains utility used by the formatter.
2+
* This package offers all the functionality for working with messages containing code.
33
*/
44
@MethodsReturnNonnullByDefault
55
@ParametersAreNonnullByDefault
6-
package org.togetherjava.tjbot.formatter.util;
6+
package org.togetherjava.tjbot.commands.code;
77

88
import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault;
99

0 commit comments

Comments
 (0)