From 2fec219dc94d9f5c32e1e34aa9289c1d7387eb5c Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sun, 15 Sep 2024 21:19:09 -0700 Subject: [PATCH] Add search command Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .gitignore | 4 + bot.properties.example | 4 + build.gradle | 6 + .../org/geysermc/discordbot/GeyserBot.java | 12 + .../commands/search/SearchCommand.java | 372 ++++++++ .../discordbot/util/DocSearchResult.java | 833 ++++++++++++++++++ .../geysermc/discordbot/util/PageUtils.java | 202 +++++ .../discordbot/util/PropertiesManager.java | 28 + 8 files changed, 1461 insertions(+) create mode 100644 src/main/java/org/geysermc/discordbot/commands/search/SearchCommand.java create mode 100644 src/main/java/org/geysermc/discordbot/util/DocSearchResult.java create mode 100644 src/main/java/org/geysermc/discordbot/util/PageUtils.java diff --git a/.gitignore b/.gitignore index 3b097af1..b1aeed74 100644 --- a/.gitignore +++ b/.gitignore @@ -164,6 +164,9 @@ cmake-build-*/ # IntelliJ out/ +# VSCode +.vscode/ + # mpeltonen/sbt-idea plugin .idea_modules/ @@ -237,3 +240,4 @@ gradle-app.setting # End of https://www.toptal.com/developers/gitignore/api/git,java,gradle,eclipse,netbeans,jetbrains+all,visualstudiocode. bot.properties +bot \ No newline at end of file diff --git a/bot.properties.example b/bot.properties.example index 4b2cfb0d..907581d9 100644 --- a/bot.properties.example +++ b/bot.properties.example @@ -12,3 +12,7 @@ github-token: github_oauth_token sentry-dsn: https://xxx@xxx.ingest.sentry.io/xxx sentry-env: production ocr-path: /usr/share/tesseract-ocr/4.00/tessdata +algolia-application-id: 0DTHI9QFCH +algolia-search-api-key: 3cc0567f76d2ed3ffdb4cc94f0ac9815 +algolia-index-name: geysermc +algolia-site-search-url: https://geysermc.org/search?q= diff --git a/build.gradle b/build.gradle index 1f46755f..ef02df7e 100644 --- a/build.gradle +++ b/build.gradle @@ -89,6 +89,12 @@ dependencies { // Image processing and OCR implementation 'net.sourceforge.tess4j:tess4j:5.12.0' implementation 'org.imgscalr:imgscalr-lib:4.2' + + // Agolia Search (For Wiki) + // We should eventually switch to v4, but currently it doesn't serialize POJOs correctly... + // implementation 'com.algolia:algoliasearch:4.3.1' + implementation 'com.algolia:algoliasearch-core:3.16.9' + implementation 'com.algolia:algoliasearch-java-net:3.16.9' } jar { diff --git a/src/main/java/org/geysermc/discordbot/GeyserBot.java b/src/main/java/org/geysermc/discordbot/GeyserBot.java index 7c8b3ce5..dd11967c 100644 --- a/src/main/java/org/geysermc/discordbot/GeyserBot.java +++ b/src/main/java/org/geysermc/discordbot/GeyserBot.java @@ -25,6 +25,8 @@ package org.geysermc.discordbot; +import com.algolia.search.DefaultSearchClient; +import com.algolia.search.SearchIndex; import com.jagrosh.jdautilities.command.Command; import com.jagrosh.jdautilities.command.CommandClientBuilder; import com.jagrosh.jdautilities.command.ContextMenu; @@ -51,6 +53,7 @@ import org.geysermc.discordbot.tags.TagsManager; import org.geysermc.discordbot.updates.UpdateManager; import org.geysermc.discordbot.util.BotHelpers; +import org.geysermc.discordbot.util.DocSearchResult; import org.geysermc.discordbot.util.PropertiesManager; import org.geysermc.discordbot.util.RssFeedManager; import org.geysermc.discordbot.util.SentryEventManager; @@ -87,6 +90,7 @@ public class GeyserBot { private static JDA jda; private static GitHub github; private static Server httpServer; + private static SearchIndex algolia; static { // Gathers all commands from "commands" package. @@ -161,6 +165,10 @@ public static void main(String[] args) throws IOException { // Connect to github github = new GitHubBuilder().withOAuthToken(PropertiesManager.getGithubToken()).build(); + // Connect to Algolia + algolia = DefaultSearchClient.create(PropertiesManager.getAlgoliaApplicationId(), PropertiesManager.getAlgoliaSearchApiKey()) + .initIndex(PropertiesManager.getAlgoliaIndexName(), DocSearchResult.class); + // Initialize the waiter EventWaiter waiter = new EventWaiter(); @@ -298,6 +306,10 @@ public static GitHub getGithub() { return github; } + public static SearchIndex getAlgolia() { + return algolia; + } + public static ScheduledExecutorService getGeneralThreadPool() { return generalThreadPool; } diff --git a/src/main/java/org/geysermc/discordbot/commands/search/SearchCommand.java b/src/main/java/org/geysermc/discordbot/commands/search/SearchCommand.java new file mode 100644 index 00000000..df4bc4e6 --- /dev/null +++ b/src/main/java/org/geysermc/discordbot/commands/search/SearchCommand.java @@ -0,0 +1,372 @@ +/* + * Copyright (c) 2020-2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/GeyserDiscordBot + */ + +package org.geysermc.discordbot.commands.search; + +import com.algolia.search.models.indexing.Query; +import com.jagrosh.jdautilities.command.CommandEvent; +import com.jagrosh.jdautilities.command.SlashCommand; +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.geysermc.discordbot.GeyserBot; +import org.geysermc.discordbot.util.BotColors; +import org.geysermc.discordbot.util.DocSearchResult; +import org.geysermc.discordbot.util.MessageHelper; +import org.geysermc.discordbot.util.PageUtils; +import org.geysermc.discordbot.util.PropertiesManager; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** + * A command to search the Geyser wiki for a query. + */ +public class SearchCommand extends SlashCommand { + /** + * The attributes to retrieve from the Algolia search. + */ + private static final List ATTRIBUTES = Arrays.asList("hierarchy.lvl0", "hierarchy.lvl1", "hierarchy.lvl2", + "hierarchy.lvl3", "hierarchy.lvl4", "hierarchy.lvl5", "hierarchy.lvl6", "content", "type", "url"); + + /** + * The facets to filter the Algolia search by. + */ + private static final List> FACETS = Arrays.asList(Arrays.asList("language:en"), + Arrays.asList("docusaurus_tag:default", "docusaurus_tag:docs-default-current")); + + /** + * The tag with which to surround exact matches. + */ + private static final String HIGHLIGHT_TAG = "***"; + + /** + * The maximum number of results to return from Algolia. + */ + private static final int MAX_RESULTS = 10; + + /** + * The constructor for the SearchCommand. + */ + public SearchCommand() { + this.name = "search"; + this.arguments = ""; + this.help = "Search the Geyser wiki for a query"; + this.guildOnly = false; + + this.options = Arrays.asList(new OptionData(OptionType.STRING, "query", "The search query", true)); + } + + /** + * Executes the command for a SlashCommandEvent. + * + * @param event The SlashCommandEvent. + */ + @Override + protected void execute(SlashCommandEvent event) { + String query = event.optString("query", ""); + + if (query.isEmpty()) { + MessageHelper.errorResponse(event, "Invalid usage", + "Missing query to search. `" + event.getName() + " `"); + return; + } + + getEmbedsFuture(query).whenComplete((embeds, throwable) -> { + if (throwable != null) { + MessageHelper.errorResponse(event, "Search Error", + "An error occurred while searching for `" + query + "`"); + return; + } + + new PageUtils(embeds, event, -1); + }); + + } + + /** + * Executes the command for a CommandEvent. + * + * @param event The CommandEvent. + */ + @Override + protected void execute(CommandEvent event) { + String query = event.getArgs(); + + if (query.isEmpty()) { + MessageHelper.errorResponse(event, "Invalid usage", + "Missing query to search. `" + event.getPrefix() + name + " `"); + return; + } + + getEmbedsFuture(query).whenComplete((embeds, throwable) -> { + if (throwable != null) { + MessageHelper.errorResponse(event, "Search Error", + "An error occurred while searching for `" + query + "`"); + return; + } + + new PageUtils(embeds, event, -1); + }); + } + + /** + * Gets a CompletableFuture of a list of MessageEmbeds for a query. + * + * @param query The query to search for. + * @return A CompletableFuture of a list of MessageEmbeds. + */ + private CompletableFuture> getEmbedsFuture(String query) { + CompletableFuture> future = new CompletableFuture<>(); + + try { + GeyserBot.getAlgolia().searchAsync(new Query(query) + .setHitsPerPage(MAX_RESULTS) + .setHighlightPreTag(HIGHLIGHT_TAG) + .setHighlightPostTag(HIGHLIGHT_TAG) + .setAttributesToSnippet(ATTRIBUTES) + .setAttributesToRetrieve(ATTRIBUTES) + .setFacetFilters(FACETS)) + .whenComplete((results, throwable) -> { + if (throwable != null) { + GeyserBot.LOGGER.error("An error occurred while searching for `" + query + "`", throwable); + future.completeExceptionally(throwable); + return; + } + + List embeds = new ArrayList<>(); + + for (int i = 0; i < results.getHits().size(); i++) { + DocSearchResult result = results.getHits().get(i); + + EmbedBuilder embed = new EmbedBuilder() + .setUrl(result.getUrl()) + .setTitle("Search Result", result.getUrl()) + .setFooter("Page " + (i + 1) + " of " + results.getHits().size() + " | Query: " + query) + .setColor(BotColors.SUCCESS.getColor()); + + DocSearchResult.SnippetResult sr = result.get_snippetResult(); + if (sr != null && sr.getContent() != null && sr.getContent().getMatchLevel().equals("full")) { + embed.addField("Match:", getMatchFieldBody(sr), false); + } + + embed.addField("", getSeeAllFieldBody(query, results.getNbHits()), false); + + int remainingLength = Math.min(MessageEmbed.EMBED_MAX_LENGTH_BOT - embed.length(), MessageEmbed.DESCRIPTION_MAX_LENGTH); + embed.setDescription(getDescriptionFieldBody(result, query, remainingLength)); + + embeds.add(embed.build()); + } + + if (embeds.isEmpty()) { + embeds.add(new EmbedBuilder() + .setColor(BotColors.NEUTRAL.getColor()) + .setTitle("No results found") + .setDescription("No results were found for query: `" + query + "`.") + .build()); + } + + future.complete(embeds); + }); + } catch (Exception e) { + GeyserBot.LOGGER.error("An error occurred while searching for `" + query + "`", e); + future.completeExceptionally(e); + return future; + } + + return future; + } + + /** + * Gets the match field body for a snippet. + * + * @param snippet The snippet to get the match field body for. + * @return The match field body. + */ + private String getMatchFieldBody(DocSearchResult.SnippetResult snippet) { + String unescapedSnippet = unescapeHtml(snippet.getContent().getValue()); + return ">>> " + unescapedSnippet.replace("\r\n", " ").replace("\n", " "); + } + + /** + * Gets the see all field body for a query. + * + * @param query The query to get the see all field body for. + * @param hits The number of hits for the query. + * @return The see all field body. + */ + private String getSeeAllFieldBody(String query, long hits) { + return "[See all " + hits + " results](" + PropertiesManager.getAlgoliaSiteSearchUrl() + URLEncoder.encode(query, StandardCharsets.UTF_8) + ")"; + } + + /** + * Gets the description field body for a result. + * + * @param result The result to get the description field body for. + * @param query The query to search for. + * @param max The maximum length of the description. + * @return The description field body. + */ + private String getDescriptionFieldBody(DocSearchResult result, String query, int max) { + String header = getHierarchyChain(result.getHierarchy()); + + DocSearchResult.HighlightResult hr = result.get_highlightResult(); + if (hr != null && hr.getContent() != null && hr.getContent().getValue() != null) { + String description = ""; + + List lines = Arrays.asList(result.get_highlightResult().getContent().getValue().split("\n")) + .stream().distinct().collect(Collectors.toList()); + SortedSet includedLines = new TreeSet<>(); + + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).toLowerCase().contains(query.toLowerCase())) { + includedLines.add(i); + + for (int j = i - 1; j >= Math.max(0, i - 4); j--) { + includedLines.add(j); + } + + for (int j = i + 1; j <= Math.min(lines.size() - 1, i + 4); j++) { + includedLines.add(j); + } + } + } + + int lastLine = -1; + for (int i : includedLines) { + if (lastLine != -1 && i - lastLine > 1) { + description += "**•••**\n"; + } + + description += "> " + lines.get(i); + + if (i != includedLines.last()) { + description += "\n> \n"; + } else { + break; + } + + lastLine = i; + } + + description = header + "\n**Excerpt:**\n" + description; + + if (description.length() > max) { + description = removeDanglingFormatMarks(description.substring(0, max - 3) + "...", HIGHLIGHT_TAG); + } + + return unescapeHtml(description); + } else if (result.get_snippetResult() != null && result.get_snippetResult().getHierarchy() != null) { + return unescapeHtml(header); + } else { + return ""; + } + } + + /** + * Gets the hierarchy chain for a hierarchy as a bulleted list. + * + * @param hierarchy The hierarchy to get the chain for. + * @return The hierarchy chain as a bulleted list. + */ + private String getHierarchyChain(DocSearchResult.Hierarchy hierarchy) { + List levels = new ArrayList<>(); + + if (hierarchy.getLvl0() != null) levels.add(hierarchy.getLvl0()); + if (hierarchy.getLvl1() != null) levels.add(hierarchy.getLvl1()); + if (hierarchy.getLvl2() != null) levels.add(hierarchy.getLvl2()); + if (hierarchy.getLvl3() != null) levels.add(hierarchy.getLvl3()); + if (hierarchy.getLvl4() != null) levels.add(hierarchy.getLvl4()); + if (hierarchy.getLvl5() != null) levels.add(hierarchy.getLvl5()); + if (hierarchy.getLvl6() != null) levels.add(hierarchy.getLvl6()); + + if (levels.isEmpty()) { + return "- **Untitled**"; + } + + StringBuilder tb = new StringBuilder(); + + for (int i = 0; i < levels.size(); i++) { + String level = levels.get(i); + if (i > 0) + tb.append("\n"); + tb.append(String.join("", Collections.nCopies(i * 2, " ")) + "- "); + if (i == 0 || i == levels.size() - 1) + tb.append("**"); + tb.append(level); + if (i == 0 || i == levels.size() - 1) + tb.append("**"); + } + + return tb.toString(); + } + + /** + * Removes dangling format marks from a string. + * + * @param content The content to remove dangling format marks from. + * @param mark The format mark to remove. + * @return The content with dangling format marks removed. + */ + private String removeDanglingFormatMarks(String content, String mark) { + int count = (content.length() - content.replace(mark, "").length()) / mark.length(); + + if (count % 2 != 0) { + int lastIndex = content.lastIndexOf(mark); + if (lastIndex != -1) { + return new StringBuilder() + .append(content, 0, lastIndex) + .append(content.substring(lastIndex + mark.length())) + .toString(); + } + } + + return content; + } + + /** + * Unescapes HTML entities in a string. + * + * @param html The HTML to unescape. + * @return The unescaped HTML. + */ + private String unescapeHtml(String html) { + return html.replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&") + .replaceAll(""", "\"") + .replaceAll("'", "'"); + } +} diff --git a/src/main/java/org/geysermc/discordbot/util/DocSearchResult.java b/src/main/java/org/geysermc/discordbot/util/DocSearchResult.java new file mode 100644 index 00000000..b0e98a29 --- /dev/null +++ b/src/main/java/org/geysermc/discordbot/util/DocSearchResult.java @@ -0,0 +1,833 @@ +/* + * Copyright (c) 2020-2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/GeyserDiscordBot + */ + +package org.geysermc.discordbot.util; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Represents a search result from the Geyser documentation. + */ +public class DocSearchResult { + @JsonProperty("version") + private List version; + + @JsonProperty("tags") + private List tags; + + @JsonProperty("url") + private String url; + + @JsonProperty("content") + private String content; + + @JsonProperty("type") + private String type; + + @JsonProperty("hierarchy") + private Hierarchy hierarchy; + + @JsonProperty("objectID") + private String objectID; + + @JsonProperty("_snippetResult") + private SnippetResult _snippetResult; + + @JsonProperty("_highlightResult") + private HighlightResult _highlightResult; + + /** + * Default constructor for Jackson deserialization. + */ + public DocSearchResult() {} + + /** + * Gets the version of the result. + * + * @return The version of the result. + */ + public List getVersion() { + return version; + } + + /** + * Sets the version of the result. + * + * @param version The version of the result. + * @return The current instance of the result. + */ + public DocSearchResult setVersion(List version) { + this.version = version; + return this; + } + + /** + * Gets the tags of the result. + * + * @return The tags of the result. + */ + public List getTags() { + return tags; + } + + /** + * Sets the tags of the result. + * + * @param tags The tags of the result. + * @return The current instance of the result. + */ + public DocSearchResult setTags(List tags) { + this.tags = tags; + return this; + } + + /** + * Gets the URL of the result. + * + * @return The URL of the result. + */ + public String getUrl() { + return url; + } + + /** + * Sets the URL of the result. + * + * @param url The URL of the result. + * @return The current instance of the result. + */ + public DocSearchResult setUrl(String url) { + this.url = url; + return this; + } + + /** + * Gets the content of the result. + * + * @return The content of the result. + */ + public String getContent() { + return content; + } + + /** + * Sets the content of the result. + * + * @param content The content of the result. + * @return The current instance of the result. + */ + public DocSearchResult setContent(String content) { + this.content = content; + return this; + } + + /** + * Gets the type of the result. + * + * @return The type of the result. + */ + public String getType() { + return type; + } + + /** + * Sets the type of the result. + * + * @param type The type of the result. + * @return The current instance of the result. + */ + public DocSearchResult setType(String type) { + this.type = type; + return this; + } + + /** + * Gets the hierarchy of the result. + * + * @return The hierarchy of the result. + */ + public Hierarchy getHierarchy() { + return hierarchy; + } + + /** + * Sets the hierarchy of the result. + * + * @param hierarchy The hierarchy of the result. + * @return The current instance of the result. + */ + public DocSearchResult setHierarchy(Hierarchy hierarchy) { + this.hierarchy = hierarchy; + return this; + } + + /** + * Gets the object ID of the result. + * + * @return The object ID of the result. + */ + public String getObjectID() { + return objectID; + } + + /** + * Sets the object ID of the result. + * + * @param objectID The object ID of the result. + * @return The current instance of the result. + */ + public DocSearchResult setObjectID(String objectID) { + this.objectID = objectID; + return this; + } + + /** + * Gets the snippet result of the result. + * + * @return The snippet result of the result. + */ + public SnippetResult get_snippetResult() { + return _snippetResult; + } + + /** + * Sets the snippet result of the result. + * + * @param _snippetResult The snippet result of the result. + * @return The current instance of the result. + */ + public DocSearchResult set_snippetResult(SnippetResult _snippetResult) { + this._snippetResult = _snippetResult; + return this; + } + + /** + * Gets the highlight result of the result. + * + * @return The highlight result of the result. + */ + public HighlightResult get_highlightResult() { + return _highlightResult; + } + + /** + * Sets the highlight result of the result. + * + * @param _highlightResult The highlight result of the result. + * @return The current instance of the result. + */ + public DocSearchResult set_highlightResult(HighlightResult _highlightResult) { + this._highlightResult = _highlightResult; + return this; + } + + /** + * Represents the hierarchy of a search result. + */ + public static class Hierarchy { + @JsonProperty("lvl0") + private String lvl0; + + @JsonProperty("lvl1") + private String lvl1; + + @JsonProperty("lvl2") + private String lvl2; + + @JsonProperty("lvl3") + private String lvl3; + + @JsonProperty("lvl4") + private String lvl4; + + @JsonProperty("lvl5") + private String lvl5; + + @JsonProperty("lvl6") + private String lvl6; + + /** + * Default constructor for Jackson deserialization. + */ + public Hierarchy() {} + + /** + * Gets the zeroth level of the hierarchy. + * + * @return The zeroth level of the hierarchy. + */ + public String getLvl0() { + return lvl0; + } + + /** + * Sets the zeroth level of the hierarchy. + * + * @param lvl0 The zeroth level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl0(String lvl0) { + this.lvl0 = lvl0; + return this; + } + + /** + * Gets the first level of the hierarchy. + * + * @return The first level of the hierarchy. + */ + public String getLvl1() { + return lvl1; + } + + /** + * Sets the first level of the hierarchy. + * + * @param lvl1 The first level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl1(String lvl1) { + this.lvl1 = lvl1; + return this; + } + + /** + * Gets the second level of the hierarchy. + * + * @return The second level of the hierarchy. + */ + public String getLvl2() { + return lvl2; + } + + /** + * Sets the second level of the hierarchy. + * + * @param lvl2 The second level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl2(String lvl2) { + this.lvl2 = lvl2; + return this; + } + + /** + * Gets the third level of the hierarchy. + * + * @return The third level of the hierarchy. + */ + public String getLvl3() { + return lvl3; + } + + /** + * Sets the third level of the hierarchy. + * + * @param lvl3 The third level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl3(String lvl3) { + this.lvl3 = lvl3; + return this; + } + + /** + * Gets the fourth level of the hierarchy. + * + * @return The fourth level of the hierarchy. + */ + public String getLvl4() { + return lvl4; + } + + /** + * Sets the fourth level of the hierarchy. + * + * @param lvl4 The fourth level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl4(String lvl4) { + this.lvl4 = lvl4; + return this; + } + + /** + * Gets the fifth level of the hierarchy. + * + * @return The fifth level of the hierarchy. + */ + public String getLvl5() { + return lvl5; + } + + /** + * Sets the fifth level of the hierarchy. + * + * @param lvl5 The fifth level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl5(String lvl5) { + this.lvl5 = lvl5; + return this; + } + + /** + * Gets the sixth level of the hierarchy. + * + * @return The sixth level of the hierarchy. + */ + public String getLvl6() { + return lvl6; + } + + /** + * Sets the sixth level of the hierarchy. + * + * @param lvl6 The sixth level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl6(String lvl6) { + this.lvl6 = lvl6; + return this; + } + } + + public static class SnippetResult { + @JsonProperty("content") + private Match content; + + @JsonProperty("hierarchy") + private Hierarchy hierarchy; + + /** + * Default constructor for Jackson deserialization. + */ + public SnippetResult() {} + + /** + * Gets the content of the snippet result. + * + * @return The content of the snippet result. + */ + public Match getContent() { + return content; + } + + /** + * Sets the content of the snippet result. + * + * @param content The content of the snippet result. + * @return The current instance of the snippet result. + */ + public SnippetResult setContent(Match content) { + this.content = content; + return this; + } + + /** + * Gets the hierarchy of the snippet result. + * + * @return The hierarchy of the snippet result. + */ + public Hierarchy getHierarchy() { + return hierarchy; + } + + /** + * Sets the hierarchy of the snippet result. + * + * @param hierarchy The hierarchy of the snippet result. + * @return The current instance of the snippet result. + */ + public SnippetResult setHierarchy(Hierarchy hierarchy) { + this.hierarchy = hierarchy; + return this; + } + + /** + * Represents a match in a snippet result. + */ + public static class Match { + @JsonProperty("value") + private String value; + + @JsonProperty("matchLevel") + private String matchLevel; + + /** + * Default constructor for Jackson deserialization. + */ + public Match() {} + + /** + * Gets the value of the match. + * + * @return The value of the match. + */ + public String getValue() { + return value; + } + + /** + * Sets the value of the match. + * + * @param value The value of the match. + * @return The current instance of the match. + */ + public Match setValue(String value) { + this.value = value; + return this; + } + + /** + * Gets the match level of the match. + * + * @return The match level of the match. + */ + public String getMatchLevel() { + return matchLevel; + } + + /** + * Sets the match level of the match. + * + * @param matchLevel The match level of the match. + * @return The current instance of the match. + */ + public Match setMatchLevel(String matchLevel) { + this.matchLevel = matchLevel; + return this; + } + } + + /** + * Represents the hierarchy of a snippet result. + */ + public static class Hierarchy { + @JsonProperty("lvl0") + private Match lvl0; + + @JsonProperty("lvl1") + private Match lvl1; + + @JsonProperty("lvl2") + private Match lvl2; + + @JsonProperty("lvl3") + private Match lvl3; + + @JsonProperty("lvl4") + private Match lvl4; + + @JsonProperty("lvl5") + private Match lvl5; + + @JsonProperty("lvl6") + private Match lvl6; + + /** + * Default constructor for Jackson deserialization. + */ + public Hierarchy() {} + + /** + * Gets the zeroth level of the hierarchy. + * + * @return The zeroth level of the hierarchy. + */ + public Match getLvl0() { + return lvl0; + } + + /** + * Sets the zeroth level of the hierarchy. + * + * @param lvl0 The zeroth level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl0(Match lvl0) { + this.lvl0 = lvl0; + return this; + } + + /** + * Gets the first level of the hierarchy. + * + * @return The first level of the hierarchy. + */ + public Match getLvl1() { + return lvl1; + } + + /** + * Sets the first level of the hierarchy. + * + * @param lvl1 The first level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl1(Match lvl1) { + this.lvl1 = lvl1; + return this; + } + + /** + * Gets the second level of the hierarchy. + * + * @return The second level of the hierarchy. + */ + public Match getLvl2() { + return lvl2; + } + + /** + * Sets the second level of the hierarchy. + * + * @param lvl2 The second level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl2(Match lvl2) { + this.lvl2 = lvl2; + return this; + } + + /** + * Gets the third level of the hierarchy. + * + * @return The third level of the hierarchy. + */ + public Match getLvl3() { + return lvl3; + } + + /** + * Sets the third level of the hierarchy. + * + * @param lvl3 The third level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl3(Match lvl3) { + this.lvl3 = lvl3; + return this; + } + + /** + * Gets the fourth level of the hierarchy. + * + * @return The fourth level of the hierarchy. + */ + public Match getLvl4() { + return lvl4; + } + + /** + * Sets the fourth level of the hierarchy. + * + * @param lvl4 The fourth level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl4(Match lvl4) { + this.lvl4 = lvl4; + return this; + } + + /** + * Gets the fifth level of the hierarchy. + * + * @return The fifth level of the hierarchy. + */ + public Match getLvl5() { + return lvl5; + } + + /** + * Sets the fifth level of the hierarchy. + * + * @param lvl5 The fifth level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl5(Match lvl5) { + this.lvl5 = lvl5; + return this; + } + + /** + * Gets the sixth level of the hierarchy. + * + * @return The sixth level of the hierarchy. + */ + public Match getLvl6() { + return lvl6; + } + + /** + * Sets the sixth level of the hierarchy. + * + * @param lvl6 The sixth level of the hierarchy. + * @return The current instance of the hierarchy. + */ + public Hierarchy setLvl6(Match lvl6) { + this.lvl6 = lvl6; + return this; + } + } + } + + /** + * Represents the highlight result of a search result. + */ + public static class HighlightResult { + @JsonProperty("content") + private Content content; + + /** + * Default constructor for Jackson deserialization. + */ + public HighlightResult() {} + + /** + * Gets the content of the highlight result. + * + * @return The content of the highlight result. + */ + public Content getContent() { + return content; + } + + /** + * Sets the content of the highlight result. + * + * @param content The content of the highlight result. + * @return The current instance of the highlight result. + */ + public HighlightResult setContent(Content content) { + this.content = content; + return this; + } + + /** + * Represents the content of a highlight result. + */ + public static class Content { + @JsonProperty("value") + private String value; + + @JsonProperty("matchLevel") + private String matchLevel; + + @JsonProperty("fullyHighlighted") + private boolean fullyHighlighted; + + @JsonProperty("matchedWords") + private List matchedWords; + + /** + * Default constructor for Jackson deserialization. + */ + public Content() {} + + /** + * Gets the value of the content. + * + * @return The value of the content. + */ + public String getValue() { + return value; + } + + /** + * Sets the value of the content. + * + * @param value The value of the content. + * @return The current instance of the content. + */ + public Content setValue(String value) { + this.value = value; + return this; + } + + /** + * Gets the match level of the content. + * + * @return The match level of the content. + */ + public String getMatchLevel() { + return matchLevel; + } + + /** + * Sets the match level of the content. + * + * @param matchLevel The match level of the content. + * @return The current instance of the content. + */ + public Content setMatchLevel(String matchLevel) { + this.matchLevel = matchLevel; + return this; + } + + /** + * Checks if the content is fully highlighted. + * + * @return {@code true} if the content is fully highlighted, {@code false} otherwise. + */ + public boolean isFullyHighlighted() { + return fullyHighlighted; + } + + /** + * Sets if the content is fully highlighted. + * + * @param fullyHighlighted {@code true} if the content is fully highlighted, {@code false} otherwise. + * @return The current instance of the content. + */ + public Content setFullyHighlighted(boolean fullyHighlighted) { + this.fullyHighlighted = fullyHighlighted; + return this; + } + + /** + * Gets the matched words of the content. + * + * @return The matched words of the content. + */ + public List getMatchedWords() { + return matchedWords; + } + + /** + * Sets the matched words of the content. + * + * @param matchedWords The matched words of the content. + * @return The current instance of the content. + */ + public Content setMatchedWords(List matchedWords) { + this.matchedWords = matchedWords; + return this; + } + } + } +} diff --git a/src/main/java/org/geysermc/discordbot/util/PageUtils.java b/src/main/java/org/geysermc/discordbot/util/PageUtils.java new file mode 100644 index 00000000..18341233 --- /dev/null +++ b/src/main/java/org/geysermc/discordbot/util/PageUtils.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2020-2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/GeyserDiscordBot + */ + +package org.geysermc.discordbot.util; + +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.MessageEmbed; +import com.jagrosh.jdautilities.command.CommandEvent; +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.components.ItemComponent; + +import javax.annotation.Nonnull; +import java.util.List; +import java.util.Map; +import java.util.Collection; +import java.util.HashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * A utility class for paginating through a list of embeds using buttons in Discord JDA. + */ +public class PageUtils { + + private final Map pages; + private final List embeds; + private final long time; + private final User user; + private final JDA jda; + private final String userId; + + /** + * Constructor for Slash Commands. + * + * @param embeds The list of embeds to paginate through. + * @param event The SlashCommandEvent. + * @param time The time in milliseconds before the paginator expires (default is 5 minutes). + */ + public PageUtils( + List embeds, + SlashCommandEvent event, + long time + ) { + this.pages = new HashMap<>(); + this.embeds = embeds; + this.time = time > 0 ? time : 1000 * 60 * 5; + this.user = event.getUser(); + this.jda = event.getJDA(); + this.userId = user.getId(); + this.pages.put(userId, 0); + + event.replyEmbeds(this.embeds.get(this.pages.get(userId))) + .addActionRow(getRow()) + .setEphemeral(false) + .queue(response -> { + response.retrieveOriginal().queue(msg -> { + ButtonInteractionListener listener = new ButtonInteractionListener(msg.getId(), userId); + jda.addEventListener(listener); + + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + scheduler.schedule(() -> { + jda.removeEventListener(listener); + msg.editMessageComponents().queue(); + scheduler.shutdown(); + }, this.time, TimeUnit.MILLISECONDS); + }); + }); + } + + /** + * Constructor for Message-based Commands. + * + * @param embeds The list of embeds to paginate through. + * @param event The CommandEvent from your command framework. + * @param time The time in milliseconds before the paginator expires (default is 5 minutes). + */ + public PageUtils( + List embeds, + CommandEvent event, + long time + ) { + this.pages = new HashMap<>(); + this.embeds = embeds; + this.time = time > 0 ? time : 1000 * 60 * 5; + this.user = event.getAuthor(); + this.jda = event.getJDA(); + this.userId = user.getId(); + this.pages.put(userId, 0); + + event.getMessage().replyEmbeds(this.embeds.get(this.pages.get(userId))) + .setActionRow(getRow()) + .queue(msg -> { + ButtonInteractionListener listener = new ButtonInteractionListener(msg.getId(), userId); + jda.addEventListener(listener); + + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + scheduler.schedule(() -> { + jda.removeEventListener(listener); + msg.editMessageComponents().queue(); + scheduler.shutdown(); + }, this.time, TimeUnit.MILLISECONDS); + }); + } + + /** + * Creates an action row with previous and next buttons. + * + * @return A list containing the ActionRow with navigation buttons. + */ + private Collection getRow() { + int currentPage = pages.get(userId); + boolean isFirstPage = currentPage == 0; + boolean isLastPage = currentPage == embeds.size() - 1; + + Button prevButton = Button.secondary("prev_page", "⏮️").withDisabled(isFirstPage); + Button nextButton = Button.secondary("next_page", "⏭️").withDisabled(isLastPage); + + return ActionRow.of(prevButton, nextButton).getActionComponents(); + } + + /** + * Handles button interactions for pagination. + * + * @param event The button interaction event. + */ + private void handleInteraction(ButtonInteractionEvent event) { + String customId = event.getComponentId(); + + if (!customId.equals("prev_page") && !customId.equals("next_page")) { + return; + } + + event.deferEdit().queue(); + + int currentPage = pages.get(userId); + + if (customId.equals("prev_page") && currentPage > 0) { + pages.put(userId, --currentPage); + } else if (customId.equals("next_page") && currentPage < embeds.size() - 1) { + pages.put(userId, ++currentPage); + } + + event.getHook().editOriginalEmbeds(embeds.get(currentPage)) + .setActionRow(getRow()) + .queue(); + } + + /** + * An inner class that listens for button interactions related to pagination. + */ + private class ButtonInteractionListener extends ListenerAdapter { + private final String messageId; + private final String userId; + + public ButtonInteractionListener(String messageId, String userId) { + this.messageId = messageId; + this.userId = userId; + } + + @Override + public void onButtonInteraction(@Nonnull ButtonInteractionEvent event) { + + if (!event.getMessageId().equals(messageId)) { + return; + } + if (!event.getUser().getId().equals(userId)) { + event.reply("These buttons are not for you!").setEphemeral(true).queue(); + return; + } + + handleInteraction(event); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/geysermc/discordbot/util/PropertiesManager.java b/src/main/java/org/geysermc/discordbot/util/PropertiesManager.java index 2d23a095..50a1f891 100644 --- a/src/main/java/org/geysermc/discordbot/util/PropertiesManager.java +++ b/src/main/java/org/geysermc/discordbot/util/PropertiesManager.java @@ -131,4 +131,32 @@ public static String getSentryEnv() { */ public static String getOCRPath() { return properties.getProperty("ocr-path"); } + + /** + * @return Algolia Application ID + */ + public static String getAlgoliaApplicationId() { + return properties.getProperty("algolia-application-id"); + } + + /** + * @return Algolia Search API Key + */ + public static String getAlgoliaSearchApiKey() { + return properties.getProperty("algolia-search-api-key"); + } + + /** + * @return Algolia Index Name + */ + public static String getAlgoliaIndexName() { + return properties.getProperty("algolia-index-name"); + } + + /** + * @return The Algolia site search URL + */ + public static String getAlgoliaSiteSearchUrl() { + return properties.getProperty("algolia-site-search-url"); + } } \ No newline at end of file