From eb1fb3e9aec83eb465cca6e072ecde1b955176e3 Mon Sep 17 00:00:00 2001 From: BoShurik Date: Fri, 28 Apr 2023 18:50:27 +0300 Subject: [PATCH] Local Bot API Server support Third-party http-client support window.Telegram.WebApp.initData validation --- .github/workflows/tests.yaml | 4 + .php-cs-fixer.dist.php | 2 + CHANGELOG.md | 3 + README.md | 44 +--- composer.json | 14 +- psalm.xml | 9 +- src/BotApi.php | 282 +++++++++++------------ src/Botan.php | 6 +- src/Client.php | 7 +- src/Events/EventCollection.php | 3 +- src/Http/AbstractHttpClient.php | 37 +++ src/Http/CurlHttpClient.php | 226 ++++++++++++++++++ src/Http/HttpClientInterface.php | 31 +++ src/Http/PsrHttpClient.php | 82 +++++++ src/Http/SymfonyHttpClient.php | 54 +++++ src/HttpException.php | 4 +- tests/BotApiTest.php | 33 ++- tests/Types/ArrayOfMessageEntityTest.php | 10 +- 18 files changed, 637 insertions(+), 214 deletions(-) create mode 100644 src/Http/AbstractHttpClient.php create mode 100644 src/Http/CurlHttpClient.php create mode 100644 src/Http/HttpClientInterface.php create mode 100644 src/Http/PsrHttpClient.php create mode 100644 src/Http/SymfonyHttpClient.php diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 0072a66d..8b0618cf 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -59,6 +59,10 @@ jobs: name: Remove psalm run: composer remove vimeo/psalm --dev --no-update + - + name: Remove http client dependencies + run: composer remove psr/http-client psr/http-factory symfony/http-client guzzlehttp/guzzle --dev --no-update + - name: Install dependencies with composer run: composer update --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 84419a13..0413b8d7 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -23,10 +23,12 @@ 'no_unused_imports' => true, 'single_quote' => true, 'no_extra_blank_lines' => true, + 'array_indentation' => true, 'cast_spaces' => true, 'phpdoc_align' => [ 'align' => 'left', ], 'binary_operator_spaces' => true, + 'single_line_empty_body' => false, ]) ; diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a092388..cafc20d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ All Notable changes to `PHP Telegram Bot Api` will be documented in this file - Add `\TelegramBot\Api\BotApi::revokeChatInviteLink` api method - Add `\TelegramBot\Api\BotApi::approveChatJoinRequest` api method - Add `\TelegramBot\Api\BotApi::declineChatJoinRequest` api method +- Add support for third party http clients (`psr/http-client` and `symfony/http-client`) +- Add support for local bot API server +- Add method `\TelegramBot\Api\BotApi::validateWebAppData` to validate `window.Telegram.WebApp.initData` ## 2.5.0 - 2023-08-09 diff --git a/README.md b/README.md index 7524749e..f3b7bf04 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,6 @@ require_once "vendor/autoload.php"; try { $bot = new \TelegramBot\Api\Client('YOUR_BOT_API_TOKEN'); - // or initialize with botan.io tracker api key - // $bot = new \TelegramBot\Api\Client('YOUR_BOT_API_TOKEN', 'YOUR_BOTAN_TRACKER_API_KEY'); - //Handle /ping command $bot->command('ping', function ($message) use ($bot) { @@ -107,45 +104,26 @@ try { } ``` -### Botan SDK (not supported more) - -[Botan](http://botan.io) is a telegram bot analytics system based on [Yandex.Appmetrica](http://appmetrica.yandex.com/). -In this document you can find how to setup Yandex.Appmetrica account, as well as examples of Botan SDK usage. - -### Creating an account - * Register at http://appmetrica.yandex.com/ - * After registration you will be prompted to create Application. Please use @YourBotName as a name. - * Save an API key from settings page, you will use it as a token for Botan API calls. - * Download lib for your language, and use it as described below. Don`t forget to insert your token! - -Since we are only getting started, you may discover that some existing reports in AppMetriŅa aren't properly working for Telegram bots, like Geography, Gender, Age, Library, Devices, Traffic sources and Network sections. We will polish that later. - -## SDK usage - -#### Standalone +#### Local Bot API Server -```php -$tracker = new \TelegramBot\Api\Botan('YOUR_BOTAN_TRACKER_API_KEY'); - -$tracker->track($message, $eventName); -``` +For using custom [local bot API server](https://core.telegram.org/bots/api#using-a-local-bot-api-server) -#### API Wrapper ```php -$bot = new \TelegramBot\Api\BotApi('YOUR_BOT_API_TOKEN', 'YOUR_BOTAN_TRACKER_API_KEY'); - -$bot->track($message, $eventName); +use TelegramBot\Api\Client; +$token = 'YOUR_BOT_API_TOKEN'; +$bot = new Client($token, null, null, 'http://localhost:8081'); ``` - You can use method 'getUpdates()'and all incoming messages will be automatically tracked as `Message`-event. +#### Third-party Http Client -#### Client ```php -$bot = new \TelegramBot\Api\Client('YOUR_BOT_API_TOKEN', 'YOUR_BOTAN_TRACKER_API_KEY'); +use Symfony\Component\HttpClient\HttpClient; +use TelegramBot\Api\BotApi; +use TelegramBot\Api\Http\SymfonyHttpClient; +$token = 'YOUR_BOT_API_TOKEN'; +$bot = new Client($token, null, new SymfonyHttpClient(HttpClient::create());); ``` -_All registered commands are automatically tracked as command name_ - ## Change log Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. diff --git a/composer.json b/composer.json index 24abe76d..b835131b 100644 --- a/composer.json +++ b/composer.json @@ -24,8 +24,18 @@ }, "require-dev": { "symfony/phpunit-bridge" : "*", - "friendsofphp/php-cs-fixer": "^3.16", - "vimeo/psalm": "^5.9" + "friendsofphp/php-cs-fixer": "~3.28.0", + "vimeo/psalm": "^5.9", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "symfony/http-client": "^4.3 | ^5.0 | ^6.0", + "guzzlehttp/guzzle": "^7.0" + }, + "suggest": { + "psr/http-client": "To use psr/http-client", + "psr/http-factory": "To use psr/http-client", + "guzzlehttp/guzzle": "To use psr/http-client", + "symfony/http-client": "To use symfony/http-client" }, "autoload": { "psr-4": { diff --git a/psalm.xml b/psalm.xml index ef3650df..d0e78e7a 100644 --- a/psalm.xml +++ b/psalm.xml @@ -19,10 +19,9 @@ - - - - - + + + + diff --git a/src/BotApi.php b/src/BotApi.php index 53ef612f..1e2c3237 100644 --- a/src/BotApi.php +++ b/src/BotApi.php @@ -2,6 +2,8 @@ namespace TelegramBot\Api; +use TelegramBot\Api\Http\CurlHttpClient; +use TelegramBot\Api\Http\HttpClientInterface; use TelegramBot\Api\Types\ArrayOfBotCommand; use TelegramBot\Api\Types\ArrayOfChatMemberEntity; use TelegramBot\Api\Types\ArrayOfMessageEntity; @@ -41,6 +43,8 @@ class BotApi { /** + * @deprecated + * * HTTP codes * * @var array @@ -113,57 +117,59 @@ class BotApi ]; /** - * @var array + * Url prefixes */ - private $proxySettings = []; + const URL_PREFIX = 'https://api.telegram.org/bot'; + + /** + * Url prefix for files + */ + const FILE_URL_PREFIX = 'https://api.telegram.org/file/bot'; /** + * @deprecated + * * Default http status code */ const DEFAULT_STATUS_CODE = 200; /** + * @deprecated + * * Not Modified http status code */ const NOT_MODIFIED_STATUS_CODE = 304; /** + * @deprecated + * * Limits for tracked ids */ const MAX_TRACKED_EVENTS = 200; /** - * Url prefixes + * @var HttpClientInterface */ - const URL_PREFIX = 'https://api.telegram.org/bot'; + private $httpClient; /** - * Url prefix for files + * @var string */ - const FILE_URL_PREFIX = 'https://api.telegram.org/file/bot'; + private $token; /** - * CURL object - * - * @var resource + * @var string */ - protected $curl; + private $endpoint; /** - * CURL custom options - * - * @var array + * @var string|null */ - protected $customCurlOptions = []; + private $fileEndpoint; /** - * Bot token + * @deprecated * - * @var string - */ - protected $token; - - /** * Botan tracker * * @var Botan|null @@ -171,6 +177,8 @@ class BotApi protected $tracker; /** + * @deprecated + * * list of event ids * * @var array @@ -178,23 +186,18 @@ class BotApi protected $trackedEvents = []; /** - * Check whether return associative array - * - * @var bool - */ - protected $returnArray = true; - - /** - * Constructor - * * @param string $token Telegram Bot API token * @param string|null $trackerToken Yandex AppMetrica application api_key - * @throws \Exception + * @param HttpClientInterface|null $httpClient + * @param string|null $endpoint */ - public function __construct($token, $trackerToken = null) + public function __construct($token, $trackerToken = null, HttpClientInterface $httpClient = null, $endpoint = null) { - $this->curl = curl_init(); $this->token = $token; + $this->endpoint = ($endpoint ?: self::URL_PREFIX) . $token; + $this->fileEndpoint = $endpoint ? null : (self::FILE_URL_PREFIX . $token); + + $this->httpClient = $httpClient ?: new CurlHttpClient(); if ($trackerToken) { @trigger_error(sprintf('Passing $trackerToken to %s is deprecated', self::class), \E_USER_DEPRECATED); @@ -203,6 +206,36 @@ public function __construct($token, $trackerToken = null) } /** + * @param string $rawData + * @param int|null $authDateDiff + * @return bool + */ + public function validateWebAppData($rawData, $authDateDiff = null) + { + parse_str($rawData, $data); + + $sign = $data['hash']; + unset($data['hash']); + + if ($authDateDiff && (time() - $data['auth_date'] > $authDateDiff)) { + return false; + } + + ksort($data); + $checkString = ''; + foreach ($data as $k => $v) { + $checkString .= "$k=$v\n"; + } + $checkString = trim($checkString); + + $secret = hash_hmac('sha256', $this->token, 'WebAppData', true); + + return bin2hex(hash_hmac('sha256', $checkString, $secret, true)) === $sign; + } + + /** + * @deprecated + * * Set return array * * @param bool $mode @@ -211,83 +244,62 @@ public function __construct($token, $trackerToken = null) */ public function setModeObject($mode = true) { - $this->returnArray = !$mode; + @trigger_error(sprintf('Method "%s::%s" is deprecated', __CLASS__, __METHOD__), \E_USER_DEPRECATED); return $this; } /** + * @deprecated + * * Call method * * @param string $method * @param array|null $data - * @param int $timeout + * @param int|null $timeout * * @return mixed * @throws Exception * @throws HttpException * @throws InvalidJsonException */ - public function call($method, array $data = null, $timeout = 10) + public function call($method, array $data = null, $timeout = null) { - $options = $this->proxySettings + [ - CURLOPT_URL => $this->getUrl().'/'.$method, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_POST => null, - CURLOPT_POSTFIELDS => null, - CURLOPT_TIMEOUT => $timeout, - ]; - - if ($data) { - $options[CURLOPT_POST] = true; - $options[CURLOPT_POSTFIELDS] = $data; - } - - if (!empty($this->customCurlOptions)) { - $options = $this->customCurlOptions + $options; + if ($timeout !== null) { + @trigger_error(sprintf('Passing $timeout parameter in %s::%s is deprecated. Use http client options', __CLASS__, __METHOD__), \E_USER_DEPRECATED); } - $response = self::jsonValidate($this->executeCurl($options), $this->returnArray); - - if (\is_array($response)) { - if (!isset($response['ok']) || !$response['ok']) { - throw new Exception($response['description'], $response['error_code']); - } - - return $response['result']; - } + $endpoint = $this->endpoint . '/' . $method; - if (!$response->ok) { - throw new Exception($response->description, $response->error_code); - } - - return $response->result; + return $this->httpClient->request($endpoint, $data); } /** - * curl_exec wrapper for response validation + * Get file contents via cURL * - * @param array $options + * @param string $fileId * * @return string * * @throws HttpException + * @throws Exception */ - protected function executeCurl(array $options) + public function downloadFile($fileId) { - curl_setopt_array($this->curl, $options); - - /** @var string|false $result */ - $result = curl_exec($this->curl); - self::curlValidate($this->curl, $result); - if ($result === false) { - throw new HttpException(curl_error($this->curl), curl_errno($this->curl)); + $file = $this->getFile($fileId); + if (!$path = $file->getFilePath()) { + throw new Exception('Empty file_path property'); + } + if (!$this->fileEndpoint) { + return file_get_contents($path); } - return $result; + return $this->httpClient->download($this->fileEndpoint . '/' . $path); } /** + * @deprecated + * * Response validation * * @param resource $curl @@ -299,6 +311,8 @@ protected function executeCurl(array $options) */ public static function curlValidate($curl, $response = null) { + @trigger_error(sprintf('Method "%s::%s" is deprecated', __CLASS__, __METHOD__), \E_USER_DEPRECATED); + if ($response) { $json = json_decode($response, true) ?: []; } else { @@ -1426,29 +1440,6 @@ public function getFile($fileId) return File::fromResponse($this->call('getFile', ['file_id' => $fileId])); } - /** - * Get file contents via cURL - * - * @param string $fileId - * - * @return string - * - * @throws HttpException - * @throws Exception - */ - public function downloadFile($fileId) - { - $file = $this->getFile($fileId); - $options = [ - CURLOPT_HEADER => 0, - CURLOPT_HTTPGET => 1, - CURLOPT_RETURNTRANSFER => 1, - CURLOPT_URL => $this->getFileUrl().'/'.$file->getFilePath(), - ]; - - return $this->executeCurl($options); - } - /** * Use this method to send answers to an inline query. On success, True is returned. * No more than 50 results per query are allowed. @@ -1814,30 +1805,8 @@ public function deleteMessage($chatId, $messageId) } /** - * Close curl - */ - public function __destruct() - { - curl_close($this->curl); - } - - /** - * @return string - */ - public function getUrl() - { - return self::URL_PREFIX.$this->token; - } - - /** - * @return string - */ - public function getFileUrl() - { - return self::FILE_URL_PREFIX.$this->token; - } - - /** + * @deprecated + * * @param Update $update * @param string $eventName * @@ -1847,6 +1816,8 @@ public function getFileUrl() */ public function trackUpdate(Update $update, $eventName = 'Message') { + @trigger_error(sprintf('Method "%s::%s" is deprecated', __CLASS__, __METHOD__), \E_USER_DEPRECATED); + if (!in_array($update->getUpdateId(), $this->trackedEvents)) { $message = $update->getMessage(); if (!$message) { @@ -1863,6 +1834,8 @@ public function trackUpdate(Update $update, $eventName = 'Message') } /** + * @deprecated + * * Wrapper for tracker * * @param Message $message @@ -1874,6 +1847,8 @@ public function trackUpdate(Update $update, $eventName = 'Message') */ public function track(Message $message, $eventName = 'Message') { + @trigger_error(sprintf('Method "%s::%s" is deprecated', __CLASS__, __METHOD__), \E_USER_DEPRECATED); + if ($this->tracker instanceof Botan) { $this->tracker->track($message, $eventName); } @@ -2545,32 +2520,6 @@ public function sendMediaGroup( ] + $attachments)); } - /** - * Enable proxy for curl requests. Empty string will disable proxy. - * - * @param string $proxyString - * @param bool $socks5 - * - * @return BotApi - */ - public function setProxy($proxyString = '', $socks5 = false) - { - if (empty($proxyString)) { - $this->proxySettings = []; - return $this; - } - - $this->proxySettings = [ - CURLOPT_PROXY => $proxyString, - CURLOPT_HTTPPROXYTUNNEL => true, - ]; - - if ($socks5) { - $this->proxySettings[CURLOPT_PROXYTYPE] = CURLPROXY_SOCKS5; - } - return $this; - } - /** * Use this method to send a native poll. A native poll can't be sent to a private chat. * On success, the sent \TelegramBot\Api\Types\Message is returned. @@ -2882,6 +2831,25 @@ public function answerWebAppQuery($webAppQueryId, $result) ])); } + /** + * Enable proxy for curl requests. Empty string will disable proxy. + * + * @param string $proxyString + * @param bool $socks5 + * + * @return BotApi + */ + public function setProxy($proxyString = '', $socks5 = false) + { + @trigger_error(sprintf('Method "%s:%s" is deprecated. Manage options on HttpClient instance', __CLASS__, __METHOD__), \E_USER_DEPRECATED); + + if (method_exists($this->httpClient, 'setProxy')) { + $this->httpClient->setProxy($proxyString, $socks5); + } + + return $this; + } + /** * Set an option for a cURL transfer * @@ -2892,7 +2860,11 @@ public function answerWebAppQuery($webAppQueryId, $result) */ public function setCurlOption($option, $value) { - $this->customCurlOptions[$option] = $value; + @trigger_error(sprintf('Method "%s:%s" is deprecated. Manage options on http client instance', __CLASS__, __METHOD__), \E_USER_DEPRECATED); + + if (method_exists($this->httpClient, 'setOption')) { + $this->httpClient->setOption($option, $value); + } } /** @@ -2904,7 +2876,11 @@ public function setCurlOption($option, $value) */ public function unsetCurlOption($option) { - unset($this->customCurlOptions[$option]); + @trigger_error(sprintf('Method "%s:%s" is deprecated. Manage options on http client instance', __CLASS__, __METHOD__), \E_USER_DEPRECATED); + + if (method_exists($this->httpClient, 'unsetOption')) { + $this->httpClient->unsetOption($option); + } } /** @@ -2914,6 +2890,10 @@ public function unsetCurlOption($option) */ public function resetCurlOptions() { - $this->customCurlOptions = []; + @trigger_error(sprintf('Method "%s:%s" is deprecated. Manage options on http client instance', __CLASS__, __METHOD__), \E_USER_DEPRECATED); + + if (method_exists($this->httpClient, 'resetOptions')) { + $this->httpClient->resetOptions(); + } } } diff --git a/src/Botan.php b/src/Botan.php index 87665207..efc9f67f 100644 --- a/src/Botan.php +++ b/src/Botan.php @@ -34,14 +34,10 @@ class Botan * * @param string $token * - * @throws \Exception + * @throws InvalidArgumentException */ public function __construct($token) { - if (!function_exists('curl_version')) { - throw new Exception('CURL not installed'); - } - if (empty($token)) { throw new InvalidArgumentException('Token should not be empty'); } diff --git a/src/Client.php b/src/Client.php index 33322d86..c81abd68 100644 --- a/src/Client.php +++ b/src/Client.php @@ -5,6 +5,7 @@ use Closure; use ReflectionFunction; use TelegramBot\Api\Events\EventCollection; +use TelegramBot\Api\Http\HttpClientInterface; use TelegramBot\Api\Types\Update; use TelegramBot\Api\Types\Message; use TelegramBot\Api\Types\Inline\InlineKeyboardMarkup; @@ -40,13 +41,15 @@ class Client * * @param string $token Telegram Bot API token * @param string|null $trackerToken Yandex AppMetrica application api_key + * @param HttpClientInterface|null $httpClient + * @param string|null $endpoint */ - public function __construct($token, $trackerToken = null) + public function __construct($token, $trackerToken = null, HttpClientInterface $httpClient = null, $endpoint = null) { if ($trackerToken) { @trigger_error(sprintf('Passing $trackerToken to %s is deprecated', self::class), \E_USER_DEPRECATED); } - $this->api = new BotApi($token); + $this->api = new BotApi($token, $trackerToken, $httpClient, $endpoint); $this->events = new EventCollection($trackerToken); } diff --git a/src/Events/EventCollection.php b/src/Events/EventCollection.php index af0481d4..510854d5 100644 --- a/src/Events/EventCollection.php +++ b/src/Events/EventCollection.php @@ -48,8 +48,7 @@ public function __construct($trackerToken = null) public function add(Closure $event, $checker = null) { $this->events[] = !is_null($checker) ? new Event($event, $checker) - : new Event($event, function () { - }); + : new Event($event, function () {}); return $this; } diff --git a/src/Http/AbstractHttpClient.php b/src/Http/AbstractHttpClient.php new file mode 100644 index 00000000..6c04bcc2 --- /dev/null +++ b/src/Http/AbstractHttpClient.php @@ -0,0 +1,37 @@ +doRequest($url, $data); + + if (!isset($response['ok']) || !$response['ok']) { + throw new Exception($response['description'], $response['error_code']); + } + + return $response['result']; + } + + public function download($url) + { + return $this->doDownload($url); + } + + /** + * @param string $url + * @param array|null $data + * @return array + */ + abstract protected function doRequest($url, array $data = null); + + /** + * @param string $url + * @return string + */ + abstract protected function doDownload($url); +} diff --git a/src/Http/CurlHttpClient.php b/src/Http/CurlHttpClient.php new file mode 100644 index 00000000..910afda2 --- /dev/null +++ b/src/Http/CurlHttpClient.php @@ -0,0 +1,226 @@ + 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', // RFC2518 + // Success 2xx + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', // RFC4918 + 208 => 'Already Reported', // RFC5842 + 226 => 'IM Used', // RFC3229 + // Redirection 3xx + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', // 1.1 + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + // 306 is deprecated but reserved + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', // RFC7238 + // Client Error 4xx + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Payload Too Large', + 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + 422 => 'Unprocessable Entity', // RFC4918 + 423 => 'Locked', // RFC4918 + 424 => 'Failed Dependency', // RFC4918 + 425 => 'Reserved for WebDAV advanced collections expired proposal', // RFC2817 + 426 => 'Upgrade Required', // RFC2817 + 428 => 'Precondition Required', // RFC6585 + 429 => 'Too Many Requests', // RFC6585 + 431 => 'Request Header Fields Too Large', // RFC6585 + // Server Error 5xx + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates (Experimental)', // RFC2295 + 507 => 'Insufficient Storage', // RFC4918 + 508 => 'Loop Detected', // RFC5842 + 510 => 'Not Extended', // RFC2774 + 511 => 'Network Authentication Required', // RFC6585 + ]; + + /** + * Default http status code + */ + const DEFAULT_STATUS_CODE = 200; + + /** + * Not Modified http status code + */ + const NOT_MODIFIED_STATUS_CODE = 304; + + /** + * CURL object + * + * @var resource + */ + private $curl; + + /** + * @var array + */ + private $options; + + public function __construct(array $options = []) + { + $this->curl = curl_init(); + $this->options = $options; + } + + /** + * @inheritDoc + */ + protected function doRequest($url, array $data = null) + { + $options = $this->options + [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + ]; + + if ($data) { + $options[CURLOPT_POST] = true; + $options[CURLOPT_POSTFIELDS] = $data; + } + + return self::jsonValidate($this->execute($options)); + } + + /** + * @inheritDoc + */ + protected function doDownload($url) + { + $options = [ + CURLOPT_HEADER => false, + CURLOPT_HTTPGET => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_URL => $url, + ]; + + return $this->execute($options); + } + + /** + * @param array $options + * @return string + * @throws HttpException + */ + private function execute(array $options) + { + curl_setopt_array($this->curl, $options); + + /** @var string|false $result */ + $result = curl_exec($this->curl); + if ($result === false) { + throw new HttpException(curl_error($this->curl), curl_errno($this->curl)); + } + + self::curlValidate($this->curl, $result); + + return $result; + } + + /** + * @param string $jsonString + * @return array + * @throws InvalidJsonException + */ + private static function jsonValidate($jsonString) + { + /** @var array $json */ + $json = json_decode($jsonString, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new InvalidJsonException(json_last_error_msg(), json_last_error()); + } + + return $json; + } + + /** + * @param resource $curl + * @param string|null $response + * @return void + * @throws HttpException + */ + private static function curlValidate($curl, $response = null) + { + $json = json_decode((string) $response, true) ?: []; + + if (($httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE)) + && !in_array($httpCode, [self::DEFAULT_STATUS_CODE, self::NOT_MODIFIED_STATUS_CODE]) + ) { + $errorDescription = array_key_exists('description', $json) ? $json['description'] : self::$codes[$httpCode]; + $errorParameters = array_key_exists('parameters', $json) ? $json['parameters'] : []; + + throw new HttpException($errorDescription, $httpCode, null, $errorParameters); + } + } + + /** + * @param string $option + * @param string|int|bool $value + * @return void + */ + public function setOption($option, $value) + { + $this->options[$option] = $value; + } + + /** + * @param string $option + * @return void + */ + public function unsetOption($option) + { + unset($this->options[$option]); + } + + /** + * @return void + */ + public function resetOptions() + { + $this->options = []; + } +} diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php new file mode 100644 index 00000000..7503c14c --- /dev/null +++ b/src/Http/HttpClientInterface.php @@ -0,0 +1,31 @@ +http = $http; + $this->requestFactory = $requestFactory; + } + + /** + * @inheritDoc + */ + protected function doRequest($url, array $data = null) + { + if ($data) { + $method = 'POST'; + } else { + $method = 'GET'; + } + + $request = $this->requestFactory->createRequest($method, $url); + try { + $response = $this->http->sendRequest($request); + } catch (ClientExceptionInterface $exception) { + throw new HttpException($exception->getMessage(), $exception->getCode(), $exception); + } + + $content = $response->getBody()->getContents(); + + return self::jsonValidate($content); + } + + /** + * @inheritDoc + */ + protected function doDownload($url) + { + $request = $this->requestFactory->createRequest('GET', $url); + + try { + return $this->http->sendRequest($request)->getBody()->getContents(); + } catch (ClientExceptionInterface $exception) { + throw new HttpException($exception->getMessage(), $exception->getCode(), $exception); + } + } + + /** + * @param string $jsonString + * @return array + * @throws InvalidJsonException + */ + private static function jsonValidate($jsonString) + { + /** @var array $json */ + $json = json_decode($jsonString, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new InvalidJsonException(json_last_error_msg(), json_last_error()); + } + + return $json; + } +} diff --git a/src/Http/SymfonyHttpClient.php b/src/Http/SymfonyHttpClient.php new file mode 100644 index 00000000..2b229576 --- /dev/null +++ b/src/Http/SymfonyHttpClient.php @@ -0,0 +1,54 @@ +http = $http; + } + + /** + * @inheritDoc + */ + protected function doRequest($url, array $data = null) + { + $options = []; + if ($data) { + $method = 'POST'; + $options['body'] = $data; + } else { + $method = 'GET'; + } + + $response = $this->http->request($method, $url, $options); + + try { + return $response->toArray(); + } catch (ExceptionInterface $exception) { + throw new HttpException($exception->getMessage(), $exception->getCode(), $exception); + } + } + + /** + * @inheritDoc + */ + protected function doDownload($url) + { + try { + return $this->http->request('GET', $url)->getContent(); + } catch (ExceptionInterface $exception) { + throw new HttpException($exception->getMessage(), $exception->getCode(), $exception); + } + } +} diff --git a/src/HttpException.php b/src/HttpException.php index 4b826c64..e6859920 100644 --- a/src/HttpException.php +++ b/src/HttpException.php @@ -20,10 +20,10 @@ class HttpException extends Exception * * @param string $message [optional] The Exception message to throw. * @param int $code [optional] The Exception code. - * @param Exception $previous [optional] The previous throwable used for the exception chaining. + * @param \Throwable|\Exception $previous [optional] The previous throwable used for the exception chaining. * @param array $parameters [optional] Array of parameters returned from API. */ - public function __construct($message = '', $code = 0, Exception $previous = null, $parameters = []) + public function __construct($message = '', $code = 0, $previous = null, $parameters = []) { $this->parameters = $parameters; diff --git a/tests/BotApiTest.php b/tests/BotApiTest.php index 3d7ba9dd..823292d3 100644 --- a/tests/BotApiTest.php +++ b/tests/BotApiTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use TelegramBot\Api\BotApi; +use TelegramBot\Api\Http\HttpClientInterface; use TelegramBot\Api\Types\ArrayOfUpdates; use TelegramBot\Api\Types\Update; @@ -108,16 +109,18 @@ public function data() */ public function testGetUpdates($updates) { - $mock = $this->getMockBuilder(BotApi::class) - ->setMethods(['call']) - ->enableOriginalConstructor() - ->setConstructorArgs(['testToken']) - ->getMock(); + $httpClient = $this->createHttpClient(); + $botApi = $this->createBotApi($httpClient); - $mock->expects($this->once())->method('call')->willReturn($updates); + $httpClient + ->expects($this->once()) + ->method('request') + ->willReturn($updates) + ; + + $result = $botApi->getUpdates(); $expectedResult = ArrayOfUpdates::fromResponse($updates); - $result = $mock->getUpdates(); $this->assertEquals($expectedResult, $result); @@ -126,4 +129,20 @@ public function testGetUpdates($updates) $this->assertEquals($expectedResult[$key], $item); } } + + /** + * @return HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject + */ + private function createHttpClient() + { + /** @var HttpClientInterface $httpClient */ + $httpClient = $this->createMock(HttpClientInterface::class); + + return $httpClient; + } + + private function createBotApi(HttpClientInterface $httpClient) + { + return new BotApi('token', null, $httpClient); + } } diff --git a/tests/Types/ArrayOfMessageEntityTest.php b/tests/Types/ArrayOfMessageEntityTest.php index 902c52a8..ce6ca65d 100644 --- a/tests/Types/ArrayOfMessageEntityTest.php +++ b/tests/Types/ArrayOfMessageEntityTest.php @@ -11,11 +11,11 @@ class ArrayOfMessageEntityTest extends TestCase public function testFromResponse() { $items = ArrayOfMessageEntity::fromResponse([ - [ - 'type' => 'mention', - 'offset' => 0, - 'length' => 10, - ], + [ + 'type' => 'mention', + 'offset' => 0, + 'length' => 10, + ], ]); $expected = [