Files
max-bot-api-client-php/src/Client.php
T
2025-10-04 21:51:14 +03:00

259 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot;
use BushlanovDev\MaxMessengerBot\Exceptions\ForbiddenException;
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
use BushlanovDev\MaxMessengerBot\Exceptions\MethodNotAllowedException;
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
use BushlanovDev\MaxMessengerBot\Exceptions\NotFoundException;
use BushlanovDev\MaxMessengerBot\Exceptions\RateLimitExceededException;
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
use BushlanovDev\MaxMessengerBot\Exceptions\UnauthorizedException;
use InvalidArgumentException;
use JsonException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use RuntimeException;
/**
* The low-level HTTP client responsible for communicating with the Max Bot API.
* It handles request signing, error handling, and JSON serialization/deserialization.
* This class is an abstraction over any PSR-18 compatible HTTP client.
*/
final readonly class Client implements ClientApiInterface
{
/**
* @param string $accessToken Your bot's access token from @MasterBot.
* @param ClientInterface $httpClient A PSR-18 compatible HTTP client (e.g., Guzzle).
* @param RequestFactoryInterface $requestFactory A PSR-17 factory for creating requests.
* @param StreamFactoryInterface $streamFactory A PSR-17 factory for creating request body streams.
* @param string $baseUrl The base URL for API requests.
* @param string|null $apiVersion The API version to use for requests.
* @param LoggerInterface $logger
*
* @throws InvalidArgumentException
*/
public function __construct(
private string $accessToken,
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private StreamFactoryInterface $streamFactory,
private string $baseUrl,
private ?string $apiVersion = null,
private LoggerInterface $logger = new NullLogger(),
) {
if (empty($accessToken)) {
throw new InvalidArgumentException('Access token cannot be empty.');
}
}
/**
* @inheritDoc
*/
public function request(string $method, string $uri, array $queryParams = [], array $body = []): array
{
if (!empty($this->apiVersion)) {
$queryParams['v'] = $this->apiVersion;
}
$this->logger->debug('Sending API request', [
'method' => $method,
'url' => $this->baseUrl . $uri,
'body' => $body,
]);
$fullUrl = $this->baseUrl . $uri . '?' . http_build_query($queryParams);
$request = $this->requestFactory
->createRequest($method, $fullUrl)
->withHeader('Authorization', $this->accessToken);
if (!empty($body)) {
try {
$payload = json_encode($body, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new SerializationException('Failed to encode request body to JSON.', 0, $e);
}
$stream = $this->streamFactory->createStream($payload);
$request = $request
->withBody($stream)
->withHeader('Content-Type', 'application/json; charset=utf-8');
}
try {
$response = $this->httpClient->sendRequest($request);
} catch (ClientExceptionInterface $e) {
// This catches network errors, DNS failures, timeouts, etc.
$this->logger->error('Network exception during API request', [
'message' => $e->getMessage(),
'exception' => $e,
]);
throw new NetworkException($e->getMessage(), $e->getCode(), $e);
}
$this->handleErrorResponse($response);
$responseBody = (string)$response->getBody();
$this->logger->debug('Received API response', [
'status' => $response->getStatusCode(),
'body' => $responseBody,
]);
// Handle successful but empty responses (e.g., from DELETE endpoints)
if (empty($responseBody)) {
// The API spec often returns {"success": true}, so we can simulate that
// for consistency if the body is truly empty.
return ['success' => true];
}
try {
return json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new SerializationException('Failed to decode API response JSON.', 0, $e);
}
}
/**
* @inheritDoc
*/
public function multipartUpload(string $uri, mixed $fileContents, string $fileName): string
{
$boundary = '--------------------------' . microtime(true);
$bodyStream = $this->streamFactory->createStream();
$bodyStream->write("--$boundary\r\n");
$bodyStream->write("Content-Disposition: form-data; name=\"data\"; filename=\"{$fileName}\"\r\n");
$bodyStream->write("Content-Type: application/octet-stream\r\n\r\n");
if (is_resource($fileContents)) {
$bodyStream->write((string)stream_get_contents($fileContents));
} else {
$bodyStream->write((string)$fileContents);
}
$bodyStream->write("\r\n");
$bodyStream->write("--$boundary--\r\n");
$request = $this->requestFactory
->createRequest('POST', $uri)
->withHeader('Content-Type', 'multipart/form-data; boundary=' . $boundary)
->withHeader('Authorization', $this->accessToken)
->withBody($bodyStream);
try {
$response = $this->httpClient->sendRequest($request);
} catch (ClientExceptionInterface $e) {
throw new NetworkException($e->getMessage(), $e->getCode(), $e);
}
$this->handleErrorResponse($response);
return (string)$response->getBody();
}
/**
* @inheritDoc
*/
public function resumableUpload(
string $uploadUrl,
mixed $fileResource,
string $fileName,
int $fileSize,
int $chunkSize = 1048576,
): string {
if (!is_resource($fileResource) || get_resource_type($fileResource) !== 'stream') {
throw new InvalidArgumentException('fileResource must be a valid stream resource.');
}
// @phpstan-ignore-next-line
if ($fileSize <= 0) {
throw new InvalidArgumentException('File size must be greater than 0.');
}
$startByte = 0;
$finalResponseBody = '';
while (!feof($fileResource)) {
$chunk = fread($fileResource, $chunkSize);
if ($chunk === false) {
// @codeCoverageIgnoreStart
throw new RuntimeException('Failed to read chunk from file stream.');
// @codeCoverageIgnoreEnd
}
$chunkLength = strlen($chunk);
if ($chunkLength === 0) {
break;
}
$endByte = $startByte + $chunkLength - 1;
$chunkStream = $this->streamFactory->createStream($chunk);
$request = $this->requestFactory->createRequest('POST', $uploadUrl)
->withBody($chunkStream)
->withHeader('Content-Type', 'application/octet-stream')
->withHeader('Content-Disposition', 'attachment; filename="' . $fileName . '"')
->withHeader('Content-Range', "bytes {$startByte}-{$endByte}/{$fileSize}")
->withHeader('Authorization', $this->accessToken);
try {
$response = $this->httpClient->sendRequest($request);
} catch (ClientExceptionInterface $e) {
throw new NetworkException($e->getMessage(), $e->getCode(), $e);
}
$this->handleErrorResponse($response);
// The final response might contain the retval
$finalResponseBody = (string)$response->getBody();
$startByte += $chunkLength;
}
// According to docs, for video/audio the token is sent separately,
// and the upload response contains 'retval'. We return the body of the last response.
return $finalResponseBody;
}
/**
* Checks the response for an error status code and throws a corresponding typed exception.
*
* @throws ClientApiException
*/
private function handleErrorResponse(ResponseInterface $response): void
{
$statusCode = $response->getStatusCode();
// 2xx codes are considered successful.
if ($statusCode >= 200 && $statusCode < 300) {
return;
}
$responseBody = (string)$response->getBody();
$data = json_decode($responseBody, true) ?? [];
$errorCode = $data['code'] ?? 'unknown';
$errorMessage = $data['message'] ?? 'An unknown error occurred.';
$this->logger->error('API error response received', [
'status' => $statusCode,
'body' => $responseBody,
]);
throw match ($statusCode) {
401 => new UnauthorizedException($errorMessage, $errorCode, $response),
403 => new ForbiddenException($errorMessage, $errorCode, $response),
404 => new NotFoundException($errorMessage, $errorCode, $response),
405 => new MethodNotAllowedException($errorMessage, $errorCode, $response),
429 => new RateLimitExceededException($errorMessage, $errorCode, $response),
default => new ClientApiException($errorMessage, $errorCode, $response, $statusCode),
};
}
}