├── .gitignore ├── src └── Discord │ ├── Exceptions │ ├── BadRequestException.php │ ├── MethodNotAllowedException.php │ ├── NotFoundException.php │ ├── RequestFailedException.php │ ├── NoPermissionsException.php │ ├── InvalidTokenException.php │ ├── RateLimitException.php │ └── ContentTooLongException.php │ ├── EndpointInterface.php │ ├── DriverInterface.php │ ├── HttpInterface.php │ ├── Multipart │ ├── MultipartField.php │ └── MultipartBody.php │ ├── RateLimit.php │ ├── Drivers │ ├── React.php │ └── Guzzle.php │ ├── Request.php │ ├── Http.php │ ├── EndpointTrait.php │ ├── Bucket.php │ ├── Endpoint.php │ └── HttpTrait.php ├── tests ├── Drivers │ ├── GuzzleTest.php │ ├── ReactTest.php │ └── _server.php └── Discord │ ├── RequestTest.php │ ├── Multipart │ └── MultipartTest.php │ ├── DriverInterfaceTest.php │ └── EndpointTest.php ├── LICENSE ├── phpunit.xml ├── examples └── file-upload.php ├── composer.json ├── README.md └── .php-cs-fixer.dist.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock 3 | test.php 4 | .php_cs.cache 5 | .php_cs 6 | .php-cs-fixer.php 7 | .php-cs-fixer.cache 8 | .vscode 9 | .phpunit.cache 10 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/BadRequestException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when a request to Discord's REST API returned ClientErrorResponse. 16 | * 17 | * @author SQKo 18 | */ 19 | class BadRequestException extends RequestFailedException 20 | { 21 | protected $code = 400; 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/MethodNotAllowedException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when a request to Discord's REST API method is invalid. 16 | * 17 | * @author SQKo 18 | */ 19 | class MethodNotAllowedException extends RequestFailedException 20 | { 21 | protected $code = 405; 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/NotFoundException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when a 404 Not Found response is received. 16 | * 17 | * @author David Cole 18 | */ 19 | class NotFoundException extends RequestFailedException 20 | { 21 | protected $code = 404; 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/RequestFailedException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | use RuntimeException; 15 | 16 | /** 17 | * Thrown when a request to Discord's REST API fails. 18 | * 19 | * @author David Cole 20 | */ 21 | class RequestFailedException extends RuntimeException 22 | { 23 | } 24 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/NoPermissionsException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when you do not have permissions to do something. 16 | * 17 | * @author David Cole 18 | */ 19 | class NoPermissionsException extends RequestFailedException 20 | { 21 | protected $code = 403; 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/InvalidTokenException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when an invalid token is provided to a Discord endpoint. 16 | * 17 | * @author David Cole 18 | */ 19 | class InvalidTokenException extends RequestFailedException 20 | { 21 | protected $code = 401; 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/RateLimitException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when a request to Discord's REST API got rate limited and the library 16 | * does not know how to handle. 17 | * 18 | * @author SQKo 19 | */ 20 | class RateLimitException extends RequestFailedException 21 | { 22 | protected $code = 429; 23 | } 24 | -------------------------------------------------------------------------------- /tests/Drivers/GuzzleTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Tests\Discord\Http\Drivers; 13 | 14 | use Discord\Http\DriverInterface; 15 | use Discord\Http\Drivers\Guzzle; 16 | use React\EventLoop\Loop; 17 | use Tests\Discord\Http\DriverInterfaceTest; 18 | 19 | class GuzzleTest extends DriverInterfaceTest 20 | { 21 | protected function getDriver(): DriverInterface 22 | { 23 | return new Guzzle(Loop::get()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Drivers/ReactTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Tests\Discord\Http\Drivers; 13 | 14 | use Discord\Http\DriverInterface; 15 | use Discord\Http\Drivers\React; 16 | use React\EventLoop\Loop; 17 | use Tests\Discord\Http\DriverInterfaceTest; 18 | 19 | class ReactTest extends DriverInterfaceTest 20 | { 21 | protected function getDriver(): DriverInterface 22 | { 23 | return new React(Loop::get()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Discord/Exceptions/ContentTooLongException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Exceptions; 13 | 14 | /** 15 | * Thrown when the Discord servers return `content longer than 2000 characters` after 16 | * a REST request. The user must use WebSockets to obtain this data if they need it. 17 | * 18 | * @author David Cole 19 | */ 20 | class ContentTooLongException extends RequestFailedException 21 | { 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/EndpointInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | interface EndpointInterface 15 | { 16 | public function bindArgs(...$args): self; 17 | public function bindAssoc(array $args): self; 18 | public function addQuery(string $key, $value): void; 19 | public function toAbsoluteEndpoint(bool $onlyMajorParameters = false): string; 20 | public function __toString(): string; 21 | public static function bind(string $endpoint, ...$args); 22 | } 23 | -------------------------------------------------------------------------------- /src/Discord/DriverInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use Psr\Http\Message\ResponseInterface; 15 | use React\Promise\PromiseInterface; 16 | 17 | /** 18 | * Interface for an HTTP driver. 19 | * 20 | * @author David Cole 21 | */ 22 | interface DriverInterface 23 | { 24 | /** 25 | * Runs a request. 26 | * 27 | * Returns a promise resolved with a PSR response interface. 28 | * 29 | * @param Request $request 30 | * 31 | * @return PromiseInterface 32 | */ 33 | public function runRequest(Request $request): PromiseInterface; 34 | } 35 | -------------------------------------------------------------------------------- /tests/Drivers/_server.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | use React\Http\HttpServer; 13 | use React\Http\Message\Response; 14 | use React\Socket\SocketServer; 15 | 16 | require __DIR__.'/../../vendor/autoload.php'; 17 | 18 | $http = new HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) { 19 | $response = [ 20 | 'method' => $request->getMethod(), 21 | 'args' => $request->getQueryParams(), 22 | 'json' => $request->getHeader('Content-Type') === ['application/json'] 23 | ? json_decode($request->getBody()) 24 | : [], 25 | ]; 26 | 27 | return Response::json($response); 28 | }); 29 | 30 | $socket = new SocketServer('127.0.0.1:8888'); 31 | 32 | $http->listen($socket); 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-present David Cole and all 4 | contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | tests/Discord 18 | 19 | 20 | 21 | tests/Drivers 22 | 23 | 24 | 25 | 27 | 28 | src 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Discord/HttpInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use Psr\Http\Message\ResponseInterface; 15 | use React\Promise\PromiseInterface; 16 | 17 | /** 18 | * Discord HTTP client. 19 | * 20 | * @author David Cole 21 | */ 22 | interface HttpInterface 23 | { 24 | public function setDriver(DriverInterface $driver): void; 25 | public function get($url, $content = null, array $headers = []): PromiseInterface; 26 | public function post($url, $content = null, array $headers = []): PromiseInterface; 27 | public function put($url, $content = null, array $headers = []): PromiseInterface; 28 | public function patch($url, $content = null, array $headers = []): PromiseInterface; 29 | public function delete($url, $content = null, array $headers = []): PromiseInterface; 30 | public function queueRequest(string $method, Endpoint $url, $content, array $headers = []): PromiseInterface; 31 | public static function isUnboundEndpoint(Request $request): bool; 32 | public function handleError(ResponseInterface $response): \Throwable; 33 | public function getUserAgent(): string; 34 | } 35 | -------------------------------------------------------------------------------- /src/Discord/Multipart/MultipartField.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Multipart; 13 | 14 | class MultipartField 15 | { 16 | private string $name; 17 | private string $content; 18 | private array $headers; 19 | private ?string $fileName; 20 | 21 | /** 22 | * @var String[] 23 | */ 24 | public function __construct( 25 | string $name, 26 | string $content, 27 | array $headers = [], 28 | ?string $fileName = null 29 | ) { 30 | $this->name = $name; 31 | $this->content = $content; 32 | $this->headers = $headers; 33 | $this->fileName = $fileName; 34 | } 35 | 36 | public function __toString(): string 37 | { 38 | $out = 'Content-Disposition: form-data; name="'.$this->name.'"'; 39 | 40 | if (! is_null($this->fileName)) { 41 | $out .= '; filename="'.urlencode($this->fileName).'"'; 42 | } 43 | 44 | $out .= PHP_EOL; 45 | 46 | foreach ($this->headers as $header => $value) { 47 | $out .= $header.': '.$value.PHP_EOL; 48 | } 49 | 50 | $out .= PHP_EOL.$this->content.PHP_EOL; 51 | 52 | return $out; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /examples/file-upload.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | use Discord\Http\Drivers\Guzzle; 13 | use Discord\Http\Endpoint; 14 | use Discord\Http\Http; 15 | use Discord\Http\Multipart\MultipartBody; 16 | use Discord\Http\Multipart\MultipartField; 17 | use Psr\Log\NullLogger; 18 | use React\EventLoop\Loop; 19 | 20 | require './vendor/autoload.php'; 21 | 22 | $http = new Http( 23 | 'Your token', 24 | Loop::get(), 25 | new NullLogger(), 26 | new Guzzle( 27 | Loop::get() 28 | ) 29 | ); 30 | 31 | $jsonPayloadField = new MultipartField( 32 | 'json_payload', 33 | json_encode([ 34 | 'content' => 'Hello!', 35 | ]), 36 | ['Content-Type' => 'application/json'] 37 | ); 38 | 39 | $imageField = new MultipartField( 40 | 'files[0]', 41 | file_get_contents('/path/to/image.png'), 42 | ['Content-Type' => 'image/png'], 43 | 'image.png' 44 | ); 45 | 46 | $multipart = new MultipartBody([ 47 | $jsonPayloadField, 48 | $imageField, 49 | ]); 50 | 51 | $http->post( 52 | Endpoint::bind( 53 | Endpoint::CHANNEL_MESSAGES, 54 | 'Channel ID' 55 | ), 56 | $multipart 57 | )->then( 58 | function ($response) { 59 | // Do something with response.. 60 | }, 61 | function (Exception $e) { 62 | echo $e->getMessage(), PHP_EOL; 63 | } 64 | ); 65 | 66 | Loop::run(); 67 | -------------------------------------------------------------------------------- /src/Discord/Multipart/MultipartBody.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Multipart; 13 | 14 | class MultipartBody 15 | { 16 | public const BOUNDARY = 'DISCORDPHP-HTTP-BOUNDARY'; 17 | 18 | private array $fields; 19 | public string $boundary; 20 | 21 | /** 22 | * @var MultipartField[] 23 | */ 24 | public function __construct(array $fields, ?string $boundary = null) 25 | { 26 | $this->fields = $fields; 27 | $this->boundary = $boundary ?? self::BOUNDARY; 28 | } 29 | 30 | public function __toString(): string 31 | { 32 | $prefixedBoundary = '--'.$this->boundary; 33 | $boundaryEnd = $prefixedBoundary.'--'; 34 | 35 | $convertedFields = array_map( 36 | function (MultipartField $field) { 37 | return (string) $field; 38 | }, 39 | $this->fields 40 | ); 41 | 42 | $fieldsString = implode(PHP_EOL.$prefixedBoundary.PHP_EOL, $convertedFields); 43 | 44 | return implode(PHP_EOL, [ 45 | $prefixedBoundary, 46 | $fieldsString, 47 | $boundaryEnd, 48 | ]); 49 | } 50 | 51 | public function getHeaders(): array 52 | { 53 | return [ 54 | 'Content-Type' => 'multipart/form-data; boundary='.$this->boundary, 55 | 'Content-Length' => strlen((string) $this), 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-php/http", 3 | "description": "Handles HTTP requests to Discord servers", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "David Cole", 9 | "email": "david.cole1340@gmail.com" 10 | } 11 | ], 12 | "autoload": { 13 | "psr-4": { 14 | "Discord\\Http\\": "src/Discord", 15 | "Tests\\Discord\\Http\\": "tests/Discord" 16 | } 17 | }, 18 | "require": { 19 | "php": "^7.4|^8.0", 20 | "react/http": "^1.2", 21 | "psr/log": "^1.1 || ^2.0 || ^3.0", 22 | "react/promise": "^2.2 || ^3.0.0" 23 | }, 24 | "suggest": { 25 | "guzzlehttp/guzzle": "For alternative to ReactPHP/Http Browser" 26 | }, 27 | "require-dev": { 28 | "monolog/monolog": "^2.2", 29 | "friendsofphp/php-cs-fixer": "^2.17", 30 | "psy/psysh": "^0.10.6", 31 | "guzzlehttp/guzzle": "^6.0|^7.0", 32 | "phpunit/phpunit": "^9.5", 33 | "mockery/mockery": "^1.5", 34 | "react/async": "^4 || ^3" 35 | }, 36 | "scripts": { 37 | "test": "php tests/Drivers/_server.php& HTTP_SERVER_PID=$!; ./vendor/bin/phpunit; kill $HTTP_SERVER_PID;", 38 | "test-discord": "./vendor/bin/phpunit --testsuite Discord", 39 | "test-drivers": "php tests/Drivers/_server.php& HTTP_SERVER_PID=$!; ./vendor/bin/phpunit --testsuite Drivers; kill $HTTP_SERVER_PID;", 40 | "test-coverage": "php tests/Drivers/_server.php& HTTP_SERVER_PID=$!; php -d xdebug.mode=coverage ./vendor/bin/phpunit --coverage-text; kill $HTTP_SERVER_PID;", 41 | "test-coverage-html": "php tests/Drivers/_server.php& HTTP_SERVER_PID=$!; php -d xdebug.mode=coverage ./vendor/bin/phpunit --coverage-html .phpunit.cache/cov-html; kill $HTTP_SERVER_PID;" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Discord/RateLimit.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use RuntimeException; 15 | 16 | /** 17 | * Represents a rate-limit given by Discord. 18 | * 19 | * @author David Cole 20 | */ 21 | class RateLimit extends RuntimeException 22 | { 23 | /** 24 | * Whether the rate-limit is global. 25 | * 26 | * @var bool 27 | */ 28 | protected $global; 29 | 30 | /** 31 | * Time in seconds of when to retry after. 32 | * 33 | * @var float 34 | */ 35 | protected $retry_after; 36 | 37 | /** 38 | * Rate limit constructor. 39 | * 40 | * @param bool $global 41 | * @param float $retry_after 42 | */ 43 | public function __construct(bool $global, float $retry_after) 44 | { 45 | $this->global = $global; 46 | $this->retry_after = $retry_after; 47 | } 48 | 49 | /** 50 | * Gets the global parameter. 51 | * 52 | * @return bool 53 | */ 54 | public function isGlobal(): bool 55 | { 56 | return $this->global; 57 | } 58 | 59 | /** 60 | * Gets the retry after parameter. 61 | * 62 | * @return float 63 | */ 64 | public function getRetryAfter(): float 65 | { 66 | return $this->retry_after; 67 | } 68 | 69 | /** 70 | * Converts a rate-limit to a user-readable string. 71 | * 72 | * @return string 73 | */ 74 | public function __toString() 75 | { 76 | return 'RATELIMIT '.($this->global ? 'Global' : 'Non-global').', retry after '.$this->retry_after.' s'; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Discord/Drivers/React.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Drivers; 13 | 14 | use Discord\Http\DriverInterface; 15 | use Discord\Http\Request; 16 | use React\EventLoop\LoopInterface; 17 | use React\Http\Browser; 18 | use React\Promise\PromiseInterface; 19 | use React\Socket\Connector; 20 | 21 | /** 22 | * react/http driver for Discord HTTP client. 23 | * 24 | * @author David Cole 25 | */ 26 | class React implements DriverInterface 27 | { 28 | /** 29 | * ReactPHP event loop. 30 | * 31 | * @var LoopInterface 32 | */ 33 | protected $loop; 34 | 35 | /** 36 | * ReactPHP/HTTP browser. 37 | * 38 | * @var Browser 39 | */ 40 | protected $browser; 41 | 42 | /** 43 | * Constructs the React driver. 44 | * 45 | * @param LoopInterface $loop 46 | * @param array $options 47 | */ 48 | public function __construct(LoopInterface $loop, array $options = []) 49 | { 50 | $this->loop = $loop; 51 | 52 | // Allow 400 and 500 HTTP requests to be resolved rather than rejected. 53 | $browser = new Browser($loop, new Connector($loop, $options)); 54 | $this->browser = $browser->withRejectErrorResponse(false); 55 | } 56 | 57 | /** 58 | * Runs the request using the React HTTP client. 59 | * 60 | * @param Request $request The request to run. 61 | * 62 | * @return PromiseInterface 63 | */ 64 | public function runRequest($request): PromiseInterface 65 | { 66 | return $this->browser->{$request->getMethod()}( 67 | $request->getUrl(), 68 | $request->getHeaders(), 69 | $request->getContent() 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Discord/Drivers/Guzzle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http\Drivers; 13 | 14 | use Discord\Http\DriverInterface; 15 | use Discord\Http\Request; 16 | use GuzzleHttp\Client; 17 | use GuzzleHttp\RequestOptions; 18 | use React\EventLoop\LoopInterface; 19 | use React\Promise\Deferred; 20 | use React\Promise\PromiseInterface; 21 | 22 | /** 23 | * guzzlehttp/guzzle driver for Discord HTTP client. (still with React Promise). 24 | * 25 | * @author SQKo 26 | */ 27 | class Guzzle implements DriverInterface 28 | { 29 | /** 30 | * ReactPHP event loop. 31 | * 32 | * @var LoopInterface|null 33 | */ 34 | protected $loop; 35 | 36 | /** 37 | * GuzzleHTTP/Guzzle client. 38 | * 39 | * @var Client 40 | */ 41 | protected $client; 42 | 43 | /** 44 | * Constructs the Guzzle driver. 45 | * 46 | * @param LoopInterface|null $loop 47 | * @param array $options 48 | */ 49 | public function __construct(?LoopInterface $loop = null, array $options = []) 50 | { 51 | $this->loop = $loop; 52 | 53 | // Allow 400 and 500 HTTP requests to be resolved rather than rejected. 54 | $options['http_errors'] = false; 55 | $this->client = new Client($options); 56 | } 57 | 58 | public function runRequest(Request $request): PromiseInterface 59 | { 60 | // Create a React promise 61 | $deferred = new Deferred(); 62 | $reactPromise = $deferred->promise(); 63 | 64 | $promise = $this->client->requestAsync($request->getMethod(), $request->getUrl(), [ 65 | RequestOptions::HEADERS => $request->getHeaders(), 66 | RequestOptions::BODY => $request->getContent(), 67 | ])->then([$deferred, 'resolve'], [$deferred, 'reject']); 68 | 69 | if ($this->loop) { 70 | $this->loop->futureTick([$promise, 'wait']); 71 | } else { 72 | $promise->wait(); 73 | } 74 | 75 | return $reactPromise; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/Discord/RequestTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Tests\Discord\Http; 13 | 14 | use Discord\Http\Endpoint; 15 | use Discord\Http\Http; 16 | use Discord\Http\Request; 17 | use Mockery; 18 | use PHPUnit\Framework\TestCase; 19 | use React\Promise\Deferred; 20 | 21 | class RequestTest extends TestCase 22 | { 23 | private function getRequest( 24 | ?Deferred $deferred = null, 25 | string $method = '', 26 | ?Endpoint $url = null, 27 | string $content = '', 28 | array $headers = [] 29 | ) { 30 | $url = $url ?? new Endpoint(''); 31 | $deferred = $deferred ?? new Deferred(); 32 | 33 | return new Request( 34 | $deferred, 35 | $method, 36 | $url, 37 | $content, 38 | $headers 39 | ); 40 | } 41 | 42 | public function testGetDeferred() 43 | { 44 | $deferred = Mockery::mock(Deferred::class); 45 | $request = $this->getRequest($deferred); 46 | 47 | $this->assertEquals($deferred, $request->getDeferred()); 48 | } 49 | 50 | public function testGetMethod() 51 | { 52 | $request = $this->getRequest(null, '::method::'); 53 | 54 | $this->assertEquals('::method::', $request->getMethod()); 55 | } 56 | 57 | public function testGetUrl() 58 | { 59 | $request = $this->getRequest(null, '', new Endpoint('::url::')); 60 | 61 | $this->assertEquals(Http::BASE_URL.'/::url::', $request->getUrl()); 62 | } 63 | 64 | public function testGetContent() 65 | { 66 | $request = $this->getRequest(null, '', null, '::content::'); 67 | 68 | $this->assertEquals('::content::', $request->getContent()); 69 | } 70 | 71 | public function testGetHeaders() 72 | { 73 | $request = $this->getRequest(null, '', null, '::content::', ['something' => 'value']); 74 | 75 | $this->assertEquals(['something' => 'value'], $request->getHeaders()); 76 | } 77 | 78 | public function testGetBucketId() 79 | { 80 | $endpoint = Mockery::mock(Endpoint::class); 81 | $endpoint->shouldReceive('toAbsoluteEndpoint')->andReturn('::endpoint::'); 82 | 83 | $request = $this->getRequest(null, '::method::', $endpoint); 84 | 85 | $this->assertEquals('::method::::endpoint::', $request->getBucketID()); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DiscordPHP-Http 2 | 3 | Asynchronous HTTP client used for communication with the Discord REST API. 4 | 5 | ## Requirements 6 | 7 | - PHP >=7.4 8 | 9 | ## Installation 10 | 11 | ```sh 12 | $ composer require discord-php/http 13 | ``` 14 | 15 | A [psr/log](https://packagist.org/packages/psr/log)-compliant logging library is also required. We recommend [monolog](https://github.com/Seldaek/monolog) which will be used in examples. 16 | 17 | ## Usage 18 | 19 | ```php 20 | pushHandler(new StreamHandler('php://output')); 31 | $http = new Http( 32 | 'Bot xxxx.yyyy.zzzz', 33 | $loop, 34 | $logger 35 | ); 36 | 37 | // set up a driver - this example uses the React driver 38 | $driver = new React($loop); 39 | $http->setDriver($driver); 40 | 41 | // must be the last line 42 | $loop->run(); 43 | ``` 44 | 45 | All request methods have the same footprint: 46 | 47 | ```php 48 | $http->get(string $url, $content = null, array $headers = []); 49 | $http->post(string $url, $content = null, array $headers = []); 50 | $http->put(string $url, $content = null, array $headers = []); 51 | $http->patch(string $url, $content = null, array $headers = []); 52 | $http->delete(string $url, $content = null, array $headers = []); 53 | ``` 54 | 55 | For other methods: 56 | 57 | ```php 58 | $http->queueRequest(string $method, string $url, $content, array $headers = []); 59 | ``` 60 | 61 | All methods return the decoded JSON response in an object: 62 | 63 | ```php 64 | // https://discord.com/api/v8/oauth2/applications/@me 65 | $http->get('oauth2/applications/@me')->done(function ($response) { 66 | var_dump($response); 67 | }, function ($e) { 68 | echo "Error: ".$e->getMessage().PHP_EOL; 69 | }); 70 | ``` 71 | 72 | Most Discord endpoints are provided in the [Endpoint.php](src/Discord/Endpoint.php) class as constants. Parameters start with a colon, 73 | e.g. `channels/:channel_id/messages/:message_id`. You can bind parameters to then with the same class: 74 | 75 | ```php 76 | // channels/channel_id_here/messages/message_id_here 77 | $endpoint = Endpoint::bind(Endpoint::CHANNEL_MESSAGE, 'channel_id_here', 'message_id_here'); 78 | 79 | $http->get($endpoint)->done(...); 80 | ``` 81 | 82 | It is recommended that if the endpoint contains parameters you use the `Endpoint::bind()` function to sort requests into their correct rate limit buckets. 83 | For an example, see [DiscordPHP](https://github.com/discord-php/DiscordPHP). 84 | 85 | ## License 86 | 87 | This software is licensed under the MIT license which can be viewed in the [LICENSE](LICENSE) file. 88 | 89 | ## Credits 90 | 91 | - [David Cole](mailto:david.cole1340@gmail.com) 92 | - All contributors 93 | -------------------------------------------------------------------------------- /src/Discord/Request.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use React\Promise\Deferred; 15 | 16 | /** 17 | * Represents an HTTP request. 18 | * 19 | * @author David Cole 20 | */ 21 | class Request 22 | { 23 | /** 24 | * Deferred promise. 25 | * 26 | * @var Deferred 27 | */ 28 | protected $deferred; 29 | 30 | /** 31 | * Request method. 32 | * 33 | * @var string 34 | */ 35 | protected $method; 36 | 37 | /** 38 | * Request URL. 39 | * 40 | * @var Endpoint 41 | */ 42 | protected $url; 43 | 44 | /** 45 | * Request content. 46 | * 47 | * @var string 48 | */ 49 | protected $content; 50 | 51 | /** 52 | * Request headers. 53 | * 54 | * @var array 55 | */ 56 | protected $headers; 57 | 58 | /** 59 | * Request constructor. 60 | * 61 | * @param Deferred $deferred 62 | * @param string $method 63 | * @param Endpoint $url 64 | * @param string $content 65 | * @param array $headers 66 | */ 67 | public function __construct(Deferred $deferred, string $method, Endpoint $url, string $content, array $headers = []) 68 | { 69 | $this->deferred = $deferred; 70 | $this->method = $method; 71 | $this->url = $url; 72 | $this->content = $content; 73 | $this->headers = $headers; 74 | } 75 | 76 | /** 77 | * Gets the method. 78 | * 79 | * @return string 80 | */ 81 | public function getMethod(): string 82 | { 83 | return $this->method; 84 | } 85 | 86 | /** 87 | * Gets the url. 88 | * 89 | * @return string 90 | */ 91 | public function getUrl(): string 92 | { 93 | return Http::BASE_URL.'/'.$this->url; 94 | } 95 | 96 | /** 97 | * Gets the content. 98 | * 99 | * @return string 100 | */ 101 | public function getContent(): string 102 | { 103 | return $this->content; 104 | } 105 | 106 | /** 107 | * Gets the headers. 108 | * 109 | * @return string 110 | */ 111 | public function getHeaders(): array 112 | { 113 | return $this->headers; 114 | } 115 | 116 | /** 117 | * Returns the deferred promise. 118 | * 119 | * @return Deferred 120 | */ 121 | public function getDeferred(): Deferred 122 | { 123 | return $this->deferred; 124 | } 125 | 126 | /** 127 | * Returns the bucket ID for the request. 128 | * 129 | * @return string 130 | */ 131 | public function getBucketID(): string 132 | { 133 | return $this->method.$this->url->toAbsoluteEndpoint(true); 134 | } 135 | 136 | /** 137 | * Converts the request to a user-readable string. 138 | * 139 | * @return string 140 | */ 141 | public function __toString() 142 | { 143 | return 'REQ '.strtoupper($this->method).' '.$this->url; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | 7 | 8 | This file is subject to the MIT license that is bundled 9 | with this source code in the LICENSE file. 10 | EOF; 11 | 12 | $fixers = [ 13 | 'blank_line_after_namespace', 14 | 'braces', 15 | 'class_definition', 16 | 'elseif', 17 | 'encoding', 18 | 'full_opening_tag', 19 | 'function_declaration', 20 | 'lowercase_keywords', 21 | 'method_argument_space', 22 | 'no_closing_tag', 23 | 'no_spaces_after_function_name', 24 | 'no_spaces_inside_parenthesis', 25 | 'no_trailing_whitespace', 26 | 'no_trailing_whitespace_in_comment', 27 | 'single_blank_line_at_eof', 28 | 'single_class_element_per_statement', 29 | 'single_import_per_statement', 30 | 'single_line_after_imports', 31 | 'switch_case_semicolon_to_colon', 32 | 'switch_case_space', 33 | 'visibility_required', 34 | 'blank_line_after_opening_tag', 35 | 'no_multiline_whitespace_around_double_arrow', 36 | 'no_empty_statement', 37 | 'include', 38 | 'no_trailing_comma_in_list_call', 39 | 'not_operator_with_successor_space', 40 | 'no_leading_namespace_whitespace', 41 | 'no_blank_lines_after_class_opening', 42 | 'no_blank_lines_after_phpdoc', 43 | 'object_operator_without_whitespace', 44 | 'binary_operator_spaces', 45 | 'phpdoc_indent', 46 | 'general_phpdoc_tag_rename', 47 | 'phpdoc_inline_tag_normalizer', 48 | 'phpdoc_tag_type', 49 | 'phpdoc_no_access', 50 | 'phpdoc_no_package', 51 | 'phpdoc_scalar', 52 | 'phpdoc_summary', 53 | 'phpdoc_to_comment', 54 | 'phpdoc_trim', 55 | 'phpdoc_var_without_name', 56 | 'no_leading_import_slash', 57 | 'no_trailing_comma_in_singleline_array', 58 | 'single_blank_line_before_namespace', 59 | 'single_quote', 60 | 'no_singleline_whitespace_before_semicolons', 61 | 'cast_spaces', 62 | 'standardize_not_equals', 63 | 'ternary_operator_spaces', 64 | 'trim_array_spaces', 65 | 'unary_operator_spaces', 66 | 'no_unused_imports', 67 | 'no_useless_else', 68 | 'no_useless_return', 69 | 'phpdoc_no_empty_return', 70 | 'no_extra_blank_lines', 71 | 'multiline_whitespace_before_semicolons', 72 | ]; 73 | 74 | $rules = [ 75 | 'concat_space' => ['spacing' => 'none'], 76 | 'phpdoc_no_alias_tag' => ['replacements' => ['type' => 'var']], 77 | 'array_syntax' => ['syntax' => 'short'], 78 | 'binary_operator_spaces' => ['align_double_arrow' => true, 'align_equals' => true], 79 | 'header_comment' => ['header' => $header], 80 | 'indentation_type' => true, 81 | 'phpdoc_align' => [ 82 | 'align' => 'vertical', 83 | 'tags' => ['param', 'property', 'property-read', 'property-write', 'return', 'throws', 'type', 'var', 'method'], 84 | ], 85 | 'blank_line_before_statement' => ['statements' => ['return']], 86 | 'constant_case' => ['case' => 'lower'], 87 | 'echo_tag_syntax' => ['format' => 'long'], 88 | 'trailing_comma_in_multiline' => ['elements' => ['arrays']], 89 | ]; 90 | 91 | foreach ($fixers as $fix) { 92 | $rules[$fix] = true; 93 | } 94 | 95 | $config = new PhpCsFixer\Config(); 96 | 97 | return $config 98 | ->setRules($rules) 99 | ->setFinder( 100 | PhpCsFixer\Finder::create() 101 | ->in(__DIR__) 102 | ); 103 | -------------------------------------------------------------------------------- /src/Discord/Http.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use Composer\InstalledVersions; 15 | use Psr\Log\LoggerInterface; 16 | use React\EventLoop\LoopInterface; 17 | use SplQueue; 18 | 19 | /** 20 | * Discord HTTP client. 21 | * 22 | * @author David Cole 23 | */ 24 | class Http implements HttpInterface 25 | { 26 | use HttpTrait; 27 | 28 | /** 29 | * DiscordPHP-Http version. 30 | * 31 | * @var string 32 | */ 33 | public const VERSION = 'v10.5.1'; 34 | 35 | /** 36 | * Current Discord HTTP API version. 37 | * 38 | * @var string 39 | */ 40 | public const HTTP_API_VERSION = 10; 41 | 42 | /** 43 | * Discord API base URL. 44 | * 45 | * @var string 46 | */ 47 | public const BASE_URL = 'https://discord.com/api/v'.self::HTTP_API_VERSION; 48 | 49 | /** 50 | * The number of concurrent requests which can 51 | * be executed. 52 | * 53 | * @var int 54 | */ 55 | public const CONCURRENT_REQUESTS = 5; 56 | 57 | /** 58 | * Authentication token. 59 | * 60 | * @var string 61 | */ 62 | private $token; 63 | 64 | /** 65 | * Logger for HTTP requests. 66 | * 67 | * @var LoggerInterface 68 | */ 69 | protected $logger; 70 | 71 | /** 72 | * HTTP driver. 73 | * 74 | * @var DriverInterface 75 | */ 76 | protected $driver; 77 | 78 | /** 79 | * ReactPHP event loop. 80 | * 81 | * @var LoopInterface 82 | */ 83 | protected $loop; 84 | 85 | /** 86 | * Array of request buckets. 87 | * 88 | * @var Bucket[] 89 | */ 90 | protected $buckets = []; 91 | 92 | /** 93 | * The current rate-limit. 94 | * 95 | * @var RateLimit 96 | */ 97 | protected $rateLimit; 98 | 99 | /** 100 | * Timer that resets the current global rate-limit. 101 | * 102 | * @var TimerInterface 103 | */ 104 | protected $rateLimitReset; 105 | 106 | /** 107 | * Request queue to prevent API 108 | * overload. 109 | * 110 | * @var SplQueue 111 | */ 112 | protected $queue; 113 | 114 | /** 115 | * Request queue to prevent API 116 | * overload. 117 | * 118 | * @var SplQueue 119 | */ 120 | protected $unboundQueue; 121 | 122 | /** 123 | * Number of requests that are waiting for a response. 124 | * 125 | * @var int 126 | */ 127 | protected $waiting = 0; 128 | 129 | /** 130 | * Whether react/promise v3 is used, if false, using v2. 131 | */ 132 | protected $promiseV3 = true; 133 | 134 | /** 135 | * Http wrapper constructor. 136 | * 137 | * @param string $token 138 | * @param LoopInterface $loop 139 | * @param DriverInterface|null $driver 140 | */ 141 | public function __construct(string $token, LoopInterface $loop, LoggerInterface $logger, ?DriverInterface $driver = null) 142 | { 143 | $this->token = $token; 144 | $this->loop = $loop; 145 | $this->logger = $logger; 146 | $this->driver = $driver; 147 | $this->queue = new SplQueue; 148 | $this->unboundQueue = new SplQueue; 149 | 150 | $this->promiseV3 = str_starts_with(InstalledVersions::getVersion('react/promise'), '3.'); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Discord/EndpointTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | trait EndpointTrait 15 | { 16 | /** 17 | * Binds a list of arguments to the endpoint. 18 | * 19 | * @param string[] ...$args 20 | * @return this 21 | */ 22 | public function bindArgs(...$args): self 23 | { 24 | for ($i = 0; $i < count($this->vars) && $i < count($args); $i++) { 25 | $this->args[$this->vars[$i]] = $args[$i]; 26 | } 27 | 28 | return $this; 29 | } 30 | 31 | /** 32 | * Binds an associative array to the endpoint. 33 | * 34 | * @param string[] $args 35 | * @return this 36 | */ 37 | public function bindAssoc(array $args): self 38 | { 39 | $this->args = array_merge($this->args, $args); 40 | 41 | return $this; 42 | } 43 | 44 | /** 45 | * Adds a key-value query pair to the endpoint. 46 | * 47 | * @param string $key 48 | * @param string|bool $value 49 | */ 50 | public function addQuery(string $key, $value): void 51 | { 52 | if (! is_bool($value)) { 53 | $value = is_array($value) 54 | ? (implode(' ', $value)) 55 | : (string) $value; 56 | } 57 | 58 | $this->query[$key] = $value; 59 | } 60 | 61 | /** 62 | * Converts the endpoint into the absolute endpoint with 63 | * placeholders replaced. 64 | * 65 | * Passing a true boolean in will only replace the major parameters. 66 | * Used for rate limit buckets. 67 | * 68 | * @param bool $onlyMajorParameters 69 | * @return string 70 | */ 71 | public function toAbsoluteEndpoint(bool $onlyMajorParameters = false): string 72 | { 73 | $endpoint = $this->endpoint; 74 | 75 | // Process in order of longest to shortest variable name to prevent partial replacements (see #16). 76 | $vars = $this->vars; 77 | usort($vars, fn ($a, $b) => strlen($b) <=> strlen($a)); 78 | 79 | foreach ($vars as $var) { 80 | if ( 81 | ! isset($this->args[$var]) || 82 | ( 83 | $onlyMajorParameters && 84 | (method_exists($this, 'isMajorParameter') ? ! $this->isMajorParameter($var) : false) 85 | ) 86 | ) { 87 | continue; 88 | } 89 | 90 | $endpoint = str_replace(":{$var}", $this->args[$var], $endpoint); 91 | } 92 | 93 | if (! $onlyMajorParameters && count($this->query) > 0) { 94 | $endpoint .= '?'.http_build_query($this->query); 95 | } 96 | 97 | return $endpoint; 98 | } 99 | 100 | /** 101 | * Converts the endpoint to a string. 102 | * Alias of ->toAbsoluteEndpoint();. 103 | * 104 | * @return string 105 | */ 106 | public function __toString(): string 107 | { 108 | return $this->toAbsoluteEndpoint(); 109 | } 110 | 111 | /** 112 | * Creates an endpoint class and binds arguments to 113 | * the newly created instance. 114 | * 115 | * @param string $endpoint 116 | * @param string[] $args 117 | * @return Endpoint 118 | */ 119 | public static function bind(string $endpoint, ...$args) 120 | { 121 | $endpoint = new Endpoint($endpoint); 122 | $endpoint->bindArgs(...$args); 123 | 124 | return $endpoint; 125 | } 126 | 127 | /** 128 | * Checks if a parameter is a major parameter. 129 | * 130 | * @param string $param 131 | * @return bool 132 | */ 133 | private static function isMajorParameter(string $param): bool 134 | { 135 | return in_array($param, Endpoint::MAJOR_PARAMETERS); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /tests/Discord/Multipart/MultipartTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Tests\Discord\Http\Multipart; 13 | 14 | use Discord\Http\Multipart\MultipartBody; 15 | use Discord\Http\Multipart\MultipartField; 16 | use Mockery; 17 | use PHPUnit\Framework\TestCase; 18 | 19 | class MultipartTest extends TestCase 20 | { 21 | /** 22 | * @dataProvider multipartFieldStringConversionProvider 23 | */ 24 | public function testMultipartFieldStringConversion(array $constructorArgs, string $expected) 25 | { 26 | $multipartField = new MultipartField(...$constructorArgs); 27 | 28 | $this->assertEquals($expected, (string) $multipartField); 29 | } 30 | 31 | public function multipartFieldStringConversionProvider(): array 32 | { 33 | return [ 34 | 'Completely filled' => [ 35 | 'args' => [ 36 | '::name::', 37 | '::content::', 38 | [ 39 | 'Header-Name' => 'Value', 40 | ], 41 | '::filename::', 42 | ], 43 | 44 | 'expected' => << [ 53 | 'args' => [ 54 | '::name::', 55 | '::content::', 56 | [ 57 | 'Header-Name' => 'Value', 58 | ], 59 | null, 60 | ], 61 | 62 | 'expected' => << [ 71 | 'args' => [ 72 | '::name::', 73 | '::content::', 74 | [], 75 | '::filename::', 76 | ], 77 | 78 | 'expected' => <<shouldReceive('__toString')->andReturn($return); 93 | 94 | return $mock; 95 | }, ['::first field::', '::second field::', '::third field::']); 96 | 97 | $multipartBody = new MultipartBody($fields, '::boundary::'); 98 | 99 | $this->assertEquals( 100 | <<assertEquals([ 113 | 'Content-Type' => 'multipart/form-data; boundary=::boundary::', 114 | 'Content-Length' => strlen((string) $multipartBody), 115 | ], $multipartBody->getHeaders()); 116 | } 117 | 118 | public function testGeneratingBoundary() 119 | { 120 | $multipartBody = new MultipartBody([ 121 | Mockery::mock(MultipartField::class), 122 | ]); 123 | 124 | $this->assertNotNull($multipartBody->boundary); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/Discord/DriverInterfaceTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Tests\Discord\Http; 13 | 14 | use Discord\Http\DriverInterface; 15 | use Discord\Http\Request; 16 | use Mockery; 17 | use PHPUnit\Framework\TestCase; 18 | use Psr\Http\Message\ResponseInterface; 19 | use function React\Async\await; 20 | 21 | abstract class DriverInterfaceTest extends TestCase 22 | { 23 | abstract protected function getDriver(): DriverInterface; 24 | 25 | private function getRequest( 26 | string $method, 27 | string $url, 28 | string $content = '', 29 | array $headers = [] 30 | ): Request { 31 | $request = Mockery::mock(Request::class); 32 | 33 | $request->shouldReceive([ 34 | 'getMethod' => $method, 35 | 'getUrl' => $url, 36 | 'getContent' => $content, 37 | 'getHeaders' => $headers, 38 | ]); 39 | 40 | return $request; 41 | } 42 | 43 | /** 44 | * @dataProvider requestProvider 45 | */ 46 | public function testRequest(string $method, string $url, array $content = [], array $verify = []) 47 | { 48 | $driver = $this->getDriver(); 49 | $request = $this->getRequest( 50 | $method, 51 | $url, 52 | $content === [] ? '' : json_encode($content), 53 | empty($content) ? [] : ['Content-Type' => 'application/json'] 54 | ); 55 | 56 | /** @var ResponseInterface */ 57 | $response = await($driver->runRequest($request)); 58 | 59 | $this->assertNotEquals('', $response->getBody()); 60 | $this->assertEquals(200, $response->getStatusCode()); 61 | 62 | $jsonDecodedBody = json_decode($response->getBody(), true); 63 | 64 | $verify['method'] = $method; 65 | 66 | foreach ($verify as $field => $expectedValue) { 67 | $this->assertEquals( 68 | $expectedValue, 69 | $jsonDecodedBody[$field] 70 | ); 71 | } 72 | } 73 | 74 | public function requestProvider(): array 75 | { 76 | $content = ['something' => 'value']; 77 | 78 | return [ 79 | 'Plain get' => [ 80 | 'method' => 'GET', 81 | 'url' => 'http://127.0.0.1:8888', 82 | ], 83 | 'Get with params' => [ 84 | 'method' => 'GET', 85 | 'url' => 'http://127.0.0.1:8888?something=value', 86 | 'verify' => [ 87 | 'args' => $content, 88 | ], 89 | ], 90 | 91 | 'Plain post' => [ 92 | 'method' => 'POST', 93 | 'url' => 'http://127.0.0.1:8888', 94 | ], 95 | 'Post with content' => [ 96 | 'method' => 'POST', 97 | 'url' => 'http://127.0.0.1:8888', 98 | 'content' => $content, 99 | 'verify' => [ 100 | 'json' => $content, 101 | ], 102 | ], 103 | 104 | 'Plain put' => [ 105 | 'method' => 'PUT', 106 | 'url' => 'http://127.0.0.1:8888', 107 | ], 108 | 'Put with content' => [ 109 | 'method' => 'PUT', 110 | 'url' => 'http://127.0.0.1:8888', 111 | 'content' => $content, 112 | 'verify' => [ 113 | 'json' => $content, 114 | ], 115 | ], 116 | 117 | 'Plain patch' => [ 118 | 'method' => 'PATCH', 119 | 'url' => 'http://127.0.0.1:8888', 120 | ], 121 | 'Patch with content' => [ 122 | 'method' => 'PATCH', 123 | 'url' => 'http://127.0.0.1:8888', 124 | 'content' => $content, 125 | 'verify' => [ 126 | 'json' => $content, 127 | ], 128 | ], 129 | 130 | 'Plain delete' => [ 131 | 'method' => 'DELETE', 132 | 'url' => 'http://127.0.0.1:8888', 133 | ], 134 | 'Delete with content' => [ 135 | 'method' => 'DELETE', 136 | 'url' => 'http://127.0.0.1:8888', 137 | 'content' => $content, 138 | 'verify' => [ 139 | 'json' => $content, 140 | ], 141 | ], 142 | ]; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Discord/Bucket.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use Composer\InstalledVersions; 15 | use Psr\Http\Message\ResponseInterface; 16 | use Psr\Log\LoggerInterface; 17 | use React\EventLoop\LoopInterface; 18 | use React\EventLoop\TimerInterface; 19 | use SplQueue; 20 | 21 | /** 22 | * Represents a rate-limit bucket. 23 | * 24 | * @author David Cole 25 | */ 26 | class Bucket 27 | { 28 | /** 29 | * Request queue. 30 | * 31 | * @var SplQueue 32 | */ 33 | protected $queue; 34 | 35 | /** 36 | * Bucket name. 37 | * 38 | * @var string 39 | */ 40 | protected $name; 41 | 42 | /** 43 | * ReactPHP event loop. 44 | * 45 | * @var LoopInterface 46 | */ 47 | protected $loop; 48 | 49 | /** 50 | * HTTP logger. 51 | * 52 | * @var LoggerInterface 53 | */ 54 | protected $logger; 55 | 56 | /** 57 | * Callback for when a request is ready. 58 | * 59 | * @var callable 60 | */ 61 | protected $runRequest; 62 | 63 | /** 64 | * Whether we are checking the queue. 65 | * 66 | * @var bool 67 | */ 68 | protected $checkerRunning = false; 69 | 70 | /** 71 | * Number of requests allowed before reset. 72 | * 73 | * @var int 74 | */ 75 | protected $requestLimit; 76 | 77 | /** 78 | * Number of remaining requests before reset. 79 | * 80 | * @var int 81 | */ 82 | protected $requestRemaining; 83 | 84 | /** 85 | * Timer to reset the bucket. 86 | * 87 | * @var TimerInterface 88 | */ 89 | protected $resetTimer; 90 | 91 | /** 92 | * Whether react/promise v3 is used, if false, using v2. 93 | */ 94 | protected $promiseV3 = true; 95 | 96 | /** 97 | * Bucket constructor. 98 | * 99 | * @param string $name 100 | * @param callable $runRequest 101 | */ 102 | public function __construct(string $name, LoopInterface $loop, LoggerInterface $logger, callable $runRequest) 103 | { 104 | $this->queue = new SplQueue; 105 | $this->name = $name; 106 | $this->loop = $loop; 107 | $this->logger = $logger; 108 | $this->runRequest = $runRequest; 109 | 110 | $this->promiseV3 = str_starts_with(InstalledVersions::getVersion('react/promise'), '3.'); 111 | } 112 | 113 | /** 114 | * Enqueue a request. 115 | * 116 | * @param Request $request 117 | */ 118 | public function enqueue(Request $request) 119 | { 120 | $this->queue->enqueue($request); 121 | $this->logger->debug($this.' queued '.$request); 122 | $this->checkQueue(); 123 | } 124 | 125 | /** 126 | * Checks for requests in the bucket. 127 | */ 128 | public function checkQueue() 129 | { 130 | // We are already checking the queue. 131 | if ($this->checkerRunning) { 132 | return; 133 | } 134 | 135 | $this->checkerRunning = true; 136 | $this->__checkQueue(); 137 | } 138 | 139 | protected function __checkQueue() 140 | { 141 | // Check for rate-limits 142 | if ($this->requestRemaining < 1 && ! is_null($this->requestRemaining)) { 143 | $interval = 0; 144 | if ($this->resetTimer) { 145 | $interval = $this->resetTimer->getInterval() ?? 0; 146 | } 147 | $this->logger->info($this.' expecting rate limit, timer interval '.($interval * 1000).' ms'); 148 | $this->checkerRunning = false; 149 | 150 | return; 151 | } 152 | 153 | // Queue is empty, job done. 154 | if ($this->queue->isEmpty()) { 155 | $this->checkerRunning = false; 156 | 157 | return; 158 | } 159 | 160 | /** @var Request */ 161 | $request = $this->queue->dequeue(); 162 | 163 | // Promises v3 changed `->then` to behave as `->done` and removed `->then`. We still need the behaviour of `->done` in projects using v2 164 | ($this->runRequest)($request)->{$this->promiseV3 ? 'then' : 'done'}(function (ResponseInterface $response) { 165 | $resetAfter = (float) $response->getHeaderLine('X-Ratelimit-Reset-After'); 166 | $limit = $response->getHeaderLine('X-Ratelimit-Limit'); 167 | $remaining = $response->getHeaderLine('X-Ratelimit-Remaining'); 168 | 169 | if ($resetAfter) { 170 | $resetAfter = (float) $resetAfter; 171 | 172 | if ($this->resetTimer) { 173 | $this->loop->cancelTimer($this->resetTimer); 174 | } 175 | 176 | $this->resetTimer = $this->loop->addTimer($resetAfter, function () { 177 | // Reset requests remaining and check queue 178 | $this->requestRemaining = $this->requestLimit; 179 | $this->resetTimer = null; 180 | $this->checkQueue(); 181 | }); 182 | } 183 | 184 | // Check if rate-limit headers are present and store 185 | if (is_numeric($limit)) { 186 | $this->requestLimit = (int) $limit; 187 | } 188 | 189 | if (is_numeric($remaining)) { 190 | $this->requestRemaining = (int) $remaining; 191 | } 192 | 193 | // Check for more requests 194 | $this->__checkQueue(); 195 | }, function ($rateLimit) use ($request) { 196 | if ($rateLimit instanceof RateLimit) { 197 | $this->queue->enqueue($request); 198 | 199 | // Bucket-specific rate-limit 200 | // Re-queue the request and wait the retry after time 201 | if (! $rateLimit->isGlobal()) { 202 | $this->loop->addTimer($rateLimit->getRetryAfter(), fn () => $this->__checkQueue()); 203 | } 204 | // Stop the queue checker for a global rate-limit. 205 | // Will be restarted when global rate-limit finished. 206 | else { 207 | $this->checkerRunning = false; 208 | 209 | $this->logger->debug($this.' stopping queue checker'); 210 | } 211 | } else { 212 | $this->__checkQueue(); 213 | } 214 | }); 215 | } 216 | 217 | /** 218 | * Converts a bucket to a user-readable string. 219 | * 220 | * @return string 221 | */ 222 | public function __toString() 223 | { 224 | return 'BUCKET '.$this->name; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /tests/Discord/EndpointTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Tests\Discord\Http; 13 | 14 | use Discord\Http\Endpoint; 15 | use PHPUnit\Framework\TestCase; 16 | 17 | class EndpointTest extends TestCase 18 | { 19 | /** 20 | * @dataProvider majorParamProvider 21 | */ 22 | public function testBindMajorParams(string $uri, array $replacements, string $expected) 23 | { 24 | $endpoint = new Endpoint($uri); 25 | $endpoint->bindArgs(...$replacements); 26 | 27 | $this->assertEquals( 28 | $endpoint->toAbsoluteEndpoint(true), 29 | $expected 30 | ); 31 | } 32 | 33 | public function majorParamProvider(): array 34 | { 35 | return [ 36 | 'Several major params' => [ 37 | 'uri' => 'something/:guild_id/:channel_id/:webhook_id', 38 | 'replacements' => ['::guild id::', '::channel id::', '::webhook id::'], 39 | 'expected' => 'something/::guild id::/::channel id::/::webhook id::', 40 | ], 41 | 'Single major param' => [ 42 | 'uri' => 'something/:guild_id', 43 | 'replacements' => ['::guild id::'], 44 | 'expected' => 'something/::guild id::', 45 | ], 46 | 'Single major param, some minor params' => [ 47 | 'uri' => 'something/:guild_id/:some_param/:something_else', 48 | 'replacements' => ['::guild id::', '::some_param::', '::something else::'], 49 | 'expected' => 'something/::guild id::/:some_param/:something_else', 50 | ], 51 | 'Only minor params' => [ 52 | 'uri' => 'something/:something/:some_param/:something_else', 53 | 'replacements' => ['::something::', '::some_param::', '::something else::'], 54 | 'expected' => 'something/:something/:some_param/:something_else', 55 | ], 56 | 'Minor and major params in weird order' => [ 57 | 'uri' => 'something/:something/:guild_id/:something_else/:channel_id', 58 | 'replacements' => ['::something::', '::guild id::', '::something else::', '::channel id::'], 59 | 'expected' => 'something/:something/::guild id::/:something_else/::channel id::', 60 | ], 61 | ]; 62 | } 63 | 64 | /** 65 | * @dataProvider allParamProvider 66 | */ 67 | public function testBindAllParams(string $uri, array $replacements, string $expected) 68 | { 69 | $endpoint = new Endpoint($uri); 70 | $endpoint->bindArgs(...$replacements); 71 | 72 | $this->assertEquals( 73 | $expected, 74 | $endpoint->toAbsoluteEndpoint() 75 | ); 76 | } 77 | 78 | public function allParamProvider(): array 79 | { 80 | return [ 81 | 'Several major params' => [ 82 | 'uri' => 'something/:guild_id/:channel_id/:webhook_id', 83 | 'replacements' => ['::guild id::', '::channel id::', '::webhook id::'], 84 | 'expected' => 'something/::guild id::/::channel id::/::webhook id::', 85 | ], 86 | 'Single major param' => [ 87 | 'uri' => 'something/:guild_id', 88 | 'replacements' => ['::guild id::'], 89 | 'expected' => 'something/::guild id::', 90 | ], 91 | 'Single major param, some minor params' => [ 92 | 'uri' => 'something/:guild_id/:some_param/:something_else', 93 | 'replacements' => ['::guild id::', '::some param::', '::something else::'], 94 | 'expected' => 'something/::guild id::/::some param::/::something else::', 95 | ], 96 | 'Only minor params' => [ 97 | 'uri' => 'something/:something/:some_param/:other', 98 | 'replacements' => ['::something::', '::some param::', '::something else::'], 99 | 'expected' => 'something/::something::/::some param::/::something else::', 100 | ], 101 | 'Minor and major params in weird order' => [ 102 | 'uri' => 'something/:something/:guild_id/:other/:channel_id', 103 | 'replacements' => ['::something::', '::guild id::', '::something else::', '::channel id::'], 104 | 'expected' => 'something/::something::/::guild id::/::something else::/::channel id::', 105 | ], 106 | 107 | // @see https://github.com/discord-php/DiscordPHP-Http/issues/16 108 | // 'Params with same prefix, short first' => [ 109 | // 'uri' => 'something/:thing/:thing_other', 110 | // 'replacements' => ['::thing::', '::thing other::'], 111 | // 'expected' => 'something/::thing::/::thing other::', 112 | // ], 113 | // 'Params with same prefix, short first' => [ 114 | // 'uri' => 'something/:thing_other/:thing', 115 | // 'replacements' => ['::thing other::', '::thing::'], 116 | // 'expected' => 'something/::thing other::/::thing::', 117 | // ], 118 | ]; 119 | } 120 | 121 | public function testBindAssoc() 122 | { 123 | $endpoint = new Endpoint('something/:first/:second'); 124 | $endpoint->bindAssoc([ 125 | 'second' => '::second::', 126 | 'first' => '::first::', 127 | ]); 128 | 129 | $this->assertEquals( 130 | 'something/::first::/::second::', 131 | $endpoint->toAbsoluteEndpoint() 132 | ); 133 | } 134 | 135 | public function testItConvertsToString() 136 | { 137 | $this->assertEquals( 138 | 'something/::first::/::second::', 139 | (string) Endpoint::bind( 140 | 'something/:first/:second', 141 | '::first::', 142 | '::second::' 143 | ) 144 | ); 145 | } 146 | 147 | public function itCanAddQueryParams() 148 | { 149 | $endpoint = new Endpoint('something/:param'); 150 | $endpoint->bindArgs('param'); 151 | 152 | $endpoint->addQuery('something', 'value'); 153 | $endpoint->addQuery('boolval', true); 154 | 155 | $this->assertEquals( 156 | 'something/param?something=value&boolval=1', 157 | $endpoint->toAbsoluteEndpoint() 158 | ); 159 | } 160 | 161 | public function itDoesNotAddQueryParamsForMajorParameters() 162 | { 163 | $endpoint = new Endpoint('something/:guild_id'); 164 | $endpoint->bindArgs('param'); 165 | 166 | $endpoint->addQuery('something', 'value'); 167 | $endpoint->addQuery('boolval', true); 168 | 169 | $this->assertEquals( 170 | 'something/param', 171 | $endpoint->toAbsoluteEndpoint(true) 172 | ); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/Discord/Endpoint.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | class Endpoint implements EndpointInterface 15 | { 16 | use EndpointTrait; 17 | 18 | // GET 19 | public const GATEWAY = 'gateway'; 20 | // GET 21 | public const GATEWAY_BOT = self::GATEWAY.'/bot'; 22 | 23 | // GET 24 | public const APPLICATION_SKUS = 'applications/:application_id/skus'; 25 | // GET, POST 26 | public const APPLICATION_EMOJIS = 'applications/:application_id/emojis'; 27 | // GET, PATCH, DELETE 28 | public const APPLICATION_EMOJI = 'applications/:application_id/emojis/:emoji_id'; 29 | // GET, POST 30 | public const APPLICATION_ENTITLEMENTS = 'applications/:application_id/entitlements'; 31 | // DELETE 32 | public const APPLICATION_ENTITLEMENT = self::APPLICATION_ENTITLEMENTS.'/:entitlement_id'; 33 | // POST 34 | public const APPLICATION_ENTITLEMENT_CONSUME = self::APPLICATION_ENTITLEMENT.'/consume'; 35 | // GET, POST, PUT 36 | public const GLOBAL_APPLICATION_COMMANDS = 'applications/:application_id/commands'; 37 | // GET, PATCH, DELETE 38 | public const GLOBAL_APPLICATION_COMMAND = self::GLOBAL_APPLICATION_COMMANDS.'/:command_id'; 39 | // GET, POST, PUT 40 | public const GUILD_APPLICATION_COMMANDS = 'applications/:application_id/guilds/:guild_id/commands'; 41 | // GET, PUT 42 | public const GUILD_APPLICATION_COMMANDS_PERMISSIONS = self::GUILD_APPLICATION_COMMANDS.'/permissions'; 43 | // GET, PATCH, DELETE 44 | public const GUILD_APPLICATION_COMMAND = self::GUILD_APPLICATION_COMMANDS.'/:command_id'; 45 | // GET, PUT 46 | public const GUILD_APPLICATION_COMMAND_PERMISSIONS = self::GUILD_APPLICATION_COMMANDS.'/:command_id/permissions'; 47 | // POST 48 | public const INTERACTION_RESPONSE = 'interactions/:interaction_id/:interaction_token/callback'; 49 | // POST 50 | public const CREATE_INTERACTION_FOLLOW_UP = 'webhooks/:application_id/:interaction_token'; 51 | // PATCH, DELETE 52 | public const ORIGINAL_INTERACTION_RESPONSE = self::CREATE_INTERACTION_FOLLOW_UP.'/messages/@original'; 53 | // PATCH, DELETE 54 | public const INTERACTION_FOLLOW_UP = self::CREATE_INTERACTION_FOLLOW_UP.'/messages/:message_id'; 55 | 56 | // GET 57 | public const SKU_SUBSCRIPTIONS = '/skus/:sku_id/subscriptions'; 58 | // GET 59 | public const SKU_SUBSCRIPTION = self::SKU_SUBSCRIPTIONS.'/:subscription_id'; 60 | 61 | // GET 62 | public const AUDIT_LOG = 'guilds/:guild_id/audit-logs'; 63 | 64 | // GET, PATCH, DELETE 65 | public const CHANNEL = 'channels/:channel_id'; 66 | // GET, POST 67 | public const CHANNEL_MESSAGES = self::CHANNEL.'/messages'; 68 | // GET, PATCH, DELETE 69 | public const CHANNEL_MESSAGE = self::CHANNEL.'/messages/:message_id'; 70 | // POST 71 | public const CHANNEL_CROSSPOST_MESSAGE = self::CHANNEL.'/messages/:message_id/crosspost'; 72 | // POST 73 | public const CHANNEL_MESSAGES_BULK_DELETE = self::CHANNEL.'/messages/bulk-delete'; 74 | // PUT, DELETE 75 | public const CHANNEL_PERMISSIONS = self::CHANNEL.'/permissions/:overwrite_id'; 76 | // GET, POST 77 | public const CHANNEL_INVITES = self::CHANNEL.'/invites'; 78 | // POST 79 | public const CHANNEL_FOLLOW = self::CHANNEL.'/followers'; 80 | // POST 81 | public const CHANNEL_TYPING = self::CHANNEL.'/typing'; 82 | // GET 83 | /** @deprecated Use `CHANNEL_MESSAGES_PINS` */ 84 | public const CHANNEL_PINS = self::CHANNEL.'/pins'; 85 | // PUT, DELETE 86 | /** @deprecated Use `CHANNEL_MESSAGES_PINS` */ 87 | public const CHANNEL_PIN = self::CHANNEL.'/pins/:message_id'; 88 | // GET 89 | public const CHANNEL_MESSAGES_PINS = self::CHANNEL.'/messages/pins'; 90 | // PUT, DELETE 91 | public const CHANNEL_MESSAGES_PIN = self::CHANNEL.'/messages/pins/:message_id'; 92 | // POST 93 | public const CHANNEL_THREADS = self::CHANNEL.'/threads'; 94 | // POST 95 | public const CHANNEL_MESSAGE_THREADS = self::CHANNEL_MESSAGE.'/threads'; 96 | // GET 97 | public const CHANNEL_THREADS_ARCHIVED_PUBLIC = self::CHANNEL_THREADS.'/archived/public'; 98 | // GET 99 | public const CHANNEL_THREADS_ARCHIVED_PRIVATE = self::CHANNEL_THREADS.'/archived/private'; 100 | // GET 101 | public const CHANNEL_THREADS_ARCHIVED_PRIVATE_ME = self::CHANNEL.'/users/@me/threads/archived/private'; 102 | // POST 103 | public const CHANNEL_SEND_SOUNDBOARD_SOUND = self::CHANNEL.'/send-soundboard-sound'; 104 | 105 | // GET, PATCH, DELETE 106 | public const THREAD = 'channels/:thread_id'; 107 | // GET 108 | public const THREAD_MEMBERS = self::THREAD.'/thread-members'; 109 | // GET, PUT, DELETE 110 | public const THREAD_MEMBER = self::THREAD_MEMBERS.'/:user_id'; 111 | // PUT, DELETE 112 | public const THREAD_MEMBER_ME = self::THREAD_MEMBERS.'/@me'; 113 | 114 | // GET, DELETE 115 | public const MESSAGE_REACTION_ALL = self::CHANNEL.'/messages/:message_id/reactions'; 116 | // GET, DELETE 117 | public const MESSAGE_REACTION_EMOJI = self::CHANNEL.'/messages/:message_id/reactions/:emoji'; 118 | // PUT, DELETE 119 | public const OWN_MESSAGE_REACTION = self::CHANNEL.'/messages/:message_id/reactions/:emoji/@me'; 120 | // DELETE 121 | public const USER_MESSAGE_REACTION = self::CHANNEL.'/messages/:message_id/reactions/:emoji/:user_id'; 122 | 123 | // GET 124 | protected const MESSAGE_POLL = self::CHANNEL.'/polls/:message_id'; 125 | // GET 126 | public const MESSAGE_POLL_ANSWER = self::MESSAGE_POLL.'/answers/:answer_id'; 127 | // POST 128 | public const MESSAGE_POLL_EXPIRE = self::MESSAGE_POLL.'/expire'; 129 | 130 | // GET, POST 131 | public const CHANNEL_WEBHOOKS = self::CHANNEL.'/webhooks'; 132 | 133 | // POST 134 | public const GUILDS = 'guilds'; 135 | // GET, PATCH, DELETE 136 | public const GUILD = 'guilds/:guild_id'; 137 | // GET, POST, PATCH 138 | public const GUILD_CHANNELS = self::GUILD.'/channels'; 139 | // GET 140 | public const GUILD_THREADS_ACTIVE = self::GUILD.'/threads/active'; 141 | // GET 142 | public const GUILD_MESSAGES_SEARCH = self::GUILD.'/messages/search'; 143 | 144 | // GET 145 | public const GUILD_MEMBERS = self::GUILD.'/members'; 146 | // GET 147 | public const GUILD_MEMBERS_SEARCH = self::GUILD.'/members/search'; 148 | // GET, PATCH, PUT, DELETE 149 | public const GUILD_MEMBER = self::GUILD.'/members/:user_id'; 150 | // PATCH 151 | public const GUILD_MEMBER_SELF = self::GUILD.'/members/@me'; 152 | /** @deprecated 9.0.9 Use `GUILD_MEMBER_SELF` */ 153 | public const GUILD_MEMBER_SELF_NICK = self::GUILD.'/members/@me/nick'; 154 | // PUT, DELETE 155 | public const GUILD_MEMBER_ROLE = self::GUILD.'/members/:user_id/roles/:role_id'; 156 | 157 | // GET 158 | public const GUILD_BANS = self::GUILD.'/bans'; 159 | // GET, PUT, DELETE 160 | public const GUILD_BAN = self::GUILD.'/bans/:user_id'; 161 | // POST 162 | public const GUILD_BAN_BULK = self::GUILD.'/bulk-ban'; 163 | 164 | // GET, PATCH 165 | public const GUILD_ROLES = self::GUILD.'/roles'; 166 | // GET 167 | public const GUILD_ROLES_MEMBER_COUNTS = self::GUILD.'/roles/member-counts'; 168 | // GET, POST, PATCH, DELETE 169 | public const GUILD_ROLE = self::GUILD.'/roles/:role_id'; 170 | 171 | // POST 172 | public const GUILD_MFA = self::GUILD.'/mfa'; 173 | 174 | // GET, POST 175 | public const GUILD_INVITES = self::GUILD.'/invites'; 176 | 177 | // GET, POST 178 | public const GUILD_INTEGRATIONS = self::GUILD.'/integrations'; 179 | // PATCH, DELETE 180 | public const GUILD_INTEGRATION = self::GUILD.'/integrations/:integration_id'; 181 | // POST 182 | public const GUILD_INTEGRATION_SYNC = self::GUILD.'/integrations/:integration_id/sync'; 183 | 184 | // GET, POST 185 | public const GUILD_EMOJIS = self::GUILD.'/emojis'; 186 | // GET, PATCH, DELETE 187 | public const GUILD_EMOJI = self::GUILD.'/emojis/:emoji_id'; 188 | 189 | // GET 190 | public const GUILD_PREVIEW = self::GUILD.'/preview'; 191 | // GET, POST 192 | public const GUILD_PRUNE = self::GUILD.'/prune'; 193 | // GET 194 | public const GUILD_REGIONS = self::GUILD.'/regions'; 195 | // GET, PATCH 196 | public const GUILD_WIDGET_SETTINGS = self::GUILD.'/widget'; 197 | // GET 198 | public const GUILD_WIDGET = self::GUILD.'/widget.json'; 199 | // GET 200 | public const GUILD_WIDGET_IMAGE = self::GUILD.'/widget.png'; 201 | // GET, PATCH 202 | public const GUILD_WELCOME_SCREEN = self::GUILD.'/welcome-screen'; 203 | // GET 204 | public const GUILD_ONBOARDING = self::GUILD.'/onboarding'; 205 | // GET 206 | public const LIST_VOICE_REGIONS = 'voice/regions'; 207 | // GET, PATCH 208 | public const GUILD_USER_CURRENT_VOICE_STATE = self::GUILD.'/voice-states/@me'; 209 | // GET, PATCH 210 | public const GUILD_USER_VOICE_STATE = self::GUILD.'/voice-states/:user_id'; 211 | // GET 212 | public const GUILD_VANITY_URL = self::GUILD.'/vanity-url'; 213 | // GET, PATCH 214 | public const GUILD_MEMBERSHIP_SCREENING = self::GUILD.'/member-verification'; 215 | // GET 216 | public const GUILD_WEBHOOKS = self::GUILD.'/webhooks'; 217 | 218 | // GET, POST 219 | public const GUILD_STICKERS = self::GUILD.'/stickers'; 220 | // GET, PATCH, DELETE 221 | public const GUILD_STICKER = self::GUILD.'/stickers/:sticker_id'; 222 | 223 | // GET 224 | public const STICKER = 'stickers/:sticker_id'; 225 | // GET 226 | public const STICKER_PACKS = 'sticker-packs'; 227 | 228 | // GET, POST 229 | public const GUILD_SCHEDULED_EVENTS = self::GUILD.'/scheduled-events'; 230 | // GET, PATCH, DELETE 231 | public const GUILD_SCHEDULED_EVENT = self::GUILD.'/scheduled-events/:guild_scheduled_event_id'; 232 | // GET 233 | public const GUILD_SCHEDULED_EVENT_USERS = self::GUILD.'/scheduled-events/:guild_scheduled_event_id/users'; 234 | 235 | // GET, POST 236 | public const GUILD_SOUNDBOARD_SOUNDS = self::GUILD.'/soundboard-sounds'; 237 | // GET, PATCH, DELETE 238 | public const GUILD_SOUNDBOARD_SOUND = self::GUILD.'/soundboard-sounds/:sound_id'; 239 | 240 | // GET, DELETE 241 | public const INVITE = 'invites/:code'; 242 | 243 | // POST 244 | public const STAGE_INSTANCES = 'stage-instances'; 245 | // GET, PATCH, DELETE 246 | public const STAGE_INSTANCE = 'stage-instances/:channel_id'; 247 | 248 | // GET, POST 249 | public const GUILDS_TEMPLATE = self::GUILDS.'/templates/:template_code'; 250 | // GET, POST 251 | public const GUILD_TEMPLATES = self::GUILD.'/templates'; 252 | // PUT, PATCH, DELETE 253 | public const GUILD_TEMPLATE = self::GUILD.'/templates/:template_code'; 254 | 255 | // GET, POST 256 | public const GUILD_AUTO_MODERATION_RULES = self::GUILD.'/auto-moderation/rules'; 257 | // GET, PATCH, DELETE 258 | public const GUILD_AUTO_MODERATION_RULE = self::GUILD.'/auto-moderation/rules/:auto_moderation_rule_id'; 259 | 260 | // POST 261 | public const LOBBIES = 'lobbies'; 262 | // GET, PATCH, DELETE 263 | public const LOBBY = self::LOBBIES.'/:lobby_id'; 264 | // PUT, DELETE 265 | public const LOBBY_MEMBER = self::LOBBY.'/members/:user_id/'; 266 | // DELETE 267 | public const LOBBY_SELF = self::LOBBY.'/members/@me'; 268 | // PATCH 269 | public const LOBBY_CHANNEL_LINKING = self::LOBBY.'/channel-linking'; 270 | 271 | // GET 272 | public const SOUNDBOARD_DEFAULT_SOUNDS = 'soundboard-default-sounds'; 273 | 274 | // GET, PATCH 275 | public const USER_CURRENT = 'users/@me'; 276 | // GET 277 | public const USER = 'users/:user_id'; 278 | // GET 279 | public const USER_CURRENT_GUILDS = self::USER_CURRENT.'/guilds'; 280 | // DELETE 281 | public const USER_CURRENT_GUILD = self::USER_CURRENT.'/guilds/:guild_id'; 282 | // GET 283 | public const USER_CURRENT_MEMBER = self::USER_CURRENT_GUILD.'/member'; 284 | // GET, POST 285 | public const USER_CURRENT_CHANNELS = self::USER_CURRENT.'/channels'; 286 | // GET 287 | public const USER_CURRENT_CONNECTIONS = self::USER_CURRENT.'/connections'; 288 | // GET, PUT 289 | public const USER_CURRENT_APPLICATION_ROLE_CONNECTION = self::USER_CURRENT.'/applications/:application_id/role-connection'; 290 | // GET, PATCH 291 | public const APPLICATION_CURRENT = 'applications/@me'; 292 | // GET 293 | public const APPLICATION_ACTIVITY_INSTANCE = 'applications/:application_id/activity-instances/:instance_id'; 294 | 295 | // GET, PATCH, DELETE 296 | public const WEBHOOK = 'webhooks/:webhook_id'; 297 | // GET, PATCH, DELETE 298 | public const WEBHOOK_TOKEN = 'webhooks/:webhook_id/:webhook_token'; 299 | // POST 300 | public const WEBHOOK_EXECUTE = self::WEBHOOK_TOKEN; 301 | // POST 302 | public const WEBHOOK_EXECUTE_SLACK = self::WEBHOOK_EXECUTE.'/slack'; 303 | // POST 304 | public const WEBHOOK_EXECUTE_GITHUB = self::WEBHOOK_EXECUTE.'/github'; 305 | // PATCH, DELETE 306 | public const WEBHOOK_MESSAGE = self::WEBHOOK_TOKEN.'/messages/:message_id'; 307 | 308 | // GET, PUT 309 | public const APPLICATION_ROLE_CONNECTION_METADATA = 'applications/:application_id/role-connections/metadata'; 310 | 311 | /** 312 | * Regex to identify parameters in endpoints. 313 | * 314 | * @var string 315 | */ 316 | public const REGEX = '/:([^\/]*)/'; 317 | 318 | /** 319 | * A list of parameters considered 'major' by Discord. 320 | * 321 | * @see https://discord.com/developers/docs/topics/rate-limits 322 | * @var string[] 323 | */ 324 | public const MAJOR_PARAMETERS = ['channel_id', 'guild_id', 'webhook_id', 'thread_id']; 325 | 326 | /** 327 | * The string version of the endpoint, including all parameters. 328 | * 329 | * @var string 330 | */ 331 | protected $endpoint; 332 | 333 | /** 334 | * Array of placeholders to be replaced in the endpoint. 335 | * 336 | * @var string[] 337 | */ 338 | protected $vars = []; 339 | 340 | /** 341 | * Array of arguments to substitute into the endpoint. 342 | * 343 | * @var string[] 344 | */ 345 | protected $args = []; 346 | 347 | /** 348 | * Array of query data to be appended 349 | * to the end of the endpoint with `http_build_query`. 350 | * 351 | * @var array 352 | */ 353 | protected $query = []; 354 | 355 | /** 356 | * Creates an endpoint class. 357 | * 358 | * @param string $endpoint 359 | */ 360 | public function __construct(string $endpoint) 361 | { 362 | $this->endpoint = $endpoint; 363 | 364 | if (preg_match_all(self::REGEX, $endpoint, $vars)) { 365 | $this->vars = $vars[1] ?? []; 366 | } 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /src/Discord/HttpTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This file is subject to the MIT license that is bundled 9 | * with this source code in the LICENSE file. 10 | */ 11 | 12 | namespace Discord\Http; 13 | 14 | use Discord\Http\Exceptions\BadRequestException; 15 | use Discord\Http\Exceptions\ContentTooLongException; 16 | use Discord\Http\Exceptions\InvalidTokenException; 17 | use Discord\Http\Exceptions\MethodNotAllowedException; 18 | use Discord\Http\Exceptions\NoPermissionsException; 19 | use Discord\Http\Exceptions\NotFoundException; 20 | use Discord\Http\Exceptions\RateLimitException; 21 | use Discord\Http\Exceptions\RequestFailedException; 22 | use Discord\Http\Multipart\MultipartBody; 23 | use Psr\Http\Message\ResponseInterface; 24 | use React\Promise\Deferred; 25 | use React\Promise\PromiseInterface; 26 | 27 | /** 28 | * Discord HTTP client. 29 | * 30 | * @author David Cole 31 | */ 32 | trait HttpTrait 33 | { 34 | /** 35 | * Sets the driver of the HTTP client. 36 | * 37 | * @param DriverInterface $driver 38 | */ 39 | public function setDriver(DriverInterface $driver): void 40 | { 41 | $this->driver = $driver; 42 | } 43 | 44 | /** 45 | * Runs a GET request. 46 | * 47 | * @param string|Endpoint $url 48 | * @param mixed $content 49 | * @param array $headers 50 | * 51 | * @return PromiseInterface 52 | */ 53 | public function get($url, $content = null, array $headers = []): PromiseInterface 54 | { 55 | if (! ($url instanceof Endpoint)) { 56 | $url = Endpoint::bind($url); 57 | } 58 | 59 | return $this->queueRequest('get', $url, $content, $headers); 60 | } 61 | 62 | /** 63 | * Runs a POST request. 64 | * 65 | * @param string|Endpoint $url 66 | * @param mixed $content 67 | * @param array $headers 68 | * 69 | * @return PromiseInterface 70 | */ 71 | public function post($url, $content = null, array $headers = []): PromiseInterface 72 | { 73 | if (! ($url instanceof Endpoint)) { 74 | $url = Endpoint::bind($url); 75 | } 76 | 77 | return $this->queueRequest('post', $url, $content, $headers); 78 | } 79 | 80 | /** 81 | * Runs a PUT request. 82 | * 83 | * @param string|Endpoint $url 84 | * @param mixed $content 85 | * @param array $headers 86 | * 87 | * @return PromiseInterface 88 | */ 89 | public function put($url, $content = null, array $headers = []): PromiseInterface 90 | { 91 | if (! ($url instanceof Endpoint)) { 92 | $url = Endpoint::bind($url); 93 | } 94 | 95 | return $this->queueRequest('put', $url, $content, $headers); 96 | } 97 | 98 | /** 99 | * Runs a PATCH request. 100 | * 101 | * @param string|Endpoint $url 102 | * @param mixed $content 103 | * @param array $headers 104 | * 105 | * @return PromiseInterface 106 | */ 107 | public function patch($url, $content = null, array $headers = []): PromiseInterface 108 | { 109 | if (! ($url instanceof Endpoint)) { 110 | $url = Endpoint::bind($url); 111 | } 112 | 113 | return $this->queueRequest('patch', $url, $content, $headers); 114 | } 115 | 116 | /** 117 | * Runs a DELETE request. 118 | * 119 | * @param string|Endpoint $url 120 | * @param mixed $content 121 | * @param array $headers 122 | * 123 | * @return PromiseInterface 124 | */ 125 | public function delete($url, $content = null, array $headers = []): PromiseInterface 126 | { 127 | if (! ($url instanceof Endpoint)) { 128 | $url = Endpoint::bind($url); 129 | } 130 | 131 | return $this->queueRequest('delete', $url, $content, $headers); 132 | } 133 | 134 | /** 135 | * Builds and queues a request. 136 | * 137 | * @param string $method 138 | * @param Endpoint $url 139 | * @param mixed $content 140 | * @param array $headers 141 | * 142 | * @return PromiseInterface 143 | */ 144 | public function queueRequest(string $method, Endpoint $url, $content, array $headers = []): PromiseInterface 145 | { 146 | $deferred = new Deferred(); 147 | 148 | if (is_null($this->driver)) { 149 | $deferred->reject(new \Exception('HTTP driver is missing.')); 150 | 151 | return $deferred->promise(); 152 | } 153 | 154 | $headers = array_merge($headers, [ 155 | 'User-Agent' => $this->getUserAgent(), 156 | 'Authorization' => $this->token, 157 | 'X-Ratelimit-Precision' => 'millisecond', 158 | ]); 159 | 160 | $baseHeaders = [ 161 | 'User-Agent' => $this->getUserAgent(), 162 | 'Authorization' => $this->token, 163 | 'X-Ratelimit-Precision' => 'millisecond', 164 | ]; 165 | 166 | if (! is_null($content) && ! isset($headers['Content-Type'])) { 167 | $baseHeaders = array_merge( 168 | $baseHeaders, 169 | $this->guessContent($content) 170 | ); 171 | } 172 | 173 | $headers = array_merge($baseHeaders, $headers); 174 | 175 | $request = new Request($deferred, $method, $url, $content ?? '', $headers); 176 | $this->sortIntoBucket($request); 177 | 178 | return $deferred->promise(); 179 | } 180 | 181 | /** 182 | * Guesses the headers and transforms the content of a request. 183 | * 184 | * @param mixed $content 185 | */ 186 | protected function guessContent(&$content) 187 | { 188 | if ($content instanceof MultipartBody) { 189 | $headers = $content->getHeaders(); 190 | $content = (string) $content; 191 | 192 | return $headers; 193 | } 194 | 195 | $content = json_encode($content); 196 | 197 | return [ 198 | 'Content-Type' => 'application/json', 199 | 'Content-Length' => strlen($content), 200 | ]; 201 | } 202 | 203 | /** 204 | * Executes a request. 205 | * 206 | * @param Request $request 207 | * @param Deferred|null $deferred 208 | * 209 | * @return PromiseInterface 210 | */ 211 | protected function executeRequest(Request $request, ?Deferred $deferred = null): PromiseInterface 212 | { 213 | if ($deferred === null) { 214 | $deferred = new Deferred(); 215 | } 216 | 217 | if ($this->rateLimit) { 218 | $deferred->reject($this->rateLimit); 219 | 220 | return $deferred->promise(); 221 | } 222 | 223 | // Promises v3 changed `->then` to behave as `->done` and removed `->then`. We still need the behaviour of `->done` in projects using v2 224 | $this->driver->runRequest($request)->{$this->promiseV3 ? 'then' : 'done'}(function (ResponseInterface $response) use ($request, $deferred) { 225 | $data = json_decode((string) $response->getBody()); 226 | $statusCode = $response->getStatusCode(); 227 | 228 | // Discord Rate-limit 229 | if ($statusCode == 429) { 230 | if (! isset($data->global)) { 231 | if ($response->hasHeader('X-RateLimit-Global')) { 232 | $data->global = $response->getHeader('X-RateLimit-Global')[0] == 'true'; 233 | } else { 234 | // Some other 429 235 | $this->logger->error($request.' does not contain global rate-limit value'); 236 | $rateLimitError = new RateLimitException('No rate limit global response', $statusCode); 237 | $deferred->reject($rateLimitError); 238 | $request->getDeferred()->reject($rateLimitError); 239 | 240 | return; 241 | } 242 | } 243 | 244 | if (! isset($data->retry_after)) { 245 | if ($response->hasHeader('Retry-After')) { 246 | $data->retry_after = $response->getHeader('Retry-After')[0]; 247 | } else { 248 | // Some other 429 249 | $this->logger->error($request.' does not contain retry after rate-limit value'); 250 | $rateLimitError = new RateLimitException('No rate limit retry after response', $statusCode); 251 | $deferred->reject($rateLimitError); 252 | $request->getDeferred()->reject($rateLimitError); 253 | 254 | return; 255 | } 256 | } 257 | 258 | $rateLimit = new RateLimit($data->global, $data->retry_after); 259 | $this->logger->warning($request.' hit rate-limit: '.$rateLimit); 260 | 261 | if ($rateLimit->isGlobal() && ! $this->rateLimit) { 262 | $this->rateLimit = $rateLimit; 263 | $this->rateLimitReset = $this->loop->addTimer($rateLimit->getRetryAfter(), function () { 264 | $this->rateLimit = null; 265 | $this->rateLimitReset = null; 266 | $this->logger->info('global rate-limit reset'); 267 | 268 | // Loop through all buckets and check for requests 269 | foreach ($this->buckets as $bucket) { 270 | $bucket->checkQueue(); 271 | } 272 | }); 273 | } 274 | 275 | $deferred->reject($rateLimit->isGlobal() ? $this->rateLimit : $rateLimit); 276 | } 277 | // Bad Gateway 278 | // Cloudflare SSL Handshake error 279 | // Push to the back of the bucket to be retried. 280 | elseif ($statusCode == 502 || $statusCode == 525) { 281 | $this->logger->warning($request.' 502/525 - retrying request'); 282 | 283 | $this->executeRequest($request, $deferred); 284 | } 285 | // Any other unsuccessful status codes 286 | elseif ($statusCode < 200 || $statusCode >= 300) { 287 | $error = $this->handleError($response); 288 | $this->logger->warning($request.' failed: '.$error); 289 | 290 | $deferred->reject($error); 291 | $request->getDeferred()->reject($error); 292 | } 293 | // All is well 294 | else { 295 | $this->logger->debug($request.' successful'); 296 | 297 | $deferred->resolve($response); 298 | $request->getDeferred()->resolve($data); 299 | } 300 | }, function (\Exception $e) use ($request, $deferred) { 301 | $this->logger->warning($request.' failed: '.$e->getMessage()); 302 | 303 | $deferred->reject($e); 304 | $request->getDeferred()->reject($e); 305 | }); 306 | 307 | return $deferred->promise(); 308 | } 309 | 310 | /** 311 | * Sorts a request into a bucket. 312 | * 313 | * @param Request $request 314 | */ 315 | protected function sortIntoBucket(Request $request): void 316 | { 317 | $bucket = $this->getBucket($request->getBucketID()); 318 | $bucket->enqueue($request); 319 | } 320 | 321 | /** 322 | * Gets a bucket. 323 | * 324 | * @param string $key 325 | * 326 | * @return Bucket 327 | */ 328 | protected function getBucket(string $key): Bucket 329 | { 330 | if (! isset($this->buckets[$key])) { 331 | $bucket = new Bucket($key, $this->loop, $this->logger, function (Request $request) { 332 | $deferred = new Deferred(); 333 | self::isUnboundEndpoint($request) 334 | ? $this->unboundQueue->enqueue([$request, $deferred]) 335 | : $this->queue->enqueue([$request, $deferred]); 336 | $this->checkQueue(); 337 | 338 | return $deferred->promise(); 339 | }); 340 | 341 | $this->buckets[$key] = $bucket; 342 | } 343 | 344 | return $this->buckets[$key]; 345 | } 346 | 347 | /** 348 | * Checks the request queue to see if more requests can be 349 | * sent out. 350 | */ 351 | protected function checkQueue(bool $check_interactions = true): void 352 | { 353 | if ($check_interactions) { 354 | $this->checkunboundQueue(); 355 | } 356 | 357 | if ($this->waiting >= Http::CONCURRENT_REQUESTS || $this->queue->isEmpty()) { 358 | $this->logger->debug('http not checking queue', ['waiting' => $this->waiting, 'empty' => $this->queue->isEmpty()]); 359 | 360 | return; 361 | } 362 | 363 | /** 364 | * @var Request $request 365 | * @var Deferred $deferred 366 | */ 367 | [$request, $deferred] = $this->queue->dequeue(); 368 | ++$this->waiting; 369 | 370 | $this->executeRequest($request)->then(function ($result) use ($deferred) { 371 | --$this->waiting; 372 | $this->checkQueue(false); 373 | $deferred->resolve($result); 374 | }, function ($e) use ($deferred) { 375 | --$this->waiting; 376 | $this->checkQueue(false); 377 | $deferred->reject($e); 378 | }); 379 | } 380 | 381 | /** 382 | * Checks the interaction queue to see if more requests can be 383 | * sent out. 384 | */ 385 | protected function checkunboundQueue(): void 386 | { 387 | if ($this->unboundQueue->isEmpty()) { 388 | $this->logger->debug('http not checking interaction queue', ['waiting' => $this->waiting, 'empty' => $this->unboundQueue->isEmpty()]); 389 | 390 | return; 391 | } 392 | 393 | /** 394 | * @var Request $request 395 | * @var Deferred $deferred 396 | */ 397 | [$request, $deferred] = $this->unboundQueue->dequeue(); 398 | 399 | $this->executeRequest($request)->then(function ($result) use ($deferred) { 400 | $this->checkQueue(); 401 | $deferred->resolve($result); 402 | }, function ($e) use ($deferred) { 403 | $this->checkQueue(); 404 | $deferred->reject($e); 405 | }); 406 | } 407 | 408 | /** 409 | * Checks if the request is for an endpoint not bound by the global rate limit. 410 | * 411 | * @link https://discord.com/developers/docs/interactions/receiving-and-responding#endpoints 412 | * 413 | * @param Request $request 414 | * @return bool 415 | */ 416 | public static function isUnboundEndpoint(Request $request): bool 417 | { 418 | $url = $request->getUrl(); 419 | 420 | return 421 | (strpos($url, '/interactions') === 0 && strpos($url, '/callback') !== false) 422 | || strpos($url, '/webhooks') === 0; 423 | } 424 | 425 | /** 426 | * Returns an exception based on the request. 427 | * 428 | * @param ResponseInterface $response 429 | * 430 | * @return \Throwable 431 | */ 432 | public function handleError(ResponseInterface $response): \Throwable 433 | { 434 | $reason = $response->getReasonPhrase().' - '; 435 | 436 | $errorBody = (string) $response->getBody(); 437 | $errorCode = $response->getStatusCode(); 438 | 439 | // attempt to prettyify the response content 440 | if (($content = json_decode($errorBody)) !== null) { 441 | if (! empty($content->code)) { 442 | $errorCode = $content->code; 443 | } 444 | $reason .= json_encode($content, JSON_PRETTY_PRINT); 445 | } else { 446 | $reason .= $errorBody; 447 | } 448 | 449 | switch ($response->getStatusCode()) { 450 | case 400: 451 | return new BadRequestException($reason, $errorCode); 452 | case 401: 453 | return new InvalidTokenException($reason, $errorCode); 454 | case 403: 455 | return new NoPermissionsException($reason, $errorCode); 456 | case 404: 457 | return new NotFoundException($reason, $errorCode); 458 | case 405: 459 | return new MethodNotAllowedException($reason, $errorCode); 460 | case 500: 461 | if (strpos(strtolower($errorBody), 'longer than 2000 characters') !== false || 462 | strpos(strtolower($errorBody), 'string value is too long') !== false) { 463 | // Response was longer than 2000 characters and was blocked by Discord. 464 | return new ContentTooLongException('Response was more than 2000 characters. Use another method to get this data.', $errorCode); 465 | } 466 | default: 467 | return new RequestFailedException($reason, $errorCode); 468 | } 469 | } 470 | 471 | /** 472 | * Returns the User-Agent of the HTTP client. 473 | * 474 | * @return string 475 | */ 476 | public function getUserAgent(): string 477 | { 478 | return 'DiscordBot (https://github.com/discord-php/DiscordPHP-HTTP, '.Http::VERSION.')'; 479 | } 480 | } 481 | --------------------------------------------------------------------------------