diff --git a/src/main/java/org/javacord/bot/commands/WikiCommand.java b/src/main/java/org/javacord/bot/commands/WikiCommand.java index b872207..a367890 100644 --- a/src/main/java/org/javacord/bot/commands/WikiCommand.java +++ b/src/main/java/org/javacord/bot/commands/WikiCommand.java @@ -2,28 +2,165 @@ import de.btobastian.sdcf4j.Command; import de.btobastian.sdcf4j.CommandExecutor; +import org.javacord.api.DiscordApi; import org.javacord.api.entity.channel.TextChannel; import org.javacord.api.entity.message.embed.EmbedBuilder; +import org.javacord.api.util.logging.ExceptionLogger; import org.javacord.bot.Constants; +import org.javacord.bot.util.wiki.parser.WikiPage; +import org.javacord.bot.util.wiki.parser.WikiParser; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * The !wiki command which is used to link to Javacord's wiki. */ public class WikiCommand implements CommandExecutor { + private static final Pattern HTML_TAG = Pattern.compile("<[^>]++>"); + /** * Executes the {@code !wiki} command. * + * @param api The Discord api. * @param channel The channel where the command was issued. + * @param args The command's arguments. + * @throws IOException If the connection to the wiki failed. */ @Command(aliases = {"!wiki"}, async = true) - public void onCommand(TextChannel channel) { - EmbedBuilder embed = new EmbedBuilder() - .setTitle("Javacord Wiki") - .setDescription("https://javacord.org/wiki") - .setThumbnail(getClass().getClassLoader().getResourceAsStream("javacord3_icon.png"), "png") - .setColor(Constants.JAVACORD_ORANGE); - channel.sendMessage(embed).join(); + public void onCommand(DiscordApi api, TextChannel channel, String[] args) throws IOException { + try { + if (args.length == 0) { // Just an overview + EmbedBuilder embed = new EmbedBuilder() + .setTitle("Javacord Wiki") + .setDescription("The [Javacord Wiki](" + WikiParser.BASE_URL + "/wiki) is an excellent " + + "resource to get you started with Javacord.\n") + .addInlineField("Hint", "You can search the wiki using `!wiki [title|full] ") + .setThumbnail(getClass().getClassLoader().getResourceAsStream("javacord3_icon.png"), "png") + .setColor(Constants.JAVACORD_ORANGE); + channel.sendMessage(embed).join(); + } else { + EmbedBuilder embed = new EmbedBuilder() + .setThumbnail(getClass().getClassLoader().getResourceAsStream("javacord3_icon.png"), "png") + .setColor(Constants.JAVACORD_ORANGE); + String searchString = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).toLowerCase(); + switch (args[0]) { + case "page": + case "p": + case "title": + case "t": + populatePages(api, embed, titleOnly(searchString)); + break; + case "full": + case "f": + case "content": + case "c": + populatePages(api, embed, fullSearch(searchString)); + break; + default: + searchString = String.join(" ", Arrays.copyOfRange(args, 0, args.length)).toLowerCase(); + populatePages(api, embed, defaultSearch(searchString)); + } + channel.sendMessage(embed).join(); + } + } catch (Throwable t) { + channel.sendMessage("Something went wrong: ```" + ExceptionLogger.unwrapThrowable(t).getMessage() + "```") + .join(); + // Throw the caught exception again. The sdcf4j will log it. + throw t; + } + } + + private Predicate defaultSearch(String searchString) { + return titleOnly(searchString).or(keywordsOnly(searchString)); + } + + private Predicate fullSearch(String searchString) { + return titleOnly(searchString).or(keywordsOnly(searchString)).or(contentOnly(searchString)); + } + + private Predicate titleOnly(String searchString) { + return p -> p.getTitle().toLowerCase().contains(searchString); + } + + private Predicate keywordsOnly(String searchString) { + return p -> Arrays.stream(p.getKeywords()) + .map(String::toLowerCase) + .anyMatch(k -> k.contains(searchString)); + } + + private Predicate contentOnly(String searchString) { + return p -> p.getContent().toLowerCase().contains(searchString); + } + + + private void populatePages(DiscordApi api, EmbedBuilder embed, Predicate criteria) throws IOException { + List pages; + + pages = new WikiParser(api) + .getPagesBlocking().stream() + .filter(criteria) + .sorted() + .collect(Collectors.toList()); + + if (pages.isEmpty()) { + embed.setTitle("Javacord Wiki"); + embed.setUrl(WikiParser.BASE_URL + "/wiki/"); + embed.setDescription("No pages found. Maybe try another search."); + embed.addField("Standard Search", "Use `!wiki ` to search page titles and keywords."); + embed.addField("Title Search", "Use `!wiki [page|p|title|t] ` to exclusively search page titles."); + embed.addField("Full Search", "Use `!wiki [full|f|content|c] ` to perform a full search."); + } else if (pages.size() == 1) { + WikiPage page = pages.get(0); + displayPagePreview(embed, page); + } else { + displayPageList(embed, pages); + } + } + + private void displayPagePreview(EmbedBuilder embed, WikiPage page) { + embed.setTitle("Javacord Wiki"); + String cleanedDescription = HTML_TAG.matcher(page.getContent()).replaceAll("").trim(); + int length = 0; + int sentences = 0; + while (length < 600 && sentences < 3) { + int tmpLength = cleanedDescription.indexOf(". ", length); + length = (tmpLength > length) ? tmpLength : cleanedDescription.indexOf(".\n"); + + sentences++; + } + StringBuilder description = new StringBuilder() + .append(String.format("**[%s](%s)**\n\n", page.getTitle(), WikiParser.BASE_URL + page.getUrl())) + .append(cleanedDescription, 0, length + 1); + if (length < cleanedDescription.length()) { + description.append("\n\n[*view full page*](").append(WikiParser.BASE_URL).append(page.getUrl()).append(")"); + } + embed.setDescription(description.toString()); + } + + private void displayPageList(EmbedBuilder embed, List pages) { + embed.setTitle("Javacord Wiki"); + embed.setUrl(WikiParser.BASE_URL + "/wiki/"); + + StringBuilder builder = new StringBuilder(); + int counter = 0; + for (WikiPage page : pages) { + String pageLink = "• " + page.asMarkdownLink(); + if (builder.length() + pageLink.length() > 1900) { // Prevent hitting the description size limit + break; + } + builder.append(pageLink).append("\n"); + counter++; + } + if (pages.size() > counter) { + builder.append("and ").append(pages.size() - counter).append(" more ..."); + } + embed.setDescription(builder.toString()); } } diff --git a/src/main/java/org/javacord/bot/util/wiki/parser/WikiPage.java b/src/main/java/org/javacord/bot/util/wiki/parser/WikiPage.java new file mode 100644 index 0000000..83b7a90 --- /dev/null +++ b/src/main/java/org/javacord/bot/util/wiki/parser/WikiPage.java @@ -0,0 +1,78 @@ +package org.javacord.bot.util.wiki.parser; + +/** + * A class representing a page on the wiki. + */ +public class WikiPage implements Comparable { + + private final String title; + private final String[] keywords; + private final String url; + private final String content; + + /** + * Creates a new wiki page. + * + * @param title The title of the page. + * @param keywords The keywords the page is tagged with. + * @param url The URL of the page, relative to the wiki's base URL. + * @param content The content of the page. + */ + public WikiPage(String title, String[] keywords, String url, String content) { + this.title = title; + this.keywords = keywords; + this.url = url; + this.content = content; + } + + /** + * Gets the title. + * + * @return The title of the page. + */ + public String getTitle() { + return title; + } + + /** + * Gets the keywords. + * + * @return The keywords for the page. + */ + public String[] getKeywords() { + return keywords; + } + + /** + * Gets the relative URL. + * + * @return The page URL. + */ + public String getUrl() { + return url; + } + + /** + * Gets the content. + * + * @return The content of the page. + */ + public String getContent() { + return content; + } + + /** + * Gets a markdown-formatted link to the page. + * + * @return The markdown for a link to the page. + */ + public String asMarkdownLink() { + return String.format("[%s](%s)", title, WikiParser.BASE_URL + url); + } + + @Override + public int compareTo(WikiPage that) { + return this.title.compareTo(that.title); + } + +} diff --git a/src/main/java/org/javacord/bot/util/wiki/parser/WikiParser.java b/src/main/java/org/javacord/bot/util/wiki/parser/WikiParser.java new file mode 100644 index 0000000..ff875cf --- /dev/null +++ b/src/main/java/org/javacord/bot/util/wiki/parser/WikiParser.java @@ -0,0 +1,114 @@ +package org.javacord.bot.util.wiki.parser; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.javacord.api.DiscordApi; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +/** + * A parser for the Javacord wiki. + */ +public class WikiParser { + + public static final String API_URL = "https://javacord.org/api/wiki.json"; + public static final String BASE_URL = "https://javacord.org"; // the /wiki/ part of the url will be returned by the API + + private static final OkHttpClient client = new OkHttpClient(); + private static final ObjectMapper mapper = new ObjectMapper(); + + private final DiscordApi discordApi; + private final String apiUrl; + + /** + * Creates a new wiki parser. + * + * @param api The Discord Api of which to use the HTTP client. + */ + public WikiParser(DiscordApi api) { + this(api, API_URL); + } + + /** + * Creates a new Wiki parser. + * + * @param api The Discord Api of which to use the HTTP client. + * @param apiUrl The URL for the json file with the page list. + */ + public WikiParser(DiscordApi api, String apiUrl) { + this.discordApi = api; + this.apiUrl = apiUrl; + } + + /** + * Gets the pages asynchronously. + * + * @return The pages of the wiki. + */ + public CompletableFuture> getPages() { + return CompletableFuture.supplyAsync(() -> { + try { + return getPagesBlocking(); + } catch (Throwable t) { + throw new CompletionException(t); + } + }, discordApi.getThreadPool().getExecutorService()); + } + + /** + * Gets the pages synchronously. + * + * @return The pages of the wiki. + * @throws IOException If the connection to the wiki failed. + */ + public Set getPagesBlocking() throws IOException { + Request request = new Request.Builder() + .url(apiUrl) + .build(); + + Response response = client.newCall(request).execute(); + ResponseBody body = response.body(); + Set pages = new HashSet<>(); + if (body == null) { + return pages; + } + JsonNode array = mapper.readTree(body.charStream()); + if (!array.isArray()) { + throw new AssertionError("Format of wiki page list not as expected"); + } + for (JsonNode node : array) { + if (node.has("title") && node.has("keywords") && node.has("url") && node.has("content")) { + pages.add(new WikiPage( + node.get("title").asText(), + asStringArray(node.get("keywords")), + node.get("url").asText(), + node.get("content").asText() + )); + } else { + throw new AssertionError("Format of wiki page list not as expected"); + } + } + return pages; + } + + private String[] asStringArray(JsonNode arrayNode) { + if (!arrayNode.isArray()) { + return new String[] {}; + } + String[] result = new String[arrayNode.size()]; + int i = 0; + for (JsonNode node : arrayNode) { + result[i++] = node.asText(); + } + return result; + } + +}