From b9a895bfdff0b4c34e825bef1c082747d198b892 Mon Sep 17 00:00:00 2001 From: Hossein Mirhadi Date: Fri, 18 Sep 2026 15:45:18 +0330 Subject: [PATCH 1/2] fix: verify TLS certificates for Telegram API requests --- Client.php | 2 +- tests/transport.php | 47 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/transport.php diff --git a/Client.php b/Client.php index 94fc4cd..4a159ad 100644 --- a/Client.php +++ b/Client.php @@ -44,7 +44,7 @@ public function request($method, $params = []) try { $response = $client->post($this->getUrl($method), [ - 'verify' => false, + 'verify' => true, 'multipart' => $multipartParams ?: null ]); $response = json_decode($response->getBody()); diff --git a/tests/transport.php b/tests/transport.php new file mode 100644 index 0000000..0e5c352 --- /dev/null +++ b/tests/transport.php @@ -0,0 +1,47 @@ +request('sendMessage', ['chat_id' => 42, 'text' => 'hello']); + check(\GuzzleHttp\Client::$lastOptions['verify'] === true, 'TLS certificate verification must be enabled'); + check(\GuzzleHttp\Client::$lastUrl === 'https://api.telegram.org/bottest-token/sendMessage', 'HTTPS endpoint changed'); + check(\GuzzleHttp\Client::$lastOptions['multipart'][0]['contents'] === '42', 'Scalar multipart conversion changed'); + check($response->getOk() && $response->getResult()->message_id === 123, 'Response parsing changed'); + $client->request('getMe'); + check(\GuzzleHttp\Client::$lastOptions['multipart'] === null, 'Empty request changed'); + echo "PASS: TLS verification and request/response contract\n"; +} From dffb41f87b1862197c481a8bfccdb896b71b4b0e Mon Sep 17 00:00:00 2001 From: Hossein Mirhadi Date: Fri, 18 Sep 2026 18:04:56 +0330 Subject: [PATCH 2/2] fix: modernize PHP transport and preserve current Telegram fields --- .github/workflows/tests.yml | 26 +++++++++++++++ .gitignore | 3 ++ Client.php | 43 +++++++++++++++++------- Type/Type.php | 4 ++- composer.json | 9 ++++- readme.md | 19 +++++++++-- tests/guzzle.php | 65 +++++++++++++++++++++++++++++++++++++ tests/transport.php | 2 +- 8 files changed, 155 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 tests/guzzle.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..dc81dd9 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: PHP client tests +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + strategy: + fail-fast: false + matrix: + include: + - {os: ubuntu-latest, php: '8.2'} + - {os: ubuntu-latest, php: '8.4'} + - {os: ubuntu-latest, php: '8.5'} + - {os: windows-latest, php: '8.3'} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: none + - run: composer validate --strict + - run: composer install --no-interaction --prefer-dist --no-plugins --no-scripts + - run: composer test + - run: composer audit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99793e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/composer.lock +/.codegraph/ diff --git a/Client.php b/Client.php index 4a159ad..91dfaf6 100644 --- a/Client.php +++ b/Client.php @@ -16,12 +16,14 @@ class Client implements ClientInterface */ private $token; - /** - * @param string $token - */ - public function __construct($token) + /** @var \GuzzleHttp\ClientInterface */ + private $httpClient; + + /** @param string $token */ + public function __construct($token, ?\GuzzleHttp\ClientInterface $httpClient = null) { $this->token = $token; + $this->httpClient = $httpClient ?: new \GuzzleHttp\Client(); } /** @@ -32,10 +34,16 @@ public function __construct($token) */ public function request($method, $params = []) { - $client = new \GuzzleHttp\Client(); - $multipartParams = []; foreach ($params as $key => $value) { + if ($value === null) { + continue; + } + if (is_array($value) || (is_object($value) && !($value instanceof \Psr\Http\Message\StreamInterface))) { + $value = json_encode($value, JSON_THROW_ON_ERROR); + } elseif (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } $multipartParams[] = [ 'name' => $key, 'contents' => is_scalar($value) ? (string)$value : $value @@ -43,16 +51,29 @@ public function request($method, $params = []) } try { - $response = $client->post($this->getUrl($method), [ + $response = $this->httpClient->request('POST', $this->getUrl($method), [ 'verify' => true, + 'allow_redirects' => false, 'multipart' => $multipartParams ?: null ]); - $response = json_decode($response->getBody()); - return new Response($response->ok, $response->result); } catch (ClientException $e) { - $response = json_decode($e->getResponse()->getBody()->getContents()); - return new Response($response->ok, null, $response->error_code, $response->description); + $response = $e->getResponse(); + } + $payload = json_decode((string)$response->getBody()); + if (!is_object($payload) || !isset($payload->ok) || !is_bool($payload->ok)) { + throw new \UnexpectedValueException('Invalid Telegram API response.'); + } + if ($payload->ok) { + if (!property_exists($payload, 'result')) { + throw new \UnexpectedValueException('Telegram API response has no result.'); + } + return new Response(true, $payload->result); + } + if (!isset($payload->error_code, $payload->description) + || !is_int($payload->error_code) || !is_string($payload->description)) { + throw new \UnexpectedValueException('Invalid Telegram API error response.'); } + return new Response(false, null, $payload->error_code, $payload->description); } /** diff --git a/Type/Type.php b/Type/Type.php index 6556872..a7986e8 100644 --- a/Type/Type.php +++ b/Type/Type.php @@ -4,12 +4,14 @@ use stdClass; +// Telegram may add fields beyond the historical typed wrappers. +#[\AllowDynamicProperties] abstract class Type implements TypeInterface { /** * @param stdClass $result */ - public function __construct(stdClass $result = null) + public function __construct(?stdClass $result = null) { if ($result instanceof stdClass) { $this->loadResult($result); diff --git a/composer.json b/composer.json index 489b168..41e0a1b 100644 --- a/composer.json +++ b/composer.json @@ -20,11 +20,18 @@ "source": "https://github.com/zelenin/telegram-bot-api" }, "require": { - "guzzlehttp/guzzle": "~6" + "php": "^8.2", + "guzzlehttp/guzzle": "^7.10" }, "autoload": { "psr-4": { "Zelenin\\Telegram\\Bot\\": "" } + }, + "scripts": { + "test": [ + "@php tests/transport.php", + "@php tests/guzzle.php" + ] } } diff --git a/readme.md b/readme.md index c9c9410..c24d825 100644 --- a/readme.md +++ b/readme.md @@ -2,11 +2,26 @@ [Telegram](https://telegram.org) [Bot](https://core.telegram.org/bots) [API](https://core.telegram.org/bots/api) Client +## Maintenance branch requirements + +This fork's maintenance branch targets PHP 8.2+ and Guzzle 7.10+. This is a +compatibility change from the historical PHP/Guzzle 6 version. No new Packagist +release has been published: the commands below refer to the original upstream +package, not these unreleased changes. To test this checkout, run `composer install` +and `composer test`. + +The generic `Api::request()` can call newer Bot API methods; the historical typed +wrappers are not a complete model of the current Telegram API. TLS verification +stays enabled and redirects are rejected. Arrays and objects are encoded as JSON, +booleans as `true`/`false`, null fields are omitted, and upload streams are preserved. +Malformed responses raise `UnexpectedValueException`; valid API errors continue +through `Response` / `NotOkException`. Network and server exceptions propagate. + ## Installation ### Composer -The preferred way to install this extension is through [Composer](http://getcomposer.org/). +The preferred way to install this extension is through [Composer](https://getcomposer.org/). Either run @@ -30,7 +45,7 @@ $client = new Zelenin\Telegram\Bot\Api($token); try { $response = $client->sendMessage([ 'chat_id' => $chatId, - 'text' => 'Test message + 'text' => 'Test message' ]); print_r($response); diff --git a/tests/guzzle.php b/tests/guzzle.php new file mode 100644 index 0000000..57aeabe --- /dev/null +++ b/tests/guzzle.php @@ -0,0 +1,65 @@ +push(Middleware::history($history)); + return new Client('fixture-token', new HttpClient(['handler' => $stack])); +} +function expectInvalid($body) { + $client = makeClient([new HttpResponse(200, [], $body)], $history); + try { $client->request('getMe'); } catch (UnexpectedValueException $e) { return; } + throw new RuntimeException('Malformed response was accepted'); +} +$client = makeClient([new HttpResponse(200, [], '{"ok":true,"result":{"message_id":7}}')], $history); +$file = fopen('php://temp', 'r+'); +fwrite($file, 'fixture upload'); rewind($file); +$response = $client->request('sendDocument', [ + 'chat_id' => 12, 'reply_markup' => ['inline_keyboard' => []], + 'disable_notification' => false, 'protect_content' => true, + 'unused' => null, 'document' => $file, +]); +check($response->getResult()->message_id === 7, 'Success result'); +$options = $history[0]['options']; +check($options['verify'] === true && $options['allow_redirects'] === false, 'Transport security'); +$body = (string)$history[0]['request']->getBody(); +foreach (['{"inline_keyboard":[]}', "\r\n\r\nfalse\r\n", "\r\n\r\ntrue\r\n", 'fixture upload'] as $expected) { + check(str_contains($body, $expected), 'Multipart encoding: ' . $expected); +} +check(!str_contains($body, 'name="unused"'), 'Null field must be omitted'); +fclose($file); +foreach ([200, 400, 429] as $status) { + $client = makeClient([new HttpResponse($status, [], '{"ok":false,"error_code":429,"description":"Retry later"}')], $history); + $response = $client->request('getMe'); + check(!$response->getOk() && $response->getErrorCode() === 429 && $response->getDescription() === 'Retry later', 'API error parsing'); +} +foreach (['bad gateway', '{}', '{"ok":1,"result":true}', '{"ok":true}', '{"ok":false}', 'null'] as $body) { + expectInvalid($body); +} +$client = makeClient([new HttpResponse(302, ['Location' => 'https://example.invalid/'])], $history); +try { $client->request('getMe'); throw new RuntimeException('Redirect accepted'); } +catch (UnexpectedValueException $e) { check(count($history) === 1, 'Redirect must not be followed'); } +$client = makeClient([new HttpResponse(200, [], '{"ok":true,"result":false}')], $history); +check($client->request('getMe')->getResult() === false, 'False result preserved'); +$client = makeClient([], $history); +try { $client->request('sendMessage', ['reply_markup' => ["\xB1"]]); throw new RuntimeException('Invalid UTF-8 accepted'); } +catch (JsonException $e) { check(count($history) === 0, 'Invalid JSON must not be sent'); } +$api = new \Zelenin\Telegram\Bot\Api('fixture-token'); +$api->setClient(makeClient([new HttpResponse(200, [], '{"ok":true,"result":{"id":12,"first_name":"Fixture","is_bot":true,"new_api_field":"preserved"}}')], $history)); +$user = $api->getMe(); +check($user->id === 12 && $user->is_bot === true && $user->new_api_field === 'preserved', 'Modern API fields must survive typed wrappers'); +echo "PASS: Guzzle multipart, TLS, redirects, API errors, malformed responses, and JSON failure\n"; diff --git a/tests/transport.php b/tests/transport.php index 0e5c352..bd1dc93 100644 --- a/tests/transport.php +++ b/tests/transport.php @@ -6,7 +6,7 @@ class Client public static $lastUrl; public static $lastOptions; - public function post($url, $options) + public function request($method, $url, $options) { self::$lastUrl = $url; self::$lastOptions = $options;