diff --git a/player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php b/player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php new file mode 100644 index 00000000..0ec43fdd --- /dev/null +++ b/player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php @@ -0,0 +1,156 @@ +server; + if (!$server) { + return null; + } + + $variable = $server->variables()->where('env_variable', 'QUERY_PORT')->first(); + if (!$variable) { + return null; + } + + $port = (int) ($variable->server_value ?: $variable->default_value); + + return $port > 0 ? $port : null; + } + + /** @return ?array{hostname: string, map: string, current_players: int, max_players: int, players: ?array} */ + public function process(string $ip, int $port): ?array + { + $socket = null; + + try { + $socket = @fsockopen($ip, $port, $errno, $errstr, 5); + if ($socket === false) { + throw new Exception("Could not connect to TeamSpeak ServerQuery: $errstr ($errno)"); + } + + stream_set_timeout($socket, 5); + + // Read greeting: "TS3", welcome text, empty line + $greeting = fgets($socket); + if (!str_starts_with(trim($greeting), 'TS3')) { + throw new Exception('Not a TeamSpeak 3 ServerQuery interface'); + } + fgets($socket); // welcome line + fgets($socket); // empty line + + // Select first virtual server + fwrite($socket, "use sid=1\n"); + $this->readUntilError($socket); + + // Get server info + fwrite($socket, "serverinfo\n"); + $infoLine = $this->readUntilError($socket); + + // Get client list + fwrite($socket, "clientlist\n"); + $clientLine = $this->readUntilError($socket); + + fwrite($socket, "quit\n"); + + $info = $this->parseLine($infoLine); + $clients = $this->parseClientList($clientLine); + + return [ + 'hostname' => $this->unescape($info['virtualserver_name'] ?? 'Unknown'), + 'map' => 'TeamSpeak', + 'current_players' => count($clients), + 'max_players' => (int) ($info['virtualserver_maxclients'] ?? 0), + 'players' => array_map(fn ($c) => [ + 'id' => $c['clid'] ?? '0', + 'name' => $this->unescape($c['client_nickname'] ?? 'Unknown'), + ], $clients), + ]; + } catch (Exception $exception) { + report($exception); + } finally { + if (!empty($socket)) { + fclose($socket); + } + } + + return null; + } + + /** @param resource $socket */ + private function readUntilError($socket): string + { + $data = ''; + while (!feof($socket)) { + $line = fgets($socket); + if ($line === false) { + break; + } + $trimmed = trim($line); + if (str_starts_with($trimmed, 'error ')) { + break; + } + if ($trimmed !== '') { + $data = $trimmed; + } + } + + return $data; + } + + /** @return array */ + private function parseLine(string $line): array + { + $result = []; + foreach (explode(' ', $line) as $token) { + if (str_contains($token, '=')) { + [$key, $value] = explode('=', $token, 2); + $result[$key] = $value; + } + } + + return $result; + } + + /** @return array> */ + private function parseClientList(string $line): array + { + $clients = []; + foreach (explode('|', $line) as $entry) { + $client = $this->parseLine($entry); + // Skip ServerQuery clients (client_type=1) + if (($client['client_type'] ?? '0') === '1') { + continue; + } + $clients[] = $client; + } + + return $clients; + } + + private function unescape(string $value): string + { + return str_replace( + ['\\s', '\\p', '\\/', '\\\\', '\\n', '\\r', '\\t', '\\a', '\\b', '\\f', '\\v'], + [' ', '|', '/', '\\', "\n", "\r", "\t", "\x07", "\x08", "\x0C", "\x0B"], + $value + ); + } +} diff --git a/player-counter/src/Models/GameQuery.php b/player-counter/src/Models/GameQuery.php index 29a880c8..3f407925 100644 --- a/player-counter/src/Models/GameQuery.php +++ b/player-counter/src/Models/GameQuery.php @@ -39,13 +39,26 @@ public function runQuery(Allocation $allocation): ?array return null; } - $ip = config('player-counter.use_alias') && is_ip($allocation->alias) ? $allocation->alias : $allocation->ip; + $ip = static::resolveIp($allocation); $ip = is_ipv6($ip) ? '[' . $ip . ']' : $ip; /** @var QueryTypeService $service */ $service = app(QueryTypeService::class); - return $service->get($this->query_type)?->process($ip, $allocation->port + ($this->query_port_offset ?? 0)); + $schema = $service->get($this->query_type); + if (!$schema) { + return null; + } + + // Allow schema to provide its own port resolution (e.g. from server variables) + if (method_exists($schema, 'resolvePort')) { + $resolvedPort = $schema->resolvePort($allocation); + if ($resolvedPort !== null) { + return $schema->process($ip, $resolvedPort); + } + } + + return $schema->process($ip, $allocation->port + ($this->query_port_offset ?? 0)); } public static function canRunQuery(?Allocation $allocation): bool @@ -54,8 +67,17 @@ public static function canRunQuery(?Allocation $allocation): bool return false; } - $ip = config('player-counter.use_alias') && is_ip($allocation->alias) ? $allocation->alias : $allocation->ip; + $ip = static::resolveIp($allocation); return !in_array($ip, ['0.0.0.0', '::']); } + + protected static function resolveIp(Allocation $allocation): string + { + if (config('player-counter.use_alias') && !empty($allocation->ip_alias)) { + return $allocation->ip_alias; + } + + return $allocation->ip; + } } diff --git a/player-counter/src/Providers/PlayerCounterPluginProvider.php b/player-counter/src/Providers/PlayerCounterPluginProvider.php index 0f90c67d..64eb3704 100644 --- a/player-counter/src/Providers/PlayerCounterPluginProvider.php +++ b/player-counter/src/Providers/PlayerCounterPluginProvider.php @@ -12,6 +12,7 @@ use Boy132\PlayerCounter\Extensions\Query\Schemas\MinecraftBedrockQueryTypeSchema; use Boy132\PlayerCounter\Extensions\Query\Schemas\MinecraftJavaQueryTypeSchema; use Boy132\PlayerCounter\Extensions\Query\Schemas\SourceQueryTypeSchema; +use Boy132\PlayerCounter\Extensions\Query\Schemas\TeamSpeakQueryTypeSchema; use Boy132\PlayerCounter\Filament\Server\Widgets\ServerPlayerWidget; use Boy132\PlayerCounter\Models\EggGameQuery; use Boy132\PlayerCounter\Models\GameQuery; @@ -35,6 +36,7 @@ public function register(): void $service->register(new MinecraftJavaQueryTypeSchema()); $service->register(new MinecraftBedrockQueryTypeSchema()); $service->register(new CitizenFXQueryTypeSchema()); + $service->register(new TeamSpeakQueryTypeSchema()); return $service; });