From e431046a1ab4d5e2dd345884f0e9137434c4fa57 Mon Sep 17 00:00:00 2001 From: Timofey <57951811+smotim@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:08:35 +0500 Subject: [PATCH 1/2] Add editBotCommands for PATCH /me/commands, deprecate editBotInfo The API no longer accepts PATCH /me: it answers "Path /me is not recognized", so editBotInfo() fails, including for commands. Commands now have their own endpoint, PATCH /me/commands (editMyCommands in the official schema); bot name, description and photo are edited on the MAX partner platform. - Api::editBotCommands(BotCommand[]): BotCommandsInfo - BotCommandsInfo model and ModelFactory::createBotCommandsInfo() - editBotInfo() and BotPatch marked @deprecated - README coverage map and docs updated --- README.md | 3 +- docs/README.md | 20 +++++++++- src/Api.php | 32 ++++++++++++++++ src/ModelFactory.php | 14 +++++++ src/Models/BotCommandsInfo.php | 22 +++++++++++ src/Models/BotPatch.php | 2 + tests/ApiTest.php | 56 ++++++++++++++++++++++++++++ tests/ModelFactoryTest.php | 19 ++++++++++ tests/Models/BotCommandsInfoTest.php | 48 ++++++++++++++++++++++++ 9 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 src/Models/BotCommandsInfo.php create mode 100644 tests/Models/BotCommandsInfoTest.php diff --git a/README.md b/README.md index ce73e70..90c2f19 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,8 @@ $handler->handle(); #### Bots - [x] `GET /me` (`getBotInfo`) - [*Получение информации о боте.*](./docs/README.md#Получение-информации-о-боте) -- [x] `PATCH /me` (`editBotInfo`) - [*Редактирование информации о боте.*](./docs/README.md#Редактирование-информации-о-боте) +- [x] `PATCH /me/commands` (`editBotCommands`) - [*Редактирование команд бота.*](./docs/README.md#Редактирование-команд-бота) +- [x] ~~`PATCH /me` (`editBotInfo`) - [*Редактирование информации о боте.*](./docs/README.md#Редактирование-информации-о-боте-deprecated)~~ (deprecated) #### Chats diff --git a/docs/README.md b/docs/README.md index 4dae330..b53b0e3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,8 @@ - [Инициализация бота](#Инициализация-бота) - [Информация о боте](#Информация-о-боте) - `GET /me` (`getBotInfo`) - [*Получение информации о боте.*](#Получение-информации-о-боте) - - `PATCH /me` (`editBotInfo`) - [*Редактирование информации о боте.*](#Редактирование-информации-о-боте) + - `PATCH /me/commands` (`editBotCommands`) - [*Редактирование команд бота.*](#Редактирование-команд-бота) + - ~~`PATCH /me` (`editBotInfo`)~~ - [*Редактирование информации о боте.*](#Редактирование-информации-о-боте-deprecated) (deprecated) - [Чаты](#Чаты) - ~~`GET /chats` (`getChats`)~~ - [*Получение списка всех чатов бота.*](#Получение-списка-всех-чатов-бота-deprecated) (deprecated) - `GET /chats/{chatLink}` (`getChatByLink`) - [*Получение информации о чате по ссылке.*](#Получение-информации-о-чате-по-ссылке) @@ -119,7 +120,22 @@ $api = new BushlanovDev\MaxMessengerBot\Api( $botInfo = $api->getBotInfo(); ``` -### Редактирование информации о боте +### Редактирование команд бота + +Задаёт команды, которые пользователь видит подсказками, когда набирает «/». Не больше 32 команд, описание команды +необязательно. + +```php +$botCommands = $api->editBotCommands([ + new BotCommand('start', 'Начать'), + new BotCommand('help', 'Помощь'), +]); +``` + +### Редактирование информации о боте (deprecated) + +API больше не принимает `PATCH /me` и отвечает `Path /me is not recognized`. Команды задаются методом +[`editBotCommands`](#Редактирование-команд-бота), имя, описание и аватар бота — на платформе MAX для партнёров. Обратите внимание, что данный метод отправляется PATCH запросом. Это значит, что будут обновлены только переданные поля. diff --git a/src/Api.php b/src/Api.php index 59ffaef..f5ca7b3 100644 --- a/src/Api.php +++ b/src/Api.php @@ -17,6 +17,8 @@ use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\AudioAttachmentRequ use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\FileAttachmentRequest; use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\PhotoAttachmentRequest; use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\VideoAttachmentRequest; +use BushlanovDev\MaxMessengerBot\Models\BotCommand; +use BushlanovDev\MaxMessengerBot\Models\BotCommandsInfo; use BushlanovDev\MaxMessengerBot\Models\BotInfo; use BushlanovDev\MaxMessengerBot\Models\BotPatch; use BushlanovDev\MaxMessengerBot\Models\Chat; @@ -61,6 +63,7 @@ class Api private const string METHOD_PUT = 'PUT'; private const string ACTION_ME = '/me'; + private const string ACTION_ME_COMMANDS = '/me/commands'; private const string ACTION_SUBSCRIPTIONS = '/subscriptions'; private const string ACTION_MESSAGES = '/messages'; private const string ACTION_UPLOADS = '/uploads'; @@ -252,6 +255,31 @@ class Api ); } + /** + * Edits the bot commands shown to users as hints when they type "/". + * + * Example: editBotCommands([new BotCommand('start', 'Start the bot'), new BotCommand('help', 'Help')]); + * + * @param BotCommand[] $commands Up to 32 commands. + * + * @return BotCommandsInfo + * @throws ClientApiException + * @throws NetworkException + * @throws ReflectionException + * @throws SerializationException + */ + public function editBotCommands(array $commands): BotCommandsInfo + { + return $this->modelFactory->createBotCommandsInfo( + $this->client->request( + self::METHOD_PATCH, + self::ACTION_ME_COMMANDS, + [], + ['commands' => array_map(static fn(BotCommand $command) => $command->toArray(), $commands)], + ) + ); + } + /** * List of all active webhook subscriptions. * @@ -1099,6 +1127,10 @@ class Api * * Example: editBotInfo(new BotPatch(name: 'New Bot Name', description: null)); * + * @deprecated The API no longer accepts `PATCH /me` and answers "Path /me is not recognized". + * Set commands with editBotCommands(); name, description and photo are edited + * on the MAX partner platform. + * * @param BotPatch $botPatch * * @return BotInfo diff --git a/src/ModelFactory.php b/src/ModelFactory.php index 9c1c08a..a091760 100644 --- a/src/ModelFactory.php +++ b/src/ModelFactory.php @@ -32,6 +32,7 @@ use BushlanovDev\MaxMessengerBot\Models\Attachments\PhotoAttachment; use BushlanovDev\MaxMessengerBot\Models\Attachments\ShareAttachment; use BushlanovDev\MaxMessengerBot\Models\Attachments\StickerAttachment; use BushlanovDev\MaxMessengerBot\Models\Attachments\VideoAttachment; +use BushlanovDev\MaxMessengerBot\Models\BotCommandsInfo; use BushlanovDev\MaxMessengerBot\Models\BotInfo; use BushlanovDev\MaxMessengerBot\Models\Chat; use BushlanovDev\MaxMessengerBot\Models\ChatList; @@ -104,6 +105,19 @@ readonly class ModelFactory return Result::fromArray($data); } + /** + * Commands of the current bot. + * + * @param array $data + * + * @return BotCommandsInfo + * @throws ReflectionException + */ + public function createBotCommandsInfo(array $data): BotCommandsInfo + { + return BotCommandsInfo::fromArray($data); + } + /** * Information about the current bot. * diff --git a/src/Models/BotCommandsInfo.php b/src/Models/BotCommandsInfo.php new file mode 100644 index 0000000..823ec21 --- /dev/null +++ b/src/Models/BotCommandsInfo.php @@ -0,0 +1,22 @@ +assertSame($expectedResult, $result); } + #[Test] + public function editBotCommandsSendsCommandsToDedicatedEndpoint(): void + { + $rawResponseData = [ + 'commands' => [ + ['name' => 'start', 'description' => 'Start the bot'], + ['name' => 'help', 'description' => null], + ], + ]; + $expectedResult = new BotCommandsInfo([ + new BotCommand('start', 'Start the bot'), + new BotCommand('help', null), + ]); + + $this->clientMock + ->expects($this->once()) + ->method('request') + ->with('PATCH', '/me/commands', [], $rawResponseData) + ->willReturn($rawResponseData); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createBotCommandsInfo') + ->with($rawResponseData) + ->willReturn($expectedResult); + + $result = $this->api->editBotCommands([ + new BotCommand('start', 'Start the bot'), + new BotCommand('help', null), + ]); + + $this->assertSame($expectedResult, $result); + } + + #[Test] + public function editBotCommandsSendsAnEmptyList(): void + { + $this->clientMock + ->expects($this->once()) + ->method('request') + ->with('PATCH', '/me/commands', [], ['commands' => []]) + ->willReturn(['commands' => []]); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createBotCommandsInfo') + ->with(['commands' => []]) + ->willReturn(new BotCommandsInfo([])); + + $this->api->editBotCommands([]); + } + #[Test] public function editBotInfoSendsCorrectPatchBody(): void { diff --git a/tests/ModelFactoryTest.php b/tests/ModelFactoryTest.php index c2b1ea4..1ac7df1 100644 --- a/tests/ModelFactoryTest.php +++ b/tests/ModelFactoryTest.php @@ -27,6 +27,7 @@ use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\ShareAttachmentRequ use BushlanovDev\MaxMessengerBot\Models\Attachments\PhotoAttachment; use BushlanovDev\MaxMessengerBot\Models\Attachments\ShareAttachment; use BushlanovDev\MaxMessengerBot\Models\BotCommand; +use BushlanovDev\MaxMessengerBot\Models\BotCommandsInfo; use BushlanovDev\MaxMessengerBot\Models\BotInfo; use BushlanovDev\MaxMessengerBot\Models\Chat; use BushlanovDev\MaxMessengerBot\Models\ChatList; @@ -61,6 +62,7 @@ use Psr\Log\LoggerInterface; #[CoversClass(ModelFactory::class)] #[UsesClass(BotInfo::class)] #[UsesClass(BotCommand::class)] +#[UsesClass(BotCommandsInfo::class)] #[UsesClass(Result::class)] #[UsesClass(Subscription::class)] #[UsesClass(ArrayOf::class)] @@ -171,6 +173,23 @@ final class ModelFactoryTest extends TestCase $this->assertSame('help', $botInfo->commands[1]->name); } + #[Test] + public function createBotCommandsInfoHydratesCommands(): void + { + $botCommands = $this->factory->createBotCommandsInfo([ + 'commands' => [ + ['name' => 'start', 'description' => 'Start the bot'], + ['name' => 'help', 'description' => null], + ], + ]); + + $this->assertInstanceOf(BotCommandsInfo::class, $botCommands); + $this->assertCount(2, $botCommands->commands); + $this->assertInstanceOf(BotCommand::class, $botCommands->commands[0]); + $this->assertSame('start', $botCommands->commands[0]->name); + $this->assertNull($botCommands->commands[1]->description); + } + #[Test] public function createBotInfoHandlesNullCommands(): void { diff --git a/tests/Models/BotCommandsInfoTest.php b/tests/Models/BotCommandsInfoTest.php new file mode 100644 index 0000000..d9fe9f2 --- /dev/null +++ b/tests/Models/BotCommandsInfoTest.php @@ -0,0 +1,48 @@ + [ + ['name' => 'start', 'description' => 'Start the bot'], + ], + ]); + + $this->assertCount(1, $info->commands); + $this->assertInstanceOf(BotCommand::class, $info->commands[0]); + $this->assertSame('start', $info->commands[0]->name); + $this->assertSame('Start the bot', $info->commands[0]->description); + } + + #[Test] + public function commandsAreNullWhenTheBotHasNone(): void + { + $this->assertNull(BotCommandsInfo::fromArray([])->commands); + } + + #[Test] + public function toArraySerializesCommands(): void + { + $info = new BotCommandsInfo([new BotCommand('help', 'Help')]); + + $this->assertSame(['commands' => [['name' => 'help', 'description' => 'Help']]], $info->toArray()); + } +} From 919970fa20441d158dc44a3c7a84b91df690f016 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 30 Sep 2026 23:40:03 +0300 Subject: [PATCH 2/2] Convert non-array update payloads to LogicException instead of TypeError --- src/ModelFactory.php | 13 ++++++-- tests/ModelFactoryTest.php | 61 ++++++++++++++++++++++++++++++++++++ tests/WebhookHandlerTest.php | 36 +++++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/ModelFactory.php b/src/ModelFactory.php index 3894515..b17c508 100644 --- a/src/ModelFactory.php +++ b/src/ModelFactory.php @@ -369,14 +369,23 @@ readonly class ModelFactory /** * Creates a specific Update model based on the 'update_type' field. * - * @param array $data Raw data for a single update. + * @param mixed $data Raw data for a single update. Anything that is not an array + * (e.g. a scalar produced by json_decode) is converted to LogicException, + * so a webhook or a long-polling loop can skip it instead of crashing. * * @return AbstractUpdate * @throws ReflectionException * @throws LogicException */ - public function createUpdate(array $data): AbstractUpdate + public function createUpdate(mixed $data): AbstractUpdate { + if (!is_array($data)) { + $message = sprintf('Failed to parse update: expected JSON object, got %s', get_debug_type($data)); + $this->logger->warning($message, ['payload' => $data]); + + throw new LogicException($message); + } + try { return match (UpdateType::tryFrom($data['update_type'] ?? '')) { UpdateType::MessageCreated => MessageCreatedUpdate::fromArray($data), diff --git a/tests/ModelFactoryTest.php b/tests/ModelFactoryTest.php index dcfd2c9..5050a2a 100644 --- a/tests/ModelFactoryTest.php +++ b/tests/ModelFactoryTest.php @@ -840,6 +840,67 @@ final class ModelFactoryTest extends TestCase $this->assertSame(7, $updateList->marker); } + public static function nonArrayPayloadProvider(): array + { + return [ + 'string' => ['123'], + 'integer' => [42], + 'null' => [null], + 'float' => [3.14], + 'boolean' => [true], + ]; + } + + #[Test] + #[DataProvider('nonArrayPayloadProvider')] + public function createUpdateTurnsNonArrayPayloadIntoLogicException(mixed $payload): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with( + 'Failed to parse update: expected JSON object, got ' . get_debug_type($payload), + ['payload' => $payload], + ); + $factory = new ModelFactory($logger); + + try { + $factory->createUpdate($payload); + $this->fail('LogicException expected'); + } catch (LogicException $e) { + $this->assertNull($e->getPrevious()); + } + } + + #[Test] + public function createUpdateListSkipsNonArrayElements(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(2))->method('warning'); + $factory = new ModelFactory($logger); + + $updateList = $factory->createUpdateList([ + 'updates' => [ + 'junk', + 41, + [ + 'update_type' => 'message_created', + 'timestamp' => 3, + 'message' => [ + 'timestamp' => 3, + 'body' => ['mid' => 'mid.2', 'seq' => 2], + 'recipient' => ['chat_type' => 'dialog'], + ], + ], + ], + 'marker' => 9, + ]); + + $this->assertCount(1, $updateList->updates); + $this->assertInstanceOf(MessageCreatedUpdate::class, $updateList->updates[0]); + $this->assertSame(9, $updateList->marker); + } + #[Test] public function createUpdateListCatchesAndLogsLogicException(): void { diff --git a/tests/WebhookHandlerTest.php b/tests/WebhookHandlerTest.php index 3f2b114..309315a 100644 --- a/tests/WebhookHandlerTest.php +++ b/tests/WebhookHandlerTest.php @@ -249,4 +249,40 @@ final class WebhookHandlerTest extends TestCase $this->assertFalse($handlerWasCalled); } + + public static function nonJsonObjectBodyProvider(): array + { + return [ + 'number' => ['123'], + 'null' => ['null'], + 'string' => ['"abc"'], + 'boolean' => ['true'], + ]; + } + + #[Test] + #[DataProvider('nonJsonObjectBodyProvider')] + public function handleSkipsBodyThatIsNotAJsonObject(string $payload): void + { + $request = $this->createMockRequest($payload, self::SECRET); + + $this->loggerMock->expects($this->once()) + ->method('warning') + ->with($this->stringContains('Failed to parse update: expected JSON object')); + + $handlerWasCalled = false; + $this->dispatcher->addHandler(UpdateType::MessageCreated, function () use (&$handlerWasCalled) { + $handlerWasCalled = true; + }); + + $handler = new WebhookHandler( + $this->dispatcher, + new ModelFactory($this->loggerMock), + $this->loggerMock, + self::SECRET, + ); + $handler->handle($request); + + $this->assertFalse($handlerWasCalled); + } }