Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4db4d2d
First draft of format command
Zabuzard Oct 9, 2022
6fa8d5a
adding onMessageDeleted to message receiver
Zabuzard Oct 10, 2022
09eddf5
some stuff
Zabuzard Oct 10, 2022
6834a02
moved code formatting responsibility over to the message handler
Zabuzard Oct 11, 2022
d2f0e3a
Draft of final UX
Zabuzard Oct 13, 2022
fbe65fc
Generify, support multiple commands
Zabuzard Oct 13, 2022
2cb3014
signature after rebase, ignore bots
Zabuzard Oct 20, 2022
aaaf3f4
some logging
Zabuzard Oct 20, 2022
84a4dbd
Caffeine cache
Zabuzard Oct 20, 2022
afd176c
cant do much about that duplication
Zabuzard Oct 20, 2022
ba3b420
Improved code extraction, polished design, javadoc
Zabuzard Oct 21, 2022
3a8dc08
fixed bug where newlines are not matched
Zabuzard Oct 24, 2022
1297aa4
Removed code-section feature (not needed)
Zabuzard Oct 24, 2022
2bfbd41
Improved tokens
Zabuzard Oct 25, 2022
d669ea4
Got rid of line-wise lexing, improved patterns
Zabuzard Oct 25, 2022
d0e2d51
Improved matching to not solely rely on regex
Zabuzard Oct 26, 2022
8b1ca42
Improved lexer to use rolling window on string view
Zabuzard Oct 26, 2022
9e0e5ee
Improved formatter interface
Zabuzard Oct 26, 2022
a524da6
Improved actual formatter engine, rules and queue
Zabuzard Oct 26, 2022
bef9dbb
patch multi line comments
Zabuzard Oct 27, 2022
bd60354
Adjusted tokenqueue to actual needs
Zabuzard Oct 27, 2022
1b57ea8
javadoc
Zabuzard Oct 27, 2022
3a9ca14
extract code tests, got rid of regex
Zabuzard Oct 27, 2022
72545b9
Expanded list of tokens
Zabuzard Oct 27, 2022
b29b44a
unit tests for matching
Zabuzard Oct 27, 2022
b342edd
Lexer tests
Zabuzard Oct 27, 2022
472ee55
unit tests for tokenqueue
Zabuzard Oct 28, 2022
a4756fe
formatting tests and bugfixes
Zabuzard Oct 28, 2022
e1eb5b4
got rid of example duplication
Zabuzard Oct 28, 2022
4387654
Added java as default language to always have syntax highlighting
Zabuzard Nov 3, 2022
9fd8e1a
CR Tais
Zabuzard Nov 3, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions application/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ dependencies {

implementation project(':database')
implementation project(':utils')
implementation project(':formatter')

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.togetherjava.tjbot.commands.basic.RoleSelectCommand;
import org.togetherjava.tjbot.commands.basic.SuggestionsUpDownVoter;
import org.togetherjava.tjbot.commands.basic.VcActivityCommand;
import org.togetherjava.tjbot.commands.code.CodeMessageHandler;
import org.togetherjava.tjbot.commands.filesharing.FileSharingMessageListener;
import org.togetherjava.tjbot.commands.help.*;
import org.togetherjava.tjbot.commands.mathcommands.TeXCommand;
Expand Down Expand Up @@ -94,6 +95,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
features.add(new MediaOnlyChannelListener(config));
features.add(new FileSharingMessageListener(config));
features.add(new BlacklistedAttachmentListener(config, modAuditLogWriter));
features.add(new CodeMessageHandler());

// Event receivers
features.add(new RejoinModerationRoleListener(actionsStore, config));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.togetherjava.tjbot.commands;

import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;

Expand Down Expand Up @@ -46,4 +47,13 @@ public interface MessageReceiver extends Feature {
* message that was edited
*/
void onMessageUpdated(MessageUpdateEvent event);

/**
* Triggered by the core system whenever an existing message was deleted in a text channel of a
* guild the bot has been added to.
*
* @param event the event that triggered this, containing information about the corresponding
* message that was deleted
*/
void onMessageDeleted(MessageDeleteEvent event);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.togetherjava.tjbot.commands;

import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;

Expand Down Expand Up @@ -43,4 +44,10 @@ public void onMessageReceived(MessageReceivedEvent event) {
public void onMessageUpdated(MessageUpdateEvent event) {
// Adapter does not react by default, subclasses may change this behavior
}

@SuppressWarnings("NoopMethodInAbstractClass")
@Override
public void onMessageDeleted(MessageDeleteEvent event) {
// Adapter does not react by default, subclasses may change this behavior
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.togetherjava.tjbot.commands.code;

import net.dv8tion.jda.api.entities.MessageEmbed;

import org.togetherjava.tjbot.commands.utils.CodeFence;

/**
* Actions that can be executed on code, such as running it.
*/
interface CodeAction {
/**
* The name of the action, displayed to the user for applying it.
*
* @return the label of the action
*/
String getLabel();

/**
* Applies the action to the given code and returns a message.
*
* @param codeFence the code to apply the action to
* @return the message to send to the user
*/
MessageEmbed apply(CodeFence codeFence);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
package org.togetherjava.tjbot.commands.code;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent;
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
import net.dv8tion.jda.api.interactions.components.buttons.Button;
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import net.dv8tion.jda.internal.requests.CompletedRestAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.togetherjava.tjbot.commands.MessageReceiverAdapter;
import org.togetherjava.tjbot.commands.UserInteractionType;
import org.togetherjava.tjbot.commands.UserInteractor;
import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator;
import org.togetherjava.tjbot.commands.componentids.ComponentIdInteractor;
import org.togetherjava.tjbot.commands.utils.CodeFence;
import org.togetherjava.tjbot.commands.utils.MessageUtils;

import javax.annotation.Nullable;

import java.awt.Color;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
* Handler that detects code in messages and offers code actions to the user, such as formatting
* their code.
* <p>
* Code actions are automatically updated whenever the code in the original message is edited or
* deleted.
*/
public final class CodeMessageHandler extends MessageReceiverAdapter implements UserInteractor {
private static final Logger logger = LoggerFactory.getLogger(CodeMessageHandler.class);

static final Color AMBIENT_COLOR = Color.decode("#FDFD96");

private final ComponentIdInteractor componentIdInteractor;
private final Map<String, CodeAction> labelToCodeAction;

/**
* Memorizes the ID of the bots code-reply message that a message belongs to. That way, the
* code-reply can be retrieved and managed easily when the original message is edited or
* deleted. Losing this cache, for example during bot-restart, effectively disables this
* update-feature for old messages.
* <p>
* The feature is secondary though, which is why its kept in RAM and not in the DB.
*/
private final Cache<Long, Long> originalMessageToCodeReply =
Caffeine.newBuilder().maximumSize(2_000).build();

/**
* Creates a new instance.
*/
public CodeMessageHandler() {
super(Pattern.compile(".*"));

componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName());

List<CodeAction> codeActions = List.of(new FormatCodeCommand());

labelToCodeAction = codeActions.stream()
.collect(Collectors.toMap(CodeAction::getLabel, Function.identity(), (x, y) -> y,
LinkedHashMap::new));
}

@Override
public String getName() {
return "code-actions";
}

@Override
public UserInteractionType getInteractionType() {
return UserInteractionType.OTHER;
}

@Override
public void onSelectMenuSelection(SelectMenuInteractionEvent event, List<String> args) {
throw new UnsupportedOperationException("Not used");
}

@Override
public void onModalSubmitted(ModalInteractionEvent event, List<String> args) {
throw new UnsupportedOperationException("Not used");
}

@Override
public void acceptComponentIdGenerator(ComponentIdGenerator generator) {
componentIdInteractor.acceptComponentIdGenerator(generator);
}

@Override
public void onMessageReceived(MessageReceivedEvent event) {
if (event.isWebhookMessage() || event.getAuthor().isBot()) {
return;
}

Message originalMessage = event.getMessage();
String content = originalMessage.getContentRaw();

Optional<CodeFence> maybeCode = MessageUtils.extractCode(content);
if (maybeCode.isEmpty()) {
// There is no code in the message, ignore it
return;
}

// Suggest code actions and remember the message <-> reply
originalMessage.reply(createCodeReplyMessage(originalMessage.getIdLong()))
.onSuccess(replyMessage -> originalMessageToCodeReply.put(originalMessage.getIdLong(),
replyMessage.getIdLong()))
.queue();
}

private MessageCreateData createCodeReplyMessage(long originalMessageId) {
return new MessageCreateBuilder().setContent("Detected code, here are some useful tools:")
.setActionRow(createButtons(originalMessageId, null))
.build();
}

private List<Button> createButtons(long originalMessageId,
@Nullable CodeAction currentlyActiveAction) {
return labelToCodeAction.values().stream().map(action -> {
Button button = createButtonForAction(action, originalMessageId);
return action == currentlyActiveAction ? button.asDisabled() : button;
}).toList();
}

private Button createButtonForAction(CodeAction action, long originalMessageId) {
return Button.primary(
componentIdInteractor.generateComponentId(Long.toString(originalMessageId)),
action.getLabel());
}

@Override
public void onButtonClick(ButtonInteractionEvent event, List<String> args) {
long originalMessageId = Long.parseLong(args.get(0));
CodeAction codeAction = getActionOfEvent(event);

event.deferEdit().queue();

// User decided for an action, apply it to the code
event.getChannel()
.retrieveMessageById(originalMessageId)
.mapToResult()
.flatMap(originalMessage -> {
if (originalMessage.isFailure()) {
return event.getHook()
.sendMessage(
"Sorry, I am unable to locate the original message that contained the code, was it deleted?")
.setEphemeral(true);
}

// If the bot got restarted in the meantime, it forgot about the message
// since we have the context here, we can restore that information
originalMessageToCodeReply.put(originalMessageId, event.getMessageIdLong());

Optional<CodeFence> maybeCode =
MessageUtils.extractCode(originalMessage.get().getContentRaw());
if (maybeCode.isEmpty()) {
return event.getHook()
.sendMessage(
"Sorry, I am unable to locate any code in the original message, was it removed?")
.setEphemeral(true);
}

// Apply the selected action
return event.getHook()
.editOriginalEmbeds(codeAction.apply(maybeCode.orElseThrow()))
.setActionRow(createButtons(originalMessageId, codeAction));
})
.queue();
}

private CodeAction getActionOfEvent(ButtonInteractionEvent event) {
return labelToCodeAction.get(event.getButton().getLabel());
}

@Override
public void onMessageUpdated(MessageUpdateEvent event) {
long originalMessageId = event.getMessageIdLong();

Long codeReplyMessageId = originalMessageToCodeReply.getIfPresent(originalMessageId);
if (codeReplyMessageId == null) {
// Some unrelated non-code message was edited
return;
}

// Edit the code reply as well by re-applying the current action
String content = event.getMessage().getContentRaw();

Optional<CodeFence> maybeCode = MessageUtils.extractCode(content);
if (maybeCode.isEmpty()) {
// The original message had code, but now the code was removed
return;
}

event.getChannel().retrieveMessageById(codeReplyMessageId).flatMap(codeReplyMessage -> {
Optional<CodeAction> maybeCodeAction = getCurrentActionFromCodeReply(codeReplyMessage);
if (maybeCodeAction.isEmpty()) {
// The user did not decide on an action yet, nothing to update
return new CompletedRestAction<>(event.getJDA(), null);
}

// Re-apply the current action
return codeReplyMessage
.editMessageEmbeds(maybeCodeAction.orElseThrow().apply(maybeCode.orElseThrow()));
}).queue(any -> {
}, failure -> logger.warn(
"Attempted to update a code-reply-message ({}), but failed. The original code-message was {}",
codeReplyMessageId, originalMessageId, failure));
}

private Optional<CodeAction> getCurrentActionFromCodeReply(Message codeReplyMessage) {
// The disabled action is the currently applied action
return codeReplyMessage.getButtons()
.stream()
.filter(Button::isDisabled)
.map(Button::getLabel)
.map(labelToCodeAction::get)
.findAny();
}

@Override
public void onMessageDeleted(MessageDeleteEvent event) {
long originalMessageId = event.getMessageIdLong();

Long codeReplyMessageId = originalMessageToCodeReply.getIfPresent(originalMessageId);
if (codeReplyMessageId == null) {
// Some unrelated non-code message was deleted
return;
}

// Delete the code reply as well
originalMessageToCodeReply.invalidate(codeReplyMessageId);

event.getChannel().deleteMessageById(codeReplyMessageId).queue(any -> {
}, failure -> logger.warn(
"Attempted to delete a code-reply-message ({}), but failed. The original code-message was {}",
codeReplyMessageId, originalMessageId, failure));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.togetherjava.tjbot.commands.code;

import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.MessageEmbed;

import org.togetherjava.tjbot.commands.utils.CodeFence;
import org.togetherjava.tjbot.formatter.Formatter;

/**
* Formats the given code.
* <p>
* While it will attempt formatting for any language, best results are achieved for Java code.
*/
final class FormatCodeCommand implements CodeAction {
private final Formatter formatter = new Formatter();

@Override
public String getLabel() {
return "Format";
}

@Override
public MessageEmbed apply(CodeFence codeFence) {
String formattedCode = formatCode(codeFence.code());
// Any syntax highlighting is better than none
String language = codeFence.language() == null ? "java" : codeFence.language();

CodeFence formattedCodeFence = new CodeFence(language, formattedCode);

return new EmbedBuilder().setTitle("Formatted code")
.setDescription(formattedCodeFence.toMarkdown())
.setColor(CodeMessageHandler.AMBIENT_COLOR)
.build();
}

private String formatCode(CharSequence code) {
return formatter.format(code);
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* Contains utility used by the formatter.
* This package offers all the functionality for working with messages containing code.
*/
@MethodsReturnNonnullByDefault
@ParametersAreNonnullByDefault
package org.togetherjava.tjbot.formatter.util;
package org.togetherjava.tjbot.commands.code;

import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault;

Expand Down
Loading