mirror of
https://github.com/BushlanovDev/max-bot-api-client-php.git
synced 2026-08-17 12:13:12 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
@@ -0,0 +1,5 @@
|
||||
/.gitattributes export-ignore
|
||||
/.gitignore export-ignore
|
||||
/.github export-ignore
|
||||
/phpunit.xml export-ignore
|
||||
/tests export-ignore
|
||||
@@ -0,0 +1,61 @@
|
||||
name: CI
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
|
||||
phpstan:
|
||||
|
||||
name: phpstan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.3"
|
||||
coverage: none
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction
|
||||
|
||||
- name: Run Static Analysis
|
||||
run: ./vendor/bin/phpstan analyse -c phpstan.neon --error-format=github
|
||||
|
||||
tests:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
php-versions: [ "8.3", "8.4" ]
|
||||
name: PHP ${{ matrix.php-versions }} Test on ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-versions }}
|
||||
coverage: xdebug
|
||||
|
||||
- name: Get composer cache directory
|
||||
id: composer-cache
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction
|
||||
|
||||
- name: PHPUnit
|
||||
run: ./vendor/bin/phpunit --configuration=phpunit.xml --coverage-text
|
||||
@@ -0,0 +1,7 @@
|
||||
.idea
|
||||
.php-cs-fixer.cache
|
||||
.phpunit.result.cache
|
||||
.phpunit.cache
|
||||
composer.lock
|
||||
coverage
|
||||
vendor
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Aleksandr Bushlanov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Max Bot API Client library for Golang
|
||||
|
||||
[](https://github.com/BushlanovDev/max-bot-api-client-php/actions)
|
||||
[]()
|
||||
[](LICENSE)
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "bushlanov-dev/max-bot-api-client-php",
|
||||
"description": "",
|
||||
"keywords": [
|
||||
"max messenger",
|
||||
"bot",
|
||||
"max",
|
||||
"api"
|
||||
],
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Aleksandr Bushlanov",
|
||||
"email": "alex@bushlanov.dev"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.3",
|
||||
"ext-json": "*",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"guzzlehttp/guzzle": "^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"friendsofphp/php-cs-fixer": "^3.77"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BushlanovDev\\MaxMessengerBot\\": "src"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"BushlanovDev\\MaxMessengerBot\\Tests\\": "tests"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
},
|
||||
"scripts": {
|
||||
"analyse": "vendor/bin/phpstan analyse -c phpstan.neon",
|
||||
"format": "vendor/bin/php-cs-fixer fix --allow-risky=yes src",
|
||||
"test": "vendor/bin/phpunit",
|
||||
"test-coverage": "vendor/bin/phpunit --coverage-html coverage"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
parameters:
|
||||
level: 6
|
||||
paths:
|
||||
- src
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
executionOrder="depends,defects"
|
||||
requireCoverageMetadata="true"
|
||||
beStrictAboutCoverageMetadata="true"
|
||||
beStrictAboutOutputDuringTests="true"
|
||||
displayDetailsOnPhpunitDeprecations="true"
|
||||
failOnPhpunitDeprecation="true"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="default">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
<exclude>
|
||||
<directory suffix=".php">src/Exceptions</directory>
|
||||
</exclude>
|
||||
</source>
|
||||
</phpunit>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
|
||||
|
||||
/**
|
||||
* The main entry point for interacting with the Max Bot API.
|
||||
* This class provides a clean, object-oriented interface over the raw HTTP API.
|
||||
*
|
||||
* @see https://dev.max.ru
|
||||
*/
|
||||
class Api
|
||||
{
|
||||
private const string METHOD_GET = 'GET';
|
||||
|
||||
// private const string METHOD_POST = 'POST';
|
||||
|
||||
private const string ACTION_ME = '/me';
|
||||
|
||||
private readonly ClientApiInterface $client;
|
||||
|
||||
private readonly ModelFactory $modelFactory;
|
||||
|
||||
/**
|
||||
* Api constructor.
|
||||
*
|
||||
* @param string $accessToken Your bot's access token from @MasterBot.
|
||||
* @param ClientApiInterface|null $client Http api client.
|
||||
* @param ModelFactory|null $modelFactory
|
||||
*/
|
||||
public function __construct(string $accessToken, ?ClientApiInterface $client = null, ?ModelFactory $modelFactory = null)
|
||||
{
|
||||
$this->client = $client ?? new Client(
|
||||
$accessToken,
|
||||
new \GuzzleHttp\Client(),
|
||||
new \GuzzleHttp\Psr7\HttpFactory(),
|
||||
new \GuzzleHttp\Psr7\HttpFactory(),
|
||||
);
|
||||
$this->modelFactory = $modelFactory ?? new ModelFactory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about the current bot, identified by an access token.
|
||||
*
|
||||
* @return BotInfo
|
||||
*/
|
||||
public function getBotInfo(): BotInfo
|
||||
{
|
||||
return $this->modelFactory->createBotInfo(
|
||||
$this->client->request(self::METHOD_GET, self::ACTION_ME)
|
||||
);
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\ForbiddenException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NotFoundException;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 class Client implements ClientApiInterface
|
||||
{
|
||||
private const string API_BASE_URL = 'https://botapi.max.ru';
|
||||
|
||||
private const string DEFAULT_API_VERSION = '0.0.6';
|
||||
|
||||
/**
|
||||
* @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 $apiVersion The API version to use for requests.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $accessToken,
|
||||
private readonly ClientInterface $httpClient,
|
||||
private readonly RequestFactoryInterface $requestFactory,
|
||||
private readonly StreamFactoryInterface $streamFactory,
|
||||
private readonly string $apiVersion = self::DEFAULT_API_VERSION,
|
||||
) {
|
||||
if (empty($accessToken)) {
|
||||
throw new InvalidArgumentException('Access token cannot be empty.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function request(string $method, string $uri, array $queryParams = [], array $body = []): array
|
||||
{
|
||||
$queryParams['access_token'] = $this->accessToken;
|
||||
$queryParams['v'] = $this->apiVersion;
|
||||
|
||||
$fullUrl = self::API_BASE_URL . $uri . '?' . http_build_query($queryParams);
|
||||
$request = $this->requestFactory->createRequest($method, $fullUrl);
|
||||
|
||||
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.
|
||||
throw new NetworkException($e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
|
||||
$this->handleErrorResponse($response);
|
||||
|
||||
$responseBody = (string)$response->getBody();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.';
|
||||
|
||||
$exception = match ($statusCode) {
|
||||
401 => new UnauthorizedException($errorMessage, $errorCode, $response),
|
||||
403 => new ForbiddenException($errorMessage, $errorCode, $response),
|
||||
404 => new NotFoundException($errorMessage, $errorCode, $response),
|
||||
default => new ClientApiException($errorMessage, $errorCode, $response, $statusCode),
|
||||
};
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
|
||||
interface ClientApiInterface
|
||||
{
|
||||
/**
|
||||
* Performs a request to the Max Bot API.
|
||||
*
|
||||
* @param string $method The HTTP method (GET, POST, PATCH, etc.).
|
||||
* @param string $uri The API endpoint (e.g., '/me', '/messages').
|
||||
* @param array<string, mixed> $queryParams Query parameters for the request.
|
||||
* @param array<string, mixed> $body The request body.
|
||||
*
|
||||
* @return array<string, mixed> The decoded JSON response as an associative array.
|
||||
*
|
||||
* @throws ClientApiException for API-level errors (4xx, 5xx).
|
||||
* @throws NetworkException for network-related issues.
|
||||
* @throws SerializationException for JSON encoding/decoding failures.
|
||||
*/
|
||||
public function request(string $method, string $uri, array $queryParams = [], array $body = []): array;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Exceptions;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ClientApiException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly string $errorCode,
|
||||
public readonly ?ResponseInterface $response = null,
|
||||
public readonly ?int $httpStatusCode = null,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $httpStatusCode ?? 0, $previous);
|
||||
}
|
||||
|
||||
public function getHttpStatusCode(): ?int
|
||||
{
|
||||
return $this->httpStatusCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Exceptions;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Throwable;
|
||||
|
||||
class ForbiddenException extends ClientApiException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
string $errorCode,
|
||||
?ResponseInterface $response,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $errorCode, $response, 403, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class NetworkException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Exceptions;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Throwable;
|
||||
|
||||
class NotFoundException extends ClientApiException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
string $errorCode,
|
||||
?ResponseInterface $response,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $errorCode, $response, 404, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Exceptions;
|
||||
|
||||
use LogicException;
|
||||
use Throwable;
|
||||
|
||||
class SerializationException extends LogicException
|
||||
{
|
||||
public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Exceptions;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Throwable;
|
||||
|
||||
class UnauthorizedException extends ClientApiException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
string $errorCode,
|
||||
?ResponseInterface $response,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $errorCode, $response, 401, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Models\BotCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
|
||||
|
||||
/**
|
||||
* Creates DTOs from raw associative arrays returned by the API client.
|
||||
*/
|
||||
class ModelFactory
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return BotInfo
|
||||
*/
|
||||
public function createBotInfo(array $data): BotInfo
|
||||
{
|
||||
$data['commands'] = isset($data['commands']) && is_array($data['commands'])
|
||||
? array_map([BotCommand::class, 'fromArray'], $data['commands']) : null;
|
||||
|
||||
return BotInfo::fromArray($data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Models;
|
||||
|
||||
abstract readonly class AbstractModel
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
abstract public static function fromArray(array $data): static;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return get_object_vars($this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Models;
|
||||
|
||||
/**
|
||||
* Command supported by the bot.
|
||||
*/
|
||||
final readonly class BotCommand extends AbstractModel
|
||||
{
|
||||
/**
|
||||
* @param string $name Command name (1 to 64 characters)
|
||||
* @param string|null $description Command description (1 to 128 characters)
|
||||
*/
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public ?string $description,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public static function fromArray(array $data): static
|
||||
{
|
||||
return new static(
|
||||
(string)$data['name'],
|
||||
$data['description'] ? (string)$data['description'] : null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Models;
|
||||
|
||||
/**
|
||||
* Information about the current bot, which is identified using an access token.
|
||||
*/
|
||||
final readonly class BotInfo extends AbstractModel
|
||||
{
|
||||
/**
|
||||
* @param int $user_id ID user
|
||||
* @param string $first_name User display name
|
||||
* @param string|null $last_name User's display last name
|
||||
* @param string|null $username Unique public name of the user, may be null if the user is not available or no name is set
|
||||
* @param bool $is_bot Is the user a bot
|
||||
* @param int $last_activity_time User last activity time in MAX (Unix time in milliseconds). May be irrelevant if the user has disabled the "online" status in the settings
|
||||
* @param string|null $description User description, may be null if the user has not filled it in (up to 16000 characters)
|
||||
* @param string|null $avatar_url Avatar URL
|
||||
* @param string|null $full_avatar_url Larger Avatar URL
|
||||
* @param BotCommand[]|null $commands Commands supported by the bot (up to 32 elements)
|
||||
*/
|
||||
public function __construct(
|
||||
public int $user_id,
|
||||
public string $first_name,
|
||||
public ?string $last_name,
|
||||
public ?string $username,
|
||||
public bool $is_bot,
|
||||
public int $last_activity_time,
|
||||
public ?string $description,
|
||||
public ?string $avatar_url,
|
||||
public ?string $full_avatar_url,
|
||||
public ?array $commands,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public static function fromArray(array $data): static
|
||||
{
|
||||
return new static(
|
||||
(int)$data['user_id'],
|
||||
(string)$data['first_name'],
|
||||
$data['last_name'] ? (string)$data['last_name'] : null,
|
||||
$data['username'] ? (string)$data['username'] : null,
|
||||
(bool)$data['is_bot'],
|
||||
(int)$data['last_activity_time'],
|
||||
$data['description'] ? (string)$data['description'] : null,
|
||||
$data['avatar_url'] ? (string)$data['avatar_url'] : null,
|
||||
$data['full_avatar_url'] ? (string)$data['full_avatar_url'] : null,
|
||||
isset($data['commands']) && is_array($data['commands']) ? $data['commands'] : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Client;
|
||||
use BushlanovDev\MaxMessengerBot\ClientApiInterface;
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
|
||||
#[CoversClass(Api::class)]
|
||||
#[UsesClass(BotInfo::class)]
|
||||
#[UsesClass(Client::class)]
|
||||
final class ApiTest extends TestCase
|
||||
{
|
||||
private MockObject&ClientApiInterface $clientMock;
|
||||
private MockObject&ModelFactory $modelFactoryMock;
|
||||
private Api $api;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->clientMock = $this->createMock(ClientApiInterface::class);
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
|
||||
$this->api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function constructorCanCreateDefaultDependencies(): void
|
||||
{
|
||||
$api = new Api('some-token');
|
||||
|
||||
$reflection = new ReflectionClass($api);
|
||||
|
||||
$clientProp = $reflection->getProperty('client');
|
||||
$clientProp->setAccessible(true);
|
||||
$this->assertInstanceOf(ClientApiInterface::class, $clientProp->getValue($api));
|
||||
|
||||
$factoryProp = $reflection->getProperty('modelFactory');
|
||||
$factoryProp->setAccessible(true);
|
||||
$this->assertInstanceOf(ModelFactory::class, $factoryProp->getValue($api));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getBotInfoCallsClientAndFactoryCorrectly(): void
|
||||
{
|
||||
$rawResponseData = ['user_id' => 123, 'first_name' => 'ApiTestBot'];
|
||||
|
||||
$expectedBotInfo = new BotInfo(
|
||||
123,
|
||||
'ApiTestBot',
|
||||
null, null, true, 0, null, null, null, null,
|
||||
);
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('request')
|
||||
->with('GET', '/me')
|
||||
->willReturn($rawResponseData);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createBotInfo')
|
||||
->with($rawResponseData)
|
||||
->willReturn($expectedBotInfo);
|
||||
|
||||
$result = $this->api->getBotInfo();
|
||||
|
||||
$this->assertSame($expectedBotInfo, $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Client;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\ForbiddenException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NotFoundException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\UnauthorizedException;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\Exception;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Http\Message\RequestFactoryInterface;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
#[CoversClass(Client::class)]
|
||||
final class ClientTest extends TestCase
|
||||
{
|
||||
private const string FAKE_TOKEN = '12345:abcdef';
|
||||
private const string API_VERSION = '0.0.6';
|
||||
private const string API_BASE_URL = 'https://botapi.max.ru';
|
||||
|
||||
private MockObject&ClientInterface $httpClientMock;
|
||||
private MockObject&RequestFactoryInterface $requestFactoryMock;
|
||||
private MockObject&StreamFactoryInterface $streamFactoryMock;
|
||||
private MockObject&RequestInterface $requestMock;
|
||||
private MockObject&ResponseInterface $responseMock;
|
||||
private MockObject&StreamInterface $streamMock;
|
||||
|
||||
private Client $client;
|
||||
|
||||
/**
|
||||
* This method is called before each test.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create mocks for all PSR interfaces
|
||||
$this->httpClientMock = $this->createMock(ClientInterface::class);
|
||||
$this->requestFactoryMock = $this->createMock(RequestFactoryInterface::class);
|
||||
$this->streamFactoryMock = $this->createMock(StreamFactoryInterface::class);
|
||||
$this->requestMock = $this->createMock(RequestInterface::class);
|
||||
$this->responseMock = $this->createMock(ResponseInterface::class);
|
||||
$this->streamMock = $this->createMock(StreamInterface::class);
|
||||
|
||||
// Common mock setups
|
||||
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
|
||||
$this->responseMock->method('getBody')->willReturn($this->streamMock);
|
||||
$this->httpClientMock->method('sendRequest')->willReturn($this->responseMock);
|
||||
$this->requestMock->method('withBody')->willReturn($this->requestMock);
|
||||
$this->requestMock->method('withHeader')->willReturn($this->requestMock);
|
||||
|
||||
// Instantiate the System Under Test (SUT)
|
||||
$this->client = new Client(
|
||||
self::FAKE_TOKEN,
|
||||
$this->httpClientMock,
|
||||
$this->requestFactoryMock,
|
||||
$this->streamFactoryMock,
|
||||
self::API_VERSION,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function constructorThrowsExceptionOnEmptyToken(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Access token cannot be empty.');
|
||||
|
||||
new Client('', $this->httpClientMock, $this->requestFactoryMock, $this->streamFactoryMock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successfulGetRequest(): void
|
||||
{
|
||||
$uri = '/me';
|
||||
$expectedUrl = self::API_BASE_URL . $uri . '?' . http_build_query([
|
||||
'access_token' => self::FAKE_TOKEN,
|
||||
'v' => self::API_VERSION,
|
||||
]);
|
||||
$responsePayload = ['ok' => true, 'result' => ['id' => 987, 'name' => 'TestBot']];
|
||||
|
||||
// Configure mocks for this specific test
|
||||
$this->requestFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createRequest')
|
||||
->with('GET', $expectedUrl)
|
||||
->willReturn($this->requestMock);
|
||||
|
||||
$this->httpClientMock
|
||||
->expects($this->once())
|
||||
->method('sendRequest')
|
||||
->with($this->requestMock)
|
||||
->willReturn($this->responseMock);
|
||||
|
||||
$this->responseMock->method('getStatusCode')->willReturn(200);
|
||||
$this->streamMock->method('__toString')->willReturn(json_encode($responsePayload));
|
||||
|
||||
// Execute and assert
|
||||
$result = $this->client->request('GET', $uri);
|
||||
$this->assertSame($responsePayload, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsNetworkExceptionOnClientError(): void
|
||||
{
|
||||
$this->expectException(NetworkException::class);
|
||||
|
||||
// Create a generic PSR-18 exception
|
||||
$psrException = new class extends \Exception implements ClientExceptionInterface {};
|
||||
|
||||
$this->httpClientMock
|
||||
->method('sendRequest')
|
||||
->willThrowException($psrException);
|
||||
|
||||
$this->client->request('GET', '/me');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsSerializationExceptionOnInvalidJsonResponse(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Failed to decode API response JSON.');
|
||||
|
||||
$this->responseMock->method('getStatusCode')->willReturn(200);
|
||||
$this->streamMock->method('__toString')->willReturn('{not-valid-json');
|
||||
|
||||
$this->client->request('GET', '/me');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsSerializationExceptionOnInvalidRequestBody(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Failed to encode request body to JSON.');
|
||||
|
||||
// \NAN cannot be encoded in JSON
|
||||
$invalidBody = ['value' => \NAN];
|
||||
|
||||
$this->client->request('POST', '/messages', [], $invalidBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for testing various API error status codes.
|
||||
*/
|
||||
public static function apiErrorProvider(): array
|
||||
{
|
||||
return [
|
||||
'401 Unauthorized' => [401, UnauthorizedException::class, 'verify.token', 'Invalid access_token'],
|
||||
'403 Forbidden' => [403, ForbiddenException::class, 'access.denied', 'You don\'t have permissions'],
|
||||
'404 Not Found' => [404, NotFoundException::class, 'not.found', 'Resource not found'],
|
||||
'400 Bad Request' => [400, ClientApiException::class, 'bad.request', 'Invalid parameters'],
|
||||
'503 Service Unavailable' => [
|
||||
503,
|
||||
ClientApiException::class,
|
||||
'service.unavailable',
|
||||
'Service is temporarily unavailable',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('apiErrorProvider')]
|
||||
public function throwsCorrectExceptionForApiErrorStatusCodes(
|
||||
int $statusCode,
|
||||
string $exceptionClass,
|
||||
string $errorCode,
|
||||
string $errorMessage,
|
||||
): void {
|
||||
$this->expectException($exceptionClass);
|
||||
$this->expectExceptionMessage($errorMessage);
|
||||
|
||||
$errorPayload = json_encode(['code' => $errorCode, 'message' => $errorMessage]);
|
||||
|
||||
$this->responseMock->method('getStatusCode')->willReturn($statusCode);
|
||||
$this->streamMock->method('__toString')->willReturn($errorPayload);
|
||||
|
||||
try {
|
||||
$this->client->request('GET', '/some/failing/endpoint');
|
||||
} catch (ClientApiException $e) {
|
||||
// Also assert the specific properties of our custom exception
|
||||
$this->assertSame($statusCode, $e->getHttpStatusCode());
|
||||
$this->assertSame($errorCode, $e->errorCode);
|
||||
$this->assertSame($this->responseMock, $e->response);
|
||||
throw $e; // Re-throw for PHPUnit to catch the expected exception type
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
use BushlanovDev\MaxMessengerBot\Models\BotCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(ModelFactory::class)]
|
||||
#[UsesClass(BotInfo::class)]
|
||||
#[UsesClass(BotCommand::class)]
|
||||
final class ModelFactoryTest extends TestCase
|
||||
{
|
||||
private ModelFactory $factory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->factory = new ModelFactory();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createBotInfoCorrectlyHydratesCommands(): void
|
||||
{
|
||||
$rawData = [
|
||||
'user_id' => 12345,
|
||||
'first_name' => 'Test',
|
||||
'last_name' => 'Bot',
|
||||
'username' => 'test_bot',
|
||||
'is_bot' => true,
|
||||
'last_activity_time' => 1678886400000,
|
||||
'description' => 'A test bot.',
|
||||
'avatar_url' => 'http://example.com/avatar.jpg',
|
||||
'full_avatar_url' => 'http://example.com/full_avatar.jpg',
|
||||
'commands' => [
|
||||
['name' => 'start', 'description' => 'Start the bot'],
|
||||
['name' => 'help', 'description' => 'Show help'],
|
||||
],
|
||||
];
|
||||
|
||||
$botInfo = $this->factory->createBotInfo($rawData);
|
||||
|
||||
$this->assertInstanceOf(BotInfo::class, $botInfo);
|
||||
$this->assertSame(12345, $botInfo->user_id);
|
||||
|
||||
$this->assertIsArray($botInfo->commands);
|
||||
$this->assertCount(2, $botInfo->commands);
|
||||
$this->assertInstanceOf(BotCommand::class, $botInfo->commands[0]);
|
||||
$this->assertSame('start', $botInfo->commands[0]->name);
|
||||
$this->assertInstanceOf(BotCommand::class, $botInfo->commands[1]);
|
||||
$this->assertSame('help', $botInfo->commands[1]->name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createBotInfoHandlesNullCommands(): void
|
||||
{
|
||||
$rawData = [
|
||||
'user_id' => 12345,
|
||||
'first_name' => 'Test',
|
||||
'last_name' => null,
|
||||
'username' => 'test_bot',
|
||||
'is_bot' => true,
|
||||
'last_activity_time' => 1678886400000,
|
||||
'description' => null,
|
||||
'avatar_url' => null,
|
||||
'full_avatar_url' => null,
|
||||
'commands' => null,
|
||||
];
|
||||
|
||||
$botInfo = $this->factory->createBotInfo($rawData);
|
||||
|
||||
$this->assertInstanceOf(BotInfo::class, $botInfo);
|
||||
$this->assertNull($botInfo->commands);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests\Models;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
|
||||
use BushlanovDev\MaxMessengerBot\Models\BotCommand;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(BotCommand::class)]
|
||||
#[UsesClass(AbstractModel::class)]
|
||||
final class BotCommandTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function canBeCreatedFromArrayWithAllData(): void
|
||||
{
|
||||
$data = [
|
||||
'name' => 'start',
|
||||
'description' => 'Start the bot',
|
||||
];
|
||||
|
||||
$command = BotCommand::fromArray($data);
|
||||
|
||||
$this->assertInstanceOf(BotCommand::class, $command);
|
||||
$this->assertSame('start', $command->name);
|
||||
$this->assertSame('Start the bot', $command->description);
|
||||
|
||||
$arrayResult = $command->toArray();
|
||||
|
||||
$this->assertIsArray($arrayResult);
|
||||
$this->assertSame($data, $arrayResult);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function canBeCreatedFromArrayWithOptionalDataNull(): void
|
||||
{
|
||||
$data = [
|
||||
'name' => 'help',
|
||||
'description' => null,
|
||||
];
|
||||
|
||||
$command = BotCommand::fromArray($data);
|
||||
|
||||
$this->assertInstanceOf(BotCommand::class, $command);
|
||||
$this->assertSame('help', $command->name);
|
||||
$this->assertNull($command->description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests\Models;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Models\BotCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(BotInfo::class)]
|
||||
#[UsesClass(BotCommand::class)]
|
||||
final class BotInfoTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function canBeCreatedFromArray(): void
|
||||
{
|
||||
$data = [
|
||||
'user_id' => 12345,
|
||||
'first_name' => 'Test',
|
||||
'last_name' => 'Bot',
|
||||
'username' => 'test_bot',
|
||||
'is_bot' => true,
|
||||
'last_activity_time' => 1678886400000,
|
||||
'description' => 'A test bot.',
|
||||
'avatar_url' => 'http://example.com/avatar.jpg',
|
||||
'full_avatar_url' => 'http://example.com/full_avatar.jpg',
|
||||
'commands' => [
|
||||
new BotCommand('start', 'Start the bot'),
|
||||
new BotCommand('help', 'Show help'),
|
||||
],
|
||||
];
|
||||
|
||||
$botInfo = BotInfo::fromArray($data);
|
||||
|
||||
$this->assertInstanceOf(BotInfo::class, $botInfo);
|
||||
$this->assertSame(12345, $botInfo->user_id);
|
||||
$this->assertSame('Test', $botInfo->first_name);
|
||||
$this->assertTrue($botInfo->is_bot);
|
||||
$this->assertCount(2, $botInfo->commands);
|
||||
$this->assertInstanceOf(BotCommand::class, $botInfo->commands[0]);
|
||||
$this->assertSame('start', $botInfo->commands[0]->name);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user