mirror of
https://github.com/BushlanovDev/max-bot-api-client-php.git
synced 2026-10-12 23:09:05 +00:00
Added WebhookHandler
This commit is contained in:
+9
-10
@@ -1,26 +1,25 @@
|
||||
{
|
||||
"name": "bushlanov-dev/max-bot-api-client-php",
|
||||
"description": "",
|
||||
"keywords": [
|
||||
"max messenger",
|
||||
"bot",
|
||||
"max",
|
||||
"api"
|
||||
],
|
||||
"type": "project",
|
||||
"description": "Max Bot API Client library",
|
||||
"keywords": ["max messenger", "bot", "max", "api"],
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Aleksandr Bushlanov",
|
||||
"email": "alex@bushlanov.dev"
|
||||
"email": "alex@bushlanov.dev",
|
||||
"homepage": "https://bushlanov.dev",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.3",
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/guzzle": "^6.5.8||^7.0",
|
||||
"guzzlehttp/psr7": "^1.8||^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"guzzlehttp/guzzle": "^6.0|^7.0"
|
||||
"psr/http-message": "^1.0||^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"roave/security-advisories": "dev-latest",
|
||||
|
||||
+34
-8
@@ -66,17 +66,43 @@ class Api
|
||||
?ClientApiInterface $client = null,
|
||||
?ModelFactory $modelFactory = null
|
||||
) {
|
||||
$this->client = $client ?? new Client(
|
||||
$accessToken,
|
||||
new \GuzzleHttp\Client(),
|
||||
new \GuzzleHttp\Psr7\HttpFactory(),
|
||||
new \GuzzleHttp\Psr7\HttpFactory(),
|
||||
self::API_BASE_URL,
|
||||
self::API_VERSION,
|
||||
);
|
||||
if ($client === null) {
|
||||
if (!class_exists(\GuzzleHttp\Client::class) || !class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) {
|
||||
throw new LogicException(
|
||||
'No client was provided and "guzzlehttp/guzzle" is not found. ' .
|
||||
'Please run "composer require guzzlehttp/guzzle" or create and pass your own implementation of ClientApiInterface.'
|
||||
);
|
||||
}
|
||||
|
||||
$guzzle = new \GuzzleHttp\Client();
|
||||
$httpFactory = new \GuzzleHttp\Psr7\HttpFactory();
|
||||
$client = new Client(
|
||||
$accessToken,
|
||||
$guzzle,
|
||||
$httpFactory,
|
||||
$httpFactory,
|
||||
self::API_BASE_URL,
|
||||
self::API_VERSION,
|
||||
);
|
||||
}
|
||||
|
||||
$this->client = $client;
|
||||
$this->modelFactory = $modelFactory ?? new ModelFactory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a WebhookHandler instance, pre-configured with the necessary dependencies.
|
||||
*
|
||||
* @param string|null $secret The secret key for request verification.
|
||||
* Should be the same one you used when calling the subscribe() method.
|
||||
*
|
||||
* @return WebhookHandler
|
||||
*/
|
||||
public function createWebhookHandler(?string $secret = null): WebhookHandler
|
||||
{
|
||||
return new WebhookHandler($this, $this->modelFactory, $secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about the current bot, identified by an access token.
|
||||
*
|
||||
|
||||
+7
-7
@@ -25,7 +25,7 @@ use Psr\Http\Message\StreamFactoryInterface;
|
||||
* 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
|
||||
final readonly class Client implements ClientApiInterface
|
||||
{
|
||||
/**
|
||||
* @param string $accessToken Your bot's access token from @MasterBot.
|
||||
@@ -38,12 +38,12 @@ final class Client implements ClientApiInterface
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $accessToken,
|
||||
private readonly ClientInterface $httpClient,
|
||||
private readonly RequestFactoryInterface $requestFactory,
|
||||
private readonly StreamFactoryInterface $streamFactory,
|
||||
private readonly string $baseUrl,
|
||||
private readonly ?string $apiVersion = null,
|
||||
private string $accessToken,
|
||||
private ClientInterface $httpClient,
|
||||
private RequestFactoryInterface $requestFactory,
|
||||
private StreamFactoryInterface $streamFactory,
|
||||
private string $baseUrl,
|
||||
private ?string $apiVersion = null,
|
||||
) {
|
||||
if (empty($accessToken)) {
|
||||
throw new InvalidArgumentException('Access token cannot be empty.');
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Exceptions;
|
||||
|
||||
use LogicException;
|
||||
|
||||
class SecurityException extends LogicException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* A class designed to process incoming webhook requests from the Max API.
|
||||
* It verifies the request's authenticity, parses it, and dispatches it
|
||||
* to the appropriate registered event handler.
|
||||
*/
|
||||
final class WebhookHandler
|
||||
{
|
||||
/**
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
private array $handlers = [];
|
||||
|
||||
/**
|
||||
* @param Api $api An instance of the Api to be passed to handlers for immediate responses.
|
||||
* @param ModelFactory $modelFactory An instance of the model factory to create Update objects.
|
||||
* @param string|null $secret The secret key provided during webhook subscription to verify requests.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly Api $api,
|
||||
private readonly ModelFactory $modelFactory,
|
||||
private readonly ?string $secret = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for a specific update type.
|
||||
*
|
||||
* @param UpdateType $type The type of update to handle.
|
||||
* @param callable $handler The function to execute when the update is received.
|
||||
* The handler will receive the specific Update object (e.g., MessageCreatedUpdate) and the Api instance.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function addHandler(UpdateType $type, callable $handler): self
|
||||
{
|
||||
$this->handlers[$type->value] = $handler;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageCreated, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageCreatedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function onMessageCreated(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageCreated, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotStarted, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotStartedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function onBotStarted(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotStarted, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an incoming webhook request.
|
||||
* This is the main entry point. It reads the HTTP request body and headers,
|
||||
* verifies the signature, parses the update, and calls the appropriate handler.
|
||||
* It automatically sends the correct HTTP response code.
|
||||
*
|
||||
* @param ServerRequestInterface|null $request The Psr7 HTTP request to process.
|
||||
*
|
||||
* @throws \ReflectionException
|
||||
* @throws SecurityException
|
||||
* @throws SerializationException
|
||||
* @throws \LogicException
|
||||
*/
|
||||
public function handle(?ServerRequestInterface $request = null): void
|
||||
{
|
||||
if ($request === null) {
|
||||
if (!class_exists(\GuzzleHttp\Psr7\ServerRequest::class)) {
|
||||
throw new \LogicException(
|
||||
'No ServerRequest was provided and "guzzlehttp/psr7" is not found. ' .
|
||||
'Please run "composer require guzzlehttp/psr7" or create and pass your own PSR-7 request object.',
|
||||
);
|
||||
}
|
||||
$request = \GuzzleHttp\Psr7\ServerRequest::fromGlobals();
|
||||
}
|
||||
|
||||
|
||||
$update = $this->parseUpdate($request);
|
||||
$this->dispatch($update);
|
||||
|
||||
http_response_code(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw request data and returns a typed Update object.
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
*
|
||||
* @return AbstractUpdate
|
||||
* @throws \ReflectionException
|
||||
* @throws SecurityException
|
||||
* @throws SerializationException
|
||||
* @throws \LogicException
|
||||
*/
|
||||
public function parseUpdate(ServerRequestInterface $request): AbstractUpdate
|
||||
{
|
||||
$payload = (string)$request->getBody();
|
||||
$signature = $request->getHeaderLine('X-Max-Bot-Api-Secret');
|
||||
|
||||
if (empty($payload)) {
|
||||
throw new SerializationException('Webhook body is empty.');
|
||||
}
|
||||
|
||||
$this->verifySignature($signature);
|
||||
|
||||
try {
|
||||
$data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
throw new SerializationException('Failed to decode webhook body as JSON.', 0, $e);
|
||||
}
|
||||
|
||||
return $this->modelFactory->createUpdate($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a parsed Update object to its registered handler.
|
||||
*
|
||||
* @param AbstractUpdate $update
|
||||
*/
|
||||
public function dispatch(AbstractUpdate $update): void
|
||||
{
|
||||
$handler = $this->handlers[$update->updateType->value] ?? null;
|
||||
|
||||
if ($handler) {
|
||||
$handler($update, $this->api);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the 'X-Max-Bot-Api-Secret' header if a secret is configured.
|
||||
*
|
||||
* @param string $signature
|
||||
*
|
||||
* @throws SecurityException
|
||||
*/
|
||||
private function verifySignature(string $signature): void
|
||||
{
|
||||
if ($this->secret === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hash_equals($this->secret, $signature)) {
|
||||
throw new SecurityException('Signature verification failed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\ChatType;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Message;
|
||||
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Recipient;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(WebhookHandler::class)]
|
||||
#[UsesClass(Message::class)]
|
||||
#[UsesClass(MessageBody::class)]
|
||||
#[UsesClass(Recipient::class)]
|
||||
#[UsesClass(AbstractUpdate::class)]
|
||||
#[UsesClass(MessageCreatedUpdate::class)]
|
||||
final class WebhookHandlerTest extends TestCase
|
||||
{
|
||||
private MockObject&Api $apiMock;
|
||||
private MockObject&ModelFactory $modelFactoryMock;
|
||||
private const string SECRET = 'my-super-secret-key';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->apiMock = $this->createMock(Api::class);
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
}
|
||||
|
||||
private function createValidUpdatePayload(): string
|
||||
{
|
||||
return json_encode([
|
||||
'update_type' => 'message_created',
|
||||
'timestamp' => 1678886400,
|
||||
'message' => [
|
||||
'timestamp' => 1678886400,
|
||||
'body' => ['mid' => 'm.123', 'seq' => 1, 'text' => 'Hello World'],
|
||||
'recipient' => ['chat_type' => 'dialog', 'chat_id' => 101, 'user_id' => 101],
|
||||
'sender' => null,
|
||||
'url' => null,
|
||||
],
|
||||
'user_locale' => 'ru-RU',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createRealUpdateObject(array $data): MessageCreatedUpdate
|
||||
{
|
||||
$messageBody = new MessageBody(
|
||||
$data['message']['body']['mid'],
|
||||
$data['message']['body']['seq'],
|
||||
$data['message']['body']['text']
|
||||
);
|
||||
$recipient = new Recipient(
|
||||
ChatType::from($data['message']['recipient']['chat_type']),
|
||||
$data['message']['recipient']['user_id'],
|
||||
$data['message']['recipient']['chat_id']
|
||||
);
|
||||
$message = new Message(
|
||||
$data['message']['timestamp'],
|
||||
$messageBody,
|
||||
$recipient,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
return new MessageCreatedUpdate(
|
||||
$data['timestamp'],
|
||||
$message,
|
||||
$data['user_locale']
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleMethodProcessesPsr7RequestAndDispatches(): void
|
||||
{
|
||||
$payload = $this->createValidUpdatePayload();
|
||||
$signature = self::SECRET;
|
||||
$updateData = json_decode($payload, true);
|
||||
$expectedUpdate = $this->createRealUpdateObject($updateData);
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => $signature], $payload
|
||||
);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->with($updateData)
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$handlerWasCalled = false;
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, self::SECRET);
|
||||
$webhookHandler->onMessageCreated(function (AbstractUpdate $update) use (&$handlerWasCalled, $expectedUpdate) {
|
||||
$this->assertSame($expectedUpdate, $update);
|
||||
$handlerWasCalled = true;
|
||||
});
|
||||
|
||||
$webhookHandler->handle($request);
|
||||
|
||||
$this->assertTrue($handlerWasCalled, 'The registered handler was not dispatched from handle() method.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dispatchCallsCorrectHandlerForRegisteredEvent(): void
|
||||
{
|
||||
$handlerWasCalled = false;
|
||||
$updateData = json_decode($this->createValidUpdatePayload(), true);
|
||||
$testUpdate = $this->createRealUpdateObject($updateData);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
|
||||
$webhookHandler->onMessageCreated(
|
||||
function (MessageCreatedUpdate $update, Api $api) use (&$handlerWasCalled, $testUpdate) {
|
||||
$this->assertSame($testUpdate, $update);
|
||||
$this->assertSame($this->apiMock, $api);
|
||||
$handlerWasCalled = true;
|
||||
}
|
||||
);
|
||||
|
||||
$webhookHandler->dispatch($testUpdate);
|
||||
|
||||
$this->assertTrue($handlerWasCalled, 'The registered handler for onMessageCreated was not called.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dispatchDoesNothingForUnregisteredEvent(): void
|
||||
{
|
||||
$updateData = json_decode($this->createValidUpdatePayload(), true);
|
||||
$testUpdate = $this->createRealUpdateObject($updateData);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
|
||||
$webhookHandler->dispatch($testUpdate);
|
||||
|
||||
$this->expectNotToPerformAssertions();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForEmptyPayload(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Webhook body is empty.');
|
||||
|
||||
$request = new ServerRequest('POST', '/webhook', [], '');
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForInvalidJson(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Failed to decode webhook body as JSON.');
|
||||
|
||||
$request = new ServerRequest('POST', '/webhook', [], '{invalid-json');
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForInvalidSignature(): void
|
||||
{
|
||||
$this->expectException(SecurityException::class);
|
||||
$this->expectExceptionMessage('Signature verification failed.');
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => 'wrong-signature'], $this->createValidUpdatePayload()
|
||||
);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, self::SECRET);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function signatureVerificationIsSkippedWhenNoSecretIsConfigured(): void
|
||||
{
|
||||
$payload = $this->createValidUpdatePayload();
|
||||
$updateData = json_decode($payload, true);
|
||||
$expectedUpdate = $this->createRealUpdateObject($updateData);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => 'any-signature-or-empty'], $payload
|
||||
);
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, null);
|
||||
|
||||
$result = $webhookHandler->parseUpdate($request);
|
||||
|
||||
$this->assertSame($expectedUpdate, $result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user