From 69e1e73c07fd0655d1704a32120ddb0c9d72f6bf Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 22 Jul 2025 19:15:05 +0300 Subject: [PATCH] Added WebhookHandler --- composer.json | 19 ++- src/Api.php | 42 ++++-- src/Client.php | 14 +- src/Exceptions/SecurityException.php | 11 ++ src/WebhookHandler.php | 171 ++++++++++++++++++++++ tests/WebhookHandlerTest.php | 207 +++++++++++++++++++++++++++ 6 files changed, 439 insertions(+), 25 deletions(-) create mode 100644 src/Exceptions/SecurityException.php create mode 100644 src/WebhookHandler.php create mode 100644 tests/WebhookHandlerTest.php diff --git a/composer.json b/composer.json index e15f04d..d318b2c 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/src/Api.php b/src/Api.php index c5d9d0f..ebe4197 100644 --- a/src/Api.php +++ b/src/Api.php @@ -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. * diff --git a/src/Client.php b/src/Client.php index 20e90ae..8531093 100644 --- a/src/Client.php +++ b/src/Client.php @@ -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.'); diff --git a/src/Exceptions/SecurityException.php b/src/Exceptions/SecurityException.php new file mode 100644 index 0000000..1a3e6d4 --- /dev/null +++ b/src/Exceptions/SecurityException.php @@ -0,0 +1,11 @@ + + */ + 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.'); + } + } +} diff --git a/tests/WebhookHandlerTest.php b/tests/WebhookHandlerTest.php new file mode 100644 index 0000000..e840242 --- /dev/null +++ b/tests/WebhookHandlerTest.php @@ -0,0 +1,207 @@ +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); + } +}