From 011d1b34a87b80cbe27628e55b9657d66b8e10f8 Mon Sep 17 00:00:00 2001 From: Kas-tle <26531652+Kas-tle@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:43:18 -0700 Subject: [PATCH] Add wiki command (#317) * Add search command Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> * Use unicode codepoints and rename PageUtils > PageHelper Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> * Fallback to include up to first 9 lines if no match is found Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> * Address reviews; fix Algolia v4 Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> * Ensure no profane wiki searches and other commands Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> * License header Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> * Update src/main/java/org/geysermc/discordbot/listeners/SwearHandler.java Co-authored-by: rtm516 --------- Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Co-authored-by: rtm516 --- .gitignore | 4 + bot.properties.example | 4 + build.gradle | 3 + .../org/geysermc/discordbot/GeyserBot.java | 15 + .../commands/FloodgateUuidCommand.java | 6 +- .../discordbot/commands/GithubCommand.java | 6 +- .../discordbot/commands/IssueCommand.java | 6 +- .../discordbot/commands/PingCommand.java | 6 +- .../discordbot/commands/TagCommand.java | 6 +- .../discordbot/commands/TagsCommand.java | 6 +- .../commands/filter/FilteredSlashCommand.java | 73 ++ .../commands/search/ProviderCommand.java | 7 +- .../commands/search/WikiCommand.java | 381 ++++++++ .../discordbot/listeners/SwearHandler.java | 6 +- .../discordbot/util/DocSearchResult.java | 833 ++++++++++++++++++ .../discordbot/util/MessageHelper.java | 2 +- .../geysermc/discordbot/util/PageHelper.java | 203 +++++ .../discordbot/util/PropertiesManager.java | 28 + 18 files changed, 1571 insertions(+), 24 deletions(-) create mode 100644 src/main/java/org/geysermc/discordbot/commands/filter/FilteredSlashCommand.java create mode 100644 src/main/java/org/geysermc/discordbot/commands/search/WikiCommand.java create mode 100644 src/main/java/org/geysermc/discordbot/util/DocSearchResult.java create mode 100644 src/main/java/org/geysermc/discordbot/util/PageHelper.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..b6d31c3f 100644 --- a/build.gradle +++ b/build.gradle @@ -89,6 +89,9 @@ dependencies { // Image processing and OCR implementation 'net.sourceforge.tess4j:tess4j:5.12.0' implementation 'org.imgscalr:imgscalr-lib:4.2' + + // Agolia Search (For Wiki) + implementation 'com.algolia:algoliasearch:4.3.2' } jar { diff --git a/src/main/java/org/geysermc/discordbot/GeyserBot.java b/src/main/java/org/geysermc/discordbot/GeyserBot.java index 7c8b3ce5..78009ed4 100644 --- a/src/main/java/org/geysermc/discordbot/GeyserBot.java +++ b/src/main/java/org/geysermc/discordbot/GeyserBot.java @@ -25,6 +25,7 @@ package org.geysermc.discordbot; +import com.algolia.api.SearchClient; import com.jagrosh.jdautilities.command.Command; import com.jagrosh.jdautilities.command.CommandClientBuilder; import com.jagrosh.jdautilities.command.ContextMenu; @@ -65,6 +66,7 @@ import java.io.FileInputStream; import java.io.IOException; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.List; import java.util.Properties; @@ -87,6 +89,7 @@ public class GeyserBot { private static JDA jda; private static GitHub github; private static Server httpServer; + private static SearchClient algolia; static { // Gathers all commands from "commands" package. @@ -114,6 +117,11 @@ public class GeyserBot { if (theClass.getName().contains("SubCommand")) { continue; } + // Don't load abstract classes + if (Modifier.isAbstract(theClass.getModifiers())) { + continue; + } + slashCommands.add(theClass.getDeclaredConstructor().newInstance()); LoggerFactory.getLogger(theClass).debug("Loaded SlashCommand Successfully!"); } @@ -161,6 +169,9 @@ public static void main(String[] args) throws IOException { // Connect to github github = new GitHubBuilder().withOAuthToken(PropertiesManager.getGithubToken()).build(); + // Connect to Algolia + algolia = new SearchClient(PropertiesManager.getAlgoliaApplicationId(), PropertiesManager.getAlgoliaSearchApiKey()); + // Initialize the waiter EventWaiter waiter = new EventWaiter(); @@ -298,6 +309,10 @@ public static GitHub getGithub() { return github; } + public static SearchClient getAlgolia() { + return algolia; + } + public static ScheduledExecutorService getGeneralThreadPool() { return generalThreadPool; } diff --git a/src/main/java/org/geysermc/discordbot/commands/FloodgateUuidCommand.java b/src/main/java/org/geysermc/discordbot/commands/FloodgateUuidCommand.java index 0ba5fb73..e19a501d 100644 --- a/src/main/java/org/geysermc/discordbot/commands/FloodgateUuidCommand.java +++ b/src/main/java/org/geysermc/discordbot/commands/FloodgateUuidCommand.java @@ -25,11 +25,11 @@ package org.geysermc.discordbot.commands; -import com.jagrosh.jdautilities.command.SlashCommand; import com.jagrosh.jdautilities.command.SlashCommandEvent; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.geysermc.discordbot.commands.filter.FilteredSlashCommand; import org.geysermc.discordbot.util.BotColors; import org.json.JSONObject; import pw.chew.chewbotcca.util.RestClient; @@ -39,7 +39,7 @@ import java.util.Collections; import java.util.UUID; -public class FloodgateUuidCommand extends SlashCommand { +public class FloodgateUuidCommand extends FilteredSlashCommand { public FloodgateUuidCommand() { this.name = "uuid"; @@ -54,7 +54,7 @@ public FloodgateUuidCommand() { } @Override - protected void execute(SlashCommandEvent event) { + protected void executeFiltered(SlashCommandEvent event) { // get bedrock username, replace char in case they include Floodgate prefix. String username = event.optString("bedrock-username", "").replace(".", ""); EmbedBuilder builder = new EmbedBuilder(); diff --git a/src/main/java/org/geysermc/discordbot/commands/GithubCommand.java b/src/main/java/org/geysermc/discordbot/commands/GithubCommand.java index 1e341408..d68b66c3 100644 --- a/src/main/java/org/geysermc/discordbot/commands/GithubCommand.java +++ b/src/main/java/org/geysermc/discordbot/commands/GithubCommand.java @@ -25,12 +25,12 @@ package org.geysermc.discordbot.commands; -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.commands.filter.FilteredSlashCommand; import org.geysermc.discordbot.util.BotColors; import org.geysermc.discordbot.util.BotHelpers; import org.geysermc.discordbot.util.MessageHelper; @@ -40,7 +40,7 @@ import java.io.IOException; import java.util.Arrays; -public class GithubCommand extends SlashCommand { +public class GithubCommand extends FilteredSlashCommand { public GithubCommand() { this.name = "github"; @@ -54,7 +54,7 @@ public GithubCommand() { } @Override - protected void execute(SlashCommandEvent event) { + protected void executeFiltered(SlashCommandEvent event) { String repository = event.optString("repo", ""); String owner = event.optString("owner", ""); event.deferReply(false).queue(interactionHook -> { diff --git a/src/main/java/org/geysermc/discordbot/commands/IssueCommand.java b/src/main/java/org/geysermc/discordbot/commands/IssueCommand.java index e6b24566..4ae29b3e 100644 --- a/src/main/java/org/geysermc/discordbot/commands/IssueCommand.java +++ b/src/main/java/org/geysermc/discordbot/commands/IssueCommand.java @@ -26,13 +26,13 @@ package org.geysermc.discordbot.commands; 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.commands.filter.FilteredSlashCommand; import org.geysermc.discordbot.util.BotColors; import org.geysermc.discordbot.util.MessageHelper; import org.kohsuke.github.GHFileNotFoundException; @@ -50,7 +50,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -public class IssueCommand extends SlashCommand { +public class IssueCommand extends FilteredSlashCommand { private static final Pattern REPO_PATTERN = Pattern.compile("(^| )([\\w.\\-]+/)?([\\w.\\-]+)( |$)", Pattern.CASE_INSENSITIVE); private static final Pattern ISSUE_PATTERN = Pattern.compile("(^| )#?([0-9]+)( |$)", Pattern.CASE_INSENSITIVE); @@ -69,7 +69,7 @@ public IssueCommand() { } @Override - protected void execute(SlashCommandEvent event) { + protected void executeFiltered(SlashCommandEvent event) { // Issue int issue = (int) event.optLong("number", 0); // Repo diff --git a/src/main/java/org/geysermc/discordbot/commands/PingCommand.java b/src/main/java/org/geysermc/discordbot/commands/PingCommand.java index 781bbe8b..2b566fbe 100644 --- a/src/main/java/org/geysermc/discordbot/commands/PingCommand.java +++ b/src/main/java/org/geysermc/discordbot/commands/PingCommand.java @@ -30,7 +30,6 @@ import br.com.azalim.mcserverping.MCPingResponse; import br.com.azalim.mcserverping.MCPingUtil; import com.jagrosh.jdautilities.command.CommandEvent; -import com.jagrosh.jdautilities.command.SlashCommand; import com.jagrosh.jdautilities.command.SlashCommandEvent; import com.nukkitx.protocol.bedrock.BedrockClient; import com.nukkitx.protocol.bedrock.BedrockPong; @@ -39,6 +38,7 @@ import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.geysermc.discordbot.commands.filter.FilteredSlashCommand; import org.geysermc.discordbot.util.BotColors; import org.geysermc.discordbot.util.BotHelpers; import org.geysermc.discordbot.util.MessageHelper; @@ -52,7 +52,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; -public class PingCommand extends SlashCommand { +public class PingCommand extends FilteredSlashCommand { private static final int TIMEOUT = 1250; // in ms, has to stay below 1500 (1.5s for each platform, total of 3s) public PingCommand() { @@ -71,7 +71,7 @@ public PingCommand() { } @Override - protected void execute(SlashCommandEvent event) { + protected void executeFiltered(SlashCommandEvent event) { // Defer to wait for us to load a response and allows for files to be uploaded InteractionHook interactionHook = event.deferReply().complete(); diff --git a/src/main/java/org/geysermc/discordbot/commands/TagCommand.java b/src/main/java/org/geysermc/discordbot/commands/TagCommand.java index 0c7d045f..620bf5f1 100644 --- a/src/main/java/org/geysermc/discordbot/commands/TagCommand.java +++ b/src/main/java/org/geysermc/discordbot/commands/TagCommand.java @@ -25,13 +25,13 @@ package org.geysermc.discordbot.commands; -import com.jagrosh.jdautilities.command.SlashCommand; import com.jagrosh.jdautilities.command.SlashCommandEvent; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.interactions.commands.Command; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.geysermc.discordbot.commands.filter.FilteredSlashCommand; import org.geysermc.discordbot.tags.SlashTag; import org.geysermc.discordbot.tags.TagsManager; import org.geysermc.discordbot.util.BotColors; @@ -41,7 +41,7 @@ import java.util.Collections; import java.util.List; -public class TagCommand extends SlashCommand { +public class TagCommand extends FilteredSlashCommand { public TagCommand() { this.name = "tag"; @@ -56,7 +56,7 @@ public TagCommand() { } @Override - protected void execute(SlashCommandEvent event) { + protected void executeFiltered(SlashCommandEvent event) { String tagName = event.getOption("name").getAsString(); SlashTag tag = null; diff --git a/src/main/java/org/geysermc/discordbot/commands/TagsCommand.java b/src/main/java/org/geysermc/discordbot/commands/TagsCommand.java index 28a3c4b4..e6f444b8 100644 --- a/src/main/java/org/geysermc/discordbot/commands/TagsCommand.java +++ b/src/main/java/org/geysermc/discordbot/commands/TagsCommand.java @@ -27,12 +27,12 @@ import com.jagrosh.jdautilities.command.Command; 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.commands.filter.FilteredSlashCommand; import org.geysermc.discordbot.tags.TagsManager; import org.geysermc.discordbot.util.BotColors; import org.geysermc.discordbot.util.PropertiesManager; @@ -42,7 +42,7 @@ import java.util.Collections; import java.util.List; -public class TagsCommand extends SlashCommand { +public class TagsCommand extends FilteredSlashCommand { public TagsCommand() { this.name = "tags"; this.arguments = "[search]"; @@ -55,7 +55,7 @@ public TagsCommand() { } @Override - protected void execute(SlashCommandEvent event) { + protected void executeFiltered(SlashCommandEvent event) { String search = event.optString("search", ""); event.replyEmbeds(handle(search)).queue(); diff --git a/src/main/java/org/geysermc/discordbot/commands/filter/FilteredSlashCommand.java b/src/main/java/org/geysermc/discordbot/commands/filter/FilteredSlashCommand.java new file mode 100644 index 00000000..559d9c30 --- /dev/null +++ b/src/main/java/org/geysermc/discordbot/commands/filter/FilteredSlashCommand.java @@ -0,0 +1,73 @@ +/* + * 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.filter; + +import com.jagrosh.jdautilities.command.SlashCommand; +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import org.geysermc.discordbot.listeners.SwearHandler; +import org.geysermc.discordbot.storage.ServerSettings; +import org.geysermc.discordbot.util.BotColors; + +import java.util.regex.Pattern; + +public abstract class FilteredSlashCommand extends SlashCommand { + @Override + protected final void execute(SlashCommandEvent event) { + Pattern filterPattern = null; + for (OptionMapping option : event.getOptions()) { + if (option.getType() != OptionType.STRING) continue; + if ((filterPattern = SwearHandler.checkString(option.getAsString())) != null) break; + } + + if (filterPattern != null) { + event.reply(event.getUser().getAsMention() + + " your command cannot be processed because it contains profanity! Please read our rules for more information.") + .setEphemeral(true).queue(); + + // Log the event + if (event.getGuild() != null) { + String channel = event.getChannel() == null ? "Unknown" : event.getChannel().getAsMention(); + + ServerSettings.getLogChannel(event.getGuild()).sendMessageEmbeds(new EmbedBuilder() + .setTitle("Profanity blocked command") + .setDescription("**Sender:** " + event.getUser().getAsMention() + "\n" + + "**Channel:** " + channel + "\n" + + "**Regex:** `" + filterPattern + "`\n" + + "**Command:** " + event.getCommandString()) + .setColor(BotColors.FAILURE.getColor()) + .build()).queue(); + } + return; + } + + executeFiltered(event); + } + + protected abstract void executeFiltered(SlashCommandEvent event); +} diff --git a/src/main/java/org/geysermc/discordbot/commands/search/ProviderCommand.java b/src/main/java/org/geysermc/discordbot/commands/search/ProviderCommand.java index d2037c84..de4b9dfc 100644 --- a/src/main/java/org/geysermc/discordbot/commands/search/ProviderCommand.java +++ b/src/main/java/org/geysermc/discordbot/commands/search/ProviderCommand.java @@ -26,7 +26,6 @@ package org.geysermc.discordbot.commands.search; 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; @@ -34,6 +33,7 @@ import net.dv8tion.jda.api.interactions.commands.Command; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.geysermc.discordbot.commands.filter.FilteredSlashCommand; import org.geysermc.discordbot.util.BotColors; import org.geysermc.discordbot.util.DicesCoefficient; import org.geysermc.discordbot.util.MessageHelper; @@ -44,9 +44,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Map; -public class ProviderCommand extends SlashCommand { +public class ProviderCommand extends FilteredSlashCommand { private List cache = null; private long cacheTime = 0; @@ -63,7 +62,7 @@ public ProviderCommand() { } @Override - protected void execute(SlashCommandEvent event) { + protected void executeFiltered(SlashCommandEvent event) { event.replyEmbeds(handle(event.optString("provider", ""))).queue(); } diff --git a/src/main/java/org/geysermc/discordbot/commands/search/WikiCommand.java b/src/main/java/org/geysermc/discordbot/commands/search/WikiCommand.java new file mode 100644 index 00000000..e2c8368e --- /dev/null +++ b/src/main/java/org/geysermc/discordbot/commands/search/WikiCommand.java @@ -0,0 +1,381 @@ +/* + * 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.model.search.FacetFilters; +import com.algolia.model.search.SearchParamsObject; +import com.jagrosh.jdautilities.command.CommandEvent; +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.commands.filter.FilteredSlashCommand; +import org.geysermc.discordbot.util.BotColors; +import org.geysermc.discordbot.util.DocSearchResult; +import org.geysermc.discordbot.util.MessageHelper; +import org.geysermc.discordbot.util.PageHelper; +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 WikiCommand extends FilteredSlashCommand { + /** + * 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 FacetFilters FACETS = FacetFilters.of(""" + ["language:en",["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 WikiCommand. + */ + public WikiCommand() { + this.name = "wiki"; + this.arguments = ""; + this.help = "Search the Geyser wiki for a query"; + this.guildOnly = false; + + this.options = Collections.singletonList(new OptionData(OptionType.STRING, "query", "The search query", true)); + } + + /** + * Executes the command for a SlashCommandEvent. + * + * @param event The SlashCommandEvent. + */ + @Override + protected void executeFiltered(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 PageHelper(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 PageHelper(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().searchSingleIndexAsync( + PropertiesManager.getAlgoliaIndexName(), new SearchParamsObject() + .setQuery(query) + .setHitsPerPage(MAX_RESULTS) + .setHighlightPreTag(HIGHLIGHT_TAG) + .setHighlightPostTag(HIGHLIGHT_TAG) + .setAttributesToSnippet(ATTRIBUTES) + .setAttributesToRetrieve(ATTRIBUTES) + .setFacetFilters(FACETS), DocSearchResult.class) + .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, Integer hits) { + return "[See all " + hits + " results on the wiki](" + 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); + } + } + } + + if (includedLines.isEmpty()) { + for (int i = 0; i < Math.min(lines.size(), 9); i++) { + includedLines.add(i); + } + } + + int lastLine = -1; + for (int i : includedLines) { + if (lastLine != -1 && i - lastLine > 1) { + description += "**\u2022\u2022\u2022**\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/listeners/SwearHandler.java b/src/main/java/org/geysermc/discordbot/listeners/SwearHandler.java index 58c360ff..c0bbcc50 100644 --- a/src/main/java/org/geysermc/discordbot/listeners/SwearHandler.java +++ b/src/main/java/org/geysermc/discordbot/listeners/SwearHandler.java @@ -29,14 +29,18 @@ import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; import net.dv8tion.jda.api.events.guild.member.update.GuildMemberUpdateNicknameEvent; +import net.dv8tion.jda.api.events.interaction.command.GenericCommandInteractionEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; import org.geysermc.discordbot.GeyserBot; import org.geysermc.discordbot.storage.ServerSettings; import org.geysermc.discordbot.util.BotColors; import org.geysermc.discordbot.util.BotHelpers; import org.jetbrains.annotations.NotNull; +import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.IOException; import java.net.URISyntaxException; @@ -99,7 +103,7 @@ public static void loadFilters() { } @Nullable - private Pattern checkString(String input) { + public static Pattern checkString(String input) { // TODO: Maybe only clean start and end? Then run through the same as normalInput? input = input.toLowerCase(); String cleanInput = CLEAN_PATTERN.matcher(input).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/MessageHelper.java b/src/main/java/org/geysermc/discordbot/util/MessageHelper.java index 9bcaabe3..8b848f93 100644 --- a/src/main/java/org/geysermc/discordbot/util/MessageHelper.java +++ b/src/main/java/org/geysermc/discordbot/util/MessageHelper.java @@ -75,7 +75,7 @@ public static MessageEmbed errorResponse(Object event, String title, String mess .setEphemeral(true) // Only show error to the user .queue(); } else { - throw new IllegalArgumentException("Event must be one of CommandEvent, SlashCommandEvent"); + throw new IllegalArgumentException("Event must be one of CommandEvent, SlashCommandEvent; got: " + event.getClass().getName()); } return null; diff --git a/src/main/java/org/geysermc/discordbot/util/PageHelper.java b/src/main/java/org/geysermc/discordbot/util/PageHelper.java new file mode 100644 index 00000000..ce0a68d7 --- /dev/null +++ b/src/main/java/org/geysermc/discordbot/util/PageHelper.java @@ -0,0 +1,203 @@ +/* + * 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.emoji.Emoji; +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 PageHelper { + + 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 PageHelper( + 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. + * @param time The time in milliseconds before the paginator expires (default is 5 minutes). + */ + public PageHelper( + 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", Emoji.fromUnicode("\u23ee")).withDisabled(isFirstPage); + Button nextButton = Button.secondary("next_page", Emoji.fromUnicode("\u23ed")).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