* @license MIT */ namespace Pterodactyl\Addons\AdvancedAdmin\Services\Console; use Illuminate\Support\Facades\Cache; use Pterodactyl\Addons\AdvancedAdmin\Models\CommandDefinition; use Pterodactyl\Addons\AdvancedAdmin\Models\ConsolePlayerCache; class AutocompleteEngine { /** * Generate suggestions for a given query on a server. * * @return array */ public function suggest(int $serverId, ?int $nodeId, ?int $eggId, string $query): array { $query = $this->sanitize($query); if (strlen($query) < 1) return []; $definitions = $this->loadDefinitions($serverId, $nodeId, $eggId); $players = $this->getOnlinePlayers($serverId); // Determine if we're suggesting a command name or an argument $parts = explode(' ', $query); // No space yet → suggest command names if (count($parts) === 1) { return $this->suggestCommands($query, $definitions); } // Space present → suggest arguments for the matched command $commandName = strtolower($parts[0]); return $this->suggestArguments($commandName, $parts, $definitions, $players); } /** * Sanitize query — prevent XSS/injection in rendered suggestions. */ private function sanitize(string $query): string { // Strip null bytes, control chars, HTML $query = preg_replace('/[\x00-\x1F\x7F]/', '', $query); $query = strip_tags($query); return mb_substr(trim($query), 0, 256); } private function suggestCommands(string $prefix, array $definitions): array { $prefix = strtolower($prefix); $results = []; $max = config('advanced-admin.console.max_suggestions', 10); foreach ($definitions as $def) { $names = array_merge([$def['name']], $def['aliases'] ?? []); foreach ($names as $name) { if (str_starts_with(strtolower($name), $prefix)) { $results[] = $this->formatSuggestion($def, $name); if (count($results) >= $max) break 2; } } } return $results; } private function suggestArguments(string $cmd, array $parts, array $definitions, array $players): array { $definition = null; foreach ($definitions as $def) { $names = array_merge([$def['name']], $def['aliases'] ?? []); if (in_array($cmd, array_map('strtolower', $names), true)) { $definition = $def; break; } } if (!$definition) return []; $argIndex = count($parts) - 2; // 0-based index of current argument $args = $definition['args'] ?? []; if (!isset($args[$argIndex])) return []; $argDef = $args[$argIndex]; $partial = strtolower(end($parts)); $max = config('advanced-admin.console.max_suggestions', 10); $results = []; if ($argDef['type'] === 'player') { foreach ($players as $player) { if (str_starts_with(strtolower($player), $partial)) { $results[] = ['command' => $player, 'description' => 'Online player', 'syntax' => '', 'dangerous' => false, 'plugin' => 'server']; if (count($results) >= $max) break; } } } elseif (!empty($argDef['suggestions'])) { foreach ($argDef['suggestions'] as $s) { if (str_starts_with(strtolower($s), $partial)) { $results[] = ['command' => $s, 'description' => $argDef['name'] ?? '', 'syntax' => '', 'dangerous' => false, 'plugin' => '']; if (count($results) >= $max) break; } } } // Return parent command suggestion with full syntax if (empty($results)) { $results[] = $this->formatSuggestion($definition, $definition['name']); } return $results; } private function formatSuggestion(array $def, string $displayName): array { $syntax = '/' . $displayName; foreach ($def['args'] ?? [] as $arg) { $syntax .= $arg['required'] ? " <{$arg['name']}>" : " [{$arg['name']}]"; } return [ 'command' => $displayName, 'description' => $def['description'] ?? '', 'syntax' => $syntax, 'dangerous' => $def['dangerous'] ?? false, 'danger_reason' => $def['dangerReason'] ?? null, 'plugin' => $def['plugin'] ?? 'vanilla', 'examples' => $def['examples'] ?? [], ]; } /** * Load command definitions for this server (server-specific → node → egg → global). * Cached for 60 seconds per server. */ private function loadDefinitions(int $serverId, ?int $nodeId, ?int $eggId): array { $cacheKey = "addon_commands_{$serverId}_{$nodeId}_{$eggId}"; return Cache::remember($cacheKey, 60, function () use ($serverId, $nodeId, $eggId) { // DB custom definitions $dbDefs = CommandDefinition::query() ->where('is_active', true) ->where(function ($q) use ($serverId, $nodeId, $eggId) { $q->whereNull('server_id') ->orWhere('server_id', $serverId); }) ->get() ->map(fn ($d) => $d->definition) ->toArray(); // Built-in JSON definitions from config/addon-commands/ $builtIn = $this->loadBuiltInDefinitions(); return array_merge($builtIn, $dbDefs); }); } private function loadBuiltInDefinitions(): array { $path = config('advanced-admin.console.definitions_path', base_path('config/addon-commands')); $files = glob($path . '/*.json') ?: []; $defs = []; foreach ($files as $file) { $raw = file_get_contents($file); if (!$raw) continue; $decoded = json_decode($raw, true); if (json_last_error() !== JSON_ERROR_NONE) continue; $defs = array_merge($defs, is_array($decoded[0] ?? null) ? $decoded : [$decoded]); } return $defs; } private function getOnlinePlayers(int $serverId): array { return ConsolePlayerCache::query() ->where('server_id', $serverId) ->where('last_seen_at', '>=', now()->subMinutes( config('advanced-admin.console.player_cache_ttl_minutes', 30) )) ->pluck('player_name') ->toArray(); } }