diff --git a/application/build.gradle b/application/build.gradle index 1d2426892e..7cea2d56ea 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -57,6 +57,8 @@ dependencies { implementation 'io.mikael:urlbuilder:2.0.9' + implementation 'org.jsoup:jsoup:1.15.3' + implementation 'org.scilab.forge:jlatexmath:1.0.7' implementation 'org.scilab.forge:jlatexmath-font-greek:1.0.7' implementation 'org.scilab.forge:jlatexmath-font-cyrillic:1.0.7' @@ -68,6 +70,8 @@ dependencies { implementation 'com.github.freva:ascii-table:1.8.0' + implementation 'io.github.url-detector:url-detector:0.1.23' + implementation 'com.github.ben-manes.caffeine:caffeine:3.1.1' testImplementation 'org.mockito:mockito-core:4.8.0' diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java index 5818a34afb..679b4101e7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java @@ -1,21 +1,29 @@ package org.togetherjava.tjbot.commands.tags; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.interactions.commands.Command; import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +import net.dv8tion.jda.api.utils.FileUpload; +import net.dv8tion.jda.api.utils.messages.MessageEditBuilder; import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.utils.LinkPreview; +import org.togetherjava.tjbot.commands.utils.LinkPreviews; import org.togetherjava.tjbot.commands.utils.StringDistances; import java.time.Instant; -import java.util.Collection; +import java.util.*; /** * Implements the {@code /tag} command which lets the bot respond content of a tag that has been @@ -47,28 +55,6 @@ public TagCommand(TagSystem tagSystem) { "Optionally, the user who you want to reply to", false)); } - @Override - public void onSlashCommand(SlashCommandInteractionEvent event) { - String id = event.getOption(ID_OPTION).getAsString(); - OptionMapping replyToUserOption = event.getOption(REPLY_TO_USER_OPTION); - - if (tagSystem.handleIsUnknownTag(id, event)) { - return; - } - - ReplyCallbackAction message = event - .replyEmbeds(new EmbedBuilder().setDescription(tagSystem.getTag(id).orElseThrow()) - .setFooter(event.getUser().getName() + " • used " + event.getCommandString()) - .setTimestamp(Instant.now()) - .setColor(TagSystem.AMBIENT_COLOR) - .build()); - - if (replyToUserOption != null) { - message = message.setContent(replyToUserOption.getAsUser().getAsMention()); - } - message.queue(); - } - @Override public void onAutoComplete(CommandAutoCompleteInteractionEvent event) { AutoCompleteQuery focusedOption = event.getFocusedOption(); @@ -86,4 +72,68 @@ public void onAutoComplete(CommandAutoCompleteInteractionEvent event) { event.replyChoices(choices).queue(); } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + String id = event.getOption(ID_OPTION).getAsString(); + OptionMapping replyToUserOption = event.getOption(REPLY_TO_USER_OPTION); + + if (tagSystem.handleIsUnknownTag(id, event)) { + return; + } + + String tagContent = tagSystem.getTag(id).orElseThrow(); + MessageEmbed contentEmbed = new EmbedBuilder().setDescription(tagContent) + .setFooter(event.getUser().getName() + " • used " + event.getCommandString()) + .setTimestamp(Instant.now()) + .setColor(TagSystem.AMBIENT_COLOR) + .build(); + + Optional replyToUserMention = Optional.ofNullable(replyToUserOption) + .map(OptionMapping::getAsUser) + .map(User::getAsMention); + + List links = LinkPreviews.extractLinks(tagContent) + .stream() + .limit(Message.MAX_EMBED_COUNT - 1L) + .toList(); + if (links.isEmpty()) { + // No link previews + ReplyCallbackAction message = event.replyEmbeds(contentEmbed); + replyToUserMention.ifPresent(message::setContent); + message.queue(); + return; + } + + event.deferReply().queue(); + + respondWithLinkPreviews(event.getHook(), links, contentEmbed, replyToUserMention); + } + + private void respondWithLinkPreviews(InteractionHook eventHook, List links, + MessageEmbed contentEmbed, Optional replyToUserMention) { + LinkPreviews.createLinkPreviews(links).thenAccept(linkPreviews -> { + if (linkPreviews.isEmpty()) { + // Did not find any previews + MessageEditBuilder message = new MessageEditBuilder().setEmbeds(contentEmbed); + replyToUserMention.ifPresent(message::setContent); + eventHook.editOriginal(message.build()).queue(); + return; + } + + Collection embeds = new ArrayList<>(); + embeds.add(contentEmbed); + embeds.addAll(linkPreviews.stream().map(LinkPreview::embed).toList()); + + List attachments = linkPreviews.stream() + .map(LinkPreview::attachment) + .filter(Objects::nonNull) + .toList(); + + MessageEditBuilder message = + new MessageEditBuilder().setEmbeds(embeds).setFiles(attachments); + replyToUserMention.ifPresent(message::setContent); + eventHook.editOriginal(message.build()).queue(); + }); + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreview.java b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreview.java new file mode 100644 index 0000000000..e6a0e66509 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreview.java @@ -0,0 +1,73 @@ +package org.togetherjava.tjbot.commands.utils; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.utils.FileUpload; + +import javax.annotation.Nullable; + +import java.io.InputStream; + +/** + * Preview of a URL for display as embed in Discord. + *

+ * If the attachment is not {@code null}, it has to be made available to the message as well, for + * example + * {@code new MessageCreateBuilder().addEmbeds(linkPreview.embed).addFiles(linkPreview.attachment)}. + * + * @param attachment the thumbnail image of the link, referenced within the embed, if present + * @param embed the embed representing the preview of the link + */ +public record LinkPreview(@Nullable FileUpload attachment, MessageEmbed embed) { + /** + * Creates a new instance of this link preview, but with the given thumbnail. + *

+ * Any previous thumbnail is overridden and replaced. + * + * @param thumbnailName the name of the thumbnail, with extension, e.g. {@code foo.png} + * @param thumbnail the thumbnails data as raw data stream + * @return this preview, but with a thumbnail + */ + LinkPreview withThumbnail(String thumbnailName, InputStream thumbnail) { + return createWithThumbnail(embed, thumbnailName, thumbnail); + } + + /** + * Creates a link preview that only has a thumbnail and no other text. + * + * @param thumbnailName the name of the thumbnail, with extension, e.g. {@code foo.png} + * @param thumbnail the thumbnails data as raw data stream + * @return the thumbnail as link preview + */ + static LinkPreview ofThumbnail(String thumbnailName, InputStream thumbnail) { + return createWithThumbnail(null, thumbnailName, thumbnail); + } + + /** + * Creates a link preview that consists of the given text. + *

+ * Use {@link #withThumbnail(String, InputStream)} to decorate the preview also with a thumbnail + * image. + * + * @param title the title of the preview, if present + * @param url the link to the resource this preview represents + * @param description the description of the preview, if present + * @return the text as link preview + */ + static LinkPreview ofText(@Nullable String title, String url, @Nullable String description) { + MessageEmbed embed = + new EmbedBuilder().setTitle(title, url).setDescription(description).build(); + + return new LinkPreview(null, embed); + } + + private static LinkPreview createWithThumbnail(@Nullable MessageEmbed embedToDecorate, + String thumbnailName, InputStream thumbnail) { + FileUpload attachment = FileUpload.fromData(thumbnail, thumbnailName); + MessageEmbed embed = + new EmbedBuilder(embedToDecorate).setThumbnail("attachment://" + thumbnailName) + .build(); + + return new LinkPreview(attachment, embed); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreviews.java b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreviews.java new file mode 100644 index 0000000000..bdbc1e556c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreviews.java @@ -0,0 +1,201 @@ +package org.togetherjava.tjbot.commands.utils; + +import com.linkedin.urls.Url; +import com.linkedin.urls.detection.UrlDetector; +import com.linkedin.urls.detection.UrlDetectorOptions; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; +import java.util.stream.IntStream; + +/** + * Provides means to create previews of links. See {@link #extractLinks(String)} and + * {@link #createLinkPreviews(List)}. + */ +public final class LinkPreviews { + private static final Logger logger = LoggerFactory.getLogger(LinkPreviews.class); + + private static final String IMAGE_CONTENT_TYPE_PREFIX = "image"; + private static final String IMAGE_META_NAME = "image"; + + private static final HttpClient CLIENT = HttpClient.newHttpClient(); + + private LinkPreviews() { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Extracts all links from the given content. + * + * @param content the content to search through + * @return a list of all found links, can be empty + */ + public static List extractLinks(String content) { + return new UrlDetector(content, UrlDetectorOptions.BRACKET_MATCH).detect() + .stream() + .map(Url::getFullUrl) + .toList(); + } + + /** + * Attempts to create previews of all given links. + *

+ * A link preview is a short representation of the links contents, for example a thumbnail with + * a description. + *

+ * The returned result does not necessarily contain a preview for all given links. Preview + * creation can fail for various reasons, failed previews are omitted in the result. + * + * @param links the links to preview + * @return a list of all previews created successfully, can be empty + */ + public static CompletableFuture> createLinkPreviews(List links) { + if (links.isEmpty()) { + return CompletableFuture.completedFuture(List.of()); + } + + List>> tasks = IntStream.range(0, links.size()) + .mapToObj(i -> createLinkPreview(links.get(i), i + ".png")) + .toList(); + + var allDoneTask = CompletableFuture.allOf(tasks.toArray(CompletableFuture[]::new)); + return allDoneTask.thenApply(any -> extractResults(tasks)); + } + + private static List extractResults( + Collection>> tasks) { + return tasks.stream() + .filter(Predicate.not(CompletableFuture::isCompletedExceptionally)) + .map(CompletableFuture::join) + .flatMap(Optional::stream) + .toList(); + } + + private static CompletableFuture> createLinkPreview(String link, + String attachmentName) { + return readLinkContent(link).thenCompose(maybeContent -> { + if (maybeContent.isEmpty()) { + return noResult(); + } + HttpContent content = maybeContent.orElseThrow(); + + if (content.type.startsWith(IMAGE_CONTENT_TYPE_PREFIX)) { + return result(LinkPreview.ofThumbnail(attachmentName, content.dataStream)); + } + + if (content.type.startsWith("text/html")) { + return parseWebsite(link, attachmentName, content.dataStream); + } + return noResult(); + }); + } + + private static CompletableFuture> readLinkContent(String link) { + URI linkAsUri; + try { + linkAsUri = URI.create(link); + } catch (IllegalArgumentException e) { + logger.warn("Attempted to create a preview for {}, but the URL is invalid.", link, e); + return noResult(); + } + + HttpRequest request = HttpRequest.newBuilder(linkAsUri).build(); + CompletableFuture> task = + CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); + + return task.thenApply(response -> { + int statusCode = response.statusCode(); + if (statusCode < HttpURLConnection.HTTP_OK + || statusCode >= HttpURLConnection.HTTP_MULT_CHOICE) { + logger.warn("Attempted to create a preview for {}, but the site returned code {}.", + link, statusCode); + return Optional.empty(); + } + + String contentType = response.headers().firstValue("Content-Type").orElse(""); + return Optional.of(new HttpContent(contentType, response.body())); + }); + } + + private record HttpContent(String type, InputStream dataStream) { + } + + private static CompletableFuture> parseWebsite(String link, + String attachmentName, InputStream websiteContent) { + Document doc; + try { + doc = Jsoup.parse(websiteContent, null, link); + } catch (IOException e) { + logger.warn("Attempted to create a preview for {}, but the content invalid.", link, e); + return noResult(); + } + + String title = parseOpenGraphTwitterMeta(doc, "title", doc.title()).orElse(null); + String description = + parseOpenGraphTwitterMeta(doc, "description", doc.title()).orElse(null); + + LinkPreview textPreview = LinkPreview.ofText(title, link, description); + + String image = parseOpenGraphMeta(doc, IMAGE_META_NAME).orElse(null); + if (image == null) { + return result(textPreview); + } + + return readLinkContent(image).thenCompose(maybeContent -> { + if (maybeContent.isEmpty()) { + return result(textPreview); + } + HttpContent content = maybeContent.orElseThrow(); + + if (!content.type.startsWith(IMAGE_CONTENT_TYPE_PREFIX)) { + return result(textPreview); + } + + return result(textPreview.withThumbnail(attachmentName, content.dataStream)); + }); + } + + private static Optional parseOpenGraphTwitterMeta(Document doc, String metaProperty, + @Nullable String fallback) { + String value = Optional + .ofNullable(doc.selectFirst("meta[property=og:%s]".formatted(metaProperty))) + .or(() -> Optional + .ofNullable(doc.selectFirst("meta[property=twitter:%s".formatted(metaProperty)))) + .map(element -> element.attr("content")) + .orElse(fallback); + if (value == null) { + return Optional.empty(); + } + return value.isBlank() ? Optional.empty() : Optional.of(value); + } + + private static Optional parseOpenGraphMeta(Document doc, String metaProperty) { + return Optional.ofNullable(doc.selectFirst("meta[property=og:%s]".formatted(metaProperty))) + .map(element -> element.attr("content")) + .filter(Predicate.not(String::isBlank)); + } + + private static CompletableFuture> noResult() { + return CompletableFuture.completedFuture(Optional.empty()); + } + + private static CompletableFuture> result(T content) { + return CompletableFuture.completedFuture(Optional.of(content)); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/utils/StringDistances.java b/application/src/main/java/org/togetherjava/tjbot/commands/utils/StringDistances.java index b3f522bb22..87208e8335 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/utils/StringDistances.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/StringDistances.java @@ -77,7 +77,7 @@ public static Collection closeMatches(CharSequence prefix, bestMatches.addAll(scoredMatches); return Stream.generate(bestMatches::poll) - .limit(limit) + .limit(Math.min(limit, bestMatches.size())) .takeWhile(matchScore -> isCloseEnough(matchScore, prefix)) .map(MatchScore::candidate) .toList();