-
Notifications
You must be signed in to change notification settings - Fork 41
feat(player-counter): add TeamSpeak 3 ServerQuery support #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| <?php | ||
|
|
||
| namespace Boy132\PlayerCounter\Extensions\Query\Schemas; | ||
|
|
||
| use App\Models\Allocation; | ||
| use Boy132\PlayerCounter\Extensions\Query\QueryTypeSchemaInterface; | ||
| use Exception; | ||
|
|
||
| class TeamSpeakQueryTypeSchema implements QueryTypeSchemaInterface | ||
| { | ||
| public function getId(): string | ||
| { | ||
| return 'teamspeak'; | ||
| } | ||
|
|
||
| public function getName(): string | ||
| { | ||
| return 'TeamSpeak 3 (ServerQuery)'; | ||
| } | ||
|
|
||
| public function resolvePort(Allocation $allocation): ?int | ||
| { | ||
| $server = $allocation->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<array{id: string, name: string}>} */ | ||
| 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; | ||
| } | ||
|
Comment on lines
+98
to
+116
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -type f -name "TeamSpeakQueryTypeSchema.php" | head -20Repository: pelican-dev/plugins Length of output: 137 🏁 Script executed: cat -n ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php | sed -n '90,130p'Repository: pelican-dev/plugins Length of output: 1385 🏁 Script executed: cat -n ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php | head -50Repository: pelican-dev/plugins Length of output: 1787 🏁 Script executed: cat -n ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php | sed -n '50,100p'Repository: pelican-dev/plugins Length of output: 2182 🏁 Script executed: rg "readUntilError" ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.phpRepository: pelican-dev/plugins Length of output: 273 🏁 Script executed: cat -n ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.php | sed -n '116,127p'Repository: pelican-dev/plugins Length of output: 514
The function stops on any Proposed fix- private function readUntilError($socket): string
+ /**
+ * `@param` resource $socket
+ */
+ private function readUntilError(mixed $socket): string
{
$data = '';
while (!feof($socket)) {
$line = fgets($socket);
if ($line === false) {
break;
}
$trimmed = trim($line);
if (str_starts_with($trimmed, 'error ')) {
+ $error = $this->parseLine($trimmed);
+ if (($error['id'] ?? '1') !== '0') {
+ throw new Exception('TeamSpeak ServerQuery command failed: ' . ($error['msg'] ?? 'unknown'));
+ }
break;
}
if ($trimmed !== '') {
$data = $trimmed;
}
}
return $data;
}🧰 Tools🪛 GitHub Check: PHPStan (8.2)[failure] 97-97: 🪛 GitHub Check: PHPStan (8.3)[failure] 97-97: 🪛 GitHub Check: PHPStan (8.4)[failure] 97-97: 🪛 GitHub Check: PHPStan (8.5)[failure] 97-97: 🤖 Prompt for AI Agents |
||
|
|
||
| /** @return array<string, string> */ | ||
| 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<int, array<string, string>> */ | ||
| 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 | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, find the GameQuery.php file
find . -name "GameQuery.php" -type fRepository: pelican-dev/plugins Length of output: 104 🏁 Script executed: # Read the GameQuery.php file to inspect the code
cat -n player-counter/src/Models/GameQuery.phpRepository: pelican-dev/plugins Length of output: 2940 🏁 Script executed: # Look for PHPStan configuration
find . -name "phpstan.neon*" -o -name "phpstan.json" -o -name ".phpstan*" 2>/dev/null | head -20Repository: pelican-dev/plugins Length of output: 77 🏁 Script executed: # Search for any CI configuration that runs PHPStan
find . -name ".github" -o -name ".gitlab-ci.yml" -o -name ".travis.yml" -o -name "*.yml" 2>/dev/null | grep -E "(github|gitlab|github)" | head -10Repository: pelican-dev/plugins Length of output: 146 🏁 Script executed: # Check if there are other usages of this private method besides lines 42 and 70
rg "resolveIp" player-counter/src/Models/GameQuery.php -nRepository: pelican-dev/plugins Length of output: 233 🏁 Script executed: cat -n phpstan.neonRepository: pelican-dev/plugins Length of output: 385 🏁 Script executed: cat -n .github/workflows/lint.ymlRepository: pelican-dev/plugins Length of output: 2537 Use Lines 42 and 70 call a Proposed fix- $ip = static::resolveIp($allocation);
+ $ip = self::resolveIp($allocation);Apply to lines 42 and 70. 📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Check: PHPStan (8.2)[failure] 42-42: 🪛 GitHub Check: PHPStan (8.3)[failure] 42-42: 🪛 GitHub Check: PHPStan (8.4)[failure] 42-42: 🪛 GitHub Check: PHPStan (8.5)[failure] 42-42: 🤖 Prompt for AI Agents |
||||||
| $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; | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: pelican-dev/plugins
Length of output: 137
🏁 Script executed:
Repository: pelican-dev/plugins
Length of output: 45
🏁 Script executed:
# Read the file to verify the concerns cat -n ./player-counter/src/Extensions/Query/Schemas/TeamSpeakQueryTypeSchema.phpRepository: pelican-dev/plugins
Length of output: 5772
🏁 Script executed:
Repository: pelican-dev/plugins
Length of output: 97
🏁 Script executed:
Repository: pelican-dev/plugins
Length of output: 45
🏁 Script executed:
Repository: pelican-dev/plugins
Length of output: 89
🏁 Script executed:
# Check PHPStan and Pint configurations cat -n ./phpstan.neonRepository: pelican-dev/plugins
Length of output: 385
🏁 Script executed:
# Check Pint configuration cat -n ./pint.jsonRepository: pelican-dev/plugins
Length of output: 367
Guard greeting reads and catch
\Throwableto prevent uncaught runtime failures.Line 53:
fgets()can returnfalseon timeout or stream closure. Callingtrim($greeting)with afalsevalue throwsTypeErrorin PHP 8+, which is not caught bycatch (Exception)sinceTypeErrorextends\Error, not\Exception.Additionally, line 97 has a missing parameter type hint on
readUntilError($socket), which violates PHPStan level 6 checking.Proposed fix
🤖 Prompt for AI Agents