|
| 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 | +} |
0 commit comments