diff --git a/bukkit/src/main/java/com/discordsrv/bukkit/BukkitDiscordSRV.java b/bukkit/src/main/java/com/discordsrv/bukkit/BukkitDiscordSRV.java index 9d2e0e94..bd0d6ecd 100644 --- a/bukkit/src/main/java/com/discordsrv/bukkit/BukkitDiscordSRV.java +++ b/bukkit/src/main/java/com/discordsrv/bukkit/BukkitDiscordSRV.java @@ -19,7 +19,7 @@ package com.discordsrv.bukkit; import com.discordsrv.api.DiscordSRVApi; -import com.discordsrv.bukkit.command.game.BukkitAutoCompleteHelper; +import com.discordsrv.bukkit.command.game.BukkitGameCommandExecutionHelper; import com.discordsrv.bukkit.command.game.handler.AbstractBukkitCommandHandler; import com.discordsrv.bukkit.component.translation.BukkitTranslationLoader; import com.discordsrv.bukkit.config.connection.BukkitConnectionConfig; @@ -40,7 +40,6 @@ import com.discordsrv.bukkit.scheduler.BukkitScheduler; import com.discordsrv.bukkit.scheduler.FoliaScheduler; import com.discordsrv.bukkit.scheduler.IBukkitScheduler; import com.discordsrv.common.ServerDiscordSRV; -import com.discordsrv.common.command.discord.commands.subcommand.ExecuteCommand; import com.discordsrv.common.command.game.handler.ICommandHandler; import com.discordsrv.common.config.manager.ConnectionConfigManager; import com.discordsrv.common.config.manager.MainConfigManager; @@ -68,7 +67,7 @@ public class BukkitDiscordSRV extends ServerDiscordSRV getAliases(String command) { + PluginCommand pluginCommand = discordSRV.server().getPluginCommand(command); + if (pluginCommand == null) { + return Collections.emptyList(); + } + + List aliases = new ArrayList<>(pluginCommand.getAliases()); + aliases.add(pluginCommand.getName()); + + String pluginName = pluginCommand.getName().toLowerCase(Locale.ROOT); + int originalMax = aliases.size(); + for (int i = 0; i < originalMax; i++) { + // plugin:command + aliases.add(pluginName + ":" + aliases.get(i)); + } + return aliases; + } + + @Override + public boolean isSameCommand(String command1, String command2) { + PluginCommand pluginCommand1 = discordSRV.server().getPluginCommand(command1); + PluginCommand pluginCommand2 = discordSRV.server().getPluginCommand(command2); + + return pluginCommand1 == pluginCommand2; + } } diff --git a/common/src/main/java/com/discordsrv/common/DiscordSRV.java b/common/src/main/java/com/discordsrv/common/DiscordSRV.java index 9c7b47e5..dddc71e5 100644 --- a/common/src/main/java/com/discordsrv/common/DiscordSRV.java +++ b/common/src/main/java/com/discordsrv/common/DiscordSRV.java @@ -23,7 +23,7 @@ import com.discordsrv.api.module.type.Module; import com.discordsrv.api.placeholder.DiscordPlaceholders; import com.discordsrv.common.bootstrap.IBootstrap; import com.discordsrv.common.channel.ChannelConfigHelper; -import com.discordsrv.common.command.discord.commands.subcommand.ExecuteCommand; +import com.discordsrv.common.command.game.GameCommandExecutionHelper; import com.discordsrv.common.command.game.handler.ICommandHandler; import com.discordsrv.common.component.ComponentFactory; import com.discordsrv.common.config.connection.ConnectionConfig; @@ -154,7 +154,7 @@ public interface DiscordSRV extends DiscordSRVApi { CompletableFuture invokeDisable(); @Nullable - default ExecuteCommand.AutoCompleteHelper autoCompleteHelper() { + default GameCommandExecutionHelper executeHelper() { return null; } diff --git a/common/src/main/java/com/discordsrv/common/command/discord/commands/DiscordSRVDiscordCommand.java b/common/src/main/java/com/discordsrv/common/command/discord/commands/DiscordSRVDiscordCommand.java index 819b288f..bb4d7e2f 100644 --- a/common/src/main/java/com/discordsrv/common/command/discord/commands/DiscordSRVDiscordCommand.java +++ b/common/src/main/java/com/discordsrv/common/command/discord/commands/DiscordSRVDiscordCommand.java @@ -7,6 +7,7 @@ import com.discordsrv.common.command.combined.commands.DebugCommand; import com.discordsrv.common.command.combined.commands.ResyncCommand; import com.discordsrv.common.command.combined.commands.VersionCommand; import com.discordsrv.common.command.discord.commands.subcommand.ExecuteCommand; +import com.discordsrv.common.config.main.DiscordCommandConfig; public class DiscordSRVDiscordCommand { @@ -16,11 +17,18 @@ public class DiscordSRVDiscordCommand { public static DiscordCommand get(DiscordSRV discordSRV) { if (INSTANCE == null) { - INSTANCE = DiscordCommand.chatInput(IDENTIFIER, "discordsrv", "DiscordSRV related commands") + DiscordCommandConfig config = discordSRV.config().discordCommand; + + DiscordCommand.ChatInputBuilder builder = DiscordCommand.chatInput(IDENTIFIER, "discordsrv", "DiscordSRV related commands") .addSubCommand(DebugCommand.getDiscord(discordSRV)) .addSubCommand(VersionCommand.getDiscord(discordSRV)) - .addSubCommand(ResyncCommand.getDiscord(discordSRV)) - .addSubCommand(ExecuteCommand.get(discordSRV)) + .addSubCommand(ResyncCommand.getDiscord(discordSRV)); + + if (config.execute.enabled) { + builder = builder.addSubCommand(ExecuteCommand.get(discordSRV)); + } + + INSTANCE = builder .setGuildOnly(false) .setDefaultPermission(DiscordCommand.DefaultPermission.ADMINISTRATOR) .build(); diff --git a/common/src/main/java/com/discordsrv/common/command/discord/commands/subcommand/ExecuteCommand.java b/common/src/main/java/com/discordsrv/common/command/discord/commands/subcommand/ExecuteCommand.java index ee69ef4e..8dffcde6 100644 --- a/common/src/main/java/com/discordsrv/common/command/discord/commands/subcommand/ExecuteCommand.java +++ b/common/src/main/java/com/discordsrv/common/command/discord/commands/subcommand/ExecuteCommand.java @@ -1,11 +1,16 @@ package com.discordsrv.common.command.discord.commands.subcommand; +import com.discordsrv.api.discord.entity.DiscordUser; +import com.discordsrv.api.discord.entity.guild.DiscordGuildMember; import com.discordsrv.api.discord.entity.interaction.command.CommandOption; import com.discordsrv.api.discord.entity.interaction.command.DiscordCommand; import com.discordsrv.api.discord.entity.interaction.component.ComponentIdentifier; import com.discordsrv.api.discord.events.interaction.command.DiscordChatInputInteractionEvent; import com.discordsrv.api.discord.events.interaction.command.DiscordCommandAutoCompleteInteractionEvent; import com.discordsrv.common.DiscordSRV; +import com.discordsrv.common.command.game.GameCommandExecutionHelper; +import com.discordsrv.common.config.main.DiscordCommandConfig; +import com.discordsrv.common.config.main.generic.GameCommandFilterConfig; import com.discordsrv.common.logging.Logger; import com.discordsrv.common.logging.NamedLogger; import net.dv8tion.jda.api.interactions.commands.OptionMapping; @@ -14,7 +19,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Locale; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -26,11 +30,13 @@ public class ExecuteCommand implements Consumer " + command); } @Override public void autoComplete(DiscordCommandAutoCompleteInteractionEvent event) { + if (helper == null) { + // No suggestions available. + return; + } + + DiscordCommandConfig.ExecuteConfig config = discordSRV.config().discordCommand.execute; + if (!config.suggest) { + return; + } + OptionMapping mapping = event.asJDA().getOption("command"); if (mapping == null) { return; @@ -71,13 +111,7 @@ public class ExecuteCommand implements Consumer parts = new ArrayList<>(Arrays.asList(command.split(" "))); - AutoCompleteHelper helper = discordSRV.autoCompleteHelper(); - if (helper == null) { - // No suggestions available. - return; - } - - List suggestions = getSuggestions(helper, parts); + List suggestions = getSuggestions(parts); if (suggestions == null) { return; } @@ -85,7 +119,7 @@ public class ExecuteCommand implements Consumer newSuggestions = getSuggestions(helper, parts); + List newSuggestions = getSuggestions(parts); if (newSuggestions == null) { return; } @@ -110,13 +144,18 @@ public class ExecuteCommand implements Consumer= 25) break; + if (event.getChoices().size() >= 25) { + break; + } + if (config.filterSuggestions && isNotAcceptableCommand(event.getMember(), event.getUser(), suggestion, true)) { + continue; + } event.addChoice(suggestion, suggestion); } } - private List getSuggestions(AutoCompleteHelper helper, List parts) { + private List getSuggestions(List parts) { try { return helper.suggestCommands(new ArrayList<>(parts)).get(2, TimeUnit.SECONDS); } catch (InterruptedException e) { @@ -132,9 +171,4 @@ public class ExecuteCommand implements Consumer> suggestCommands(List parts); - } } diff --git a/common/src/main/java/com/discordsrv/common/command/game/GameCommandExecutionHelper.java b/common/src/main/java/com/discordsrv/common/command/game/GameCommandExecutionHelper.java new file mode 100644 index 00000000..7da552fa --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/command/game/GameCommandExecutionHelper.java @@ -0,0 +1,12 @@ +package com.discordsrv.common.command.game; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface GameCommandExecutionHelper { + + CompletableFuture> suggestCommands(List parts); + List getAliases(String command); + boolean isSameCommand(String command1, String command2); + +} diff --git a/common/src/main/java/com/discordsrv/common/config/main/DiscordCommandConfig.java b/common/src/main/java/com/discordsrv/common/config/main/DiscordCommandConfig.java index e6176461..00f55f4e 100644 --- a/common/src/main/java/com/discordsrv/common/config/main/DiscordCommandConfig.java +++ b/common/src/main/java/com/discordsrv/common/config/main/DiscordCommandConfig.java @@ -1,6 +1,12 @@ package com.discordsrv.common.config.main; +import com.discordsrv.common.config.main.generic.GameCommandFilterConfig; import org.spongepowered.configurate.objectmapping.ConfigSerializable; +import org.spongepowered.configurate.objectmapping.meta.Comment; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; @ConfigSerializable public class DiscordCommandConfig { @@ -10,6 +16,26 @@ public class DiscordCommandConfig { @ConfigSerializable public static class ExecuteConfig { + public ExecuteConfig() { + filters.add( + new GameCommandFilterConfig( + new ArrayList<>(), + false, + new ArrayList<>(Arrays.asList("say", "/gamemode(?: (?:survival|spectator)(?: .+)?)?/")) + ) + ); + } + public boolean enabled = true; + + @Comment("At least one condition has to match to allow execution") + public List filters = new ArrayList<>(); + + @Comment("If commands should be suggested while typing\n" + + "Suggestions go through the server's main thread (on servers with a main thread) to ensure compatability.") + public boolean suggest = true; + + @Comment("If suggestions should be filtered based on the \"filters\" option") + public boolean filterSuggestions = true; } } diff --git a/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java b/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java index 05c3c0e2..c62c4160 100644 --- a/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java +++ b/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java @@ -29,7 +29,9 @@ import com.discordsrv.common.config.main.linking.LinkedAccountConfig; import org.spongepowered.configurate.objectmapping.ConfigSerializable; import org.spongepowered.configurate.objectmapping.meta.Comment; -import java.util.*; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; @ConfigSerializable public abstract class MainConfig implements Config { @@ -79,6 +81,9 @@ public abstract class MainConfig implements Config { @Comment("In-game command configuration") public GameCommandConfig gameCommand = new GameCommandConfig(); + @Comment("Discord command configuration") + public DiscordCommandConfig discordCommand = new DiscordCommandConfig(); + @Comment("Configuration for the %discord_invite% placeholder. The below options will be attempted in the order they are in") public DiscordInviteConfig invite = new DiscordInviteConfig(); diff --git a/common/src/main/java/com/discordsrv/common/config/main/generic/GameCommandFilterConfig.java b/common/src/main/java/com/discordsrv/common/config/main/generic/GameCommandFilterConfig.java index 08d94513..4a331daf 100644 --- a/common/src/main/java/com/discordsrv/common/config/main/generic/GameCommandFilterConfig.java +++ b/common/src/main/java/com/discordsrv/common/config/main/generic/GameCommandFilterConfig.java @@ -1,20 +1,116 @@ package com.discordsrv.common.config.main.generic; +import com.discordsrv.api.discord.entity.DiscordUser; +import com.discordsrv.api.discord.entity.guild.DiscordGuildMember; +import com.discordsrv.api.discord.entity.guild.DiscordRole; +import com.discordsrv.common.command.game.GameCommandExecutionHelper; import org.spongepowered.configurate.objectmapping.ConfigSerializable; import org.spongepowered.configurate.objectmapping.meta.Comment; -import java.util.ArrayList; -import java.util.List; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @ConfigSerializable public class GameCommandFilterConfig { + public GameCommandFilterConfig() {} + + public GameCommandFilterConfig(List roleAndUserIds, boolean blacklist, List commands) { + this.roleAndUserIds = roleAndUserIds; + this.blacklist = blacklist; + this.commands = commands; + } + + @Comment("The role and user ids which this filter applies to") + public List roleAndUserIds = new ArrayList<>(); + @Comment("true for blacklist (blocking commands), false for whitelist (allowing commands)") public boolean blacklist = true; - @Comment("The role and user ids which this set of allowed/blocked commands is for") - public List roleAndUserIds = new ArrayList<>(); - - @Comment("The commands that are allowed/blocked. Use / at the beginning and end of a value for a regular expression (regex)") + @Comment("The commands and/or patterns that are allowed/blocked.\n" + + "The command needs to start with input, this will attempt to normalize command aliases where possible (for the main command)\n" + + "If the command start and ends with /, the input will be treated as a regular expression (regex) and it will pass if it matches the entire command") public List commands = new ArrayList<>(); + + public static boolean isCommandMatch(String configCommand, String command, boolean suggestions, GameCommandExecutionHelper helper) { + if (configCommand.startsWith("/") && configCommand.endsWith("/")) { + // Regex handling + Pattern pattern = Pattern.compile(configCommand.substring(1, configCommand.length() - 1)); + + Matcher matcher = pattern.matcher(command); + return matcher.matches() && matcher.start() == 0 && matcher.end() == command.length(); + } + + // Normal handling + configCommand = configCommand.toLowerCase(Locale.ROOT); + command = command.toLowerCase(Locale.ROOT); + + List parts = new ArrayList<>(Arrays.asList(configCommand.split(" "))); + String rootCommand = parts.remove(0); + + Set rootCommands = new LinkedHashSet<>(); + rootCommands.add(rootCommand); + if (helper != null) { + rootCommands.addAll(helper.getAliases(rootCommand)); + } + + if (suggestions) { + // Allow suggesting the commands up to the allowed command + for (String rootCmd : rootCommands) { + if (command.matches("^" + Pattern.quote(rootCmd) + " ?$")) { + return true; + } + + StringBuilder built = new StringBuilder(rootCmd); + for (String part : parts) { + built.append(" ").append(part); + if (command.matches("^" + Pattern.quote(built.toString()) + " ?$")) { + return true; + } + } + } + } + + String arguments = String.join(" ", parts); + for (String rootCmd : rootCommands) { + String joined = rootCmd + (arguments.isEmpty() ? "" : " " + arguments); + + // This part at the end prevents "command list" matching "command listsecrets" + if (command.matches("^" + Pattern.quote(joined) + "(?:$| .+)")) { + // Make sure it's the same command, the alias may be used by another command + return helper == null || helper.isSameCommand(rootCommand, rootCmd); + } + } + + return false; + } + + public boolean isAcceptableCommand(DiscordGuildMember member, DiscordUser user, String command, boolean suggestions, GameCommandExecutionHelper helper) { + long userId = user.getId(); + List roleIds = new ArrayList<>(); + if (member != null) { + for (DiscordRole role : member.getRoles()) { + roleIds.add(role.getId()); + } + } + + boolean match = false; + for (Long id : roleAndUserIds) { + if (id == userId || roleIds.contains(id)) { + match = true; + break; + } + } + if (!match) { + return true; + } + + for (String configCommand : commands) { + if (isCommandMatch(configCommand, command, suggestions, helper) != blacklist) { + return true; + } + } + return false; + } } diff --git a/common/src/test/java/com/discordsrv/common/command/game/GameCommandFilterTest.java b/common/src/test/java/com/discordsrv/common/command/game/GameCommandFilterTest.java new file mode 100644 index 00000000..678c86a3 --- /dev/null +++ b/common/src/test/java/com/discordsrv/common/command/game/GameCommandFilterTest.java @@ -0,0 +1,110 @@ +package com.discordsrv.common.command.game; + +import com.discordsrv.common.config.main.generic.GameCommandFilterConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public class GameCommandFilterTest { + + private final ExecutionHelper helper = new ExecutionHelper(); + + @Test + public void test1() { + Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("test", "test", false, helper)); + } + + @Test + public void test2() { + Assertions.assertFalse(GameCommandFilterConfig.isCommandMatch("test", "tester", false, helper)); + } + + @Test + public void argumentTest() { + Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("test arg", "test arg", false, helper)); + } + + @Test + public void suggestTest() { + Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("test arg", "test", true, helper)); + } + + @Test + public void extraTest() { + Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("test arg", "test arg extra arguments after that", false, helper)); + } + + @Test + public void argumentOverflowTest1() { + Assertions.assertFalse(GameCommandFilterConfig.isCommandMatch("test arg", "test argument", false, helper)); + } + + @Test + public void sameCommandTest1() { + Assertions.assertFalse(GameCommandFilterConfig.isCommandMatch("plugin1:test", "test", false, helper)); + } + + @Test + public void sameCommandTest2() { + Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("plugin2:test", "test", false, helper)); + } + + @Test + public void regexTest1() { + Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("/test/", "test", false, helper)); + } + + @Test + public void regexTest2() { + Assertions.assertFalse(GameCommandFilterConfig.isCommandMatch("/test/", "test extra", false, helper)); + } + + @Test + public void regexTest3() { + Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("/test( argument)?/", "test argument", false, helper)); + } + + @Test + public void regexTest4() { + Assertions.assertFalse(GameCommandFilterConfig.isCommandMatch("/test( argument)?/", "test fail", false, helper)); + } + + @Test + public void regexTest5() { + Assertions.assertTrue(GameCommandFilterConfig.isCommandMatch("/test( argument)?/", "test", true, helper)); + } + + public static class ExecutionHelper implements GameCommandExecutionHelper { + + private final List TEST1_USED = Collections.singletonList("plugin1:test"); + private final List TEST1 = Arrays.asList("test", "plugin1:test"); + private final List TEST2 = Arrays.asList("test", "plugin2:test"); + private final List TESTER = Arrays.asList("tester", "plugin2:tester"); + + @Override + public CompletableFuture> suggestCommands(List parts) { + return null; + } + + @Override + public List getAliases(String command) { + if (TEST1_USED.contains(command)) { + return TEST1; + } else if (TEST2.contains(command)) { + return TEST2; + } else if (TESTER.contains(command)) { + return TESTER; + } + return Collections.emptyList(); + } + + @Override + public boolean isSameCommand(String command1, String command2) { + return getAliases(command1) == getAliases(command2); + } + } +}