From 06492a698fe98585408ac99c8dfc25e39e8f734d Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 25 Jul 2025 08:34:38 +0300 Subject: [PATCH] Added BotRemovedFromChatUpdate & ChatTitleChangedUpdate & MessageChatCreatedUpdate & UserAddedToChatUpdate & UserRemovedFromChatUpdate --- README.md | 51 +++++ src/Api.php | 180 +++++++++++++---- src/ModelFactory.php | 10 + .../Updates/BotRemovedFromChatUpdate.php | 29 +++ src/Models/Updates/ChatTitleChangedUpdate.php | 29 +++ .../Updates/MessageChatCreatedUpdate.php | 30 +++ src/Models/Updates/UserAddedToChatUpdate.php | 32 +++ .../Updates/UserRemovedFromChatUpdate.php | 32 +++ src/WebhookHandler.php | 90 ++++++++- tests/ApiTest.php | 184 ++++++++++++++++++ .../Updates/BotRemovedFromChatUpdateTest.php | 74 +++++++ .../Updates/ChatTitleChangedUpdateTest.php | 51 +++++ .../Updates/MessageChatCreatedUpdateTest.php | 76 ++++++++ .../Updates/UserAddedToChatUpdateTest.php | 83 ++++++++ .../Updates/UserRemovedFromChatUpdateTest.php | 83 ++++++++ tests/WebhookHandlerTest.php | 21 ++ 16 files changed, 1013 insertions(+), 42 deletions(-) create mode 100644 src/Models/Updates/BotRemovedFromChatUpdate.php create mode 100644 src/Models/Updates/ChatTitleChangedUpdate.php create mode 100644 src/Models/Updates/MessageChatCreatedUpdate.php create mode 100644 src/Models/Updates/UserAddedToChatUpdate.php create mode 100644 src/Models/Updates/UserRemovedFromChatUpdate.php create mode 100644 tests/Models/Updates/BotRemovedFromChatUpdateTest.php create mode 100644 tests/Models/Updates/ChatTitleChangedUpdateTest.php create mode 100644 tests/Models/Updates/MessageChatCreatedUpdateTest.php create mode 100644 tests/Models/Updates/UserAddedToChatUpdateTest.php create mode 100644 tests/Models/Updates/UserRemovedFromChatUpdateTest.php diff --git a/README.md b/README.md index 98fe3a9..ca0c1be 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ [![PHP version](https://img.shields.io/badge/php-%3E%3D%208.3-8892BF.svg?style=flat-square)](https://github.com/BushlanovDev/max-bot-api-client-php) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) +> [!CAUTION] +> На мой взгляд `Max Messenger` является ни чем иным как малварью, созданной для слежки за гражданами РФ. +> Настоятельно не рекомендую использовать свой настоящий номер телефона, на реальных устройствах, и для личной переписки. > [!IMPORTANT] > Библиотека в стадии активной разработки. @@ -16,3 +19,51 @@ Откройте диалог с [MasterBot](https://max.ru/MasterBot), следуйте инструкциям и создайте нового бота. После создания бота MasterBot отправит вам токен. + +## Реализованные методы + +#### Bots + +- `[x] GET /me` (`getBotInfo`) — *Получение информации о боте.* +- `[ ] PATCH /me` (`editBotInfo`) — *Редактирование информации о боте.* + +#### Chats + +- `[ ] GET /chats` (`getChats`) — *Получение списка всех чатов бота.* +- `[ ] GET /chats/{chatLink}` (`getChatByLink`) — *Получение информации о чате по ссылке.* +- `[x] GET /chats/{chatId}` (`getChat`) — *Получение информации о чате по ID.* +- `[ ] PATCH /chats/{chatId}` (`editChat`) — *Редактирование информации о чате.* +- `[ ] DELETE /chats/{chatId}` (`deleteChat`) — *Удаление чата.* +- `[ ] POST /chats/{chatId}/actions` (`sendAction`) — *Отправка действия в чат (например, "печатает...").* +- `[ ] GET /chats/{chatId}/pin` (`getPinnedMessage`) — *Получение закрепленного сообщения.* +- `[ ] PUT /chats/{chatId}/pin` (`pinMessage`) — *Закрепление сообщения.* +- `[ ] DELETE /chats/{chatId}/pin` (`unpinMessage`) — *Открепление сообщения.* +- `[ ] GET /chats/{chatId}/members/me` (`getMembership`) — *Получение информации о членстве бота в чате.* +- `[ ] DELETE /chats/{chatId}/members/me` (`leaveChat`) — *Выход бота из чата.* +- `[ ] GET /chats/{chatId}/members/admins` (`getAdmins`) — *Получение администраторов чата.* +- `[ ] POST /chats/{chatId}/members/admins` (`postAdmins`) — *Назначение администраторов чата.* +- `[ ] DELETE /chats/{chatId}/members/admins/{userId}` (`deleteAdmins`) — *Снятие прав администратора.* +- `[ ] GET /chats/{chatId}/members` (`getMembers`) — *Получение участников чата.* +- `[ ] POST /chats/{chatId}/members` (`addMembers`) — *Добавление участников в чат.* +- `[ ] DELETE /chats/{chatId}/members` (`removeMember`) — *Удаление участника из чата.* + +#### Subscriptions + +- `[x] GET /subscriptions` (`getSubscriptions`) — *Получение списка Webhook-подписок.* +- `[x] POST /subscriptions` (`subscribe`) — *Создание Webhook-подписки.* +- `[x] DELETE /subscriptions` (`unsubscribe`) — *Удаление Webhook-подписки.* +- `[x] GET /updates` (`getUpdates`) — *Получение обновлений через Long-Polling.* + +#### Upload + +- `[x] POST /uploads` (`getUploadUrl`) — *Получение URL для загрузки файла.* + +#### Messages + +- `[ ] GET /messages` (`getMessages`) — *Получение списка сообщений из чата.* +- `[x] POST /messages` (`sendMessage`) — *Отправка сообщения.* +- `[ ] PUT /messages` (`editMessage`) — *Редактирование сообщения.* +- `[ ] DELETE /messages` (`deleteMessage`) — *Удаление сообщения.* +- `[ ] GET /messages/{messageId}` (`getMessageById`) — *Получение сообщения по ID.* +- `[ ] GET /videos/{videoToken}` (`getVideoAttachmentDetails`) — *Получение детальной информации о видео.* +- `[ ] POST /answers` (`answerOnCallback`) — *Ответ на нажатие callback-кнопки.* diff --git a/src/Api.php b/src/Api.php index ae79395..3a315b8 100644 --- a/src/Api.php +++ b/src/Api.php @@ -9,6 +9,7 @@ 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; @@ -20,9 +21,11 @@ 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 InvalidArgumentException; use LogicException; +use Psr\Http\Message\ServerRequestInterface; use ReflectionException; use RuntimeException; @@ -105,6 +108,146 @@ class Api return new WebhookHandler($this, $this->modelFactory, $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. + * + * @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 + */ + public function getWebhookUpdate(?string $secret = null, ?ServerRequestInterface $request = null): AbstractUpdate + { + 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 $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); + } + + /** + * You can use this method for getting updates in case your bot is not subscribed to WebHook. + * The method is based on long polling. + * + * @param int|null $limit Maximum number of updates to be retrieved (1-1000). + * @param int|null $timeout Timeout in seconds for long polling (0-90). + * @param int|null $marker Pass `null` to get updates you didn't get yet. + * @param UpdateType[]|null $types Comma separated list of update types your bot want to receive. + * + * @return UpdateList + * @throws ClientApiException + * @throws NetworkException + * @throws ReflectionException + * @throws SerializationException + */ + public function getUpdates( + ?int $limit = null, + ?int $timeout = null, + ?int $marker = null, + ?array $types = null, + ): UpdateList { + $query = [ + 'limit' => $limit, + 'timeout' => $timeout, + 'marker' => $marker, + 'types' => $types !== null ? implode(',', array_map(fn($type) => $type->value, $types)) : null, + ]; + + return $this->modelFactory->createUpdateList( + $this->client->request( + self::METHOD_GET, + self::ACTION_UPDATES, + array_filter($query, fn($value) => $value !== null), + ) + ); + } + + /** + * Starts a long-polling loop to process updates using callbacks. + * This method will run indefinitely until the script is terminated. + * + * @param array $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) { + error_log("Network error: " . $e->getMessage()); + sleep(5); + } catch (\Exception $e) { + error_log("An error occurred: " . $e->getMessage()); + 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 $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. * @@ -330,41 +473,4 @@ class Api $this->client->request(self::METHOD_GET, self::ACTION_CHATS . '/' . $chatId) ); } - - /** - * You can use this method for getting updates in case your bot is not subscribed to WebHook. - * The method is based on long polling. - * - * @param int|null $limit Maximum number of updates to be retrieved (1-1000). - * @param int|null $timeout Timeout in seconds for long polling (0-90). - * @param int|null $marker Pass `null` to get updates you didn't get yet. - * @param UpdateType[]|null $types Comma separated list of update types your bot want to receive. - * - * @return UpdateList - * @throws ClientApiException - * @throws NetworkException - * @throws ReflectionException - * @throws SerializationException - */ - public function getUpdates( - ?int $limit = null, - ?int $timeout = null, - ?int $marker = null, - ?array $types = null, - ): UpdateList { - $query = [ - 'limit' => $limit, - 'timeout' => $timeout, - 'marker' => $marker, - 'types' => $types !== null ? implode(',', array_map(fn($type) => $type->value, $types)) : null, - ]; - - return $this->modelFactory->createUpdateList( - $this->client->request( - self::METHOD_GET, - self::ACTION_UPDATES, - array_filter($query, fn($value) => $value !== null), - ) - ); - } } diff --git a/src/ModelFactory.php b/src/ModelFactory.php index a102795..f423dc4 100644 --- a/src/ModelFactory.php +++ b/src/ModelFactory.php @@ -13,11 +13,16 @@ use BushlanovDev\MaxMessengerBot\Models\Subscription; use BushlanovDev\MaxMessengerBot\Models\UpdateList; use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\BotAddedToChatUpdate; +use BushlanovDev\MaxMessengerBot\Models\Updates\BotRemovedFromChatUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\BotStartedUpdate; +use BushlanovDev\MaxMessengerBot\Models\Updates\ChatTitleChangedUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCallbackUpdate; +use BushlanovDev\MaxMessengerBot\Models\Updates\MessageChatCreatedUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\MessageEditedUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\MessageRemovedUpdate; +use BushlanovDev\MaxMessengerBot\Models\Updates\UserAddedToChatUpdate; +use BushlanovDev\MaxMessengerBot\Models\Updates\UserRemovedFromChatUpdate; use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint; use LogicException; use ReflectionException; @@ -162,7 +167,12 @@ class ModelFactory UpdateType::MessageEdited => MessageEditedUpdate::fromArray($data), UpdateType::MessageRemoved => MessageRemovedUpdate::fromArray($data), UpdateType::BotAdded => BotAddedToChatUpdate::fromArray($data), + UpdateType::BotRemoved => BotRemovedFromChatUpdate::fromArray($data), + UpdateType::UserAdded => UserAddedToChatUpdate::fromArray($data), + UpdateType::UserRemoved => UserRemovedFromChatUpdate::fromArray($data), UpdateType::BotStarted => BotStartedUpdate::fromArray($data), + UpdateType::ChatTitleChanged => ChatTitleChangedUpdate::fromArray($data), + UpdateType::MessageChatCreated => MessageChatCreatedUpdate::fromArray($data), default => throw new LogicException( 'Unknown or unsupported update type received: ' . ($data['update_type'] ?? 'none') ), diff --git a/src/Models/Updates/BotRemovedFromChatUpdate.php b/src/Models/Updates/BotRemovedFromChatUpdate.php new file mode 100644 index 0000000..5262da1 --- /dev/null +++ b/src/Models/Updates/BotRemovedFromChatUpdate.php @@ -0,0 +1,29 @@ +addHandler(UpdateType::MessageRemoved, $handler); } + /** * A convenient alias for addHandler(UpdateType::BotAdded, $handler). * @@ -113,6 +114,45 @@ final class WebhookHandler 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). * @@ -126,6 +166,32 @@ final class WebhookHandler 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); + } + /** * Processes an incoming webhook request. * This is the main entry point. It reads the HTTP request body and headers, @@ -140,6 +206,24 @@ 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)) { @@ -153,11 +237,7 @@ final class WebhookHandler $request = \GuzzleHttp\Psr7\ServerRequest::fromGlobals(); } - - $update = $this->parseUpdate($request); - $this->dispatch($update); - - http_response_code(200); + return $this->parseUpdate($request); } /** diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 5523e85..3d345b6 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -28,8 +28,13 @@ use BushlanovDev\MaxMessengerBot\Models\Result; use BushlanovDev\MaxMessengerBot\Models\Sender; use BushlanovDev\MaxMessengerBot\Models\Subscription; use BushlanovDev\MaxMessengerBot\Models\UpdateList; +use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate; +use BushlanovDev\MaxMessengerBot\Models\Updates\BotStartedUpdate; +use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate; use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint; +use BushlanovDev\MaxMessengerBot\Models\User; use BushlanovDev\MaxMessengerBot\WebhookHandler; +use GuzzleHttp\Psr7\ServerRequest; use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -58,6 +63,10 @@ use ReflectionClass; #[UsesClass(Chat::class)] #[UsesClass(UpdateList::class)] #[UsesClass(WebhookHandler::class)] +#[UsesClass(User::class)] +#[UsesClass(AbstractUpdate::class)] +#[UsesClass(BotStartedUpdate::class)] +#[UsesClass(MessageCreatedUpdate::class)] final class ApiTest extends TestCase { private MockObject&ClientApiInterface $clientMock; @@ -568,4 +577,179 @@ final class ApiTest extends TestCase $secretProperty = $reflection->getProperty('secret'); $this->assertSame($secret, $secretProperty->getValue($webhookHandler)); } + + #[Test] + public function getWebhookUpdateCreatesHandlerAndReturnsUpdate(): void + { + $api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock); + + $payload = '{"update_type":"bot_started","timestamp":123,"chat_id":1,"user":{"user_id":1,"first_name":"Test","is_bot":false,"last_activity_time":123}}'; + $request = new ServerRequest('POST', '/webhook', [], $payload); + + $expectedUpdate = new BotStartedUpdate( + 123, + 1, + new User(1, 'Test', null, null, false, 123, null, null, null), + null, + null, + ); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createUpdate') + ->with(json_decode($payload, true)) + ->willReturn($expectedUpdate); + + $result = $api->getWebhookUpdate(null, $request); + + $this->assertSame($expectedUpdate, $result); + } + + #[Test] + public function handleWebhooksDispatchesCorrectHandler(): void + { + $api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock); + $secret = 'my-secret'; + + $payload = '{"update_type":"message_created","timestamp":123,"message":{"timestamp":1,"body":{"mid":"m1","seq":1},"recipient":{"chat_type":"dialog"}}}'; + $request = new \GuzzleHttp\Psr7\ServerRequest( + 'POST', + '/webhook', + ['X-Max-Bot-Api-Secret' => $secret], + $payload, + ); + + $expectedUpdate = new MessageCreatedUpdate( + 123, + Message::fromArray( + ['timestamp' => 1, 'body' => ['mid' => 'm1', 'seq' => 1], 'recipient' => ['chat_type' => 'dialog']] + ), + null, + ); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createUpdate') + ->with(json_decode($payload, true)) + ->willReturn($expectedUpdate); + + $messageHandlerCallCount = 0; + $messageHandlerCapturedUpdate = null; + + $messageHandler = function (MessageCreatedUpdate $update, Api $receivedApi) use ( + &$messageHandlerCallCount, + &$messageHandlerCapturedUpdate, + ) { + $messageHandlerCallCount++; + $messageHandlerCapturedUpdate = $update; + }; + + $botStartedHandlerCallCount = 0; + $botStartedHandler = function () use (&$botStartedHandlerCallCount) { + $botStartedHandlerCallCount++; + }; + + $handlers = [ + UpdateType::MessageCreated->value => $messageHandler, + UpdateType::BotStarted->value => $botStartedHandler, + ]; + + $api->handleWebhooks($handlers, $secret, $request); + + $this->assertSame(1, $messageHandlerCallCount, 'MessageCreated handler should be called once.'); + $this->assertSame(0, $botStartedHandlerCallCount, 'BotStarted handler should not be called.'); + $this->assertSame($expectedUpdate, $messageHandlerCapturedUpdate); + } + + #[Test] + public function processUpdatesBatchDispatchesHandlersAndUpdatesMarker(): void + { + $messageUpdate = new MessageCreatedUpdate( + 1, + Message::fromArray( + ['timestamp' => 1, 'body' => ['mid' => 'm1', 'seq' => 1], 'recipient' => ['chat_type' => 'dialog']] + ), + null, + ); + $botStartedUpdate = new BotStartedUpdate( + 2, 123, + User::fromArray(['user_id' => 1, 'first_name' => 'Test', 'is_bot' => false, 'last_activity_time' => 1]), + null, + null, + ); + + $messageHandlerCallCount = 0; + $botStartedHandlerCallCount = 0; + $handlers = [ + UpdateType::MessageCreated->value => function () use (&$messageHandlerCallCount) { + $messageHandlerCallCount++; + }, + UpdateType::BotStarted->value => function () use (&$botStartedHandlerCallCount) { + $botStartedHandlerCallCount++; + }, + ]; + + $apiMock = $this->getMockBuilder(Api::class) + ->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock]) + ->onlyMethods(['getUpdates']) + ->getMock(); + + $apiMock->expects($this->once()) + ->method('getUpdates') + ->willReturn(new UpdateList([$messageUpdate, $botStartedUpdate], 12345)); + + $marker = null; + + $apiMock->processUpdatesBatch($handlers, 90, $marker); + + $this->assertSame(1, $messageHandlerCallCount, 'MessageCreated handler should have been called once.'); + $this->assertSame(1, $botStartedHandlerCallCount, 'BotStarted handler should have been called once.'); + $this->assertSame(12345, $marker, 'Marker should have been updated to the new value.'); + } + + /** + * @var int Counter for processUpdatesBatch method calls. + */ + private int $processUpdatesBatchCallCount = 0; + + /** + * @throws \Throwable We catch a base \Error, so we need to declare it here. + */ + #[Test] + public function handleUpdatesLoopContinuesAfterException(): void + { + $this->processUpdatesBatchCallCount = 0; + $handlers = [UpdateType::MessageCreated->value => fn() => null]; + + $apiMock = $this->getMockBuilder(Api::class) + ->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock]) + ->onlyMethods(['processUpdatesBatch']) + ->getMock(); + + $apiMock->expects($this->any()) + ->method('processUpdatesBatch') + ->willReturnCallback(function () { + switch ($this->processUpdatesBatchCallCount++) { + case 0: + return; + case 1: + throw new \BushlanovDev\MaxMessengerBot\Exceptions\NetworkException("Simulated network error"); + default: + throw new \Error("LoopBreak"); + } + }); + + $this->expectOutputRegex('/Network error: Simulated network error/'); + + try { + $apiMock->handleUpdates($handlers); + } catch (\Error $e) { + $this->assertSame('LoopBreak', $e->getMessage()); + $this->assertSame( + 3, + $this->processUpdatesBatchCallCount, + 'processUpdatesBatch should have been called 3 times.', + ); + } + } } diff --git a/tests/Models/Updates/BotRemovedFromChatUpdateTest.php b/tests/Models/Updates/BotRemovedFromChatUpdateTest.php new file mode 100644 index 0000000..e3a1ca8 --- /dev/null +++ b/tests/Models/Updates/BotRemovedFromChatUpdateTest.php @@ -0,0 +1,74 @@ + 'bot_removed', + 'timestamp' => 1679100000, + 'chat_id' => 123456, + 'user' => [ + 'user_id' => 555, + 'first_name' => 'Admin', + 'username' => 'chat_admin', + 'is_bot' => false, + 'last_activity_time' => 1679000000, + 'last_name' => null, + 'description' => null, + 'avatar_url' => null, + 'full_avatar_url' => null, + ], + 'is_channel' => false, + ]; + + $update = BotRemovedFromChatUpdate::fromArray($data); + + $this->assertInstanceOf(BotRemovedFromChatUpdate::class, $update); + $this->assertSame(UpdateType::BotRemoved, $update->updateType); + $this->assertSame(1679100000, $update->timestamp); + $this->assertSame(123456, $update->chatId); + $this->assertFalse($update->isChannel); + $this->assertInstanceOf(User::class, $update->user); + $this->assertSame(555, $update->user->userId); + $this->assertEquals($data, $update->toArray()); + } + + #[Test] + public function canBeCreatedForChannel(): void + { + $data = [ + 'update_type' => 'bot_removed', + 'timestamp' => 1679100001, + 'chat_id' => 777888999, + 'user' => [ + 'user_id' => 556, + 'first_name' => 'AdminChan', + 'is_bot' => false, + 'last_activity_time' => 1679000001, + ], + 'is_channel' => true, + ]; + + $update = BotRemovedFromChatUpdate::fromArray($data); + + $this->assertInstanceOf(BotRemovedFromChatUpdate::class, $update); + $this->assertTrue($update->isChannel); + $this->assertSame(777888999, $update->chatId); + } +} diff --git a/tests/Models/Updates/ChatTitleChangedUpdateTest.php b/tests/Models/Updates/ChatTitleChangedUpdateTest.php new file mode 100644 index 0000000..8d6f09d --- /dev/null +++ b/tests/Models/Updates/ChatTitleChangedUpdateTest.php @@ -0,0 +1,51 @@ + 'chat_title_changed', + 'timestamp' => 1680000000, + 'chat_id' => 12345, + 'user' => [ + 'user_id' => 54321, + 'first_name' => 'John', + 'is_bot' => false, + 'last_activity_time' => 1679999999, + 'last_name' => null, + 'username' => null, + 'description' => null, + 'avatar_url' => null, + 'full_avatar_url' => null, + ], + 'title' => 'New Awesome Chat Title', + ]; + + $update = ChatTitleChangedUpdate::fromArray($data); + + $this->assertInstanceOf(ChatTitleChangedUpdate::class, $update); + $this->assertSame(UpdateType::ChatTitleChanged, $update->updateType); + $this->assertSame(1680000000, $update->timestamp); + $this->assertSame(12345, $update->chatId); + $this->assertSame('New Awesome Chat Title', $update->title); + $this->assertInstanceOf(User::class, $update->user); + $this->assertSame(54321, $update->user->userId); + $this->assertEquals($data, $update->toArray()); + } +} diff --git a/tests/Models/Updates/MessageChatCreatedUpdateTest.php b/tests/Models/Updates/MessageChatCreatedUpdateTest.php new file mode 100644 index 0000000..7979558 --- /dev/null +++ b/tests/Models/Updates/MessageChatCreatedUpdateTest.php @@ -0,0 +1,76 @@ + 'message_chat_created', + 'timestamp' => 1683000000, + 'chat' => [ + 'chat_id' => 12345, + 'type' => 'chat', + 'status' => 'active', + 'last_event_time' => 1683000000, + 'participants_count' => 1, + 'is_public' => false, + 'title' => 'New Discussion', + ], + 'message_id' => 'mid.original.with.button', + 'start_payload' => 'payload_from_chat_button', + ]; + + $update = MessageChatCreatedUpdate::fromArray($data); + + $this->assertInstanceOf(MessageChatCreatedUpdate::class, $update); + $this->assertSame(UpdateType::MessageChatCreated, $update->updateType); + $this->assertInstanceOf(Chat::class, $update->chat); + $this->assertSame(12345, $update->chat->chatId); + $this->assertSame('mid.original.with.button', $update->messageId); + $this->assertSame('payload_from_chat_button', $update->startPayload); + } + + #[Test] + public function canBeCreatedWithNullStartPayload(): void + { + $data = [ + 'update_type' => 'message_chat_created', + 'timestamp' => 1683000001, + 'chat' => [ + 'chat_id' => 54321, + 'type' => 'chat', + 'status' => 'active', + 'last_event_time' => 1683000001, + 'participants_count' => 1, + 'is_public' => false, + 'title' => 'Another Discussion', + ], + 'message_id' => 'mid.another.message', + 'start_payload' => null, + ]; + + $update = MessageChatCreatedUpdate::fromArray($data); + + $this->assertInstanceOf(MessageChatCreatedUpdate::class, $update); + $this->assertNull($update->startPayload); + } +} diff --git a/tests/Models/Updates/UserAddedToChatUpdateTest.php b/tests/Models/Updates/UserAddedToChatUpdateTest.php new file mode 100644 index 0000000..8e3832d --- /dev/null +++ b/tests/Models/Updates/UserAddedToChatUpdateTest.php @@ -0,0 +1,83 @@ + 'user_added', + 'timestamp' => 1681000000, + 'chat_id' => 12345, + 'user' => [ + 'user_id' => 101, + 'first_name' => 'New', + 'last_name' => 'Member', + 'is_bot' => false, + 'last_activity_time' => 1680000000, + 'username' => null, + 'description' => null, + 'avatar_url' => null, + 'full_avatar_url' => null, + ], + 'inviter_id' => 202, + 'is_channel' => false, + ]; + + $update = UserAddedToChatUpdate::fromArray($data); + + $this->assertInstanceOf(UserAddedToChatUpdate::class, $update); + $this->assertSame(UpdateType::UserAdded, $update->updateType); + $this->assertSame(12345, $update->chatId); + $this->assertSame(202, $update->inviterId); + $this->assertFalse($update->isChannel); + $this->assertInstanceOf(User::class, $update->user); + $this->assertSame(101, $update->user->userId); + $this->assertEquals($data, $update->toArray()); + } + + #[Test] + public function canBeCreatedWhenJoinedByLink(): void + { + $data = [ + 'update_type' => 'user_added', + 'timestamp' => 1681000001, + 'chat_id' => 54321, + 'user' => [ + 'user_id' => 303, + 'first_name' => 'Link', + 'last_name' => 'Joiner', + 'is_bot' => false, + 'last_activity_time' => 1680000001, + 'username' => null, + 'description' => null, + 'avatar_url' => null, + 'full_avatar_url' => null, + ], + 'inviter_id' => null, + 'is_channel' => true, + ]; + + $update = UserAddedToChatUpdate::fromArray($data); + + $this->assertInstanceOf(UserAddedToChatUpdate::class, $update); + $this->assertNull($update->inviterId); + $this->assertTrue($update->isChannel); + $this->assertSame(303, $update->user->userId); + $this->assertEquals($data, $update->toArray()); + } +} diff --git a/tests/Models/Updates/UserRemovedFromChatUpdateTest.php b/tests/Models/Updates/UserRemovedFromChatUpdateTest.php new file mode 100644 index 0000000..985cf3f --- /dev/null +++ b/tests/Models/Updates/UserRemovedFromChatUpdateTest.php @@ -0,0 +1,83 @@ + 'user_removed', + 'timestamp' => 1682000000, + 'chat_id' => 98765, + 'user' => [ + 'user_id' => 111, + 'first_name' => 'Removed', + 'last_name' => 'User', + 'is_bot' => false, + 'last_activity_time' => 1681000000, + 'username' => null, + 'description' => null, + 'avatar_url' => null, + 'full_avatar_url' => null, + ], + 'admin_id' => 222, + 'is_channel' => false, + ]; + + $update = UserRemovedFromChatUpdate::fromArray($data); + + $this->assertInstanceOf(UserRemovedFromChatUpdate::class, $update); + $this->assertSame(UpdateType::UserRemoved, $update->updateType); + $this->assertSame(98765, $update->chatId); + $this->assertSame(222, $update->adminId); + $this->assertFalse($update->isChannel); + $this->assertInstanceOf(User::class, $update->user); + $this->assertSame(111, $update->user->userId); + $this->assertEquals($data, $update->toArray()); + } + + #[Test] + public function canBeCreatedWhenUserLeft(): void + { + $data = [ + 'update_type' => 'user_removed', + 'timestamp' => 1682000001, + 'chat_id' => 112233, + 'user' => [ + 'user_id' => 444, + 'first_name' => 'Self', + 'last_name' => 'Remover', + 'is_bot' => false, + 'last_activity_time' => 1681000001, + 'username' => null, + 'description' => null, + 'avatar_url' => null, + 'full_avatar_url' => null, + ], + 'admin_id' => null, + 'is_channel' => true, + ]; + + $update = UserRemovedFromChatUpdate::fromArray($data); + + $this->assertInstanceOf(UserRemovedFromChatUpdate::class, $update); + $this->assertNull($update->adminId); + $this->assertTrue($update->isChannel); + $this->assertSame(444, $update->user->userId); + $this->assertEquals($data, $update->toArray()); + } +} diff --git a/tests/WebhookHandlerTest.php b/tests/WebhookHandlerTest.php index e840242..7166f8e 100644 --- a/tests/WebhookHandlerTest.php +++ b/tests/WebhookHandlerTest.php @@ -204,4 +204,25 @@ final class WebhookHandlerTest extends TestCase $this->assertSame($expectedUpdate, $result); } + + #[Test] + public function getUpdateParsesRequestAndReturnsUpdateObject(): void + { + $payload = $this->createValidUpdatePayload(); + $updateData = json_decode($payload, true); + $expectedUpdate = $this->createRealUpdateObject($updateData); + + $request = new ServerRequest('POST', '/webhook', [], $payload); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createUpdate') + ->with($updateData) + ->willReturn($expectedUpdate); + + $webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock); + $result = $webhookHandler->getUpdate($request); + + $this->assertSame($expectedUpdate, $result); + } }