189 lines
6.8 KiB
PHP
189 lines
6.8 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Autocomplete Engine — resolves command suggestions from stored definitions + player cache.
|
|
*
|
|
* @author Ball Studios <https://git.balls.studio>
|
|
* @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<int, array{command: string, description: string, syntax: string, dangerous: bool, plugin: string}>
|
|
*/
|
|
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();
|
|
}
|
|
}
|