Refactoring update dispatchers

This commit is contained in:
Alex
2025-08-08 22:51:35 +03:00
parent 1b00bae23a
commit 9cd5dd2c48
9 changed files with 818 additions and 757 deletions
+32 -110
View File
@@ -10,7 +10,6 @@ use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Enums\UploadType;
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\AbstractAttachmentRequest;
@@ -31,12 +30,10 @@ use BushlanovDev\MaxMessengerBot\Models\MessageLink;
use BushlanovDev\MaxMessengerBot\Models\Result;
use BushlanovDev\MaxMessengerBot\Models\Subscription;
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
use InvalidArgumentException;
use LogicException;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use ReflectionException;
@@ -83,13 +80,16 @@ class Api
private readonly LoggerInterface $logger;
private readonly UpdateDispatcher $updateDispatcher;
/**
* Api constructor.
*
* @param string $accessToken Your bot's access token from @MasterBot.
* @param ClientApiInterface|null $client Http api client.
* @param ModelFactory|null $modelFactory
* @param LoggerInterface|null $logger
* @param ModelFactory|null $modelFactory The model factory.
* @param LoggerInterface|null $logger PSR LoggerInterface.
* @param UpdateDispatcher|null $updateDispatcher The update dispatcher.
*
* @throws InvalidArgumentException
*/
@@ -98,6 +98,7 @@ class Api
?ClientApiInterface $client = null,
?ModelFactory $modelFactory = null,
?LoggerInterface $logger = null,
?UpdateDispatcher $updateDispatcher = null,
) {
$this->logger = $logger ?? new NullLogger();
@@ -129,6 +130,7 @@ class Api
$this->client = $client;
$this->modelFactory = $modelFactory ?? new ModelFactory();
$this->updateDispatcher = $updateDispatcher ?? new UpdateDispatcher($this);
}
/**
@@ -150,68 +152,46 @@ class Api
return $this->client->request($method, $uri, $queryParams, $body);
}
/**
* Gets the central update dispatcher instance. Use this to register your event and command handlers.
*
* @return UpdateDispatcher
* @codeCoverageIgnore
*/
public function getUpdateDispatcher(): UpdateDispatcher
{
return $this->updateDispatcher;
}
/**
* 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, $this->logger);
return new WebhookHandler(
$this->updateDispatcher,
$this->modelFactory,
$this->logger,
$secret,
);
}
/**
* Parses an incoming webhook request and returns a single Update object.
* This is an alternative to the event-driven WebhookHandler::handle() method,
* allowing for manual processing of updates.
* Creates a LongPollingHandler instance, pre-configured for running a long-polling loop.
*
* @param string|null $secret The secret key to verify the request signature.
* @param ServerRequestInterface|null $request The PSR-7 request object. If null, it's created from globals.
*
* @return AbstractUpdate The parsed update object (e.g., MessageCreatedUpdate).
* @throws \ReflectionException
* @throws SecurityException
* @throws SerializationException
* @throws \LogicException
* @return LongPollingHandler
*/
public function getWebhookUpdate(?string $secret = null, ?ServerRequestInterface $request = null): AbstractUpdate
public function createLongPollingHandler(): LongPollingHandler
{
return $this->createWebhookHandler($secret)->getUpdate($request);
}
/**
* A simple way to process a single incoming webhook request using callbacks.
* This method creates a WebhookHandler, registers the provided callbacks, and processes the request.
*
* @param array<string, callable> $handlers An associative array where keys are UpdateType string values
* (e.g., UpdateType::MessageCreated->value) and values are handlers.
* @param string|null $secret The secret key for request verification.
* @param ServerRequestInterface|null $request The PSR-7 request object.
*
* @throws SecurityException
* @throws SerializationException
* @throws ReflectionException
* @throws LogicException
*/
public function handleWebhooks(
array $handlers,
?string $secret = null,
?ServerRequestInterface $request = null,
): void {
$webhookHandler = $this->createWebhookHandler($secret);
foreach ($handlers as $updateType => $callback) {
$updateType = UpdateType::tryFrom($updateType);
// @phpstan-ignore-next-line
if ($updateType && is_callable($callback)) {
$webhookHandler->addHandler($updateType, $callback);
}
}
$webhookHandler->handle($request);
return new LongPollingHandler(
$this,
$this->updateDispatcher,
$this->logger,
);
}
/**
@@ -251,64 +231,6 @@ class Api
);
}
/**
* Starts a long-polling loop to process updates using callbacks.
* This method will run indefinitely until the script is terminated.
*
* @param array<string, callable> $handlers An associative array where keys are UpdateType enums
* and values are the corresponding handler functions.
* @param int|null $timeout Timeout in seconds for long polling (0-90). Defaults to 90.
* @param int|null $marker Pass `null` to get updates you didn't get yet.
*/
public function handleUpdates(array $handlers, ?int $timeout = null, ?int $marker = null): void
{
// @phpstan-ignore-next-line
while (true) {
try {
$this->processUpdatesBatch($handlers, $timeout, $marker);
} catch (NetworkException $e) {
$this->logger->error(
'Long-polling network error: {message}',
['message' => $e->getMessage(), 'exception' => $e],
);
sleep(5);
} catch (\Exception $e) {
$this->logger->error(
'An error occurred during long-polling: {message}',
['message' => $e->getMessage(), 'exception' => $e],
);
sleep(1);
}
}
}
/**
* Processes a single batch of updates. This is the core logic used by handleUpdates().
* Useful for custom loop implementations or for testing.
*
* @param array<string, callable> $handlers An associative array of update handlers.
* @param int|null $timeout Timeout for the getUpdates call.
* @param int|null $marker The marker for which updates to fetch.
*
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function processUpdatesBatch(array $handlers, ?int $timeout, ?int &$marker = null): void
{
$updateList = $this->getUpdates(timeout: $timeout, marker: $marker);
foreach ($updateList->updates as $update) {
$handler = $handlers[$update->updateType->value] ?? null;
if ($handler) {
$handler($update, $this);
}
}
$marker = $updateList->marker;
}
/**
* Information about the current bot, identified by an access token.
*
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot;
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
use Psr\Log\LoggerInterface;
/**
* Handles receiving updates via long polling.
*/
final readonly class LongPollingHandler
{
/**
* @param Api $api
* @param UpdateDispatcher $dispatcher The update dispatcher.
* @param LoggerInterface $logger PSR LoggerInterface.
*/
public function __construct(
private Api $api,
private UpdateDispatcher $dispatcher,
private LoggerInterface $logger,
) {
}
/**
* Processes a single batch of updates. Useful for custom loop implementations or for testing.
*
* @param int $timeout Timeout for the getUpdates call.
* @param int|null $marker The marker for which updates to fetch.
* @return int|null The new marker to be used for the next iteration.
* @throws \Exception Re-throws exceptions from the API or dispatcher.
*/
public function processSingleBatch(int $timeout, ?int $marker): ?int
{
$updateList = $this->api->getUpdates(timeout: $timeout, marker: $marker);
foreach ($updateList->updates as $update) {
$this->dispatcher->dispatch($update);
}
return $updateList->marker;
}
/**
* Starts a long-polling loop to process updates.
* This method will run indefinitely until the script is terminated.
*
* @param int $timeout Timeout in seconds for long polling (0-90).
* @param int|null $marker Initial marker. Pass `null` to get updates you didn't get yet.
*/
public function handle(int $timeout = 90, ?int $marker = null): void
{
// @phpstan-ignore-next-line
while (true) {
try {
$marker = $this->processSingleBatch($timeout, $marker);
} catch (NetworkException $e) {
$this->logger->error(
'Long-polling network error: {message}',
['message' => $e->getMessage(), 'exception' => $e],
);
sleep(5);
} catch (\Exception $e) {
$this->logger->error(
'An error occurred during long-polling: {message}',
['message' => $e->getMessage(), 'exception' => $e],
);
sleep(1);
}
}
}
}
+231
View File
@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
/**
* Dispatches updates to registered handlers. Supports handling specific update types and text commands.
*/
final class UpdateDispatcher
{
/**
* @var array<string, callable>
*/
private array $handlers = [];
/**
* @var array<string, callable>
*/
private array $commandHandlers = [];
/**
* @param Api $api
*/
public function __construct(private readonly Api $api)
{
}
/**
* 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.
*
* @return $this
*/
public function addHandler(UpdateType $type, callable $handler): self
{
$this->handlers[$type->value] = $handler;
return $this;
}
/**
* Registers a handler for a text command without a command prefix "/" (e.g., "start").
* The command must be the first word in a message.
*
* @param string $command The command string (e.g., "start").
* @param callable(MessageCreatedUpdate, Api): void $handler The handler to execute.
*
* @return $this
*/
public function onCommand(string $command, callable $handler): self
{
$this->commandHandlers[$command] = $handler;
return $this;
}
/**
* Dispatches a parsed Update object to its registered handler.
* Command handlers are prioritized over generic message handlers.
*
* @param AbstractUpdate $update The update object to dispatch.
*/
public function dispatch(AbstractUpdate $update): void
{
if ($update instanceof MessageCreatedUpdate && $update->message->body?->text) {
$text = $update->message->body->text;
$parts = explode(' ', trim($text));
$command = $parts[0];
if (isset($this->commandHandlers[$command])) {
$this->commandHandlers[$command]($update, $this->api);
return;
}
}
$handler = $this->handlers[$update->updateType->value] ?? null;
if ($handler) {
$handler($update, $this->api);
}
}
/**
* A convenient alias for addHandler(UpdateType::MessageCreated, $handler).
*
* @param callable(Models\Updates\MessageCreatedUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onMessageCreated(callable $handler): self
{
return $this->addHandler(UpdateType::MessageCreated, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::MessageCallback, $handler).
*
* @param callable(Models\Updates\MessageCallbackUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onMessageCallback(callable $handler): self
{
return $this->addHandler(UpdateType::MessageCallback, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::MessageEdited, $handler).
*
* @param callable(Models\Updates\MessageEditedUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onMessageEdited(callable $handler): self
{
return $this->addHandler(UpdateType::MessageEdited, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::MessageRemoved, $handler).
*
* @param callable(Models\Updates\MessageRemovedUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onMessageRemoved(callable $handler): self
{
return $this->addHandler(UpdateType::MessageRemoved, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::BotAdded, $handler).
*
* @param callable(Models\Updates\BotAddedToChatUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onBotAdded(callable $handler): self
{
return $this->addHandler(UpdateType::BotAdded, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::BotRemoved, $handler).
*
* @param callable(Models\Updates\BotRemovedFromChatUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onBotRemoved(callable $handler): self
{
return $this->addHandler(UpdateType::BotRemoved, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::UserAdded, $handler).
*
* @param callable(Models\Updates\UserAddedToChatUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onUserAdded(callable $handler): self
{
return $this->addHandler(UpdateType::UserAdded, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::UserRemoved, $handler).
*
* @param callable(Models\Updates\UserRemovedFromChatUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onUserRemoved(callable $handler): self
{
return $this->addHandler(UpdateType::UserRemoved, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::BotStarted, $handler).
*
* @param callable(Models\Updates\BotStartedUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onBotStarted(callable $handler): self
{
return $this->addHandler(UpdateType::BotStarted, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::ChatTitleChanged, $handler).
*
* @param callable(Models\Updates\ChatTitleChangedUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onChatTitleChanged(callable $handler): self
{
return $this->addHandler(UpdateType::ChatTitleChanged, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::MessageChatCreated, $handler).
*
* @param callable(Models\Updates\MessageChatCreatedUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onMessageChatCreated(callable $handler): self
{
return $this->addHandler(UpdateType::MessageChatCreated, $handler);
}
}
+18 -230
View File
@@ -4,205 +4,37 @@ 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;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* 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.
* It verifies the request's authenticity, parses it, and uses an UpdateDispatcher
* to route it to the appropriate handler.
*/
final class WebhookHandler
final readonly 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.
* @param LoggerInterface $logger A PSR-3 compatible logger.
* @param UpdateDispatcher $dispatcher The update dispatcher.
* @param ModelFactory $modelFactory The model factory.
* @param LoggerInterface $logger PSR LoggerInterface.
* @param string|null $secret The secret key for request verification.
*/
public function __construct(
private readonly Api $api,
private readonly ModelFactory $modelFactory,
private readonly ?string $secret = null,
private readonly LoggerInterface $logger = new NullLogger(),
private UpdateDispatcher $dispatcher,
private ModelFactory $modelFactory,
private LoggerInterface $logger,
private ?string $secret,
) {
}
/**
* 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 WebhookHandler
*/
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 WebhookHandler
*/
public function onMessageCreated(callable $handler): self
{
return $this->addHandler(UpdateType::MessageCreated, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::MessageCallback, $handler).
*
* @param callable(Models\Updates\MessageCallbackUpdate, Api): void $handler
*
* @return WebhookHandler
* @codeCoverageIgnore
*/
public function onMessageCallback(callable $handler): self
{
return $this->addHandler(UpdateType::MessageCallback, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::MessageEdited, $handler).
*
* @param callable(Models\Updates\MessageEditedUpdate, Api): void $handler
*
* @return WebhookHandler
* @codeCoverageIgnore
*/
public function onMessageEdited(callable $handler): self
{
return $this->addHandler(UpdateType::MessageEdited, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::MessageRemoved, $handler).
*
* @param callable(Models\Updates\MessageRemovedUpdate, Api): void $handler
*
* @return WebhookHandler
* @codeCoverageIgnore
*/
public function onMessageRemoved(callable $handler): self
{
return $this->addHandler(UpdateType::MessageRemoved, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::BotAdded, $handler).
*
* @param callable(Models\Updates\BotAddedToChatUpdate, Api): void $handler
*
* @return WebhookHandler
* @codeCoverageIgnore
*/
public function onBotAdded(callable $handler): self
{
return $this->addHandler(UpdateType::BotAdded, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::BotRemoved, $handler).
*
* @param callable(Models\Updates\BotRemovedFromChatUpdate, Api): void $handler
*
* @return WebhookHandler
* @codeCoverageIgnore
*/
public function onBotRemoved(callable $handler): self
{
return $this->addHandler(UpdateType::BotRemoved, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::UserAdded, $handler).
*
* @param callable(Models\Updates\UserAddedToChatUpdate, Api): void $handler
*
* @return WebhookHandler
* @codeCoverageIgnore
*/
public function onUserAdded(callable $handler): self
{
return $this->addHandler(UpdateType::UserAdded, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::UserRemoved, $handler).
*
* @param callable(Models\Updates\UserRemovedFromChatUpdate, Api): void $handler
*
* @return WebhookHandler
* @codeCoverageIgnore
*/
public function onUserRemoved(callable $handler): self
{
return $this->addHandler(UpdateType::UserRemoved, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::BotStarted, $handler).
*
* @param callable(Models\Updates\BotStartedUpdate, Api): void $handler
*
* @return WebhookHandler
* @codeCoverageIgnore
*/
public function onBotStarted(callable $handler): self
{
return $this->addHandler(UpdateType::BotStarted, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::ChatTitleChanged, $handler).
*
* @param callable(Models\Updates\ChatTitleChangedUpdate, Api): void $handler
*
* @return WebhookHandler
* @codeCoverageIgnore
*/
public function onChatTitleChanged(callable $handler): self
{
return $this->addHandler(UpdateType::ChatTitleChanged, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::MessageChatCreated, $handler).
*
* @param callable(Models\Updates\MessageChatCreatedUpdate, Api): void $handler
*
* @return WebhookHandler
* @codeCoverageIgnore
*/
public function onMessageChatCreated(callable $handler): self
{
return $this->addHandler(UpdateType::MessageChatCreated, $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.
* It reads the HTTP request, verifies, parses, and dispatches the update.
*
* @param ServerRequestInterface|null $request The Psr7 HTTP request to process.
* @param ServerRequestInterface|null $request The PSR-7 HTTP request. If null, created from globals.
*
* @throws \ReflectionException
* @throws SecurityException
@@ -210,24 +42,6 @@ final class WebhookHandler
* @throws \LogicException
*/
public function handle(?ServerRequestInterface $request = null): void
{
$this->dispatch($this->getUpdate($request));
http_response_code(200);
}
/**
* Parses the raw request data and returns a typed Update object.
*
* @param ServerRequestInterface|null $request The Psr7 HTTP request to process.
*
* @return AbstractUpdate
* @throws \ReflectionException
* @throws SecurityException
* @throws SerializationException
* @throws \LogicException
*/
public function getUpdate(?ServerRequestInterface $request = null): AbstractUpdate
{
if ($request === null) {
if (!class_exists(\GuzzleHttp\Psr7\ServerRequest::class)) {
@@ -239,32 +53,14 @@ final class WebhookHandler
$request = \GuzzleHttp\Psr7\ServerRequest::fromGlobals();
}
return $this->parseUpdate($request);
}
/**
* 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');
$this->logger->debug('Received webhook payload', ['body' => $payload]);
if (empty($payload)) {
throw new SerializationException('Webhook body is empty.');
}
$this->verifySignature($signature);
$this->verifySignature($request->getHeaderLine('X-Max-Bot-Api-Secret'));
try {
$data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
@@ -273,20 +69,12 @@ final class WebhookHandler
throw new SerializationException('Failed to decode webhook body as JSON.', 0, $e);
}
return $this->modelFactory->createUpdate($data);
}
$update = $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;
$this->dispatcher->dispatch($update);
if ($handler) {
$handler($update, $this->api);
if (!headers_sent()) {
http_response_code(200);
}
}