diff --git a/README.md b/README.md index 9826cd8..6601a23 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Actions status](https://github.com/BushlanovDev/max-bot-api-client-php/actions/workflows/ci.yml/badge.svg?style=flat-square)](https://github.com/BushlanovDev/max-bot-api-client-php/actions) [![Coverage](https://raw.githubusercontent.com/BushlanovDev/max-bot-api-client-php/refs/heads/master/badge-coverage.svg?v=1)](https://github.com/BushlanovDev/max-bot-api-client-php/actions) +[![Packagist Version](https://img.shields.io/packagist/v/bushlanov-dev/max-bot-api-client-php.svg?style=flat-square)](https://packagist.org/packages/bushlanov-dev/max-bot-api-client-php) [![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) @@ -9,9 +10,6 @@ > На мой взгляд `Max Messenger` является ни чем иным как малварью, созданной для слежки за гражданами РФ. Настоятельно > не рекомендую использовать его на реальных устройствах, с настоящим номером телефона, и для личной переписки. -> [!IMPORTANT] -> Библиотека в стадии активной разработки. - ## Быстрый старт > Если вы новичок, то можете прочитать [официальную документацию](https://dev.max.ru/), написанную разработчиками Max. @@ -81,14 +79,14 @@ $webhookHandler->addHandler(UpdateType::BotStarted, function (BotStartedUpdate $ #### Bots - [x] `GET /me` (`getBotInfo`) - *Получение информации о боте.* -- [ ] `PATCH /me` (`editBotInfo`) - *Редактирование информации о боте.* +- [x] `PATCH /me` (`editBotInfo`) - *Редактирование информации о боте.* #### Chats - [x] `GET /chats` (`getChats`) - *Получение списка всех чатов бота.* - [x] `GET /chats/{chatLink}` (`getChatByLink`) - *Получение информации о чате по ссылке.* - [x] `GET /chats/{chatId}` (`getChat`) - *Получение информации о чате по ID.* -- [ ] `PATCH /chats/{chatId}` (`editChat`) - *Редактирование информации о чате.* +- [x] `PATCH /chats/{chatId}` (`editChat`) - *Редактирование информации о чате.* - [x] `DELETE /chats/{chatId}` (`deleteChat`) - *Удаление чата.* - [x] `POST /chats/{chatId}/actions` (`sendAction`) - *Отправка действия в чат (например, "печатает...").* - [x] `GET /chats/{chatId}/pin` (`getPinnedMessage`) - *Получение закрепленного сообщения.* @@ -121,7 +119,7 @@ $webhookHandler->addHandler(UpdateType::BotStarted, function (BotStartedUpdate $ - [x] `PUT /messages` (`editMessage`) - *Редактирование сообщения.* - [x] `DELETE /messages` (`deleteMessage`) - *Удаление сообщения.* - [x] `GET /messages/{messageId}` (`getMessageById`) - *Получение сообщения по ID.* -- [ ] `GET /videos/{videoToken}` (`getVideoAttachmentDetails`) - *Получение детальной информации о видео.* +- [x] `GET /videos/{videoToken}` (`getVideoAttachmentDetails`) - *Получение детальной информации о видео.* - [x] `POST /answers` (`answerOnCallback`) - *Ответ на нажатие callback-кнопки.* ## Лицензия diff --git a/src/Api.php b/src/Api.php index 4907f0f..d6810c7 100644 --- a/src/Api.php +++ b/src/Api.php @@ -19,11 +19,13 @@ use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\FileAttachmentReque use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\PhotoAttachmentRequest; use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\VideoAttachmentRequest; use BushlanovDev\MaxMessengerBot\Models\BotInfo; +use BushlanovDev\MaxMessengerBot\Models\BotPatch; use BushlanovDev\MaxMessengerBot\Models\Chat; use BushlanovDev\MaxMessengerBot\Models\ChatAdmin; use BushlanovDev\MaxMessengerBot\Models\ChatList; use BushlanovDev\MaxMessengerBot\Models\ChatMember; use BushlanovDev\MaxMessengerBot\Models\ChatMembersList; +use BushlanovDev\MaxMessengerBot\Models\ChatPatch; use BushlanovDev\MaxMessengerBot\Models\Message; use BushlanovDev\MaxMessengerBot\Models\MessageLink; use BushlanovDev\MaxMessengerBot\Models\Result; @@ -31,6 +33,7 @@ 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; @@ -51,7 +54,7 @@ class Api private const string METHOD_GET = 'GET'; private const string METHOD_POST = 'POST'; private const string METHOD_DELETE = 'DELETE'; -// private const string METHOD_PATCH = 'PATCH'; + private const string METHOD_PATCH = 'PATCH'; private const string METHOD_PUT = 'PUT'; private const string ACTION_ME = '/me'; @@ -67,6 +70,7 @@ class Api private const string ACTION_CHATS_MEMBERS = '/chats/%d/members'; private const string ACTION_UPDATES = '/updates'; private const string ACTION_ANSWERS = '/answers'; + private const string ACTION_VIDEO_DETAILS = '/videos/%s'; private readonly ClientApiInterface $client; @@ -1068,6 +1072,81 @@ class Api ); } + /** + * Edits the bot info. + * + * Example: editBotInfo(new BotPatch(name: 'New Bot Name', description: null)); + * + * @param BotPatch $botPatch + * + * @return BotInfo + * @throws ClientApiException + * @throws NetworkException + * @throws ReflectionException + * @throws SerializationException + */ + public function editBotInfo(BotPatch $botPatch): BotInfo + { + return $this->modelFactory->createBotInfo( + $this->client->request( + self::METHOD_PATCH, + self::ACTION_ME, + [], + $botPatch->toArray(), + ) + ); + } + + /** + * Edits chat info such as title, icon, etc. + * Instantiate ChatPatch with named arguments for the fields you want to change. + * + * Example: + * $patch = new ChatPatch(title: 'New Cool Title'); + * $api->editChat(12345, $patch); + * + * @param int $chatId The identifier of the chat to edit. + * @param ChatPatch $chatPatch An object containing the fields to update. + * + * @return Chat + * @throws ClientApiException + * @throws NetworkException + * @throws ReflectionException + * @throws SerializationException + */ + public function editChat(int $chatId, ChatPatch $chatPatch): Chat + { + return $this->modelFactory->createChat( + $this->client->request( + self::METHOD_PATCH, + self::ACTION_CHATS . '/' . $chatId, + [], + $chatPatch->toArray(), + ) + ); + } + + /** + * Returns detailed information about a video attachment, including playback URLs. + * + * @param string $videoToken The token of the video attachment. + * + * @return VideoAttachmentDetails + * @throws ClientApiException + * @throws NetworkException + * @throws ReflectionException + * @throws SerializationException + */ + public function getVideoAttachmentDetails(string $videoToken): VideoAttachmentDetails + { + return $this->modelFactory->createVideoAttachmentDetails( + $this->client->request( + self::METHOD_GET, + sprintf(self::ACTION_VIDEO_DETAILS, $videoToken), + ) + ); + } + /** * A helper to build the 'NewMessageBody' array structure consistently. * diff --git a/src/ModelFactory.php b/src/ModelFactory.php index 14008b3..70a511d 100644 --- a/src/ModelFactory.php +++ b/src/ModelFactory.php @@ -27,6 +27,7 @@ use BushlanovDev\MaxMessengerBot\Models\Updates\MessageRemovedUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\UserAddedToChatUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\UserRemovedFromChatUpdate; use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint; +use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails; use LogicException; use ReflectionException; @@ -234,4 +235,17 @@ class ModelFactory { return ChatMembersList::fromArray($data); } + + /** + * Creates a VideoAttachmentDetails object from raw API data. + * + * @param array $data + * + * @return VideoAttachmentDetails + * @throws ReflectionException + */ + public function createVideoAttachmentDetails(array $data): VideoAttachmentDetails + { + return VideoAttachmentDetails::fromArray($data); + } } diff --git a/src/Models/AbstractModel.php b/src/Models/AbstractModel.php index 8037b23..1b51895 100644 --- a/src/Models/AbstractModel.php +++ b/src/Models/AbstractModel.php @@ -42,6 +42,65 @@ abstract readonly class AbstractModel return new static(...$constructorArgs); // @phpstan-ignore-line } + /** + * @return array + * @throws ReflectionException + */ + public function toArray(): array + { + $reflectionClass = new ReflectionClass($this); + $properties = $reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC); + $result = []; + + foreach ($properties as $property) { + if (!$property->isInitialized($this)) { + continue; + } + + $phpPropertyName = $property->getName(); + $value = $property->getValue($this); + + $jsonKey = self::toSnakeCase($phpPropertyName); + + $result[$jsonKey] = $this->convertValue($value); + } + + return $result; + } + + /** + * @param string $input + * + * @return string + */ + protected static function toSnakeCase(string $input): string + { + return strtolower((string)preg_replace('/(?toArray(); + } + + if (is_array($value)) { + return array_map([$this, 'convertValue'], $value); + } + + if ($value instanceof BackedEnum) { + return $value->value; + } + + return $value; + } + /** * @param mixed $value * @param ReflectionProperty $property @@ -114,63 +173,4 @@ abstract readonly class AbstractModel return $value; } - - /** - * @param string $input - * - * @return string - */ - private static function toSnakeCase(string $input): string - { - return strtolower((string)preg_replace('/(? - * @throws ReflectionException - */ - public function toArray(): array - { - $reflectionClass = new ReflectionClass($this); - $properties = $reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC); - $result = []; - - foreach ($properties as $property) { - if (!$property->isInitialized($this)) { - continue; - } - - $phpPropertyName = $property->getName(); - $value = $property->getValue($this); - - $jsonKey = self::toSnakeCase($phpPropertyName); - - $result[$jsonKey] = $this->convertValue($value); - } - - return $result; - } - - /** - * @param mixed $value - * - * @return mixed - * @throws ReflectionException - */ - private function convertValue(mixed $value): mixed - { - if ($value instanceof AbstractModel) { - return $value->toArray(); - } - - if (is_array($value)) { - return array_map([$this, 'convertValue'], $value); - } - - if ($value instanceof BackedEnum) { - return $value->value; - } - - return $value; - } } diff --git a/src/Models/AbstractPatchModel.php b/src/Models/AbstractPatchModel.php new file mode 100644 index 0000000..4fa2a1d --- /dev/null +++ b/src/Models/AbstractPatchModel.php @@ -0,0 +1,42 @@ + + */ + protected array $patchData; + + /** + * Captures all passed named arguments into an associative array. + * + * @param mixed ...$params + */ + public function __construct(...$params) + { + $this->patchData = $params; + } + + /** + * @return array + * @throws \ReflectionException + */ + final public function toArray(): array + { + $result = []; + foreach ($this->patchData as $key => $value) { + $jsonKey = $this->toSnakeCase((string)$key); + $result[$jsonKey] = $this->convertValue($value); + } + + return $result; + } +} diff --git a/src/Models/BotPatch.php b/src/Models/BotPatch.php new file mode 100644 index 0000000..dd74760 --- /dev/null +++ b/src/Models/BotPatch.php @@ -0,0 +1,21 @@ +api->editMessage($messageId, attachments: []); $this->assertSame($expectedResult, $result); } + + #[Test] + public function editBotInfoSendsCorrectPatchBody(): void + { + $patch = new BotPatch(name: 'New Bot Name', description: null); + + $expectedBody = [ + 'name' => 'New Bot Name', + 'description' => null, + ]; + + $rawResponseData = [ + 'user_id' => 123, + 'first_name' => 'New Bot Name', + 'is_bot' => true, + 'last_activity_time' => 1, + 'description' => null, + ]; + $expectedBotInfo = new BotInfo(123, 'New Bot Name', null, null, true, 1, null, null, null, null); + + $this->clientMock + ->expects($this->once()) + ->method('request') + ->with('PATCH', '/me', [], $expectedBody) + ->willReturn($rawResponseData); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createBotInfo') + ->with($rawResponseData) + ->willReturn($expectedBotInfo); + + $this->api->editBotInfo($patch); + } + + #[Test] + public function editChatSendsCorrectPatchBody(): void + { + $chatId = 12345; + $uri = sprintf('/chats/%d', $chatId); + $patch = new ChatPatch(title: 'New Chat Title', notify: false); + + $expectedBody = [ + 'title' => 'New Chat Title', + 'notify' => false, + ]; + + $rawResponse = [ + 'chat_id' => $chatId, + 'title' => 'New Chat Title', + 'type' => 'chat', + 'status' => 'active', + 'last_event_time' => 1, + 'participants_count' => 1, + 'is_public' => false, + ]; + $expectedChat = Chat::fromArray($rawResponse); + + $this->clientMock + ->expects($this->once()) + ->method('request') + ->with('PATCH', $uri, [], $expectedBody) + ->willReturn($rawResponse); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createChat') + ->with($rawResponse) + ->willReturn($expectedChat); + + $result = $this->api->editChat($chatId, $patch); + $this->assertSame($expectedChat, $result); + } + + #[Test] + public function getVideoAttachmentDetailsCallsClientCorrectly(): void + { + $videoToken = 'some_video_token_xyz'; + $uri = sprintf('/videos/%s', $videoToken); + + $rawResponse = [ + 'token' => $videoToken, + 'width' => 1920, + 'height' => 1080, + 'duration' => 120, + 'urls' => ['mp4_1080' => 'http://example.com/video.mp4'], + ]; + $expectedDetails = VideoAttachmentDetails::fromArray($rawResponse); + + $this->clientMock + ->expects($this->once()) + ->method('request') + ->with('GET', $uri) + ->willReturn($rawResponse); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createVideoAttachmentDetails') + ->with($rawResponse) + ->willReturn($expectedDetails); + + $result = $this->api->getVideoAttachmentDetails($videoToken); + + $this->assertSame($expectedDetails, $result); + } } diff --git a/tests/ModelFactoryTest.php b/tests/ModelFactoryTest.php index 1116f7c..f86a893 100644 --- a/tests/ModelFactoryTest.php +++ b/tests/ModelFactoryTest.php @@ -8,6 +8,7 @@ use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf; use BushlanovDev\MaxMessengerBot\Enums\ChatAdminPermission; use BushlanovDev\MaxMessengerBot\Enums\UpdateType; use BushlanovDev\MaxMessengerBot\ModelFactory; +use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentRequestPayload; use BushlanovDev\MaxMessengerBot\Models\BotCommand; use BushlanovDev\MaxMessengerBot\Models\BotInfo; use BushlanovDev\MaxMessengerBot\Models\Chat; @@ -28,6 +29,8 @@ use BushlanovDev\MaxMessengerBot\Models\Updates\MessageChatCreatedUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate; use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint; use BushlanovDev\MaxMessengerBot\Models\User; +use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails; +use BushlanovDev\MaxMessengerBot\Models\VideoUrls; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -55,6 +58,9 @@ use PHPUnit\Framework\TestCase; #[UsesClass(ChatList::class)] #[UsesClass(ChatMember::class)] #[UsesClass(ChatMembersList::class)] +#[UsesClass(VideoAttachmentDetails::class)] +#[UsesClass(PhotoAttachmentRequestPayload::class)] +#[UsesClass(VideoUrls::class)] final class ModelFactoryTest extends TestCase { private ModelFactory $factory; @@ -480,4 +486,24 @@ final class ModelFactoryTest extends TestCase $this->assertEmpty($list->members); $this->assertNull($list->marker); } + + #[Test] + public function createVideoAttachmentDetailsSuccessfully(): void + { + $rawData = [ + 'token' => 'vid_token', + 'width' => 1280, + 'height' => 720, + 'duration' => 60, + 'urls' => ['mp4_720' => 'http://a.com/720.mp4'], + 'thumbnail' => ['token' => 'thumb_token'], + ]; + + $details = $this->factory->createVideoAttachmentDetails($rawData); + + $this->assertInstanceOf(VideoAttachmentDetails::class, $details); + $this->assertSame('vid_token', $details->token); + $this->assertInstanceOf(VideoUrls::class, $details->urls); + $this->assertInstanceOf(PhotoAttachmentRequestPayload::class, $details->thumbnail); + } } diff --git a/tests/Models/BotPatchTest.php b/tests/Models/BotPatchTest.php new file mode 100644 index 0000000..93ad912 --- /dev/null +++ b/tests/Models/BotPatchTest.php @@ -0,0 +1,61 @@ +assertEquals(['name' => 'New Name'], $patch->toArray()); + } + + #[Test] + public function toArrayIncludesFieldsSetToNull(): void + { + $patch = new BotPatch(description: null); + $this->assertEquals(['description' => null], $patch->toArray()); + } + + #[Test] + public function toArrayHandlesMultipleSetFields(): void + { + $photoPayload = new PhotoAttachmentRequestPayload(token: 'photo123'); + $patch = new BotPatch( + name: 'Updated Bot', + description: null, + photo: $photoPayload + ); + + $expected = [ + 'name' => 'Updated Bot', + 'description' => null, + 'photo' => [ + 'url' => null, + 'token' => 'photo123', + 'photos' => null, + ], + ]; + + $this->assertEquals($expected, $patch->toArray()); + } + + #[Test] + public function toArrayIsEmptyWhenNoArgumentsPassed(): void + { + $patch = new BotPatch(); + $this->assertEmpty($patch->toArray()); + } +} diff --git a/tests/Models/ChatPatchTest.php b/tests/Models/ChatPatchTest.php new file mode 100644 index 0000000..4f37d4d --- /dev/null +++ b/tests/Models/ChatPatchTest.php @@ -0,0 +1,54 @@ +assertEquals(['title' => 'New Title'], $patch->toArray()); + } + + #[Test] + public function toArrayHandlesMultipleFields(): void + { + $photoPayload = new PhotoAttachmentRequestPayload(token: 'icon_token'); + $patch = new ChatPatch( + title: 'Updated Chat', + pin: 'mid.12345', + icon: $photoPayload + ); + + $expected = [ + 'title' => 'Updated Chat', + 'pin' => 'mid.12345', + 'icon' => [ + 'url' => null, + 'token' => 'icon_token', + 'photos' => null, + ], + ]; + + $this->assertEquals($expected, $patch->toArray()); + } + + #[Test] + public function toArrayIsEmptyForEmptyPatch(): void + { + $patch = new ChatPatch(); + $this->assertEmpty($patch->toArray()); + } +} diff --git a/tests/Models/VideoAttachmentDetailsTest.php b/tests/Models/VideoAttachmentDetailsTest.php new file mode 100644 index 0000000..1aacf3d --- /dev/null +++ b/tests/Models/VideoAttachmentDetailsTest.php @@ -0,0 +1,41 @@ + 'video_token_123', + 'width' => 1920, + 'height' => 1080, + 'duration' => 125, + 'urls' => ['mp4_1080' => 'http://example.com/video.mp4'], + 'thumbnail' => ['token' => 'thumb_token_456'], + ]; + + $details = VideoAttachmentDetails::fromArray($data); + + $this->assertInstanceOf(VideoAttachmentDetails::class, $details); + $this->assertSame('video_token_123', $details->token); + $this->assertSame(125, $details->duration); + $this->assertInstanceOf(VideoUrls::class, $details->urls); + $this->assertInstanceOf(PhotoAttachmentRequestPayload::class, $details->thumbnail); + $this->assertSame('http://example.com/video.mp4', $details->urls->mp4_1080); + } +} diff --git a/tests/Models/VideoUrlsTest.php b/tests/Models/VideoUrlsTest.php new file mode 100644 index 0000000..eb8489e --- /dev/null +++ b/tests/Models/VideoUrlsTest.php @@ -0,0 +1,29 @@ + 'http://example.com/video_720p.mp4', + 'mp4_480' => 'http://example.com/video_480p.mp4', + ]; + + $urls = VideoUrls::fromArray($data); + + $this->assertInstanceOf(VideoUrls::class, $urls); + $this->assertSame('http://example.com/video_720p.mp4', $urls->mp4_720); + $this->assertNull($urls->mp4_1080); + } +}