Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/vendor/
/composer.lock
/.codegraph/
45 changes: 33 additions & 12 deletions Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand All @@ -32,27 +34,46 @@ 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
];
}

try {
$response = $client->post($this->getUrl($method), [
'verify' => false,
$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);
}

/**
Expand Down
4 changes: 3 additions & 1 deletion Type/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
19 changes: 17 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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);

Expand Down
65 changes: 65 additions & 0 deletions tests/guzzle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php
require __DIR__ . '/../vendor/autoload.php';

use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response as HttpResponse;
use Zelenin\Telegram\Bot\Client;

set_error_handler(function ($severity, $message, $file, $line) {
throw new ErrorException($message, 0, $severity, $file, $line);
});
function check($condition, $message) {
if (!$condition) { throw new RuntimeException($message); }
}
function makeClient($responses, &$history) {
$history = [];
$stack = HandlerStack::create(new MockHandler($responses));
$stack->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 (['<html>bad gateway</html>', '{}', '{"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";
47 changes: 47 additions & 0 deletions tests/transport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php
// Offline transport contract: no bot token or network access is needed.
namespace GuzzleHttp {
class Client
{
public static $lastUrl;
public static $lastOptions;

public function request($method, $url, $options)
{
self::$lastUrl = $url;
self::$lastOptions = $options;
return new TestResponse();
}
}

class TestResponse
{
public function getBody()
{
return '{"ok":true,"result":{"message_id":123}}';
}
}
}

namespace {
require __DIR__ . '/../ClientInterface.php';
require __DIR__ . '/../Response.php';
require __DIR__ . '/../Client.php';

function check($condition, $message)
{
if (!$condition) {
throw new \RuntimeException($message);
}
}

$client = new \Zelenin\Telegram\Bot\Client('test-token');
$response = $client->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";
}
Loading