From 37321477debb5a682ba20dde1b6e437226729539 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 7 Oct 2022 14:58:10 +0200 Subject: [PATCH 01/10] Raw draft of link preview for tags --- application/build.gradle | 2 + .../tjbot/commands/tags/TagCommand.java | 138 ++++++++++++++++-- 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/application/build.gradle b/application/build.gradle index 1d2426892e..4cf9327ed7 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -68,6 +68,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..bb9ff9b32d 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,7 +1,13 @@ package org.togetherjava.tjbot.commands.tags; +import com.linkedin.urls.Url; +import com.linkedin.urls.detection.UrlDetector; +import com.linkedin.urls.detection.UrlDetectorOptions; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +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.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.AutoCompleteQuery; import net.dv8tion.jda.api.interactions.commands.Command; @@ -9,13 +15,24 @@ 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.StringDistances; +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.time.Instant; -import java.util.Collection; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; +import java.util.stream.IntStream; /** * Implements the {@code /tag} command which lets the bot respond content of a tag that has been @@ -25,6 +42,7 @@ * {@link TagsCommand}. */ public final class TagCommand extends SlashCommandAdapter { + private static final HttpClient CLIENT = HttpClient.newHttpClient(); private final TagSystem tagSystem; private static final int MAX_SUGGESTIONS = 5; static final String ID_OPTION = "id"; @@ -56,17 +74,117 @@ public void onSlashCommand(SlashCommandInteractionEvent 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()); + 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 = + 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; + } + + // Compute link previews + event.deferReply().queue(); + + createLinkPreviews(links).thenAccept(linkPreviews -> { + if (linkPreviews.isEmpty()) { + // Did not find any previews + MessageEditBuilder message = new MessageEditBuilder().setEmbeds(contentEmbed); + replyToUserMention.ifPresent(message::setContent); + event.getHook().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).toList(); + + MessageEditBuilder message = + new MessageEditBuilder().setEmbeds(embeds).setFiles(attachments); + replyToUserMention.ifPresent(message::setContent); + event.getHook().editOriginal(message.build()).queue(); + }); + } + + private static List extractLinks(String content) { + return new UrlDetector(content, UrlDetectorOptions.BRACKET_MATCH).detect() + .stream() + .map(Url::getFullUrl) + .toList(); + } + + private static CompletableFuture> createLinkPreviews(List links) { + List>> tasks = IntStream.range(0, links.size()) + .mapToObj(i -> createLinkPreview(links.get(i), i + ".png")) + .toList(); + + return CompletableFuture.allOf(tasks.toArray(CompletableFuture[]::new)) + .thenApply(any -> tasks.stream() + .filter(Predicate.not(CompletableFuture::isCompletedExceptionally)) + .map(CompletableFuture::join) + .flatMap(Optional::stream) + .toList()); + } + + private record LinkPreview(FileUpload attachment, MessageEmbed embed) { + } - if (replyToUserOption != null) { - message = message.setContent(replyToUserOption.getAsUser().getAsMention()); + private static CompletableFuture> createLinkPreview(String link, + String name) { + URI linkAsUri; + try { + linkAsUri = URI.create(link); + } catch (IllegalArgumentException e) { + return CompletableFuture.completedFuture(Optional.empty()); } - message.queue(); + + 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) { + return Optional.empty(); + } + // TODO Add OpenGraph extraction "og:image" + if (!isResponseAnImage(response)) { + return Optional.empty(); + } + + FileUpload attachment = FileUpload.fromData(response.body(), name); + MessageEmbed embed = new EmbedBuilder() + // TODO Add title "og:title" and description "og:description", + // fallback to "twitter:title" and "twitter:description" if necessary + .setThumbnail("attachment://" + name) + .setColor(TagSystem.AMBIENT_COLOR) + .build(); + + return Optional.of(new LinkPreview(attachment, embed)); + }); + } + + private static boolean isResponseAnImage(HttpResponse response) { + return response.headers() + .firstValue("Content-Type") + .filter(contentType -> contentType.startsWith("image")) + .isPresent(); } @Override From 3970f4fdc37d1ba92609cc2daf05176a370bc4bc Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 7 Oct 2022 15:17:30 +0200 Subject: [PATCH 02/10] add todo --- .../java/org/togetherjava/tjbot/commands/tags/TagCommand.java | 1 + 1 file changed, 1 insertion(+) 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 bb9ff9b32d..db43c4aca6 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 @@ -129,6 +129,7 @@ private static List extractLinks(String content) { } private static CompletableFuture> createLinkPreviews(List links) { + // TODO This stuff needs some polishing, barely readable List>> tasks = IntStream.range(0, links.size()) .mapToObj(i -> createLinkPreview(links.get(i), i + ".png")) .toList(); From a3a8f8133cabdcf262dfe888134eb30fc323ae40 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 8 Oct 2022 22:14:36 +0200 Subject: [PATCH 03/10] draft for opengraph preview --- application/build.gradle | 2 + .../tjbot/commands/tags/TagCommand.java | 119 +++++++++++++++--- 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/application/build.gradle b/application/build.gradle index 4cf9327ed7..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' 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 db43c4aca6..be43e7527f 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 @@ -17,11 +17,16 @@ 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.jsoup.Jsoup; +import org.jsoup.nodes.Document; import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; import org.togetherjava.tjbot.commands.utils.StringDistances; +import javax.annotation.Nullable; + +import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URI; @@ -98,6 +103,7 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { // Compute link previews event.deferReply().queue(); + // TODO Move all that link preview stuff into some helper createLinkPreviews(links).thenAccept(linkPreviews -> { if (linkPreviews.isEmpty()) { // Did not find any previews @@ -143,10 +149,30 @@ private static CompletableFuture> createLinkPreviews(List> createLinkPreview(String link, - String name) { + String attachmentName) { URI linkAsUri; try { linkAsUri = URI.create(link); @@ -158,33 +184,28 @@ private static CompletableFuture> createLinkPreview(String CompletableFuture> task = CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); - return task.thenApply(response -> { + return task.thenCompose(response -> { int statusCode = response.statusCode(); if (statusCode < HttpURLConnection.HTTP_OK || statusCode >= HttpURLConnection.HTTP_MULT_CHOICE) { - return Optional.empty(); + return CompletableFuture.completedFuture(Optional.empty()); } - // TODO Add OpenGraph extraction "og:image" - if (!isResponseAnImage(response)) { - return Optional.empty(); + if (isResponseOfType(response, "image")) { + return CompletableFuture.completedFuture( + Optional.of(LinkPreview.ofThumbnail(attachmentName, response.body()))); + } + if (isResponseOfType(response, "text/html")) { + return parseWebsite(link, attachmentName, response.body()); } - FileUpload attachment = FileUpload.fromData(response.body(), name); - MessageEmbed embed = new EmbedBuilder() - // TODO Add title "og:title" and description "og:description", - // fallback to "twitter:title" and "twitter:description" if necessary - .setThumbnail("attachment://" + name) - .setColor(TagSystem.AMBIENT_COLOR) - .build(); - - return Optional.of(new LinkPreview(attachment, embed)); + return CompletableFuture.completedFuture(Optional.empty()); }); } - private static boolean isResponseAnImage(HttpResponse response) { + private static boolean isResponseOfType(HttpResponse response, String type) { return response.headers() .firstValue("Content-Type") - .filter(contentType -> contentType.startsWith("image")) + .filter(contentType -> contentType.startsWith(type)) .isPresent(); } @@ -205,4 +226,68 @@ public void onAutoComplete(CommandAutoCompleteInteractionEvent event) { event.replyChoices(choices).queue(); } + private static CompletableFuture> parseWebsite(String link, + String attachmentName, InputStream websiteContent) { + Document doc; + try { + doc = Jsoup.parse(websiteContent, null, link); + } catch (IOException e) { + return CompletableFuture.completedFuture(Optional.empty()); + } + + String title = parseOpenGraphTwitterMeta(doc, "title", doc.title()).orElse(null); + String description = + parseOpenGraphTwitterMeta(doc, "description", doc.title()).orElse(null); + String image = parseOpenGraphMeta(doc, "image").orElse(null); + + if (image == null) { + // TODO Can still do something + return CompletableFuture.completedFuture(Optional.empty()); + } + + // TODO Massive duplication + URI imageAsUri; + try { + imageAsUri = URI.create(image); + } catch (IllegalArgumentException e) { + return CompletableFuture.completedFuture(Optional.empty()); + } + + HttpRequest request = HttpRequest.newBuilder(imageAsUri).build(); + CompletableFuture> task = + CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); + + return task.thenCompose(response -> { + int statusCode = response.statusCode(); + if (statusCode < HttpURLConnection.HTTP_OK + || statusCode >= HttpURLConnection.HTTP_MULT_CHOICE) { + return CompletableFuture.completedFuture(Optional.empty()); + } + if (!isResponseOfType(response, "image")) { + return CompletableFuture.completedFuture(Optional.empty()); + } + return CompletableFuture.completedFuture(Optional.of(LinkPreview.ofContents(title, link, + description, attachmentName, response.body()))); + }); + } + + 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)); + } } From ee2f16a020bad843489bfbd26872329c34b0bba2 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Tue, 8 Nov 2022 09:50:32 +0100 Subject: [PATCH 04/10] Rebase adjustment --- .../tjbot/commands/tags/TagCommand.java | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) 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 be43e7527f..a42ebf11da 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 @@ -4,10 +4,10 @@ import com.linkedin.urls.detection.UrlDetector; import com.linkedin.urls.detection.UrlDetectorOptions; import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; 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.commands.Command; @@ -34,7 +34,10 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Instant; -import java.util.*; +import java.util.ArrayList; +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; @@ -70,6 +73,24 @@ public TagCommand(TagSystem tagSystem) { "Optionally, the user who you want to reply to", false)); } + @Override + public void onAutoComplete(CommandAutoCompleteInteractionEvent event) { + AutoCompleteQuery focusedOption = event.getFocusedOption(); + + if (!focusedOption.getName().equals(ID_OPTION)) { + throw new IllegalArgumentException( + "Unexpected option, was: " + focusedOption.getName()); + } + + Collection choices = StringDistances + .closeMatches(focusedOption.getValue(), tagSystem.getAllIds(), MAX_SUGGESTIONS) + .stream() + .map(id -> new Command.Choice(id, id)) + .toList(); + + event.replyChoices(choices).queue(); + } + @Override public void onSlashCommand(SlashCommandInteractionEvent event) { String id = event.getOption(ID_OPTION).getAsString(); @@ -209,23 +230,6 @@ private static boolean isResponseOfType(HttpResponse response, String type) { .isPresent(); } - @Override - public void onAutoComplete(CommandAutoCompleteInteractionEvent event) { - AutoCompleteQuery focusedOption = event.getFocusedOption(); - - if (!focusedOption.getName().equals(ID_OPTION)) { - throw new IllegalArgumentException( - "Unexpected option, was: " + focusedOption.getName()); - } - - Collection choices = StringDistances - .closeMatches(focusedOption.getValue(), tagSystem.getAllIds(), MAX_SUGGESTIONS) - .stream() - .map(id -> new Command.Choice(id, id)) - .toList(); - - event.replyChoices(choices).queue(); - } private static CompletableFuture> parseWebsite(String link, String attachmentName, InputStream websiteContent) { Document doc; From fa0b5c8145e4cfc67287cd2295cc08fa7eea761c Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Tue, 8 Nov 2022 10:15:20 +0100 Subject: [PATCH 05/10] moved link preview stuff into dedicated classes --- .../tjbot/commands/tags/TagCommand.java | 187 ++---------------- .../tjbot/commands/utils/LinkPreview.java | 30 +++ .../tjbot/commands/utils/LinkPreviews.java | 158 +++++++++++++++ 3 files changed, 203 insertions(+), 172 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreview.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreviews.java 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 a42ebf11da..167f54b6c0 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,8 +1,5 @@ package org.togetherjava.tjbot.commands.tags; -import com.linkedin.urls.Url; -import com.linkedin.urls.detection.UrlDetector; -import com.linkedin.urls.detection.UrlDetectorOptions; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; @@ -10,6 +7,7 @@ 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; @@ -17,30 +15,18 @@ 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.jsoup.Jsoup; -import org.jsoup.nodes.Document; 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 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.time.Instant; import java.util.ArrayList; 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; /** * Implements the {@code /tag} command which lets the bot respond content of a tag that has been @@ -50,7 +36,6 @@ * {@link TagsCommand}. */ public final class TagCommand extends SlashCommandAdapter { - private static final HttpClient CLIENT = HttpClient.newHttpClient(); private final TagSystem tagSystem; private static final int MAX_SUGGESTIONS = 5; static final String ID_OPTION = "id"; @@ -111,8 +96,10 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { .map(OptionMapping::getAsUser) .map(User::getAsMention); - List links = - extractLinks(tagContent).stream().limit(Message.MAX_EMBED_COUNT - 1L).toList(); + List links = LinkPreviews.extractLinks(tagContent) + .stream() + .limit(Message.MAX_EMBED_COUNT - 1L) + .toList(); if (links.isEmpty()) { // No link previews ReplyCallbackAction message = event.replyEmbeds(contentEmbed); @@ -121,16 +108,19 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { return; } - // Compute link previews event.deferReply().queue(); - // TODO Move all that link preview stuff into some helper - createLinkPreviews(links).thenAccept(linkPreviews -> { + 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); - event.getHook().editOriginal(message.build()).queue(); + eventHook.editOriginal(message.build()).queue(); return; } @@ -144,154 +134,7 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { MessageEditBuilder message = new MessageEditBuilder().setEmbeds(embeds).setFiles(attachments); replyToUserMention.ifPresent(message::setContent); - event.getHook().editOriginal(message.build()).queue(); - }); - } - - private static List extractLinks(String content) { - return new UrlDetector(content, UrlDetectorOptions.BRACKET_MATCH).detect() - .stream() - .map(Url::getFullUrl) - .toList(); - } - - private static CompletableFuture> createLinkPreviews(List links) { - // TODO This stuff needs some polishing, barely readable - List>> tasks = IntStream.range(0, links.size()) - .mapToObj(i -> createLinkPreview(links.get(i), i + ".png")) - .toList(); - - return CompletableFuture.allOf(tasks.toArray(CompletableFuture[]::new)) - .thenApply(any -> tasks.stream() - .filter(Predicate.not(CompletableFuture::isCompletedExceptionally)) - .map(CompletableFuture::join) - .flatMap(Optional::stream) - .toList()); - } - - private record LinkPreview(FileUpload attachment, MessageEmbed embed) { - static LinkPreview ofContents(@Nullable String title, String url, - @Nullable String description, String thumbnailName, InputStream thumbnail) { - FileUpload attachment = FileUpload.fromData(thumbnail, thumbnailName); - MessageEmbed embed = new EmbedBuilder().setTitle(title, url) - .setDescription(description) - .setThumbnail("attachment://" + thumbnailName) - .setColor(TagSystem.AMBIENT_COLOR) - .build(); - - return new LinkPreview(attachment, embed); - } - - static LinkPreview ofThumbnail(String thumbnailName, InputStream thumbnail) { - FileUpload attachment = FileUpload.fromData(thumbnail, thumbnailName); - MessageEmbed embed = new EmbedBuilder().setThumbnail("attachment://" + thumbnailName) - .setColor(TagSystem.AMBIENT_COLOR) - .build(); - - return new LinkPreview(attachment, embed); - } - } - - private static CompletableFuture> createLinkPreview(String link, - String attachmentName) { - URI linkAsUri; - try { - linkAsUri = URI.create(link); - } catch (IllegalArgumentException e) { - return CompletableFuture.completedFuture(Optional.empty()); - } - - HttpRequest request = HttpRequest.newBuilder(linkAsUri).build(); - CompletableFuture> task = - CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); - - return task.thenCompose(response -> { - int statusCode = response.statusCode(); - if (statusCode < HttpURLConnection.HTTP_OK - || statusCode >= HttpURLConnection.HTTP_MULT_CHOICE) { - return CompletableFuture.completedFuture(Optional.empty()); - } - if (isResponseOfType(response, "image")) { - return CompletableFuture.completedFuture( - Optional.of(LinkPreview.ofThumbnail(attachmentName, response.body()))); - } - if (isResponseOfType(response, "text/html")) { - return parseWebsite(link, attachmentName, response.body()); - } - - return CompletableFuture.completedFuture(Optional.empty()); + eventHook.editOriginal(message.build()).queue(); }); } - - private static boolean isResponseOfType(HttpResponse response, String type) { - return response.headers() - .firstValue("Content-Type") - .filter(contentType -> contentType.startsWith(type)) - .isPresent(); - } - - private static CompletableFuture> parseWebsite(String link, - String attachmentName, InputStream websiteContent) { - Document doc; - try { - doc = Jsoup.parse(websiteContent, null, link); - } catch (IOException e) { - return CompletableFuture.completedFuture(Optional.empty()); - } - - String title = parseOpenGraphTwitterMeta(doc, "title", doc.title()).orElse(null); - String description = - parseOpenGraphTwitterMeta(doc, "description", doc.title()).orElse(null); - String image = parseOpenGraphMeta(doc, "image").orElse(null); - - if (image == null) { - // TODO Can still do something - return CompletableFuture.completedFuture(Optional.empty()); - } - - // TODO Massive duplication - URI imageAsUri; - try { - imageAsUri = URI.create(image); - } catch (IllegalArgumentException e) { - return CompletableFuture.completedFuture(Optional.empty()); - } - - HttpRequest request = HttpRequest.newBuilder(imageAsUri).build(); - CompletableFuture> task = - CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); - - return task.thenCompose(response -> { - int statusCode = response.statusCode(); - if (statusCode < HttpURLConnection.HTTP_OK - || statusCode >= HttpURLConnection.HTTP_MULT_CHOICE) { - return CompletableFuture.completedFuture(Optional.empty()); - } - if (!isResponseOfType(response, "image")) { - return CompletableFuture.completedFuture(Optional.empty()); - } - return CompletableFuture.completedFuture(Optional.of(LinkPreview.ofContents(title, link, - description, attachmentName, response.body()))); - }); - } - - 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)); - } } 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..a4b6a033e7 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreview.java @@ -0,0 +1,30 @@ +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; + +public record LinkPreview(FileUpload attachment, MessageEmbed embed) { + static LinkPreview ofContents(@Nullable String title, String url, @Nullable String description, + String thumbnailName, InputStream thumbnail) { + FileUpload attachment = FileUpload.fromData(thumbnail, thumbnailName); + MessageEmbed embed = new EmbedBuilder().setTitle(title, url) + .setDescription(description) + .setThumbnail("attachment://" + thumbnailName) + .build(); + + return new LinkPreview(attachment, embed); + } + + static LinkPreview ofThumbnail(String thumbnailName, InputStream thumbnail) { + FileUpload attachment = FileUpload.fromData(thumbnail, thumbnailName); + MessageEmbed embed = + new EmbedBuilder().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..5cfc3165b7 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreviews.java @@ -0,0 +1,158 @@ +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 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.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; +import java.util.stream.IntStream; + +public final class LinkPreviews { + private static final HttpClient CLIENT = HttpClient.newHttpClient(); + + private LinkPreviews() { + throw new UnsupportedOperationException("Utility class"); + } + + public static List extractLinks(String content) { + return new UrlDetector(content, UrlDetectorOptions.BRACKET_MATCH).detect() + .stream() + .map(Url::getFullUrl) + .toList(); + } + + public static CompletableFuture> createLinkPreviews(List links) { + if (links.isEmpty()) { + return CompletableFuture.completedFuture(List.of()); + } + + // TODO This stuff needs some polishing, barely readable + List>> tasks = IntStream.range(0, links.size()) + .mapToObj(i -> createLinkPreview(links.get(i), i + ".png")) + .toList(); + + return CompletableFuture.allOf(tasks.toArray(CompletableFuture[]::new)) + .thenApply(any -> tasks.stream() + .filter(Predicate.not(CompletableFuture::isCompletedExceptionally)) + .map(CompletableFuture::join) + .flatMap(Optional::stream) + .toList()); + } + + private static CompletableFuture> createLinkPreview(String link, + String attachmentName) { + URI linkAsUri; + try { + linkAsUri = URI.create(link); + } catch (IllegalArgumentException e) { + return CompletableFuture.completedFuture(Optional.empty()); + } + + HttpRequest request = HttpRequest.newBuilder(linkAsUri).build(); + CompletableFuture> task = + CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); + + return task.thenCompose(response -> { + int statusCode = response.statusCode(); + if (statusCode < HttpURLConnection.HTTP_OK + || statusCode >= HttpURLConnection.HTTP_MULT_CHOICE) { + return CompletableFuture.completedFuture(Optional.empty()); + } + if (isResponseOfType(response, "image")) { + return CompletableFuture.completedFuture( + Optional.of(LinkPreview.ofThumbnail(attachmentName, response.body()))); + } + if (isResponseOfType(response, "text/html")) { + return parseWebsite(link, attachmentName, response.body()); + } + + return CompletableFuture.completedFuture(Optional.empty()); + }); + } + + private static boolean isResponseOfType(HttpResponse response, String type) { + return response.headers() + .firstValue("Content-Type") + .filter(contentType -> contentType.startsWith(type)) + .isPresent(); + } + + private static CompletableFuture> parseWebsite(String link, + String attachmentName, InputStream websiteContent) { + Document doc; + try { + doc = Jsoup.parse(websiteContent, null, link); + } catch (IOException e) { + return CompletableFuture.completedFuture(Optional.empty()); + } + + String title = parseOpenGraphTwitterMeta(doc, "title", doc.title()).orElse(null); + String description = + parseOpenGraphTwitterMeta(doc, "description", doc.title()).orElse(null); + String image = parseOpenGraphMeta(doc, "image").orElse(null); + + if (image == null) { + // TODO Can still do something + return CompletableFuture.completedFuture(Optional.empty()); + } + + // TODO Massive duplication + URI imageAsUri; + try { + imageAsUri = URI.create(image); + } catch (IllegalArgumentException e) { + return CompletableFuture.completedFuture(Optional.empty()); + } + + HttpRequest request = HttpRequest.newBuilder(imageAsUri).build(); + CompletableFuture> task = + CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); + + return task.thenCompose(response -> { + int statusCode = response.statusCode(); + if (statusCode < HttpURLConnection.HTTP_OK + || statusCode >= HttpURLConnection.HTTP_MULT_CHOICE) { + return CompletableFuture.completedFuture(Optional.empty()); + } + if (!isResponseOfType(response, "image")) { + return CompletableFuture.completedFuture(Optional.empty()); + } + return CompletableFuture.completedFuture(Optional.of(LinkPreview.ofContents(title, link, + description, attachmentName, response.body()))); + }); + } + + 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)); + } +} From 13366489707b2ea1dd0825f19a1004ae4abf09c4 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 11 Nov 2022 11:28:37 +0100 Subject: [PATCH 06/10] Reworked/Improved link previews --- .../tjbot/commands/tags/TagCommand.java | 11 +- .../tjbot/commands/utils/LinkPreview.java | 29 +++-- .../tjbot/commands/utils/LinkPreviews.java | 109 ++++++++++-------- 3 files changed, 84 insertions(+), 65 deletions(-) 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 167f54b6c0..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 @@ -23,10 +23,7 @@ import org.togetherjava.tjbot.commands.utils.StringDistances; import java.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Optional; +import java.util.*; /** * Implements the {@code /tag} command which lets the bot respond content of a tag that has been @@ -128,8 +125,10 @@ private void respondWithLinkPreviews(InteractionHook eventHook, List lin embeds.add(contentEmbed); embeds.addAll(linkPreviews.stream().map(LinkPreview::embed).toList()); - List attachments = - linkPreviews.stream().map(LinkPreview::attachment).toList(); + List attachments = linkPreviews.stream() + .map(LinkPreview::attachment) + .filter(Objects::nonNull) + .toList(); MessageEditBuilder message = new MessageEditBuilder().setEmbeds(embeds).setFiles(attachments); 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 index a4b6a033e7..a5e952fefa 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreview.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreview.java @@ -8,22 +8,29 @@ import java.io.InputStream; -public record LinkPreview(FileUpload attachment, MessageEmbed embed) { - static LinkPreview ofContents(@Nullable String title, String url, @Nullable String description, - String thumbnailName, InputStream thumbnail) { - FileUpload attachment = FileUpload.fromData(thumbnail, thumbnailName); - MessageEmbed embed = new EmbedBuilder().setTitle(title, url) - .setDescription(description) - .setThumbnail("attachment://" + thumbnailName) - .build(); - - return new LinkPreview(attachment, embed); +public record LinkPreview(@Nullable FileUpload attachment, MessageEmbed embed) { + LinkPreview withThumbnail(String thumbnailName, InputStream thumbnail) { + return withThumbnail(embed, thumbnailName, thumbnail); } static LinkPreview ofThumbnail(String thumbnailName, InputStream thumbnail) { + return withThumbnail(null, thumbnailName, thumbnail); + } + + static LinkPreview ofContents(@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 withThumbnail(@Nullable MessageEmbed embedToDecorate, + String thumbnailName, InputStream thumbnail) { FileUpload attachment = FileUpload.fromData(thumbnail, thumbnailName); MessageEmbed embed = - new EmbedBuilder().setThumbnail("attachment://" + thumbnailName).build(); + 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 index 5cfc3165b7..e8729a65a0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreviews.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreviews.java @@ -15,6 +15,7 @@ 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; @@ -22,6 +23,8 @@ import java.util.stream.IntStream; public final class LinkPreviews { + 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() { @@ -40,55 +43,68 @@ public static CompletableFuture> createLinkPreviews(List>> tasks = IntStream.range(0, links.size()) .mapToObj(i -> createLinkPreview(links.get(i), i + ".png")) .toList(); - return CompletableFuture.allOf(tasks.toArray(CompletableFuture[]::new)) - .thenApply(any -> tasks.stream() - .filter(Predicate.not(CompletableFuture::isCompletedExceptionally)) - .map(CompletableFuture::join) - .flatMap(Optional::stream) - .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 readLinkAsync(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> readLinkAsync(String link) { URI linkAsUri; try { linkAsUri = URI.create(link); } catch (IllegalArgumentException e) { - return CompletableFuture.completedFuture(Optional.empty()); + return noResult(); } HttpRequest request = HttpRequest.newBuilder(linkAsUri).build(); CompletableFuture> task = CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); - return task.thenCompose(response -> { + return task.thenApply(response -> { int statusCode = response.statusCode(); if (statusCode < HttpURLConnection.HTTP_OK || statusCode >= HttpURLConnection.HTTP_MULT_CHOICE) { - return CompletableFuture.completedFuture(Optional.empty()); - } - if (isResponseOfType(response, "image")) { - return CompletableFuture.completedFuture( - Optional.of(LinkPreview.ofThumbnail(attachmentName, response.body()))); - } - if (isResponseOfType(response, "text/html")) { - return parseWebsite(link, attachmentName, response.body()); + return Optional.empty(); } - return CompletableFuture.completedFuture(Optional.empty()); + String contentType = response.headers().firstValue("Content-Type").orElse(""); + return Optional.of(new HttpContent(contentType, response.body())); }); } - private static boolean isResponseOfType(HttpResponse response, String type) { - return response.headers() - .firstValue("Content-Type") - .filter(contentType -> contentType.startsWith(type)) - .isPresent(); + private record HttpContent(String type, InputStream dataStream) { } private static CompletableFuture> parseWebsite(String link, @@ -97,42 +113,31 @@ private static CompletableFuture> parseWebsite(String link try { doc = Jsoup.parse(websiteContent, null, link); } catch (IOException e) { - return CompletableFuture.completedFuture(Optional.empty()); + return noResult(); } String title = parseOpenGraphTwitterMeta(doc, "title", doc.title()).orElse(null); String description = parseOpenGraphTwitterMeta(doc, "description", doc.title()).orElse(null); - String image = parseOpenGraphMeta(doc, "image").orElse(null); - if (image == null) { - // TODO Can still do something - return CompletableFuture.completedFuture(Optional.empty()); - } + LinkPreview textPreview = LinkPreview.ofContents(title, link, description); - // TODO Massive duplication - URI imageAsUri; - try { - imageAsUri = URI.create(image); - } catch (IllegalArgumentException e) { - return CompletableFuture.completedFuture(Optional.empty()); + String image = parseOpenGraphMeta(doc, IMAGE_META_NAME).orElse(null); + if (image == null) { + return result(textPreview); } - HttpRequest request = HttpRequest.newBuilder(imageAsUri).build(); - CompletableFuture> task = - CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); - - return task.thenCompose(response -> { - int statusCode = response.statusCode(); - if (statusCode < HttpURLConnection.HTTP_OK - || statusCode >= HttpURLConnection.HTTP_MULT_CHOICE) { - return CompletableFuture.completedFuture(Optional.empty()); + return readLinkAsync(image).thenCompose(maybeContent -> { + if (maybeContent.isEmpty()) { + return result(textPreview); } - if (!isResponseOfType(response, "image")) { - return CompletableFuture.completedFuture(Optional.empty()); + HttpContent content = maybeContent.orElseThrow(); + + if (!content.type.startsWith(IMAGE_CONTENT_TYPE_PREFIX)) { + return result(textPreview); } - return CompletableFuture.completedFuture(Optional.of(LinkPreview.ofContents(title, link, - description, attachmentName, response.body()))); + + return result(textPreview.withThumbnail(attachmentName, content.dataStream)); }); } @@ -155,4 +160,12 @@ private static Optional parseOpenGraphMeta(Document doc, String metaProp .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)); + } } From 6938b6748494e70ee967acce5d9d795eaf086e79 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 11 Nov 2022 11:29:14 +0100 Subject: [PATCH 07/10] Bugfix with tag-autocomplete with small amount of tags --- .../org/togetherjava/tjbot/commands/utils/StringDistances.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); From cc74e138e40f9f5440e2ab354406d821d13466d5 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 11 Nov 2022 11:43:09 +0100 Subject: [PATCH 08/10] javadoc --- .../tjbot/commands/utils/LinkPreview.java | 40 ++++++++++++++++++- .../tjbot/commands/utils/LinkPreviews.java | 31 ++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) 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 index a5e952fefa..2d5db56838 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreview.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreview.java @@ -8,17 +8,53 @@ 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 withThumbnail(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 withThumbnail(null, thumbnailName, thumbnail); } - static LinkPreview ofContents(@Nullable String title, String url, - @Nullable String description) { + /** + * 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(); 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 index e8729a65a0..6ccbb1c810 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreviews.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreviews.java @@ -22,15 +22,26 @@ 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 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() @@ -38,6 +49,18 @@ public static List extractLinks(String content) { .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()); @@ -63,7 +86,7 @@ private static List extractResults( private static CompletableFuture> createLinkPreview(String link, String attachmentName) { - return readLinkAsync(link).thenCompose(maybeContent -> { + return readLinkContent(link).thenCompose(maybeContent -> { if (maybeContent.isEmpty()) { return noResult(); } @@ -80,7 +103,7 @@ private static CompletableFuture> createLinkPreview(String }); } - private static CompletableFuture> readLinkAsync(String link) { + private static CompletableFuture> readLinkContent(String link) { URI linkAsUri; try { linkAsUri = URI.create(link); @@ -120,14 +143,14 @@ private static CompletableFuture> parseWebsite(String link String description = parseOpenGraphTwitterMeta(doc, "description", doc.title()).orElse(null); - LinkPreview textPreview = LinkPreview.ofContents(title, link, description); + LinkPreview textPreview = LinkPreview.ofText(title, link, description); String image = parseOpenGraphMeta(doc, IMAGE_META_NAME).orElse(null); if (image == null) { return result(textPreview); } - return readLinkAsync(image).thenCompose(maybeContent -> { + return readLinkContent(image).thenCompose(maybeContent -> { if (maybeContent.isEmpty()) { return result(textPreview); } From 16f78d76322d3318828a2ba639ddf330991d5a91 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 11 Nov 2022 11:45:19 +0100 Subject: [PATCH 09/10] minor naming issue --- .../org/togetherjava/tjbot/commands/utils/LinkPreview.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 2d5db56838..e6a0e66509 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreview.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreview.java @@ -29,7 +29,7 @@ public record LinkPreview(@Nullable FileUpload attachment, MessageEmbed embed) { * @return this preview, but with a thumbnail */ LinkPreview withThumbnail(String thumbnailName, InputStream thumbnail) { - return withThumbnail(embed, thumbnailName, thumbnail); + return createWithThumbnail(embed, thumbnailName, thumbnail); } /** @@ -40,7 +40,7 @@ LinkPreview withThumbnail(String thumbnailName, InputStream thumbnail) { * @return the thumbnail as link preview */ static LinkPreview ofThumbnail(String thumbnailName, InputStream thumbnail) { - return withThumbnail(null, thumbnailName, thumbnail); + return createWithThumbnail(null, thumbnailName, thumbnail); } /** @@ -61,7 +61,7 @@ static LinkPreview ofText(@Nullable String title, String url, @Nullable String d return new LinkPreview(null, embed); } - private static LinkPreview withThumbnail(@Nullable MessageEmbed embedToDecorate, + private static LinkPreview createWithThumbnail(@Nullable MessageEmbed embedToDecorate, String thumbnailName, InputStream thumbnail) { FileUpload attachment = FileUpload.fromData(thumbnail, thumbnailName); MessageEmbed embed = From 9ba00ea268f886ccd47a7d2a61eb830822d5fa62 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 11 Nov 2022 11:52:21 +0100 Subject: [PATCH 10/10] logger warnings --- .../togetherjava/tjbot/commands/utils/LinkPreviews.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 index 6ccbb1c810..bdbc1e556c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreviews.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/LinkPreviews.java @@ -5,6 +5,8 @@ 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; @@ -27,6 +29,8 @@ * {@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"; @@ -71,7 +75,6 @@ public static CompletableFuture> createLinkPreviews(List extractResults(tasks)); } @@ -108,6 +111,7 @@ private static CompletableFuture> readLinkContent(String l 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(); } @@ -119,6 +123,8 @@ private static CompletableFuture> readLinkContent(String l 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(); } @@ -136,6 +142,7 @@ private static CompletableFuture> parseWebsite(String link 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(); }