diff --git a/driver/api/src/main/java/eu/cloudnetservice/driver/provider/SpecificCloudServiceProvider.java b/driver/api/src/main/java/eu/cloudnetservice/driver/provider/SpecificCloudServiceProvider.java index acd0a7556d..947519e9ab 100644 --- a/driver/api/src/main/java/eu/cloudnetservice/driver/provider/SpecificCloudServiceProvider.java +++ b/driver/api/src/main/java/eu/cloudnetservice/driver/provider/SpecificCloudServiceProvider.java @@ -200,6 +200,14 @@ default void delete() { */ void runCommand(@NonNull String command); + /** + * Gets the console-suggestion(tab-complete) using ChannelMessage, if it is not supported, the response will get empty list. + * + * @param line the command line currently + * @return fully command list if supported + */ + Collection consoleSuggestion(@NonNull String line); + /** * Gets the templates that actually are installed on the service. If a template is present in the configuration * {@link eu.cloudnetservice.driver.service.ServiceConfiguration} but wasn't pulled onto the service it won't appear diff --git a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/PlatformBridgeManagement.java b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/PlatformBridgeManagement.java index 4e4475e6bd..41234c1d66 100644 --- a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/PlatformBridgeManagement.java +++ b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/PlatformBridgeManagement.java @@ -54,6 +54,7 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -212,6 +213,10 @@ public void handleServiceUpdate(@NonNull ServiceInfoSnapshot snapshot) { } } + public @NonNull List consoleSuggestion(@NonNull String line) { + return List.of(); + } + public @NonNull Optional fallback( @NonNull UUID playerId, @Nullable String currentServerName, diff --git a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bukkit/BukkitBridgeManagement.java b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bukkit/BukkitBridgeManagement.java index 83bd65d569..bd2107a0e2 100644 --- a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bukkit/BukkitBridgeManagement.java +++ b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bukkit/BukkitBridgeManagement.java @@ -16,6 +16,7 @@ package eu.cloudnetservice.modules.bridge.impl.platform.bukkit; +import dev.derklaro.reflexion.Reflexion; import eu.cloudnetservice.driver.event.EventManager; import eu.cloudnetservice.driver.network.NetworkClient; import eu.cloudnetservice.driver.network.rpc.factory.RPCFactory; @@ -32,18 +33,21 @@ import eu.cloudnetservice.modules.bridge.player.PlayerManager; import eu.cloudnetservice.modules.bridge.player.ServicePlayer; import eu.cloudnetservice.modules.bridge.player.executor.PlayerExecutor; +import eu.cloudnetservice.utils.base.concurrent.TaskUtil; import eu.cloudnetservice.wrapper.configuration.WrapperConfiguration; import eu.cloudnetservice.wrapper.event.ServiceInfoPropertiesConfigureEvent; import eu.cloudnetservice.wrapper.holder.ServiceInfoHolder; import jakarta.inject.Inject; import jakarta.inject.Singleton; import java.util.Collections; +import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.function.BiFunction; import lombok.NonNull; import org.bukkit.Bukkit; import org.bukkit.Server; +import org.bukkit.command.CommandMap; import org.bukkit.entity.Player; import org.bukkit.permissions.Permissible; import org.bukkit.plugin.Plugin; @@ -57,6 +61,7 @@ final class BukkitBridgeManagement extends PlatformBridgeManagement commandMap; private final PlayerExecutor directGlobalExecutor; @Inject @@ -84,6 +89,15 @@ public BukkitBridgeManagement( // init fields this.server = server; this.plugin = plugin; + this.commandMap = Reflexion.onBound(server) + .findField("commandMap") + .flatMap(fieldAccessor -> { + var value = fieldAccessor.getValue(); + if (value.wasSuccessful()) { + return Optional.of((CommandMap) value.get()); + } + return Optional.empty(); + }); this.directGlobalExecutor = new BukkitDirectPlayerExecutor( plugin, PlayerExecutor.GLOBAL_UNIQUE_ID, @@ -123,6 +137,17 @@ public boolean isOnAnyFallbackInstance(@NonNull Player player) { return this.isOnAnyFallbackInstance(this.ownNetworkServiceInfo.serverName(), null, player::hasPermission); } + @Override + public @NonNull List consoleSuggestion(@NonNull String line) { + // Ensure better compatibility + var future = BukkitUtil.supplyOnMainThread( + this.plugin, + () -> this.commandMap.map(map -> map.tabComplete(this.server.getConsoleSender(), line)) + ); + return TaskUtil.getOrDefault(future, Optional.empty()) + .orElseGet(List::of); + } + @Override public @NonNull Optional fallback(@NonNull Player player) { return this.fallback(player, this.ownNetworkServiceInfo.serverName()); diff --git a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bukkit/BukkitUtil.java b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bukkit/BukkitUtil.java index 6080d5934a..298f9c6531 100644 --- a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bukkit/BukkitUtil.java +++ b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bukkit/BukkitUtil.java @@ -21,9 +21,13 @@ import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import java.util.function.Supplier; import lombok.NonNull; +import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.Nullable; final class BukkitUtil { @@ -72,4 +76,30 @@ private BukkitUtil() { // resolve the locale tag from the cache, put it in if missing return LOCALE_CACHE.computeIfAbsent(localeTag, tag -> Locale.forLanguageTag(tag.replace('_', '-'))); } + + + public static CompletableFuture supplyOnMainThread(Plugin plugin, Supplier supplier) { + CompletableFuture future = new CompletableFuture<>(); + + if (Bukkit.isPrimaryThread()) { + try { + T result = supplier.get(); + future.complete(result); + } catch (Throwable t) { + future.completeExceptionally(t); + } + return future; + } + + Bukkit.getScheduler().runTask(plugin, () -> { + try { + T result = supplier.get(); + future.complete(result); + } catch (Throwable t) { + future.completeExceptionally(t); + } + }); + + return future; + } } diff --git a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bungeecord/BungeeCordBridgeManagement.java b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bungeecord/BungeeCordBridgeManagement.java index 624b3f0d9f..fca4dbe590 100644 --- a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bungeecord/BungeeCordBridgeManagement.java +++ b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/bungeecord/BungeeCordBridgeManagement.java @@ -39,7 +39,10 @@ import eu.cloudnetservice.wrapper.holder.ServiceInfoHolder; import jakarta.inject.Inject; import jakarta.inject.Singleton; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.function.BiFunction; @@ -48,6 +51,7 @@ import net.md_5.bungee.api.ProxyServer; import net.md_5.bungee.api.connection.PendingConnection; import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.plugin.Command; import org.jetbrains.annotations.Nullable; @Singleton @@ -135,6 +139,31 @@ public boolean isOnAnyFallbackInstance(@NonNull ProxiedPlayer player) { player::hasPermission); } + @Override + public @NonNull List consoleSuggestion(@NonNull String line) { + // BungeeCord have not provided tab-complete as root directly. we need to process manually + var console = this.proxyServer.getConsole(); + List suggestions = new ArrayList<>(); + + if (line.indexOf(' ') == -1) { + for (Map.Entry entry : this.proxyServer.getPluginManager().getCommands()) { + var name = entry.getKey(); + if (name.startsWith(line)) { + var command = entry.getValue(); + String permission = command.getPermission(); + if (permission == null || permission.isEmpty() || console.hasPermission(permission)) { + suggestions.add(command.getName()); + } + } + } + } else { + // Complete command arguments + this.proxyServer.getPluginManager().dispatchCommand(console, line, suggestions); + } + + return suggestions; + } + @Override public @NonNull Optional fallback(@NonNull ProxiedPlayer player) { return this.fallback(player, player.getServer() == null ? null : player.getServer().getInfo().getName()); diff --git a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/listener/PlatformChannelMessageListener.java b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/listener/PlatformChannelMessageListener.java index 16136fece6..8a84e8c7bc 100644 --- a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/listener/PlatformChannelMessageListener.java +++ b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/listener/PlatformChannelMessageListener.java @@ -36,6 +36,10 @@ import eu.cloudnetservice.modules.bridge.player.NetworkPlayerServerInfo; import eu.cloudnetservice.modules.bridge.player.NetworkServiceInfo; import eu.cloudnetservice.modules.bridge.player.executor.ServerSelectorType; +import eu.cloudnetservice.utils.base.concurrent.TaskUtil; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.List; import lombok.NonNull; import net.kyori.adventure.text.Component; import net.kyori.adventure.title.Title; @@ -64,6 +68,28 @@ public void handleConfigurationChannelMessage(@NonNull ChannelMessageReceiveEven } } + @EventListener + public void handleConsoleSuggestionChannelMessage(@NonNull ChannelMessageReceiveEvent event) { + // TODO need a better way to get channel of 'cloudnet:internal' from NetworkConstants.INTERNAL_MSG_CHANNEL. + if (event.channel().equals("cloudnet:internal") && event.message() + .equals("request_console_suggestion")) { + // read the line + var line = event.content().readString(); + var future = TaskUtil.supplyAsync(() -> this.management.consoleSuggestion(line)); + // make sure normal while service console is lagging + var rawList = TaskUtil.getOrDefault(future, Duration.of(100, ChronoUnit.MILLIS), List.of()); + List suggestions; + // Preventing cloudnet console from dying + if (rawList.size() > 50) { + suggestions = rawList.subList(0, 50); + } else { + suggestions = rawList; + } + + event.binaryResponse(DataBuf.empty().writeObject(suggestions)); + } + } + @EventListener public void handlePlayerChannelMessage(@NonNull ChannelMessageReceiveEvent event) { if (event.channel().equals(BridgeManagement.BRIDGE_PLAYER_CHANNEL_NAME)) { diff --git a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/velocity/VelocityBridgeManagement.java b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/velocity/VelocityBridgeManagement.java index fea1990da7..323843b9ef 100644 --- a/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/velocity/VelocityBridgeManagement.java +++ b/modules/bridge/impl/src/main/java/eu/cloudnetservice/modules/bridge/impl/platform/velocity/VelocityBridgeManagement.java @@ -18,11 +18,13 @@ import static net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer.legacySection; +import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.permission.PermissionSubject; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.proxy.server.ServerInfo; +import dev.derklaro.reflexion.Reflexion; import eu.cloudnetservice.driver.event.EventManager; import eu.cloudnetservice.driver.network.NetworkClient; import eu.cloudnetservice.driver.network.rpc.factory.RPCFactory; @@ -47,8 +49,10 @@ import jakarta.inject.Singleton; import java.net.InetSocketAddress; import java.util.Collections; +import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.function.BiFunction; import lombok.NonNull; import org.jetbrains.annotations.Nullable; @@ -142,6 +146,20 @@ public boolean isOnAnyFallbackInstance(@NonNull Player player) { player::hasPermission); } + @Override + @SuppressWarnings("unchecked") + public @NonNull List consoleSuggestion(@NonNull String line) { + var commandManager = this.proxyServer.getCommandManager(); + var consoleSource = this.proxyServer.getConsoleCommandSource(); + + CompletableFuture> offerSuggestions = Reflexion.onBound(commandManager) + .findMethod("offerSuggestions", CommandSource.class, String.class) + .map((acc) -> (CompletableFuture>) acc.invokeWithArgs(consoleSource, line).get()) + .orElse(CompletableFuture.completedFuture(List.of())); + + return offerSuggestions.join(); + } + @Override public @NonNull Optional fallback(@NonNull Player player) { return this.fallback( diff --git a/node/impl/src/main/java/eu/cloudnetservice/node/impl/command/defaults/DefaultSuggestionProcessor.java b/node/impl/src/main/java/eu/cloudnetservice/node/impl/command/defaults/DefaultSuggestionProcessor.java index 40eae2a72a..5eaa23df7b 100644 --- a/node/impl/src/main/java/eu/cloudnetservice/node/impl/command/defaults/DefaultSuggestionProcessor.java +++ b/node/impl/src/main/java/eu/cloudnetservice/node/impl/command/defaults/DefaultSuggestionProcessor.java @@ -54,8 +54,16 @@ final class DefaultSuggestionProcessor implements SuggestionProcessor context, @NonNull Stream allSuggestions ) { + var commandInput = context.commandInput(); + + // fix the suggestion of greedy + var lastWhitespace = commandInput.input().lastIndexOf(' '); + if (lastWhitespace != -1) { + commandInput.cursor(lastWhitespace); + } + // íf there is no input yet, just return all suggestions - var input = context.commandInput().peekString(); + var input = commandInput.peekString(); if (Strings.isNullOrEmpty(input)) { return allSuggestions; } diff --git a/node/impl/src/main/java/eu/cloudnetservice/node/impl/command/sub/ServiceCommand.java b/node/impl/src/main/java/eu/cloudnetservice/node/impl/command/sub/ServiceCommand.java index a0ba2e8627..1a3dc2e053 100644 --- a/node/impl/src/main/java/eu/cloudnetservice/node/impl/command/sub/ServiceCommand.java +++ b/node/impl/src/main/java/eu/cloudnetservice/node/impl/command/sub/ServiceCommand.java @@ -60,6 +60,7 @@ import org.incendo.cloud.annotations.Permission; import org.incendo.cloud.annotations.parser.Parser; import org.incendo.cloud.annotations.suggestion.Suggestions; +import org.incendo.cloud.context.CommandContext; import org.incendo.cloud.context.CommandInput; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -127,6 +128,26 @@ public ServiceCommand(@NonNull EventManager eventManager, @NonNull CloudServiceP return matchedServices; } + @Suggestions("serviceCommands") + public @NonNull Stream suggestCommands(@NonNull CommandContext context, @NonNull String input) { + Collection services = context.get("name"); + var service = services.stream().findFirst().orElseThrow(); + + var fullyInput = context.rawInput().input(); + var rawInput = fullyInput.substring(fullyInput.indexOf(service.name()) + service.name().length() + 1); + String command; + if (rawInput.contains(" ")) { + command = rawInput.substring(rawInput.indexOf(' ')); + if (command.startsWith(" ")) { + command = command.substring(1); + } + } else { + command = ""; + } + + return service.provider().consoleSuggestion(command).stream(); + } + @Command("service|ser list|l") public void displayServices( @NonNull CommandSource source, @@ -301,10 +322,10 @@ public void deployResources( public void sendCommand( @NonNull CommandSource source, @NonNull @Argument("name") Collection matchedServices, - @NonNull @Greedy @Argument("command") String command + @NonNull @Argument(value = "command", suggestions = "serviceCommands") @Greedy String line ) { for (var matchedService : matchedServices) { - matchedService.provider().runCommand(command); + matchedService.provider().runCommand(line); } } diff --git a/node/impl/src/main/java/eu/cloudnetservice/node/impl/service/defaults/AbstractService.java b/node/impl/src/main/java/eu/cloudnetservice/node/impl/service/defaults/AbstractService.java index 8ac406612e..2bb8dd5857 100644 --- a/node/impl/src/main/java/eu/cloudnetservice/node/impl/service/defaults/AbstractService.java +++ b/node/impl/src/main/java/eu/cloudnetservice/node/impl/service/defaults/AbstractService.java @@ -61,6 +61,7 @@ import eu.cloudnetservice.utils.base.concurrent.TaskUtil; import eu.cloudnetservice.utils.base.io.FileUtil; import eu.cloudnetservice.utils.base.resource.CpuUsageResolver; +import io.leangen.geantyref.TypeFactory; import io.vavr.Tuple2; import java.net.Inet6Address; import java.nio.charset.StandardCharsets; @@ -411,6 +412,19 @@ public void restart() { this.updateLifecycle(ServiceLifeCycle.RUNNING); } + @Override + public Collection consoleSuggestion(@NonNull String line) { + var listPropertyType = TypeFactory.parameterizedClass(List.class, String.class); + var response = ChannelMessage.builder() + .targetService(this.serviceId().name()) + .channel(NetworkConstants.INTERNAL_MSG_CHANNEL) + .message("request_console_suggestion") + .buffer(DataBuf.empty().writeString(line)) + .build().sendSingleQuery(); + + return response != null ? response.content().readObject(listPropertyType) : List.of(); + } + @Override public @NonNull Collection installedTemplates() { return this.installedTemplates; diff --git a/node/impl/src/main/java/eu/cloudnetservice/node/impl/service/defaults/provider/EmptySpecificCloudServiceProvider.java b/node/impl/src/main/java/eu/cloudnetservice/node/impl/service/defaults/provider/EmptySpecificCloudServiceProvider.java index 7259ab4612..3b2e80b3f5 100644 --- a/node/impl/src/main/java/eu/cloudnetservice/node/impl/service/defaults/provider/EmptySpecificCloudServiceProvider.java +++ b/node/impl/src/main/java/eu/cloudnetservice/node/impl/service/defaults/provider/EmptySpecificCloudServiceProvider.java @@ -93,6 +93,11 @@ public void restart() { public void runCommand(@NonNull String command) { } + @Override + public Collection consoleSuggestion(@NonNull String line) { + return List.of(); + } + @Override public @NonNull Collection installedTemplates() { return List.of();