Compare commits

..

24 Commits

Author SHA1 Message Date
Alex 00da3af39d Documentation and some fix 2025-08-15 20:17:30 +03:00
Alex ea1947239e Added badge laravel support 2025-08-14 14:34:40 +03:00
Alex 0bb13b3a7c release 1.0.0 2025-08-13 22:00:54 +03:00
Alex 17acd6de67 Laravel support tests 2025-08-12 22:18:20 +03:00
Alex b37e7b6a73 Laravel support tests 2025-08-11 20:48:04 +03:00
Alex 3b242c1186 Laravel support 2025-08-10 15:55:15 +03:00
Alex 59a843609b Merge pull request #3 from BushlanovDev/dev
Refactoring update dispatchers 
Added onCommand method
Added LongPollingHandler
2025-08-09 19:08:00 +03:00
Alex 9d56be2c92 Refactoring LongPollingHandler 2025-08-09 18:28:24 +03:00
Alex 9cd5dd2c48 Refactoring update dispatchers 2025-08-08 22:51:35 +03:00
Alex 1b00bae23a Some fix 2025-08-07 22:19:10 +03:00
Alex ca13096ec2 Added PSR LoggerInterface 2025-08-07 19:04:07 +03:00
Alex 68da594f89 Added OpenAppButton factory method 2025-08-06 20:52:37 +03:00
Alex 62fbb6cf3b Merge remote-tracking branch 'origin/master' 2025-08-05 18:15:48 +03:00
Alex 97d016055f fix #2 Error: Cannot instantiate abstract class AbstractAttachment после отправки номера телефона 2025-08-05 18:15:34 +03:00
Alex 13e7c3fa45 fix #1 Error: Cannot instantiate abstract class AbstractAttachment после отправки номера телефона 2025-08-05 18:11:34 +03:00
Alex 32e393e044 fix #1 TypeError Api::sendUserMessage после отправки сообщения
Added PhotoAttachmentRequestPayloadTest
2025-08-04 19:43:37 +03:00
Alex 728536f064 Added MessageStat & Attachments 2025-08-04 18:48:51 +03:00
Alex c5d55a5c0f Added DataAttachment & LinkedMessage & Markups 2025-08-03 15:07:05 +03:00
Alex 51b105b847 Added ChatButton & ReplyButtons 2025-08-02 09:24:11 +03:00
Alex f5acd0ee88 Added editBotInfo & editChat & getVideoAttachmentDetails 2025-08-01 08:42:39 +03:00
Alex 8fa63fa10b Added addAdmins & addMembers & editMessage & answerOnCallback 2025-07-31 09:31:33 +03:00
Alex eb5f6c7bbe Test fix 2025-07-30 09:00:24 +03:00
Alex 3810f340e9 Added getAdmins & deleteAdmins & getMembers & removeMember 2025-07-30 08:51:31 +03:00
Alex 46e7c2ef0a Added pinMessage & getMessages & deleteMessage & getMessageById 2025-07-29 19:09:33 +03:00
160 changed files with 8456 additions and 1255 deletions
+4 -5
View File
@@ -1,5 +1,4 @@
/.gitattributes export-ignore
/.gitignore export-ignore
/.github export-ignore
/phpunit.xml export-ignore
/tests export-ignore
/.* export-ignore
/phpunit.xml export-ignore
/phpstan.neon export-ignore
/tests export-ignore
+112 -36
View File
@@ -2,73 +2,149 @@
[![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)
[![Laravel](https://img.shields.io/badge/%20Laravel%20Package-available-success?logo=laravel&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]
> Библиотека в стадии активной разработки.
## Быстрый старт
> Если вы новичок, то можете прочитать [официальную документацию](https://dev.max.ru/), написанную разработчиками Max
> Если вы новичок, то можете прочитать [официальную документацию](https://dev.max.ru/), написанную разработчиками Max.
### Получение токена
Откройте диалог с [MasterBot](https://max.ru/MasterBot), следуйте инструкциям и создайте нового бота. После создания
бота MasterBot отправит вам токен.
### Установка библиотеки
```bash
composer require bushlanov-dev/max-bot-api-client-php
```
### Использование
Отправка сообщения с клавиатурой
```php
require __DIR__.'/vendor/autoload.php';
use BushlanovDev\MaxMessengerBot\Api;
$api = new Api('YOUR_BOT_API_TOKEN');
$api->sendMessage(
userId: 123, // ID пользователя получателя сообщения
chatId: 321, // Или ID чата, в который нужно отправить сообщение
text: 'Привет!', // Текст сообщения, вы можете использовать HTML или Markdown
attachments: [
new InlineKeyboardAttachmentRequest([
[new CallbackButton('Нажми меня!', 'payload_button1')],
[new LinkButton('Нажми меня!', 'https://example.com')],
]),
],
format: MessageFormat::Markdown, // Формат сообщения (Markdown или HTML)
);
```
Создание универсального обработчика обновлений
```php
$dispatcher = $api->getUpdateDispatcher();
$dispatcher->onMessageCreated(function (MessageCreatedUpdate $update, Api $api) {
$api->sendMessage(
userId: $update->message->recipient->userId,
text: 'Привет!',
);
});
// или
$dispatcher->addHandler(UpdateType::BotStarted, function (BotStartedUpdate $update, Api $api) {
$api->sendMessage(
chatId: $update->chatId,
text: 'Я запущен!',
);
});
```
Подписка на вэб хуки
```php
$api->subscribe(
url: 'https://example.com/webhook', // URL на который будут приходить хуки
secret: 'super_secret', // Секретная фраза для проверки хуков
updateTypes: [
// Типы хуков которые вы хотите получать (либо ничего не указывать, чтобы получать все)
UpdateType::BotStarted,
UpdateType::MessageCreated,
],
);
```
Обработка обновлений
```php
$handler = $api->createWebhookHandler('super_secret'); // Обновления через вебхук
// ИЛИ
$handler = $api->createLongPollingHandler(); // Обновления через лонгполлинг
$handler->handle();
```
> ℹ️ С полной документацией [вы можете ознакомиться тут](./docs/README.md).
## Реализованные методы
#### Bots
- [x] `GET /me` (`getBotInfo`) *Получение информации о боте.*
- [ ] `PATCH /me` (`editBotInfo`) *Редактирование информации о боте.*
- [x] `GET /me` (`getBotInfo`) - *Получение информации о боте.*
- [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] `DELETE /chats/{chatId}` (`deleteChat`) *Удаление чата.*
- [x] `POST /chats/{chatId}/actions` (`sendAction`) *Отправка действия в чат (например, "печатает...").*
- [x] `GET /chats/{chatId}/pin` (`getPinnedMessage`) *Получение закрепленного сообщения.*
- [ ] `PUT /chats/{chatId}/pin` (`pinMessage`) *Закрепление сообщения.*
- [x] `DELETE /chats/{chatId}/pin` (`unpinMessage`) *Открепление сообщения.*
- [x] `GET /chats/{chatId}/members/me` (`getMembership`) *Получение информации о членстве бота в чате.*
- [x] `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`) *Удаление участника из чата.*
- [x] `GET /chats` (`getChats`) - *Получение списка всех чатов бота.*
- [x] `GET /chats/{chatLink}` (`getChatByLink`) - *Получение информации о чате по ссылке.*
- [x] `GET /chats/{chatId}` (`getChat`) - *Получение информации о чате по ID.*
- [x] `PATCH /chats/{chatId}` (`editChat`) - *Редактирование информации о чате.*
- [x] `DELETE /chats/{chatId}` (`deleteChat`) - *Удаление чата.*
- [x] `POST /chats/{chatId}/actions` (`sendAction`) - *Отправка действия в чат (например, "печатает...").*
- [x] `GET /chats/{chatId}/pin` (`getPinnedMessage`) - *Получение закрепленного сообщения.*
- [x] `PUT /chats/{chatId}/pin` (`pinMessage`) - *Закрепление сообщения.*
- [x] `DELETE /chats/{chatId}/pin` (`unpinMessage`) - *Открепление сообщения.*
- [x] `GET /chats/{chatId}/members/me` (`getMembership`) - *Получение информации о членстве бота в чате.*
- [x] `DELETE /chats/{chatId}/members/me` (`leaveChat`) - *Выход бота из чата.*
- [x] `GET /chats/{chatId}/members/admins` (`getAdmins`) - *Получение администраторов чата.*
- [x] `POST /chats/{chatId}/members/admins` (`addAdmins`) - *Назначение администраторов чата.*
- [x] `DELETE /chats/{chatId}/members/admins/{userId}` (`deleteAdmins`) - *Снятие прав администратора.*
- [x] `GET /chats/{chatId}/members` (`getMembers`) - *Получение участников чата.*
- [x] `POST /chats/{chatId}/members` (`addMembers`) - *Добавление участников в чат.*
- [x] `DELETE /chats/{chatId}/members` (`deleteMember`) - *Удаление участника из чата.*
#### Subscriptions
- [x] `GET /subscriptions` (`getSubscriptions`) *Получение списка Webhook-подписок.*
- [x] `POST /subscriptions` (`subscribe`) *Создание Webhook-подписки.*
- [x] `DELETE /subscriptions` (`unsubscribe`) *Удаление Webhook-подписки.*
- [x] `GET /updates` (`getUpdates`) *Получение обновлений через Long-Polling.*
- [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 для загрузки файла.*
- [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-кнопки.*
- [x] `GET /messages` (`getMessages`) - *Получение списка сообщений из чата.*
- [x] `POST /messages` (`sendMessage`) - *Отправка сообщения.*
- [x] `PUT /messages` (`editMessage`) - *Редактирование сообщения.*
- [x] `DELETE /messages` (`deleteMessage`) - *Удаление сообщения.*
- [x] `GET /messages/{messageId}` (`getMessageById`) - *Получение сообщения по ID.*
- [x] `GET /videos/{videoToken}` (`getVideoAttachmentDetails`) - *Получение детальной информации о видео.*
- [x] `POST /answers` (`answerOnCallback`) - *Ответ на нажатие callback-кнопки.*
## Лицензия
Данная библиотека распространяется по лицензии MIT - подробности см. в файле [LICENSE](LICENSE).
Данная библиотека распространяется под лицензией MIT - подробности см. в файле [LICENSE](LICENSE).
+16 -2
View File
@@ -1,7 +1,7 @@
{
"name": "bushlanov-dev/max-bot-api-client-php",
"description": "Max Bot API Client library",
"keywords": ["max messenger", "bot", "max", "api"],
"keywords": ["max messenger", "bot", "max", "api", "max bot", "laravel", "laravel max bot"],
"type": "library",
"license": "MIT",
"authors": [
@@ -19,12 +19,16 @@
"guzzlehttp/psr7": "^1.8||^2.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0||^2.0"
"psr/http-message": "^1.0||^2.0",
"psr/log": "^3.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.77",
"jaschilz/php-coverage-badger": "^2.0",
"laravel/framework": "^11.0",
"mikey179/vfsstream": "^1.6",
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.0",
"php-mock/php-mock-phpunit": "^2.13",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^12.0",
@@ -43,6 +47,16 @@
"config": {
"sort-packages": true
},
"extra": {
"laravel": {
"providers": [
"BushlanovDev\\MaxMessengerBot\\Laravel\\MaxBotServiceProvider"
],
"aliases": {
"MaxBot": "BushlanovDev\\MaxMessengerBot\\Laravel\\MaxBotFacade"
}
}
},
"scripts": {
"analyse": "vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=256M",
"format": "vendor/bin/php-cs-fixer fix --allow-risky=yes src",
+102
View File
@@ -0,0 +1,102 @@
- [Быстрый старт](#Быстрый-старт)
- [Получение токена](#Получение-токена)
- [Установка библиотеки](#Установка-библиотеки)
- [Инициализация бота](#Инициализация-бота)
- [Информация о боте](#Информация-о-боте)
- `GET /me` (`getBotInfo`) - [*Получение информации о боте.*](#Получение-информации-о-боте)
- `PATCH /me` (`editBotInfo`) - [*Редактирование информации о боте.*](#Редактирование-информации-о-боте)
- Чаты
- `GET /chats` (`getChats`) - *Получение списка всех чатов бота.*
- `GET /chats/{chatLink}` (`getChatByLink`) - *Получение информации о чате по ссылке.*
- `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` (`addAdmins`) - *Назначение администраторов чата.*
- `DELETE /chats/{chatId}/members/admins/{userId}` (`deleteAdmins`) - *Снятие прав администратора.*
- `GET /chats/{chatId}/members` (`getMembers`) - *Получение участников чата.*
- `POST /chats/{chatId}/members` (`addMembers`) - *Добавление участников в чат.*
- `DELETE /chats/{chatId}/members` (`deleteMember`) - *Удаление участника из чата.*
- Получение обновлений
- `GET /subscriptions` (`getSubscriptions`) - *Получение списка Webhook-подписок.*
- `POST /subscriptions` (`subscribe`) - *Создание Webhook-подписки.*
- `DELETE /subscriptions` (`unsubscribe`) - *Удаление Webhook-подписки.*
- `GET /updates` (`getUpdates`) - *Получение обновлений через Long-Polling.*
- Загрузка файлов
- `POST /uploads` (`getUploadUrl`) - *Получение URL для загрузки файла.*
- Сообщения
- `GET /messages` (`getMessages`) - *Получение списка сообщений из чата.*
- `POST /messages` (`sendMessage`) - *Отправка сообщения.*
- `PUT /messages` (`editMessage`) - *Редактирование сообщения.*
- `DELETE /messages` (`deleteMessage`) - *Удаление сообщения.*
- `GET /messages/{messageId}` (`getMessageById`) - *Получение сообщения по ID.*
- `GET /videos/{videoToken}` (`getVideoAttachmentDetails`) - *Получение детальной информации о видео.*
- `POST /answers` (`answerOnCallback`) - *Ответ на нажатие callback-кнопки.*
## Быстрый старт
> Если вы новичок, то можете прочитать [официальную документацию](https://dev.max.ru/), написанную разработчиками Max.
### Получение токена
Откройте диалог с [MasterBot](https://max.ru/MasterBot), следуйте инструкциям и создайте нового бота. После создания
бота MasterBot отправит вам токен.
### Установка библиотеки
```bash
composer require bushlanov-dev/max-bot-api-client-php
```
### Инициализация бота
Единственной обязательной настройкой является токен вашего бота.
⚠️ Никогда, и ни при каких обстоятельствах не храните токен в коде. ⚠️
Используйте переменные окружения!
```php
require __DIR__.'/vendor/autoload.php';
use BushlanovDev\MaxMessengerBot\Api;
$api = new Api('YOUR_BOT_API_TOKEN');
```
Так же вы можете создать экземпляр бота гибко настроив все зависимости под свои нужды.
```php
$api = new Api(
client: new Client(...),
modelFactory: new ModelFactory(),
logger: new YourPsrLogger(),
);
```
## Информация о боте
### Получение информации о боте
```php
$botInfo = $api->getBotInfo();
```
### Редактирование информации о боте
Обратите внимание что данный метод отправляется PATCH запросом. Это значит, что будут обновлены только переданные
поля.
В следующем примере мы изменяем только название бота и отчистим его описание. Остальные поля останутся неизменными.
```php
$botInfo = $api->editBotInfo(
new BotPatch(
name: 'Супер бот',
description: null,
)
);
```
+600 -123
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;
@@ -19,19 +18,24 @@ 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;
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;
use RuntimeException;
@@ -43,13 +47,17 @@ use RuntimeException;
*/
class Api
{
private const string API_BASE_URL = 'https://botapi.max.ru';
public const string LIBRARY_VERSION = '1.0.1';
public const string API_VERSION = '0.0.6';
private const string API_BASE_URL = 'https://botapi.max.ru';
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';
private const string ACTION_SUBSCRIPTIONS = '/subscriptions';
@@ -59,26 +67,43 @@ class Api
private const string ACTION_CHATS_ACTIONS = '/chats/%d/actions';
private const string ACTION_CHATS_PIN = '/chats/%d/pin';
private const string ACTION_CHATS_MEMBERS_ME = '/chats/%d/members/me';
private const string ACTION_CHATS_MEMBERS_ADMINS = '/chats/%d/members/admins';
private const string ACTION_CHATS_MEMBERS_ADMINS_ID = '/chats/%d/members/admins/%d';
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;
private readonly ModelFactory $modelFactory;
private readonly LoggerInterface $logger;
private readonly UpdateDispatcher $updateDispatcher;
/**
* Api constructor.
*
* @param string $accessToken Your bot's access token from @MasterBot.
* @param string|null $accessToken Your bot's access token from @MasterBot.
* @param ClientApiInterface|null $client Http api client.
* @param ModelFactory|null $modelFactory
* @param ModelFactory|null $modelFactory The model factory.
* @param LoggerInterface|null $logger PSR LoggerInterface.
*
* @throws InvalidArgumentException
*/
public function __construct(
string $accessToken,
?string $accessToken = null,
?ClientApiInterface $client = null,
?ModelFactory $modelFactory = null
?ModelFactory $modelFactory = null,
?LoggerInterface $logger = null,
) {
if (empty($accessToken) && $client === null) {
throw new InvalidArgumentException('You must provide either an access token or a client.');
}
$this->logger = $logger ?? new NullLogger();
if ($client === null) {
if (!class_exists(\GuzzleHttp\Client::class) || !class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) {
throw new LogicException(
@@ -87,7 +112,12 @@ class Api
);
}
$guzzle = new \GuzzleHttp\Client();
$guzzle = new \GuzzleHttp\Client([
'timeout' => 10,
'connect_timeout' => 5,
'read_timeout' => 10,
'headers' => ['User-Agent' => 'max-bot-api-client-php/' . self::LIBRARY_VERSION . ' PHP/' . PHP_VERSION],
]);
$httpFactory = new \GuzzleHttp\Psr7\HttpFactory();
$client = new Client(
$accessToken,
@@ -96,75 +126,74 @@ class Api
$httpFactory,
self::API_BASE_URL,
self::API_VERSION,
$this->logger,
);
}
$this->client = $client;
$this->modelFactory = $modelFactory ?? new ModelFactory();
$this->updateDispatcher = new UpdateDispatcher($this);
}
/**
* Performs a request to the Max Bot API.
*
* @param string $method The HTTP method (GET, POST, PATCH, etc.).
* @param string $uri The API endpoint (e.g., '/me', '/messages').
* @param array<string, mixed> $queryParams Query parameters for the request.
* @param array<string, mixed> $body The request body.
*
* @return array<string, mixed> The decoded JSON response as an associative array.
* @throws ClientApiException for API-level errors (4xx, 5xx).
* @throws NetworkException for network-related issues.
* @throws SerializationException for JSON encoding/decoding failures.
* @codeCoverageIgnore
*/
public function request(string $method, string $uri, array $queryParams = [], array $body = []): array
{
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);
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,
);
}
/**
@@ -204,58 +233,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) {
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<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.
*
@@ -343,7 +320,7 @@ class Api
}
/**
* Sends a message to a chat.
* Sends a message to a chat or user.
*
* @param int|null $userId Fill this parameter if you want to send message to user.
* @param int|null $chatId Fill this if you send message to chat.
@@ -376,27 +353,76 @@ class Api
'disable_link_preview' => $disableLinkPreview,
];
$body = [
'text' => $text,
'format' => $format?->value,
'notify' => $notify,
'link' => $link,
'attachments' => $attachments !== null ? array_map(
fn(AbstractModel $attachment) => $attachment->toArray(),
$attachments,
) : null,
];
$response = $this->client->request(
self::METHOD_POST,
self::ACTION_MESSAGES,
array_filter($query, fn($item) => null !== $item),
array_filter($body, fn($item) => null !== $item),
$this->buildNewMessageBody($text, $attachments, $format, $link, $notify),
);
return $this->modelFactory->createMessage($response['message']);
}
/**
* Sends a message to a user.
*
* @param int|null $userId Fill this parameter if you want to send message to user.
* @param string|null $text Message text.
* @param AbstractAttachmentRequest[]|null $attachments Message attachments.
* @param MessageFormat|null $format Message format.
* @param MessageLink|null $link Link to message.
* @param bool $notify If false, chat participants would not be notified.
* @param bool $disableLinkPreview If false, server will not generate media preview for links in text.
*
* @return Message
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
* @codeCoverageIgnore
*/
public function sendUserMessage(
?int $userId = null,
?string $text = null,
?array $attachments = null,
?MessageFormat $format = null,
?MessageLink $link = null,
bool $notify = true,
bool $disableLinkPreview = false,
): Message {
return $this->sendMessage($userId, null, $text, $attachments, $format, $link, $notify, $disableLinkPreview);
}
/**
* Sends a message to a chat.
*
* @param int|null $chatId Fill this if you send message to chat.
* @param string|null $text Message text.
* @param AbstractAttachmentRequest[]|null $attachments Message attachments.
* @param MessageFormat|null $format Message format.
* @param MessageLink|null $link Link to message.
* @param bool $notify If false, chat participants would not be notified.
* @param bool $disableLinkPreview If false, server will not generate media preview for links in text.
*
* @return Message
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
* @codeCoverageIgnore
*/
public function sendChatMessage(
?int $chatId = null,
?string $text = null,
?array $attachments = null,
?MessageFormat $format = null,
?MessageLink $link = null,
bool $notify = true,
bool $disableLinkPreview = false,
): Message {
return $this->sendMessage(null, $chatId, $text, $attachments, $format, $link, $notify, $disableLinkPreview);
}
/**
* Returns the URL for the subsequent file upload.
*
@@ -653,7 +679,7 @@ class Api
*
* @param int $chatId Chat identifier to leave from.
*
* @return Result A simple success/fail result.
* @return Result
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
@@ -668,4 +694,455 @@ class Api
)
);
}
/**
* Returns messages in a chat. Messages are traversed in reverse chronological order.
*
* @param int $chatId Identifier of the chat to get messages from.
* @param string[]|null $messageIds A comma-separated list of message IDs to retrieve.
* @param int|null $from Start time (Unix timestamp in ms) for the requested messages.
* @param int|null $to End time (Unix timestamp in ms) for the requested messages.
* @param int|null $count Maximum amount of messages in the response (1-100, default 50).
*
* @return Message[]
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function getMessages(
int $chatId,
?array $messageIds = null,
?int $from = null,
?int $to = null,
?int $count = null,
): array {
$query = [
'chat_id' => $chatId,
'message_ids' => $messageIds !== null ? implode(',', $messageIds) : null,
'from' => $from,
'to' => $to,
'count' => $count,
];
$response = $this->client->request(
self::METHOD_GET,
self::ACTION_MESSAGES,
array_filter($query, fn($value) => $value !== null),
);
return $this->modelFactory->createMessages($response);
}
/**
* Deletes a message in a dialog or in a chat if the bot has permission to delete messages.
*
* @param string $messageId Identifier of the message to be deleted.
*
* @return Result
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function deleteMessage(string $messageId): Result
{
return $this->modelFactory->createResult(
$this->client->request(
self::METHOD_DELETE,
self::ACTION_MESSAGES,
['message_id' => $messageId],
)
);
}
/**
* Returns a single message by its identifier.
*
* @param string $messageId Message identifier (`mid`) to get.
*
* @return Message
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function getMessageById(string $messageId): Message
{
return $this->modelFactory->createMessage(
$this->client->request(
self::METHOD_GET,
self::ACTION_MESSAGES . '/' . $messageId,
)
);
}
/**
* Pins a message in a chat or channel.
*
* @param int $chatId Chat identifier where the message should be pinned.
* @param string $messageId Identifier of the message to pin.
* @param bool $notify If true, participants will be notified with a system message.
*
* @return Result
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function pinMessage(int $chatId, string $messageId, bool $notify = true): Result
{
return $this->modelFactory->createResult(
$this->client->request(
self::METHOD_PUT,
sprintf(self::ACTION_CHATS_PIN, $chatId),
[],
[
'message_id' => $messageId,
'notify' => $notify,
],
)
);
}
/**
* Returns all chat administrators. The bot must be an administrator in the requested chat.
*
* @param int $chatId Chat identifier.
*
* @return ChatMembersList
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function getAdmins(int $chatId): ChatMembersList
{
return $this->modelFactory->createChatMembersList(
$this->client->request(
self::METHOD_GET,
sprintf(self::ACTION_CHATS_MEMBERS_ADMINS, $chatId),
)
);
}
/**
* Returns a paginated list of users who are participating in a chat.
*
* @param int $chatId The identifier of the chat.
* @param int[]|null $userIds A list of user identifiers to get their specific membership.
* When this parameter is passed, `count` and `marker` are ignored.
* @param int|null $marker The pagination marker to get the next page of members.
* @param int|null $count The number of members to return (1-100, default is 20).
*
* @return ChatMembersList
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function getMembers(
int $chatId,
?array $userIds = null,
?int $marker = null,
?int $count = null,
): ChatMembersList {
$query = [
'user_ids' => $userIds !== null ? implode(',', $userIds) : null,
'marker' => $marker,
'count' => $count,
];
return $this->modelFactory->createChatMembersList(
$this->client->request(
self::METHOD_GET,
sprintf(self::ACTION_CHATS_MEMBERS, $chatId),
array_filter($query, fn($value) => $value !== null),
)
);
}
/**
* Revokes admin rights from a user in the chat.
*
* @param int $chatId The identifier of the chat.
* @param int $userId The identifier of the user to revoke admin rights from.
*
* @return Result
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function deleteAdmins(int $chatId, int $userId): Result
{
return $this->modelFactory->createResult(
$this->client->request(
self::METHOD_DELETE,
sprintf(self::ACTION_CHATS_MEMBERS_ADMINS_ID, $chatId, $userId),
)
);
}
/**
* Removes a member from a chat. The bot may require additional permissions.
*
* @param int $chatId The identifier of the chat.
* @param int $userId The identifier of the user to remove.
* @param bool $block Set to true if the user should also be blocked in the chat.
* Applicable only for chats with a public or private link.
*
* @return Result
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function deleteMember(int $chatId, int $userId, bool $block = false): Result
{
return $this->modelFactory->createResult(
$this->client->request(
self::METHOD_DELETE,
sprintf(self::ACTION_CHATS_MEMBERS, $chatId),
[
'user_id' => $userId,
'block' => $block,
],
)
);
}
/**
* Sets the administrators for a chat.
*
* @param int $chatId The identifier of the chat.
* @param ChatAdmin[] $admins An array of ChatAdmin objects representing the users and their permissions.
*
* @return Result
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function addAdmins(int $chatId, array $admins): Result
{
return $this->modelFactory->createResult(
$this->client->request(
self::METHOD_POST,
sprintf(self::ACTION_CHATS_MEMBERS_ADMINS, $chatId),
[],
['admins' => array_map(fn(ChatAdmin $admin) => $admin->toArray(), $admins)],
)
);
}
/**
* Adds members to a chat. The bot may require additional permissions.
*
* @param int $chatId The identifier of the chat.
* @param int[] $userIds An array of user identifiers to add to the chat.
*
* @return Result
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function addMembers(int $chatId, array $userIds): Result
{
return $this->modelFactory->createResult(
$this->client->request(
self::METHOD_POST,
sprintf(self::ACTION_CHATS_MEMBERS, $chatId),
[],
['user_ids' => $userIds],
)
);
}
/**
* Sends an answer to a callback query. This should be called after a user clicks an inline button.
*
* @param string $callbackId The identifier of the callback query.
* @param string|null $notification A short text notification to show to the user.
* @param string|null $text If provided, the original message will be edited with this text.
* @param AbstractAttachmentRequest[]|null $attachments New attachments for the edited message.
* @param MessageLink|null $link New link for the edited message.
* @param MessageFormat|null $format Formatting for the new message text.
* @param bool $notify Notification setting for the edited message.
*
* @return Result
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function answerOnCallback(
string $callbackId,
?string $notification = null,
?string $text = null,
?array $attachments = null,
?MessageLink $link = null,
?MessageFormat $format = null,
bool $notify = true,
): Result {
$answerBody = ['notification' => $notification];
if ($text !== null || $attachments !== null || $link !== null) {
$answerBody['message'] = $this->buildNewMessageBody($text, $attachments, $format, $link, $notify);
}
return $this->modelFactory->createResult(
$this->client->request(
self::METHOD_POST,
self::ACTION_ANSWERS,
['callback_id' => $callbackId],
array_filter($answerBody, fn($value) => $value !== null)
)
);
}
/**
* Edits a message that was previously sent by the bot.
* Note on attachments:
* - To leave attachments unchanged, pass `null` (default).
* - To remove all attachments, pass an empty array `[]`.
*
* @param string $messageId The identifier of the message to edit.
* @param string|null $text New message text.
* @param AbstractAttachmentRequest[]|null $attachments New message attachments.
* @param MessageFormat|null $format Formatting for the new message text.
* @param MessageLink|null $link New link for the edited message.
* @param bool $notify Notification setting for the edited message.
*
* @return Result
* @throws ClientApiException
* @throws NetworkException
* @throws ReflectionException
* @throws SerializationException
*/
public function editMessage(
string $messageId,
?string $text = null,
?array $attachments = null,
?MessageFormat $format = null,
?MessageLink $link = null,
bool $notify = true,
): Result {
return $this->modelFactory->createResult(
$this->client->request(
self::METHOD_PUT,
self::ACTION_MESSAGES,
['message_id' => $messageId],
$this->buildNewMessageBody($text, $attachments, $format, $link, $notify),
)
);
}
/**
* 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.
*
* @param string|null $text
* @param AbstractAttachmentRequest[]|null $attachments
* @param MessageFormat|null $format
* @param MessageLink|null $link
* @param bool $notify
*
* @return array<string, mixed>
* @throws ReflectionException
*/
private function buildNewMessageBody(
?string $text,
?array $attachments,
?MessageFormat $format,
?MessageLink $link,
bool $notify,
): array {
$body = [
'text' => $text,
'format' => $format?->value,
'notify' => $notify,
'link' => $link,
'attachments' => $attachments !== null ? array_map(
fn(AbstractModel $attachment) => $attachment->toArray(),
$attachments,
) : null,
];
return array_filter($body, fn($item) => $item !== null);
}
}
+24
View File
@@ -19,6 +19,8 @@ use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* The low-level HTTP client responsible for communicating with the Max Bot API.
@@ -34,6 +36,7 @@ final readonly class Client implements ClientApiInterface
* @param StreamFactoryInterface $streamFactory A PSR-17 factory for creating request body streams.
* @param string $baseUrl The base URL for API requests.
* @param string|null $apiVersion The API version to use for requests.
* @param LoggerInterface $logger
*
* @throws InvalidArgumentException
*/
@@ -44,6 +47,7 @@ final readonly class Client implements ClientApiInterface
private StreamFactoryInterface $streamFactory,
private string $baseUrl,
private ?string $apiVersion = null,
private LoggerInterface $logger = new NullLogger(),
) {
if (empty($accessToken)) {
throw new InvalidArgumentException('Access token cannot be empty.');
@@ -60,6 +64,12 @@ final readonly class Client implements ClientApiInterface
$queryParams['v'] = $this->apiVersion;
}
$this->logger->debug('Sending API request', [
'method' => $method,
'url' => $this->baseUrl . $uri,
'body' => $body,
]);
$fullUrl = $this->baseUrl . $uri . '?' . http_build_query($queryParams);
$request = $this->requestFactory->createRequest($method, $fullUrl);
@@ -79,6 +89,10 @@ final readonly class Client implements ClientApiInterface
$response = $this->httpClient->sendRequest($request);
} catch (ClientExceptionInterface $e) {
// This catches network errors, DNS failures, timeouts, etc.
$this->logger->error('Network exception during API request', [
'message' => $e->getMessage(),
'exception' => $e,
]);
throw new NetworkException($e->getMessage(), $e->getCode(), $e);
}
@@ -86,6 +100,11 @@ final readonly class Client implements ClientApiInterface
$responseBody = (string)$response->getBody();
$this->logger->debug('Received API response', [
'status' => $response->getStatusCode(),
'body' => $responseBody,
]);
// Handle successful but empty responses (e.g., from DELETE endpoints)
if (empty($responseBody)) {
// The API spec often returns {"success": true}, so we can simulate that
@@ -161,6 +180,11 @@ final readonly class Client implements ClientApiInterface
$errorCode = $data['code'] ?? 'unknown';
$errorMessage = $data['message'] ?? 'An unknown error occurred.';
$this->logger->error('API error response received', [
'status' => $statusCode,
'body' => $responseBody,
]);
throw match ($statusCode) {
401 => new UnauthorizedException($errorMessage, $errorCode, $response),
403 => new ForbiddenException($errorMessage, $errorCode, $response),
+1 -1
View File
@@ -26,7 +26,7 @@ interface ClientApiInterface
public function request(string $method, string $uri, array $queryParams = [], array $body = []): array;
/**
* Performs a file download at the specified URL.
* Performs a file upload at the specified URL.
*
* @param string $uri URL received from the download API.
* @param resource|string $fileContents File content (stream resource or string).
+3 -1
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Enums;
enum AttachmentType: string
enum AttachmentType: string
{
case Image = 'image';
case Video = 'video';
@@ -13,6 +13,8 @@ enum AttachmentType: string
case Sticker = 'sticker';
case Contact = 'contact';
case InlineKeyboard = 'inline_keyboard';
case ReplyKeyboard = 'reply_keyboard';
case Location = 'location';
case Share = 'share';
case Data = 'data';
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Enums;
enum ButtonType: string
enum InlineButtonType: string
{
case Callback = 'callback';
case Link = 'link';
@@ -12,4 +12,5 @@ enum ButtonType: string
case RequestContact = 'request_contact';
case OpenApp = 'open_app';
case Message = 'message';
case Chat = 'chat';
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Enums;
enum MarkupType: string
{
case Strong = 'strong';
case Emphasized = 'emphasized';
case Monospaced = 'monospaced';
case Link = 'link';
case Strikethrough = 'strikethrough';
case Underline = 'underline';
case UserMention = 'user_mention';
case Heading = 'heading';
case Highlighted = 'highlighted';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Enums;
enum ReplyButtonType: string
{
case Message = 'message';
case UserGeoLocation = 'user_geo_location';
case UserContact = 'user_contact';
}
-5
View File
@@ -5,12 +5,7 @@ declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Exceptions;
use RuntimeException;
use Throwable;
class NetworkException extends RuntimeException
{
public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
@@ -5,12 +5,7 @@ declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Exceptions;
use LogicException;
use Throwable;
class SerializationException extends LogicException
{
public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Laravel\Commands;
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Artisan command to start processing updates via long polling.
*/
class PollingStartCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'maxbot:polling:start
{--timeout=90 : Timeout in seconds for long polling}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Start the bot to process updates via long polling';
/**
* Execute the console command.
*/
public function handle(MaxBotManager $botManager): int
{
$timeout = (int)$this->option('timeout');
$this->info("Starting long polling with a timeout of $timeout seconds... Press Ctrl+C to stop.");
try {
$botManager->startLongPolling($timeout);
// @codeCoverageIgnoreStart
// This part is unreachable as startLongPolling is an infinite loop
return self::SUCCESS;
// @codeCoverageIgnoreEnd
} catch (Throwable $e) {
Log::error("Long polling failed to start or crashed: {$e->getMessage()}", [
'exception' => $e,
]);
$this->error("❌ Long polling failed: {$e->getMessage()}");
return self::FAILURE;
}
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Laravel\Commands;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Artisan command for listing active webhook subscriptions.
*/
class WebhookListCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'maxbot:webhook:list';
/**
* The console command description.
*
* @var string
*/
protected $description = 'List all active webhook subscriptions';
/**
* Execute the console command.
*/
public function handle(Api $api): int
{
$this->info('Fetching webhook subscriptions...');
try {
$subscriptions = $api->getSubscriptions();
if (empty($subscriptions)) {
$this->info('No active webhook subscriptions found.');
return self::SUCCESS;
}
$this->info('Found ' . count($subscriptions) . ' active webhook subscription(s):');
$this->newLine();
$headers = ['URL', 'Update Types', 'Created At'];
$rows = [];
foreach ($subscriptions as $subscription) {
$rows[] = [
$subscription->url,
implode(', ', $subscription->updateTypes ? array_map(fn (UpdateType $updateType) => $updateType->value, $subscription->updateTypes) : ['all']),
date('Y-m-d H:i:s', $subscription->time),
];
}
$this->table($headers, $rows);
return self::SUCCESS;
} catch (Throwable $e) {
Log::error("Webhook list error: {$e->getMessage()}", [
'exception' => $e,
]);
$this->error("❌ Webhook list error: {$e->getMessage()}");
return self::FAILURE;
}
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Laravel\Commands;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use Illuminate\Console\Command;
use Illuminate\Contracts\Config\Repository as Config;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Artisan command for subscribing to webhook updates.
*/
class WebhookSubscribeCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'maxbot:webhook:subscribe
{url : The webhook URL to subscribe to}
{--secret= : Secret key for webhook verification (optional)}
{--types=* : Update types to subscribe to (optional)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Subscribe bot to webhook updates';
/**
* Execute the console command.
*/
public function handle(Api $api, Config $config): int
{
$url = (string)$this->argument('url'); // @phpstan-ignore-line
$secret = $this->option('secret') ?? $config->get('maxbot.webhook_secret');
$types = $this->option('types');
if (!filter_var($url, FILTER_VALIDATE_URL)) {
$this->error('Invalid URL provided.');
return self::FAILURE;
}
$updateTypes = null;
if (is_array($types) && !empty($types)) {
$updateTypes = [];
foreach ($types as $type) {
try {
$updateTypes[] = UpdateType::from($type);
} catch (\ValueError $e) {
$this->error("Invalid update type: $type");
return self::FAILURE;
}
}
}
$this->info('Subscribing to webhook...');
$this->line("URL: $url");
if ($secret) {
$this->line("Secret: " . str_repeat('*', strlen($secret)));
}
if ($updateTypes) {
$this->line("Update types: " . implode(', ', array_map(fn($type) => $type->value, $updateTypes)));
} else {
$this->line("Update types: All (default)");
}
try {
$result = $api->subscribe($url, $secret, $updateTypes);
if ($result->success) {
$this->info('✅ Successfully subscribed to webhook!');
return self::SUCCESS;
} else {
$this->error('❌ Failed to subscribe to webhook.');
$this->line("Response: $result->message");
return self::FAILURE;
}
} catch (Throwable $e) {
Log::error("Webhook subscription error: {$e->getMessage()}", [
'exception' => $e,
]);
$this->error("❌ Webhook subscription error: {$e->getMessage()}");
return self::FAILURE;
}
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Laravel\Commands;
use BushlanovDev\MaxMessengerBot\Api;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Artisan command for unsubscribing from webhook updates.
*/
class WebhookUnsubscribeCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'maxbot:webhook:unsubscribe
{url : The webhook URL to unsubscribe from}
{--confirm : Skip confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Unsubscribe bot from webhook updates';
/**
* Execute the console command.
*/
public function handle(Api $api): int
{
$url = (string)$this->argument('url'); // @phpstan-ignore-line
$confirm = $this->option('confirm');
if (!filter_var($url, FILTER_VALIDATE_URL)) {
$this->error('Invalid URL provided.');
return self::FAILURE;
}
if (!$confirm) {
if (!$this->confirm("Are you sure you want to unsubscribe from webhook URL: $url?")) {
$this->info('Operation cancelled.');
return self::SUCCESS;
}
}
$this->info('Unsubscribing from webhook...');
$this->line("URL: $url");
try {
$result = $api->unsubscribe($url);
if ($result->success) {
$this->info('✅ Successfully unsubscribed from webhook!');
return self::SUCCESS;
} else {
$this->error('❌ Failed to unsubscribe from webhook.');
$this->line("Response: $result->message");
return self::FAILURE;
}
} catch (Throwable $e) {
Log::error("Webhook unsubscribe error: {$e->getMessage()}", [
'exception' => $e,
]);
$this->error("❌ Webhook unsubscribe error: {$e->getMessage()}");
return self::FAILURE;
}
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Laravel;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Enums\MessageFormat;
use BushlanovDev\MaxMessengerBot\Enums\SenderAction;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Enums\UploadType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\AbstractAttachmentRequest;
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;
use BushlanovDev\MaxMessengerBot\Models\Subscription;
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
use BushlanovDev\MaxMessengerBot\WebhookHandler;
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
use Illuminate\Support\Facades\Facade;
/**
* Laravel Facade for Max Bot API Client.
*
* Provides static access to the Max Bot API methods through Laravel's facade system.
*
* @method static array<string, mixed> request(string $method, string $uri, array<string, mixed> $queryParams = [], array<string, mixed> $body = [])
* @method static UpdateDispatcher getUpdateDispatcher()
* @method static WebhookHandler createWebhookHandler(?string $secret = null)
* @method static LongPollingHandler createLongPollingHandler()
* @method static UpdateList getUpdates(?int $limit = null, ?int $timeout = null, ?int $marker = null, ?array<UpdateType> $types = null)
* @method static BotInfo getBotInfo()
* @method static Subscription[] getSubscriptions()
* @method static Result subscribe(string $url, ?string $secret = null, ?array<UpdateType> $updateTypes = null)
* @method static Result unsubscribe(string $url)
* @method static Message sendMessage(?int $userId = null, ?int $chatId = null, ?string $text = null, ?array<AbstractAttachmentRequest> $attachments = null, ?MessageFormat $format = null, ?MessageLink $link = null, bool $notify = true, bool $disableLinkPreview = false)
* @method static Message sendUserMessage(?int $userId = null, ?string $text = null, ?array<AbstractAttachmentRequest> $attachments = null, ?MessageFormat $format = null, ?MessageLink $link = null, bool $notify = true, bool $disableLinkPreview = false)
* @method static Message sendChatMessage(?int $chatId = null, ?string $text = null, ?array<AbstractAttachmentRequest> $attachments = null, ?MessageFormat $format = null, ?MessageLink $link = null, bool $notify = true, bool $disableLinkPreview = false)
* @method static UploadEndpoint getUploadUrl(UploadType $type)
* @method static AbstractAttachmentRequest uploadAttachment(UploadType $type, string $filePath)
* @method static Chat getChat(int $chatId)
* @method static Chat getChatByLink(string $chatLink)
* @method static ChatList getChats(?int $count = null, ?int $marker = null)
* @method static Result deleteChat(int $chatId)
* @method static Result sendAction(int $chatId, SenderAction $action)
* @method static Message|null getPinnedMessage(int $chatId)
* @method static Result unpinMessage(int $chatId)
* @method static ChatMember getMembership(int $chatId)
* @method static Result leaveChat(int $chatId)
* @method static Message[] getMessages(int $chatId, ?array<string> $messageIds = null, ?int $from = null, ?int $to = null, ?int $count = null)
* @method static Result deleteMessage(string $messageId)
* @method static Message getMessageById(string $messageId)
* @method static Result pinMessage(int $chatId, string $messageId, bool $notify = true)
* @method static ChatMembersList getAdmins(int $chatId)
* @method static ChatMembersList getMembers(int $chatId, ?array<int> $userIds = null, ?int $marker = null, ?int $count = null)
* @method static Result deleteAdmins(int $chatId, int $userId)
* @method static Result deleteMember(int $chatId, int $userId, bool $block = false)
* @method static Result addAdmins(int $chatId, array<ChatAdmin> $admins)
* @method static Result addMembers(int $chatId, array<int> $userIds)
* @method static Result answerOnCallback(string $callbackId, ?string $notification = null, ?string $text = null, ?array<AbstractAttachmentRequest> $attachments = null, ?MessageLink $link = null, ?MessageFormat $format = null, bool $notify = true)
* @method static Result editMessage(string $messageId, ?string $text = null, ?array<AbstractAttachmentRequest> $attachments = null, ?MessageFormat $format = null, ?MessageLink $link = null, bool $notify = true)
* @method static BotInfo editBotInfo(BotPatch $botPatch)
* @method static Chat editChat(int $chatId, ChatPatch $chatPatch)
* @method static VideoAttachmentDetails getVideoAttachmentDetails(string $videoToken)
*
* @see Api
* @codeCoverageIgnore
*/
class MaxBotFacade extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor(): string
{
return 'maxbot';
}
}
+363
View File
@@ -0,0 +1,363 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Laravel;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
use BushlanovDev\MaxMessengerBot\WebhookHandler;
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
use GuzzleHttp\Psr7\ServerRequest;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Container\Container;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Max Bot Manager for Laravel integration.
*
* Provides convenient methods for integrating Max Bot with Laravel applications.
* Handles webhook processing, long polling, and event dispatching within Laravel context.
*/
class MaxBotManager
{
/**
* @param Container $container
* @param Api $api
* @param UpdateDispatcher $dispatcher
*/
public function __construct(
private readonly Container $container,
private readonly Api $api,
private readonly UpdateDispatcher $dispatcher,
) {
}
/**
* Handle webhook request in Laravel controller.
*
* Example usage in a controller:
* ```php
* public function webhook(Request $request, MaxBotManager $botManager)
* {
* return $botManager->handleWebhook($request);
* }
* ```
*/
public function handleWebhook(Request $request): JsonResponse|Response
{
try {
/** @var WebhookHandler $webhookHandler */
$webhookHandler = $this->container->make(WebhookHandler::class);
$headers = array_map(function ($values) {
return array_filter($values, fn($value) => $value !== null);
}, $request->headers->all());
$webhookHandler->handle(
new ServerRequest(
$request->getMethod(),
$request->getUri(),
$headers,
$request->getContent(),
$request->getProtocolVersion() ?? '1.1',
)
);
return new Response('', 200);
} catch (SecurityException $e) {
Log::warning("Webhook security error: {$e->getMessage()}", [
'exception' => $e,
'headers' => $request->headers->all(),
]);
return new JsonResponse([
'status' => 'error',
'message' => 'Forbidden',
], 403);
} catch (SerializationException $e) {
Log::error("Webhook serialization error: {$e->getMessage()}", [
'exception' => $e,
'request_content' => $request->getContent(),
]);
return new JsonResponse([
'status' => 'error',
'message' => 'Bad Request',
], 400);
} catch (Throwable $e) {
Log::error("Webhook processing error: {$e->getMessage()}", [
'exception' => $e,
'request_content' => $request->getContent(),
'headers' => $request->headers->all(),
]);
return new JsonResponse([
'status' => 'error',
'message' => 'Internal Server Error',
], 500);
}
}
/**
* Start long polling in Laravel context.
* This method should be called from a Laravel command or job.
* It will run indefinitely until stopped.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function startLongPolling(int $timeout = 90, ?int $marker = null): void
{
/** @var LongPollingHandler $longPolling */
$longPolling = $this->container->make(LongPollingHandler::class);
$longPolling->handle($timeout, $marker);
}
/**
* Registers a handler for a specific update type.
*
* @param UpdateType $type The type of update to handle.
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function addHandler(UpdateType $type, callable|string $handler): void
{
$this->dispatcher->addHandler($type, $this->resolveHandler($handler));
}
/**
* Register a command handler.
*
* @param string $command Command name (without slash)
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onCommand(string $command, callable|string $handler): void
{
$this->dispatcher->onCommand($command, $this->resolveHandler($handler));
}
/**
* Register a message created handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onMessageCreated(callable|string $handler): void
{
$this->dispatcher->onMessageCreated($this->resolveHandler($handler));
}
/**
* Register a callback handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onMessageCallback(callable|string $handler): void
{
$this->dispatcher->onMessageCallback($this->resolveHandler($handler));
}
/**
* Register a message edited handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onMessageEdited(callable|string $handler): void
{
$this->dispatcher->onMessageEdited($this->resolveHandler($handler));
}
/**
* Register a message removed handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onMessageRemoved(callable|string $handler): void
{
$this->dispatcher->onMessageRemoved($this->resolveHandler($handler));
}
/**
* Register a bot added handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onBotAdded(callable|string $handler): void
{
$this->dispatcher->onBotAdded($this->resolveHandler($handler));
}
/**
* Register a bot removed handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onBotRemoved(callable|string $handler): void
{
$this->dispatcher->onBotRemoved($this->resolveHandler($handler));
}
/**
* Register a user added handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onUserAdded(callable|string $handler): void
{
$this->dispatcher->onUserAdded($this->resolveHandler($handler));
}
/**
* Register a user removed handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onUserRemoved(callable|string $handler): void
{
$this->dispatcher->onUserRemoved($this->resolveHandler($handler));
}
/**
* Register a bot started handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onBotStarted(callable|string $handler): void
{
$this->dispatcher->onBotStarted($this->resolveHandler($handler));
}
/**
* Register a chat title changed handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onChatTitleChanged(callable|string $handler): void
{
$this->dispatcher->onChatTitleChanged($this->resolveHandler($handler));
}
/**
* Register a message chat created handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onMessageChatCreated(callable|string $handler): void
{
$this->dispatcher->onMessageChatCreated($this->resolveHandler($handler));
}
/**
* Get the API instance.
* @codeCoverageIgnore
*/
public function getApi(): Api
{
return $this->api;
}
/**
* Get the update dispatcher.
* @codeCoverageIgnore
*/
public function getDispatcher(): UpdateDispatcher
{
return $this->dispatcher;
}
/**
* Resolve a handler that might be a Laravel container binding.
*
* @param callable|string $handler
* @return callable
* @phpstan-return callable(AbstractUpdate, Api): void
* @throws BindingResolutionException
*/
private function resolveHandler(callable|string $handler): callable
{
if (is_string($handler)) {
if ($this->container->bound($handler)) {
$resolved = $this->container->make($handler);
if (is_callable($resolved)) {
return $resolved;
}
if (is_object($resolved) && method_exists($resolved, 'handle')) {
/** @var callable */
return [$resolved, 'handle'];
}
throw new \InvalidArgumentException(
"Handler class '$handler' is not callable and doesn't have a handle method."
);
}
if (str_contains($handler, '@')) {
[$class, $method] = explode('@', $handler, 2);
$instance = $this->container->make($class);
/** @var callable */
return [$instance, $method];
}
if (class_exists($handler)) {
$instance = $this->container->make($handler);
if (method_exists($instance, 'handle')) {
/** @var callable */
return [$instance, 'handle'];
}
}
throw new \InvalidArgumentException("Unable to resolve handler: $handler");
}
return $handler;
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Laravel;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Client;
use BushlanovDev\MaxMessengerBot\ClientApiInterface;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\PollingStartCommand;
use BushlanovDev\MaxMessengerBot\ModelFactory;
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
use BushlanovDev\MaxMessengerBot\WebhookHandler;
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookSubscribeCommand;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookUnsubscribeCommand;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookListCommand;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Config\Repository as Config;
use Psr\Log\LoggerInterface;
use InvalidArgumentException;
use Psr\Log\NullLogger;
/**
* Laravel Service Provider for Max Bot API Client.
* Registers all necessary services in the Laravel container.
*/
class MaxBotServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->mergeConfigFrom(
__DIR__ . '/config/maxbot.php',
'maxbot',
);
$this->app->singleton(ClientApiInterface::class, function (Application $app) {
/** @var Config $config */
$config = $app->make(Config::class);
$accessToken = $config->get('maxbot.access_token');
if (empty($accessToken)) {
throw new InvalidArgumentException(
'Max Bot access token is not configured. Please set MAXBOT_ACCESS_TOKEN in your .env file.'
);
}
if (!class_exists(\GuzzleHttp\Client::class) || !class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) {
throw new \LogicException(
'Guzzle HTTP client is required. Please run "composer require guzzlehttp/guzzle".'
);
}
$logger = $config->get('maxbot.logging.enabled', false)
? $app->make(LoggerInterface::class)
: new NullLogger();
$guzzle = new \GuzzleHttp\Client([
'timeout' => (int)$config->get('maxbot.timeout', 10),
'connect_timeout' => (int)$config->get('maxbot.connect_timeout', 5),
'read_timeout' => (int)$config->get('maxbot.read_timeout', 10),
'headers' => [
'User-Agent' => 'max-bot-api-client-php/' . Api::LIBRARY_VERSION
. ' Laravel/' . $app->version() . ' PHP/' . PHP_VERSION
],
]);
$httpFactory = new \GuzzleHttp\Psr7\HttpFactory();
return new Client(
$accessToken,
$guzzle,
$httpFactory,
$httpFactory,
$config->get('maxbot.base_url', 'https://botapi.max.ru'),
$config->get('maxbot.api_version', Api::API_VERSION),
$logger,
);
});
$this->app->singleton(ModelFactory::class, function () {
return new ModelFactory();
});
$this->app->singleton(Api::class, function (Application $app) {
/** @var Config $config */
$config = $app->make(Config::class);
$accessToken = $config->get('maxbot.access_token');
if (empty($accessToken)) {
throw new InvalidArgumentException(
'Max Bot access token is not configured. Please set MAXBOT_ACCESS_TOKEN in your .env file.'
);
}
return new Api(
$accessToken,
$app->make(ClientApiInterface::class),
$app->make(ModelFactory::class),
$app->make(LoggerInterface::class),
);
});
$this->app->singleton(UpdateDispatcher::class, function (Application $app) {
return $app->make(Api::class)->getUpdateDispatcher();
});
$this->app->bind(WebhookHandler::class, function (Application $app) {
/** @var Config $config */
$config = $app->make(Config::class);
$secret = $config->get('maxbot.webhook_secret');
return new WebhookHandler(
$app->make(UpdateDispatcher::class),
$app->make(ModelFactory::class),
$app->make(LoggerInterface::class),
$secret,
);
});
$this->app->bind(LongPollingHandler::class, function (Application $app) {
return new LongPollingHandler(
$app->make(Api::class),
$app->make(UpdateDispatcher::class),
$app->make(LoggerInterface::class),
);
});
$this->app->singleton(MaxBotManager::class, function (Application $app) {
return new MaxBotManager(
$app,
$app->make(Api::class),
$app->make(UpdateDispatcher::class),
);
});
$this->app->alias(Api::class, 'maxbot');
$this->app->alias(Api::class, 'maxbot.api');
$this->app->alias(ClientApiInterface::class, 'maxbot.client');
$this->app->alias(UpdateDispatcher::class, 'maxbot.dispatcher');
$this->app->alias(WebhookHandler::class, 'maxbot.webhook');
$this->app->alias(LongPollingHandler::class, 'maxbot.polling');
$this->app->alias(MaxBotManager::class, 'maxbot.manager');
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->publishes([
__DIR__ . '/config/maxbot.php' => $this->app->configPath('maxbot.php'),
], 'maxbot-config');
if ($this->app->runningInConsole()) {
$this->commands([
WebhookSubscribeCommand::class,
WebhookUnsubscribeCommand::class,
WebhookListCommand::class,
PollingStartCommand::class,
]);
}
}
/**
* Get the services provided by the provider.
*
* @return array<int, string>
*/
public function provides(): array
{
return [
Api::class,
ClientApiInterface::class,
ModelFactory::class,
UpdateDispatcher::class,
WebhookHandler::class,
LongPollingHandler::class,
MaxBotManager::class,
'maxbot',
'maxbot.api',
'maxbot.client',
'maxbot.dispatcher',
'maxbot.webhook',
'maxbot.polling',
'maxbot.manager',
];
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
// @codeCoverageIgnoreStart
return [
/*
|--------------------------------------------------------------------------
| Max Bot Access Token
|--------------------------------------------------------------------------
|
| Your bot's access token from @MasterBot. This token is required for
| authentication with the Max Bot API. You can obtain it by creating
| a bot through @MasterBot in the Max messenger.
|
*/
'access_token' => env('MAXBOT_ACCESS_TOKEN'),
/*
|--------------------------------------------------------------------------
| Webhook Secret
|--------------------------------------------------------------------------
|
| Secret key for verifying the authenticity of webhook requests.
| This is optional but recommended for security. Set this if you're
| using webhooks to receive updates.
|
*/
'webhook_secret' => env('MAXBOT_WEBHOOK_SECRET'),
/*
|--------------------------------------------------------------------------
| API Configuration
|--------------------------------------------------------------------------
|
| Configuration for the Max Bot API connection.
|
*/
'base_url' => env('MAXBOT_BASE_URL', 'https://botapi.max.ru'),
'api_version' => env('MAXBOT_API_VERSION'),
/*
|--------------------------------------------------------------------------
| HTTP Client Configuration
|--------------------------------------------------------------------------
|
| Configuration for the HTTP client used to communicate with the API.
| All values are in seconds.
|
*/
'timeout' => (int)env('MAXBOT_TIMEOUT', 10),
'connect_timeout' => (int)env('MAXBOT_CONNECT_TIMEOUT', 5),
'read_timeout' => (int)env('MAXBOT_READ_TIMEOUT', 10),
/*
|--------------------------------------------------------------------------
| Logging Configuration
|--------------------------------------------------------------------------
|
| Whether to enable detailed logging of API requests and responses.
| This uses Laravel's configured logger.
|
*/
'logging' => [
'enabled' => (bool)env('MAXBOT_LOGGING_ENABLED', false),
'level' => env('MAXBOT_LOGGING_LEVEL', 'debug'),
],
];
// @codeCoverageIgnoreEnd
+104
View File
@@ -0,0 +1,104 @@
<?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.
* @codeCoverageIgnore
*/
public function __construct(
private Api $api,
private UpdateDispatcher $dispatcher,
private LoggerInterface $logger,
) {
if (!(\PHP_SAPI === 'cli')) {
throw new \RuntimeException('LongPollingHandler can only be used in CLI mode.');
}
}
/**
* 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 processUpdates(int $timeout, ?int $marker): ?int
{
$updateList = $this->api->getUpdates(timeout: $timeout, marker: $marker);
foreach ($updateList->updates as $update) {
try {
$this->dispatcher->dispatch($update);
} catch (\Throwable $e) {
$this->logger->error('Error dispatching update', [
'message' => $e->getMessage(),
'exception' => $e,
]);
}
}
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
{
$this->listenSignals();
// @phpstan-ignore-next-line
while (true) {
try {
$marker = $this->processUpdates($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);
}
}
}
/**
* @codeCoverageIgnore
*/
protected function listenSignals(): void
{
if (extension_loaded('pcntl')) {
pcntl_async_signals(true);
$kill = static function () {
exit(0);
};
pcntl_signal(SIGINT, $kill);
pcntl_signal(SIGQUIT, $kill);
pcntl_signal(SIGTERM, $kill);
}
}
}
+222
View File
@@ -4,12 +4,51 @@ declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Enums\InlineButtonType;
use BushlanovDev\MaxMessengerBot\Enums\MarkupType;
use BushlanovDev\MaxMessengerBot\Enums\ReplyButtonType;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\AbstractAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\AudioAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\AbstractInlineButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\CallbackButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\ChatButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\LinkButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\OpenAppButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\RequestContactButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\RequestGeoLocationButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\AbstractReplyButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\SendContactButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\SendGeoLocationButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\SendMessageButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\ContactAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\DataAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\FileAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\InlineKeyboardAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\LocationAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\PhotoAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\ReplyKeyboardAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\ShareAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\StickerAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\VideoAttachment;
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
use BushlanovDev\MaxMessengerBot\Models\Chat;
use BushlanovDev\MaxMessengerBot\Models\ChatList;
use BushlanovDev\MaxMessengerBot\Models\ChatMember;
use BushlanovDev\MaxMessengerBot\Models\ChatMembersList;
use BushlanovDev\MaxMessengerBot\Models\Markup\AbstractMarkup;
use BushlanovDev\MaxMessengerBot\Models\Markup\EmphasizedMarkup;
use BushlanovDev\MaxMessengerBot\Models\Markup\HeadingMarkup;
use BushlanovDev\MaxMessengerBot\Models\Markup\HighlightedMarkup;
use BushlanovDev\MaxMessengerBot\Models\Markup\LinkMarkup;
use BushlanovDev\MaxMessengerBot\Models\Markup\MonospacedMarkup;
use BushlanovDev\MaxMessengerBot\Models\Markup\StrikethroughMarkup;
use BushlanovDev\MaxMessengerBot\Models\Markup\StrongMarkup;
use BushlanovDev\MaxMessengerBot\Models\Markup\UnderlineMarkup;
use BushlanovDev\MaxMessengerBot\Models\Markup\UserMentionMarkup;
use BushlanovDev\MaxMessengerBot\Models\Message;
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
use BushlanovDev\MaxMessengerBot\Models\Result;
use BushlanovDev\MaxMessengerBot\Models\Subscription;
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
@@ -26,6 +65,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;
@@ -98,9 +138,139 @@ class ModelFactory
*/
public function createMessage(array $data): Message
{
if (isset($data['body']) && is_array($data['body'])) {
$data['body'] = $this->createMessageBody($data['body']);
}
return Message::fromArray($data);
}
/**
* List of messages.
*
* @param array<string, mixed> $data
*
* @return Message[]
*/
public function createMessages(array $data): array
{
return isset($data['messages']) && is_array($data['messages'])
? array_map([$this, 'createMessage'], $data['messages'])
: [];
}
/**
* Creates a MessageBody object from raw API data, handling polymorphic attachments and markup.
*
* @param array<string, mixed> $data
*
* @return MessageBody
* @throws ReflectionException
*/
private function createMessageBody(array $data): MessageBody
{
if (isset($data['attachments']) && is_array($data['attachments'])) {
$data['attachments'] = array_map(
[$this, 'createAttachment'],
$data['attachments'],
);
}
if (isset($data['markup']) && is_array($data['markup'])) {
$data['markup'] = array_map(
[$this, 'createMarkupElement'],
$data['markup'],
);
}
return MessageBody::fromArray($data);
}
/**
* Creates a specific Attachment model based on the 'type' field.
*
* @param array<string, mixed> $data
*
* @return AbstractAttachment
* @throws ReflectionException
*/
public function createAttachment(array $data): AbstractAttachment
{
$attachmentType = AttachmentType::tryFrom($data['type'] ?? '');
if ($attachmentType === AttachmentType::ReplyKeyboard
&& isset($data['buttons']) && is_array($data['buttons'])) {
$data['buttons'] = array_map(
fn($rowOfButtons) => array_map([$this, 'createReplyButton'], $rowOfButtons),
$data['buttons'],
);
}
if ($attachmentType === AttachmentType::InlineKeyboard
&& isset($data['payload']['buttons']) && is_array($data['payload']['buttons'])) {
$data['payload']['buttons'] = array_map(
fn($rowOfButtons) => array_map([$this, 'createInlineButton'], $rowOfButtons),
$data['payload']['buttons']
);
}
return match ($attachmentType) {
AttachmentType::Data => DataAttachment::fromArray($data),
AttachmentType::Share => ShareAttachment::fromArray($data),
AttachmentType::Image => PhotoAttachment::fromArray($data),
AttachmentType::Video => VideoAttachment::fromArray($data),
AttachmentType::Audio => AudioAttachment::fromArray($data),
AttachmentType::File => FileAttachment::fromArray($data),
AttachmentType::Sticker => StickerAttachment::fromArray($data),
AttachmentType::Contact => ContactAttachment::fromArray($data),
AttachmentType::InlineKeyboard => InlineKeyboardAttachment::fromArray($data),
AttachmentType::ReplyKeyboard => ReplyKeyboardAttachment::fromArray($data),
AttachmentType::Location => LocationAttachment::fromArray($data),
default => throw new LogicException('Unknown or unsupported attachment type: ' . ($data['type'] ?? 'none')),
};
}
/**
* Creates a specific ReplyButton model based on the 'type' field.
*
* @param array<string, mixed> $data
*
* @return AbstractReplyButton
* @throws ReflectionException
* @throws LogicException
*/
public function createReplyButton(array $data): AbstractReplyButton
{
return match (ReplyButtonType::tryFrom($data['type'] ?? '')) {
ReplyButtonType::Message => SendMessageButton::fromArray($data),
ReplyButtonType::UserContact => SendContactButton::fromArray($data),
ReplyButtonType::UserGeoLocation => SendGeoLocationButton::fromArray($data),
default => throw new LogicException(
'Unknown or unsupported reply button type: ' . ($data['type'] ?? 'none')
),
};
}
/**
* Creates a specific InlineButton model based on the 'type' field.
*
* @param array<string, mixed> $data
* @return AbstractInlineButton
* @throws ReflectionException
* @throws LogicException
*/
public function createInlineButton(array $data): AbstractInlineButton
{
return match (InlineButtonType::tryFrom($data['type'] ?? '')) {
InlineButtonType::Callback => CallbackButton::fromArray($data),
InlineButtonType::Link => LinkButton::fromArray($data),
InlineButtonType::RequestContact => RequestContactButton::fromArray($data),
InlineButtonType::RequestGeoLocation => RequestGeoLocationButton::fromArray($data),
InlineButtonType::Chat => ChatButton::fromArray($data),
InlineButtonType::OpenApp => OpenAppButton::fromArray($data),
default => throw new LogicException('Unknown or unsupported inline button type: ' . ($data['type'] ?? 'none')),
};
}
/**
* Endpoint you should upload to your binaries.
*
@@ -206,4 +376,56 @@ class ModelFactory
{
return ChatMember::fromArray($data);
}
/**
* Creates a ChatMembersList object from raw API data.
*
* @param array<string, mixed> $data
*
* @return ChatMembersList
* @throws ReflectionException
*/
public function createChatMembersList(array $data): ChatMembersList
{
return ChatMembersList::fromArray($data);
}
/**
* Creates a VideoAttachmentDetails object from raw API data.
*
* @param array<string, mixed> $data
*
* @return VideoAttachmentDetails
* @throws ReflectionException
*/
public function createVideoAttachmentDetails(array $data): VideoAttachmentDetails
{
return VideoAttachmentDetails::fromArray($data);
}
/**
* Creates a specific Markup model based on the 'type' field.
*
* @param array<string, mixed> $data
*
* @return AbstractMarkup
* @throws ReflectionException
*/
public function createMarkupElement(array $data): AbstractMarkup
{
return match (MarkupType::tryFrom($data['type'] ?? '')) {
MarkupType::Strong => StrongMarkup::fromArray($data),
MarkupType::Emphasized => EmphasizedMarkup::fromArray($data),
MarkupType::Monospaced => MonospacedMarkup::fromArray($data),
MarkupType::Strikethrough => StrikethroughMarkup::fromArray($data),
MarkupType::Underline => UnderlineMarkup::fromArray($data),
MarkupType::Heading => HeadingMarkup::fromArray($data),
MarkupType::Highlighted => HighlightedMarkup::fromArray($data),
MarkupType::Link => LinkMarkup::fromArray($data),
MarkupType::UserMention => UserMentionMarkup::fromArray($data),
default => throw new LogicException(
'Unknown or unsupported markup type: ' . ($data['type'] ?? 'none')
),
};
}
}
+68 -61
View File
@@ -42,6 +42,65 @@ abstract readonly class AbstractModel
return new static(...$constructorArgs); // @phpstan-ignore-line
}
/**
* @return array<string, mixed>
* @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('/(?<!^)[A-Z]/', '_$0', $input));
}
/**
* @param mixed $value
*
* @return mixed
* @throws ReflectionException
*/
protected 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;
}
/**
* @param mixed $value
* @param ReflectionProperty $property
@@ -59,6 +118,10 @@ abstract readonly class AbstractModel
$typeName = $type->getName();
if (is_object($value) && is_a($value, $typeName)) {
return $value;
}
if ($type->isBuiltin()) {
return match ($typeName) {
'int' => (int)$value,
@@ -74,7 +137,7 @@ abstract readonly class AbstractModel
return $typeName::from($value);
}
if (is_subclass_of($typeName, self::class)) {
if (is_subclass_of($typeName, self::class) && is_array($value)) {
return $typeName::fromArray($value);
}
@@ -109,66 +172,10 @@ abstract readonly class AbstractModel
}
if (is_subclass_of($itemClassName, self::class)) {
return array_map(fn($item) => $itemClassName::fromArray($item), $value);
}
return $value;
}
/**
* @param string $input
*
* @return string
*/
private static function toSnakeCase(string $input): string
{
return strtolower((string)preg_replace('/(?<!^)[A-Z]/', '_$0', $input));
}
/**
* @return array<string, mixed>
* @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 array_map(
fn($item) => is_a($item, $itemClassName) ? $item : $itemClassName::fromArray($item),
$value,
);
}
return $value;
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
/**
* An abstract base for PATCH request models. It uses a variadic constructor
* to capture named arguments, tracking which fields were explicitly set.
*/
abstract readonly class AbstractPatchModel extends AbstractModel
{
/**
* @var array<int|string, mixed>
*/
protected array $patchData;
/**
* Captures all passed named arguments into an associative array.
*
* @param mixed ...$params
*/
public function __construct(...$params)
{
$this->patchData = $params;
}
/**
* @return array<string, mixed>
* @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;
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
/**
* Represents a generic attachment received from the API.
*/
abstract readonly class AbstractAttachment extends AbstractModel
{
public function __construct(public AttachmentType $type)
{
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\MediaAttachmentPayload;
final readonly class AudioAttachment extends AbstractAttachment
{
/**
* @param MediaAttachmentPayload $payload Audio attachment payload.
* @param string|null $transcription Audio transcription.
*/
public function __construct(
public MediaAttachmentPayload $payload,
public ?string $transcription,
) {
parent::__construct(AttachmentType::Audio);
}
}
@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons;
use BushlanovDev\MaxMessengerBot\Enums\ButtonType;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
abstract readonly class AbstractButton extends AbstractModel
{
public function __construct(
public ButtonType $type,
public string $text,
) {
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline;
use BushlanovDev\MaxMessengerBot\Enums\InlineButtonType;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
abstract readonly class AbstractInlineButton extends AbstractModel
{
/**
* @param InlineButtonType $type The type of the inline button.
* @param string $text Visible text of the button.
*/
public function __construct(
public InlineButtonType $type,
public string $text,
) {
}
}
@@ -2,15 +2,15 @@
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons;
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline;
use BushlanovDev\MaxMessengerBot\Enums\ButtonType;
use BushlanovDev\MaxMessengerBot\Enums\InlineButtonType;
use BushlanovDev\MaxMessengerBot\Enums\Intent;
/**
* Sends a notification with payload to a bot (via WebHook or long polling).
*/
final readonly class CallbackButton extends AbstractButton
final readonly class CallbackButton extends AbstractInlineButton
{
public string $payload;
public ?Intent $intent;
@@ -22,7 +22,7 @@ final readonly class CallbackButton extends AbstractButton
*/
public function __construct(string $text, string $payload, ?Intent $intent = null)
{
parent::__construct(ButtonType::Callback, $text);
parent::__construct(InlineButtonType::Callback, $text);
$this->payload = $payload;
$this->intent = $intent;
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline;
use BushlanovDev\MaxMessengerBot\Enums\InlineButtonType;
/**
* Button that creates a new chat associated with the message.
* The bot will be added as an administrator by default.
*/
final readonly class ChatButton extends AbstractInlineButton
{
/**
* @param string $text Visible text of the button (1 to 128 characters).
* @param string $chatTitle Title of the chat to be created (max 200 characters).
* @param string|null $chatDescription Optional chat description (max 400 characters).
* @param string|null $startPayload Optional payload that will be sent to the bot in a `message_chat_created` update.
* @param int|null $uuid Optional unique identifier for the button. If not passed, it will be generated.
*/
public function __construct(
string $text,
public string $chatTitle,
public ?string $chatDescription = null,
public ?string $startPayload = null,
public ?int $uuid = null,
) {
parent::__construct(InlineButtonType::Chat, $text);
}
}
@@ -2,14 +2,14 @@
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons;
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline;
use BushlanovDev\MaxMessengerBot\Enums\ButtonType;
use BushlanovDev\MaxMessengerBot\Enums\InlineButtonType;
/**
* Makes a user to follow a link.
*/
final readonly class LinkButton extends AbstractButton
final readonly class LinkButton extends AbstractInlineButton
{
public string $url;
@@ -19,7 +19,7 @@ final readonly class LinkButton extends AbstractButton
*/
public function __construct(string $text, string $url)
{
parent::__construct(ButtonType::Link, $text);
parent::__construct(InlineButtonType::Link, $text);
$this->url = $url;
}
@@ -2,14 +2,14 @@
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons;
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline;
use BushlanovDev\MaxMessengerBot\Enums\ButtonType;
use BushlanovDev\MaxMessengerBot\Enums\InlineButtonType;
/**
* Opens the bot's mini-application.
*/
final readonly class OpenAppButton extends AbstractButton
final readonly class OpenAppButton extends AbstractInlineButton
{
public ?string $webApp;
public ?int $contactId;
@@ -21,7 +21,7 @@ final readonly class OpenAppButton extends AbstractButton
*/
public function __construct(string $text, ?string $webApp = null, ?int $contactId = null)
{
parent::__construct(ButtonType::OpenApp, $text);
parent::__construct(InlineButtonType::OpenApp, $text);
$this->webApp = $webApp;
$this->contactId = $contactId;
@@ -2,20 +2,20 @@
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons;
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline;
use BushlanovDev\MaxMessengerBot\Enums\ButtonType;
use BushlanovDev\MaxMessengerBot\Enums\InlineButtonType;
/**
* Requests the user permission to access contact information (phone number, short link, email).
*/
final readonly class RequestContactButton extends AbstractButton
final readonly class RequestContactButton extends AbstractInlineButton
{
/**
* @param string $text Visible button text (1 to 128 characters).
*/
public function __construct(string $text)
{
parent::__construct(ButtonType::RequestContact, $text);
parent::__construct(InlineButtonType::RequestContact, $text);
}
}
@@ -2,14 +2,14 @@
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons;
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline;
use BushlanovDev\MaxMessengerBot\Enums\ButtonType;
use BushlanovDev\MaxMessengerBot\Enums\InlineButtonType;
/**
* After pressing this type of button client sends new message with attachment of current user geo location.
*/
final readonly class RequestGeoLocationButton extends AbstractButton
final readonly class RequestGeoLocationButton extends AbstractInlineButton
{
public bool $quick;
@@ -19,7 +19,7 @@ final readonly class RequestGeoLocationButton extends AbstractButton
*/
public function __construct(string $text, bool $quick = false)
{
parent::__construct(ButtonType::RequestGeoLocation, $text);
parent::__construct(InlineButtonType::RequestGeoLocation, $text);
$this->quick = $quick;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply;
use BushlanovDev\MaxMessengerBot\Enums\ReplyButtonType;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
abstract readonly class AbstractReplyButton extends AbstractModel
{
/**
* @param ReplyButtonType $type The type of the reply button.
* @param string $text Visible text of the button.
*/
public function __construct(
public ReplyButtonType $type,
public string $text,
) {
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply;
use BushlanovDev\MaxMessengerBot\Enums\ReplyButtonType;
final readonly class SendContactButton extends AbstractReplyButton
{
/**
* @param string $text Visible text of the button.
*/
public function __construct(string $text)
{
parent::__construct(ReplyButtonType::UserContact, $text);
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply;
use BushlanovDev\MaxMessengerBot\Enums\ReplyButtonType;
final readonly class SendGeoLocationButton extends AbstractReplyButton
{
/**
* @param string $text Visible text of the button.
* @param bool $quick If `true`, sends location without asking user's confirmation.
*/
public function __construct(
string $text,
public bool $quick = false,
) {
parent::__construct(ReplyButtonType::UserGeoLocation, $text);
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply;
use BushlanovDev\MaxMessengerBot\Enums\Intent;
use BushlanovDev\MaxMessengerBot\Enums\ReplyButtonType;
final readonly class SendMessageButton extends AbstractReplyButton
{
/**
* @param string $text Visible text of the button.
* @param string|null $payload Button payload.
* @param Intent $intent Intent of button.
*/
public function __construct(
string $text,
public ?string $payload = null,
public Intent $intent = Intent::Default,
) {
parent::__construct(ReplyButtonType::Message, $text);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\ContactAttachmentPayload;
final readonly class ContactAttachment extends AbstractAttachment
{
/**
* @param ContactAttachmentPayload $payload Contact attachment payload.
*/
public function __construct(public ContactAttachmentPayload $payload)
{
parent::__construct(AttachmentType::Contact);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
/**
* Represents an attachment containing a payload from a SendMessageButton.
*/
final readonly class DataAttachment extends AbstractAttachment
{
/**
* @param string $data The payload from the button.
*/
public function __construct(public string $data)
{
parent::__construct(AttachmentType::Data);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\FileAttachmentPayload;
final readonly class FileAttachment extends AbstractAttachment
{
/**
* @param FileAttachmentPayload $payload File attachment payload.
* @param string $filename Uploaded file name.
* @param int $size File size in bytes.
*/
public function __construct(
public FileAttachmentPayload $payload,
public string $filename,
public int $size,
) {
parent::__construct(AttachmentType::File);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\KeyboardPayload;
final readonly class InlineKeyboardAttachment extends AbstractAttachment
{
/**
* @param KeyboardPayload $payload Keyboard payload.
*/
public function __construct(public KeyboardPayload $payload)
{
parent::__construct(AttachmentType::InlineKeyboard);
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
final readonly class LocationAttachment extends AbstractAttachment
{
/**
* @param float $latitude
* @param float $longitude
*/
public function __construct(
public float $latitude,
public float $longitude,
) {
parent::__construct(AttachmentType::Location);
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
use BushlanovDev\MaxMessengerBot\Models\User;
/**
* Payload of a contact attachment.
*/
final readonly class ContactAttachmentPayload extends AbstractModel
{
/**
* @param string|null $vcfInfo User info in VCF format.
* @param User|null $maxInfo User info if the contact is a Max user.
*/
public function __construct(
public ?string $vcfInfo,
public ?User $maxInfo,
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
/**
* Payload for a file attachment.
*/
final readonly class FileAttachmentPayload extends AbstractModel
{
/**
* @param string $url Media attachment URL.
* @param string $token Token to reuse the same attachment in other messages.
*/
public function __construct(
public string $url,
public string $token,
) {
}
}
@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\AbstractButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\AbstractInlineButton;
final readonly class InlineKeyboardAttachmentRequestPayload extends AbstractAttachmentRequestPayload
{
/**
* @param AbstractButton[][] $buttons
* @param AbstractInlineButton[][] $buttons
*/
public function __construct(
public array $buttons,
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\AbstractInlineButton;
/**
* Represents an inline keyboard structure.
*/
final readonly class KeyboardPayload extends AbstractModel
{
/**
* @param AbstractInlineButton[][] $buttons Two-dimensional array of buttons.
*/
public function __construct(public array $buttons)
{
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
/**
* Payload for media attachments like audio or video.
*/
final readonly class MediaAttachmentPayload extends AbstractModel
{
/**
* @param string $url Media attachment URL.
* @param string $token Token to reuse the same attachment in other messages.
*/
public function __construct(
public string $url,
public string $token,
) {
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
/**
* Payload for a photo attachment.
*/
final readonly class PhotoAttachmentPayload extends AbstractModel
{
/**
* @param string $url Media attachment URL.
* @param string $token Token to reuse the same attachment in other messages.
* @param int $photoId Unique identifier of this image.
*/
public function __construct(
public string $url,
public string $token,
public int $photoId,
) {
}
}
@@ -14,9 +14,7 @@ final readonly class PhotoToken extends AbstractModel
/**
* @param string $token Encoded information of uploaded image.
*/
public function __construct(
public string $token,
) {
public function __construct(public string $token)
{
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\AbstractReplyButton;
final readonly class ReplyKeyboardAttachmentRequestPayload extends AbstractAttachmentRequestPayload
{
/**
* @param AbstractReplyButton[][] $buttons Two-dimensional array of buttons.
* @param bool $direct Applicable only for chats.
* @param int|null $directUserId If set, reply keyboard will only be shown to this participant.
*/
public function __construct(
public array $buttons,
public bool $direct = false,
public ?int $directUserId = null,
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
/**
* Payload for a sticker attachment.
*/
final readonly class StickerAttachmentPayload extends AbstractModel
{
/**
* @param string $url Media attachment URL.
* @param string $code Sticker identifier.
*/
public function __construct(
public string $url,
public string $code,
) {
}
}
@@ -1,19 +1,18 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
/**
* Payload for attachments that are uploaded to the server first (video, audio, file).
*/
final readonly class UploadedInfoAttachmentRequestPayload extends AbstractAttachmentRequestPayload
{
/**
* @param string $token The unique token received after a successful file upload.
*/
public function __construct(
public string $token,
) {
}
}
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
/**
* Payload for attachments that are uploaded to the server first (video, audio, file).
*/
final readonly class UploadedInfoAttachmentRequestPayload extends AbstractAttachmentRequestPayload
{
/**
* @param string $token The unique token received after a successful file upload.
*/
public function __construct(public string $token)
{
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
/**
* Represents a video thumbnail image.
*/
final readonly class VideoThumbnail extends AbstractModel
{
/**
* @param string $url Media attachment URL.
*/
public function __construct(public string $url)
{
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentPayload;
final readonly class PhotoAttachment extends AbstractAttachment
{
/**
* @param PhotoAttachmentPayload $payload Photo attachment payload.
*/
public function __construct(public PhotoAttachmentPayload $payload)
{
parent::__construct(AttachmentType::Image);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\AbstractReplyButton;
final readonly class ReplyKeyboardAttachment extends AbstractAttachment
{
/**
* @param AbstractReplyButton[][] $buttons
*/
public function __construct(public array $buttons)
{
parent::__construct(AttachmentType::ReplyKeyboard);
}
}
@@ -5,13 +5,13 @@ declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Requests;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\AbstractButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\AbstractInlineButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\InlineKeyboardAttachmentRequestPayload;
final readonly class InlineKeyboardAttachmentRequest extends AbstractAttachmentRequest
{
/**
* @param AbstractButton[][] $buttons
* @param AbstractInlineButton[][] $buttons
*/
public function __construct(array $buttons)
{
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Requests;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\AbstractReplyButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\ReplyKeyboardAttachmentRequestPayload;
final readonly class ReplyKeyboardAttachmentRequest extends AbstractAttachmentRequest
{
/**
* @param AbstractReplyButton[][] $buttons
* @param bool $direct
* @param int|null $directUserId
*/
public function __construct(
array $buttons,
bool $direct = false,
?int $directUserId = null
) {
parent::__construct(
AttachmentType::ReplyKeyboard,
new ReplyKeyboardAttachmentRequestPayload($buttons, $direct, $directUserId),
);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\ShareAttachmentRequestPayload;
/**
* Represents a share (URL preview) attachment.
*/
final readonly class ShareAttachment extends AbstractAttachment
{
public function __construct(
public ShareAttachmentRequestPayload $payload,
public ?string $title,
public ?string $description,
public ?string $imageUrl,
) {
parent::__construct(AttachmentType::Share);
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\StickerAttachmentPayload;
final readonly class StickerAttachment extends AbstractAttachment
{
/**
* @param StickerAttachmentPayload $payload Sticker attachment payload.
* @param int $width
* @param int $height
*/
public function __construct(
public StickerAttachmentPayload $payload,
public int $width,
public int $height,
) {
parent::__construct(AttachmentType::Sticker);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Attachments;
use BushlanovDev\MaxMessengerBot\Enums\AttachmentType;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\MediaAttachmentPayload;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\VideoThumbnail;
final readonly class VideoAttachment extends AbstractAttachment
{
/**
* @param MediaAttachmentPayload $payload Video attachment payload.
* @param VideoThumbnail|null $thumbnail Video thumbnail.
* @param int|null $width Video width.
* @param int|null $height Video height.
* @param int|null $duration Video duration in seconds.
*/
public function __construct(
public MediaAttachmentPayload $payload,
public ?VideoThumbnail $thumbnail,
public ?int $width,
public ?int $height,
public ?int $duration,
) {
parent::__construct(AttachmentType::Video);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentRequestPayload;
/**
* Represents the data to patch for a bot. Instantiate with named arguments.
*
* Example: new BotPatch(name: 'New Bot Name', description: null);
*
* @property-read string|null $name
* @property-read string|null $description
* @property-read BotCommand[]|null $commands
* @property-read PhotoAttachmentRequestPayload|null $photo
*/
final readonly class BotPatch extends AbstractPatchModel
{
}
+2 -2
View File
@@ -24,7 +24,7 @@ final readonly class Chat extends AbstractModel
* @param int|null $ownerId Identifier of chat owner. Visible only for chat admins
* @param string|null $link Link on chat.
* @param string|null $description Chat description.
* @param User|null $dialogWithUser Another user in conversation. For `dialog` type chats only.
* @param UserWithPhoto|null $dialogWithUser Another user in conversation. For `dialog` type chats only.
* @param int|null $messagesCount Messages count in chat. Only for group chats and channels. Not available for dialogs.
* @param string|null $chatMessageId Identifier of message that contains `chat` button initialized chat.
* @param Message|null $pinnedMessage Pinned message in chat or channel. Returned only when single chat is requested.
@@ -41,7 +41,7 @@ final readonly class Chat extends AbstractModel
public ?int $ownerId,
public ?string $link,
public ?string $description,
public ?User $dialogWithUser,
public ?UserWithPhoto $dialogWithUser,
public ?int $messagesCount,
public ?string $chatMessageId,
public ?Message $pinnedMessage,
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf;
use BushlanovDev\MaxMessengerBot\Enums\ChatAdminPermission;
/**
* Represents an administrator to be set in a chat, linking a user ID with their permissions.
*/
final readonly class ChatAdmin extends AbstractModel
{
/**
* @param int $userId The identifier of the user to be made an admin.
* @param ChatAdminPermission[] $permissions The list of permissions to grant to the user.
*/
public function __construct(
public int $userId,
#[ArrayOf(ChatAdminPermission::class)]
public array $permissions,
) {
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf;
/**
* Represents a paginated list of chat members.
*/
final readonly class ChatMembersList extends AbstractModel
{
/**
* @param ChatMember[] $members A list of chat members.
* @param int|null $marker A pointer to the next page of data. Null if this is the last page.
*/
public function __construct(
#[ArrayOf(ChatMember::class)]
public array $members,
public ?int $marker,
) {
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentRequestPayload;
/**
* Represents the data to patch for a chat. Instantiate with named arguments.
*
* Example: new ChatPatch(title: 'New Chat Title');
*
* @property-read PhotoAttachmentRequestPayload|null $icon
* @property-read string|null $title
* @property-read string|null $pin Message ID to be pinned.
* @property-read bool|null $notify
*/
final readonly class ChatPatch extends AbstractPatchModel
{
}
+2 -3
View File
@@ -9,8 +9,7 @@ final readonly class Image extends AbstractModel
/**
* @param string $url URL of image.
*/
public function __construct(
public string $url,
) {
public function __construct(public string $url)
{
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
use BushlanovDev\MaxMessengerBot\Enums\MessageLinkType;
/**
* Represents a forwarded or replied message linked to the main message.
*/
final readonly class LinkedMessage extends AbstractModel
{
/**
* @param MessageLinkType $type Type of linked message (forward or reply).
* @param MessageBody $message The body of the original message.
* @param User|null $sender The sender of the original message. Can be null if posted on behalf of a channel.
* @param int|null $chatId The chat where the message was originally posted (for forwarded messages).
*/
public function __construct(
public MessageLinkType $type,
public MessageBody $message,
public ?User $sender,
public ?int $chatId,
) {
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Markup;
use BushlanovDev\MaxMessengerBot\Enums\MarkupType;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
/**
* Base class for a text markup element.
*/
abstract readonly class AbstractMarkup extends AbstractModel
{
/**
* @param MarkupType $type The type of the markup element.
* @param int $from Element start index (zero-based) in text.
* @param int $length Length of the markup element.
*/
public function __construct(
public MarkupType $type,
public int $from,
public int $length,
) {
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Markup;
use BushlanovDev\MaxMessengerBot\Enums\MarkupType;
/**
* Represents an *emphasized* (italic) part of the text.
*/
final readonly class EmphasizedMarkup extends AbstractMarkup
{
/**
* @param int $from Element start index (zero-based) in text.
* @param int $length Length of the markup element.
*/
public function __construct(
int $from,
int $length,
) {
parent::__construct(MarkupType::Emphasized, $from, $length);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Markup;
use BushlanovDev\MaxMessengerBot\Enums\MarkupType;
/**
* Represents a # header part of the text.
*/
final readonly class HeadingMarkup extends AbstractMarkup
{
/**
* @param int $from Element start index (zero-based) in text.
* @param int $length Length of the markup element.
*/
public function __construct(
int $from,
int $length,
) {
parent::__construct(MarkupType::Heading, $from, $length);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Markup;
use BushlanovDev\MaxMessengerBot\Enums\MarkupType;
/**
* Represents a ^^highlighted^^ part of the text.
*/
final readonly class HighlightedMarkup extends AbstractMarkup
{
/**
* @param int $from Element start index (zero-based) in text.
* @param int $length Length of the markup element.
*/
public function __construct(
int $from,
int $length,
) {
parent::__construct(MarkupType::Highlighted, $from, $length);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Markup;
use BushlanovDev\MaxMessengerBot\Enums\MarkupType;
/**
* Represents a link in the text.
*/
final readonly class LinkMarkup extends AbstractMarkup
{
/**
* @param int $from Element start index (zero-based) in text.
* @param int $length Length of the markup element.
* @param string $url Link's URL.
*/
public function __construct(
int $from,
int $length,
public string $url,
) {
parent::__construct(MarkupType::Link, $from, $length);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Markup;
use BushlanovDev\MaxMessengerBot\Enums\MarkupType;
/**
* Represents a `monospaced` part of the text.
*/
final readonly class MonospacedMarkup extends AbstractMarkup
{
/**
* @param int $from Element start index (zero-based) in text.
* @param int $length Length of the markup element.
*/
public function __construct(
int $from,
int $length,
) {
parent::__construct(MarkupType::Monospaced, $from, $length);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Markup;
use BushlanovDev\MaxMessengerBot\Enums\MarkupType;
/**
* Represents a ~strikethrough~ part of the text.
*/
final readonly class StrikethroughMarkup extends AbstractMarkup
{
/**
* @param int $from Element start index (zero-based) in text.
* @param int $length Length of the markup element.
*/
public function __construct(
int $from,
int $length,
) {
parent::__construct(MarkupType::Strikethrough, $from, $length);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Markup;
use BushlanovDev\MaxMessengerBot\Enums\MarkupType;
/**
* Represents a **strong** (bold) part of the text.
*/
final readonly class StrongMarkup extends AbstractMarkup
{
/**
* @param int $from Element start index (zero-based) in text.
* @param int $length Length of the markup element.
*/
public function __construct(
int $from,
int $length,
) {
parent::__construct(MarkupType::Strong, $from, $length);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Markup;
use BushlanovDev\MaxMessengerBot\Enums\MarkupType;
/**
* Represents an ++underlined++ part of the text.
*/
final readonly class UnderlineMarkup extends AbstractMarkup
{
/**
* @param int $from Element start index (zero-based) in text.
* @param int $length Length of the markup element.
*/
public function __construct(
int $from,
int $length,
) {
parent::__construct(MarkupType::Underline, $from, $length);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Markup;
use BushlanovDev\MaxMessengerBot\Enums\MarkupType;
/**
* Represents a user mention in the text.
*/
final readonly class UserMentionMarkup extends AbstractMarkup
{
/**
* @param int $from Element start index (zero-based) in text.
* @param int $length Length of the markup element.
* @param string|null $userLink "@username" of the mentioned user.
* @param int|null $userId Identifier of the mentioned user without a username.
*/
public function __construct(
int $from,
int $length,
public ?string $userLink,
public ?int $userId,
) {
parent::__construct(MarkupType::UserMention, $from, $length);
}
}
+8 -4
View File
@@ -11,17 +11,21 @@ final readonly class Message extends AbstractModel
{
/**
* @param int $timestamp Unix-time when message was created.
* @param MessageBody $body Body of created message. Text + attachments.
* @param Recipient $recipient Message recipient. Could be user or chat.
* @param Sender|null $sender User who sent this message. Can be null if message has been posted on behalf of a channel.
* @param MessageBody|null $body Body of created message. Text + attachments.
* @param User|null $sender User who sent this message. Can be null if message has been posted on behalf of a channel.
* @param string|null $url Message public URL. Can be null for dialogs or non-public chats/channels.
* @param LinkedMessage|null $link Forwarded or replied message.
* @param MessageStat|null $stat Message statistics. Available only for channels.
*/
public function __construct(
public int $timestamp,
public MessageBody $body,
public Recipient $recipient,
public ?Sender $sender,
public ?MessageBody $body,
public ?User $sender,
public ?string $url,
public ?LinkedMessage $link,
public ?MessageStat $stat,
) {
}
}
+7
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
use BushlanovDev\MaxMessengerBot\Models\Attachments\AbstractAttachment;
use BushlanovDev\MaxMessengerBot\Models\Markup\AbstractMarkup;
/**
* Body of created message. Text + attachments.
*/
@@ -13,11 +16,15 @@ final readonly class MessageBody extends AbstractModel
* @param string $mid Unique identifier of message.
* @param int $seq Sequence identifier of message in chat.
* @param string|null $text Message text.
* @param AbstractAttachment[]|null $attachments Message attachments.
* @param AbstractMarkup[]|null $markup Message text markup.
*/
public function __construct(
public string $mid,
public int $seq,
public ?string $text,
public ?array $attachments,
public ?array $markup,
) {
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
/**
* Message statistics.
*/
final readonly class MessageStat extends AbstractModel
{
/**
* @param int $views Number of views.
*/
public function __construct(public int $views)
{
}
}
+2 -7
View File
@@ -11,11 +11,9 @@ final readonly class User extends AbstractModel
* @param string $firstName Users first name.
* @param string|null $lastName Users last name.
* @param string|null $username Unique public user name. Can be `null` if user is not accessible or it is not set.
* @param bool $isBot `true` if user is bot.
* @param bool $isBot Is the user a bot.
* @param int $lastActivityTime Time of last user activity in Max (Unix timestamp in milliseconds).
* @param string|null $description User description. Can be `null` if user did not fill it out.
* @param string|null $avatarUrl URL of avatar.
* @param string|null $fullAvatarUrl URL of avatar of a bigger size.
* Can be outdated if user disabled its "online" status in settings.
*/
public function __construct(
public int $userId,
@@ -24,9 +22,6 @@ final readonly class User extends AbstractModel
public ?string $username,
public bool $isBot,
public int $lastActivityTime,
public ?string $description,
public ?string $avatarUrl,
public ?string $fullAvatarUrl,
) {
}
}
@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
/**
* User who sent this message.
*/
final readonly class Sender extends AbstractModel
final readonly class UserWithPhoto extends AbstractModel
{
/**
* @param int $userId Users identifier.
* @param string $firstName Users first name.
* @param string|null $lastName Users last name.
* @param string|null $username Unique public user name. Can be null if user is not accessible or it is not set.
* @param bool $isBot Is the user a bot.
* @param int $lastActivityTime Time of last user activity in Max (Unix timestamp in milliseconds). Can be outdated if user disabled its "online" status in settings.
* @param string|null $username Unique public user name. Can be `null` if user is not accessible or it is not set.
* @param bool $isBot `true` if user is bot.
* @param int $lastActivityTime Time of last user activity in Max (Unix timestamp in milliseconds).
* @param string|null $description UserWithPhoto description. Can be `null` if user did not fill it out.
* @param string|null $avatarUrl URL of avatar.
* @param string|null $fullAvatarUrl URL of avatar of a bigger size.
*/
public function __construct(
public int $userId,
@@ -24,6 +24,9 @@ final readonly class Sender extends AbstractModel
public ?string $username,
public bool $isBot,
public int $lastActivityTime,
public ?string $description,
public ?string $avatarUrl,
public ?string $fullAvatarUrl,
) {
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentRequestPayload;
/**
* Contains detailed information about a video attachment.
*/
final readonly class VideoAttachmentDetails extends AbstractModel
{
/**
* @param string $token The video attachment token.
* @param int $width The width of the video in pixels.
* @param int $height The height of the video in pixels.
* @param int $duration The duration of the video in seconds.
* @param VideoUrls|null $urls URLs to download or play the video. Can be null if the video is unavailable.
* @param PhotoAttachmentRequestPayload|null $thumbnail The video's thumbnail image information.
*/
public function __construct(
public string $token,
public int $width,
public int $height,
public int $duration,
public ?VideoUrls $urls = null,
public ?PhotoAttachmentRequestPayload $thumbnail = null,
) {
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
/**
* Contains URLs for a video attachment in various resolutions.
*/
final readonly class VideoUrls extends AbstractModel
{
/**
* @param string|null $mp4_1080 Video URL in 1080p resolution, if available.
* @param string|null $mp4_720 Video URL in 720p resolution, if available.
* @param string|null $mp4_480 Video URL in 480p resolution, if available.
* @param string|null $mp4_360 Video URL in 360p resolution, if available.
* @param string|null $mp4_240 Video URL in 240p resolution, if available.
* @param string|null $mp4_144 Video URL in 144p resolution, if available.
* @param string|null $hls Live streaming URL (HLS), if available.
*/
public function __construct(
public ?string $mp4_1080 = null,
public ?string $mp4_720 = null,
public ?string $mp4_480 = null,
public ?string $mp4_360 = null,
public ?string $mp4_240 = null,
public ?string $mp4_144 = null,
public ?string $hls = null,
) {
}
}
+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);
}
}
+22 -226
View File
@@ -4,201 +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;
/**
* 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 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 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
@@ -206,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)) {
@@ -235,51 +53,28 @@ 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);
} catch (\JsonException $e) {
$this->logger->error('Failed to decode webhook JSON', ['payload' => $payload, 'exception' => $e]);
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);
}
}
@@ -297,6 +92,7 @@ final class WebhookHandler
}
if (!hash_equals($this->secret, $signature)) {
$this->logger->warning('Webhook signature verification failed', ['received_signature' => $signature]);
throw new SecurityException('Signature verification failed.');
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\ClientApiInterface;
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
use BushlanovDev\MaxMessengerBot\ModelFactory;
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
use BushlanovDev\MaxMessengerBot\WebhookHandler;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use ReflectionClass;
#[CoversClass(Api::class)]
#[UsesClass(UpdateDispatcher::class)]
#[UsesClass(WebhookHandler::class)]
#[UsesClass(LongPollingHandler::class)]
final class ApiFactoryMethodsTest extends TestCase
{
private MockObject&ClientApiInterface $clientMock;
private MockObject&ModelFactory $modelFactoryMock;
private MockObject&LoggerInterface $loggerMock;
private Api $api;
protected function setUp(): void
{
$this->clientMock = $this->createMock(ClientApiInterface::class);
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);
$this->api = new Api(
'fake-token',
$this->clientMock,
$this->modelFactoryMock,
$this->loggerMock,
);
}
#[Test]
public function createWebhookHandlerReturnsCorrectlyConfiguredInstance(): void
{
$secret = 'my-test-secret';
$webhookHandler = $this->api->createWebhookHandler($secret);
$this->assertInstanceOf(WebhookHandler::class, $webhookHandler);
$this->assertSame(
$this->getPrivateProperty($this->api, 'updateDispatcher'),
$this->getPrivateProperty($webhookHandler, 'dispatcher'),
);
$this->assertSame(
$this->getPrivateProperty($this->api, 'modelFactory'),
$this->getPrivateProperty($webhookHandler, 'modelFactory'),
);
$this->assertSame(
$this->getPrivateProperty($this->api, 'logger'),
$this->getPrivateProperty($webhookHandler, 'logger'),
);
$this->assertSame(
$secret,
$this->getPrivateProperty($webhookHandler, 'secret'),
);
}
#[Test]
public function createLongPollingHandlerReturnsCorrectlyConfiguredInstance(): void
{
$longPollingHandler = $this->api->createLongPollingHandler();
$this->assertInstanceOf(LongPollingHandler::class, $longPollingHandler);
$this->assertSame(
$this->api,
$this->getPrivateProperty($longPollingHandler, 'api')
);
$this->assertSame(
$this->getPrivateProperty($this->api, 'updateDispatcher'),
$this->getPrivateProperty($longPollingHandler, 'dispatcher')
);
$this->assertSame(
$this->getPrivateProperty($this->api, 'logger'),
$this->getPrivateProperty($longPollingHandler, 'logger')
);
}
private function getPrivateProperty(object $object, string $propertyName): mixed
{
$reflection = new ReflectionClass($object);
$property = $reflection->getProperty($propertyName);
return $property->getValue($object);
}
}
+736 -244
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -28,6 +28,7 @@ use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Log\LoggerInterface;
#[CoversClass(Client::class)]
final class ClientTest extends TestCase
@@ -42,6 +43,7 @@ final class ClientTest extends TestCase
private MockObject&RequestInterface $requestMock;
private MockObject&ResponseInterface $responseMock;
private MockObject&StreamInterface $streamMock;
private MockObject&LoggerInterface $loggerMock;
private Client $client;
@@ -61,6 +63,7 @@ final class ClientTest extends TestCase
$this->requestMock = $this->createMock(RequestInterface::class);
$this->responseMock = $this->createMock(ResponseInterface::class);
$this->streamMock = $this->createMock(StreamInterface::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);
// Common mock setups
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
@@ -75,6 +78,7 @@ final class ClientTest extends TestCase
$this->streamFactory,
self::API_BASE_URL,
self::API_VERSION,
$this->loggerMock,
);
}
@@ -376,4 +380,33 @@ final class ClientTest extends TestCase
$this->streamMock->method('__toString')->willReturn('{not-a-valid-json');
$this->client->upload('http://some.url', 'content', 'file.txt');
}
#[Test]
public function requestLogsRequestAndResponseOnDebugLevel(): void
{
$this->responseMock->method('getStatusCode')->willReturn(200);
$this->streamMock->method('__toString')->willReturn('{"success":true}');
$this->loggerMock
->expects($this->exactly(2))
->method('debug');
$this->client->request('GET', '/me');
}
#[Test]
public function handleErrorResponseLogsWarning(): void
{
$this->responseMock->method('getStatusCode')->willReturn(404);
$this->streamMock->method('__toString')->willReturn('{"code":"not.found","message":"Not Found"}');
$this->loggerMock
->expects($this->once())
->method('error')
->with('API error response received', $this->anything());
$this->expectException(NotFoundException::class);
$this->client->request('GET', '/not/found');
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel\Commands;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\PollingStartCommand;
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotManager;
use Illuminate\Support\Facades\Log;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Tester\CommandTester;
#[CoversClass(PollingStartCommand::class)]
#[UsesClass(MaxBotManager::class)]
final class PollingStartCommandTest extends TestCase
{
private MockObject&MaxBotManager $botManagerMock;
private PollingStartCommand $command;
protected function setUp(): void
{
parent::setUp();
$this->botManagerMock = $this->createMock(MaxBotManager::class);
$this->container->instance(MaxBotManager::class, $this->botManagerMock);
$this->container->alias(MaxBotManager::class, 'maxbot.manager');
$this->command = new PollingStartCommand();
$this->command->setLaravel($this->container);
$application = new ConsoleApplication();
$application->add($this->command);
$commandInApp = $application->find('maxbot:polling:start');
$this->tester = new CommandTester($commandInApp);
}
#[Test]
public function handleSuccessfullyCallsManagerWithCustomTimeout(): void
{
$timeout = 60;
$this->botManagerMock
->expects($this->once())
->method('startLongPolling')
->with($timeout);
$this->tester->execute(['--timeout' => $timeout]);
$this->tester->assertCommandIsSuccessful();
$output = $this->tester->getDisplay();
$this->assertStringContainsString("Starting long polling with a timeout of $timeout seconds...", $output);
}
#[Test]
public function handleSuccessfullyUsesDefaultTimeout(): void
{
$defaultTimeout = 90;
$this->botManagerMock
->expects($this->once())
->method('startLongPolling')
->with($defaultTimeout);
$this->tester->execute([]);
$this->tester->assertCommandIsSuccessful();
$output = $this->tester->getDisplay();
$this->assertStringContainsString(
"Starting long polling with a timeout of $defaultTimeout seconds...",
$output
);
}
#[Test]
public function handleCatchesExceptionAndLogsError(): void
{
$exceptionMessage = 'Something went wrong';
$exception = new \RuntimeException($exceptionMessage);
$this->botManagerMock
->expects($this->once())
->method('startLongPolling')
->willThrowException($exception);
Log::shouldReceive('error')
->once()
->with(
"Long polling failed to start or crashed: $exceptionMessage",
['exception' => $exception],
);
$statusCode = $this->tester->execute([]);
$this->assertSame(1, $statusCode, 'Command should return a failure exit code.');
$output = $this->tester->getDisplay();
$this->assertStringContainsString("❌ Long polling failed: $exceptionMessage", $output);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel\Commands;
use Illuminate\Container\Container;
use Illuminate\Support\Facades\Facade;
use PHPUnit\Framework\TestCase as TestCaseOriginal;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Tester\CommandTester;
abstract class TestCase extends TestCaseOriginal
{
protected Container $container;
protected CommandTester $tester;
protected function setUp(): void
{
parent::setUp();
$this->container = new TestApplicationContainer();
Container::setInstance($this->container);
Facade::setFacadeApplication($this->container);
$loggerMock = $this->createMock(LoggerInterface::class);
$this->container->instance('log', $loggerMock);
}
protected function tearDown(): void
{
Container::setInstance(null);
Facade::clearResolvedInstances();
if (class_exists(\Mockery::class)) {
\Mockery::close();
}
parent::tearDown();
}
}
class TestApplicationContainer extends Container
{
public function runningUnitTests(): bool
{
return true;
}
}
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel\Commands;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookListCommand;
use BushlanovDev\MaxMessengerBot\Models\Subscription;
use Illuminate\Support\Facades\Log;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Tester\CommandTester;
#[CoversClass(WebhookListCommand::class)]
#[UsesClass(Api::class)]
#[UsesClass(Subscription::class)]
final class WebhookListCommandTest extends TestCase
{
private MockObject&Api $apiMock;
private WebhookListCommand $command;
protected function setUp(): void
{
parent::setUp();
$this->apiMock = $this->createMock(Api::class);
$this->container->instance(Api::class, $this->apiMock);
$this->command = new WebhookListCommand();
$this->command->setLaravel($this->container);
$application = new ConsoleApplication();
$application->add($this->command);
$commandInApp = $application->find('maxbot:webhook:list');
$this->tester = new CommandTester($commandInApp);
}
#[Test]
public function handleDisplaysTableWithActiveSubscriptions(): void
{
$timestamp = 1678886400; // 2023-03-15 13:20:00 UTC
$subscriptions = [
new Subscription(
'https://example.com/hook1',
$timestamp,
[UpdateType::MessageCreated, UpdateType::BotStarted],
'0.0.6'
),
new Subscription(
'https://example.com/hook2',
$timestamp + 3600,
null, // Should be rendered as 'all'
'0.0.6'
),
];
$this->apiMock
->expects($this->once())
->method('getSubscriptions')
->willReturn($subscriptions);
$this->tester->execute([]);
$this->tester->assertCommandIsSuccessful();
$output = $this->tester->getDisplay();
$this->assertStringContainsString('https://example.com/hook1', $output);
$this->assertStringContainsString('https://example.com/hook2', $output);
$this->assertStringContainsString('message_created, bot_started', $output);
$this->assertStringContainsString('all', $output);
$this->assertStringContainsString(date('Y-m-d H:i:s', $timestamp), $output);
$this->assertStringContainsString(date('Y-m-d H:i:s', $timestamp + 3600), $output);
}
#[Test]
public function handleDisplaysMessageWhenNoSubscriptionsExist(): void
{
$this->apiMock
->expects($this->once())
->method('getSubscriptions')
->willReturn([]);
$this->tester->execute([]);
$this->tester->assertCommandIsSuccessful();
$output = $this->tester->getDisplay();
$this->assertStringContainsString('No active webhook subscriptions found.', $output);
$this->assertStringNotContainsString('URL', $output, 'Table headers should not be displayed.');
}
#[Test]
public function handleCatchesExceptionAndLogsError(): void
{
$exceptionMessage = 'API is down';
$exception = new \RuntimeException($exceptionMessage);
$this->apiMock
->expects($this->once())
->method('getSubscriptions')
->willThrowException($exception);
Log::shouldReceive('error')
->once()
->with("Webhook list error: $exceptionMessage", ['exception' => $exception]);
$statusCode = $this->tester->execute([]);
$this->assertSame(1, $statusCode, 'Command should return a failure exit code.');
$output = $this->tester->getDisplay();
$this->assertStringContainsString("❌ Webhook list error: $exceptionMessage", $output);
}
}
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel\Commands;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookSubscribeCommand;
use BushlanovDev\MaxMessengerBot\Models\Result;
use Illuminate\Contracts\Config\Repository as Config;
use Illuminate\Support\Facades\Log;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Tester\CommandTester;
#[CoversClass(WebhookSubscribeCommand::class)]
#[UsesClass(Api::class)]
#[UsesClass(Result::class)]
final class WebhookSubscribeCommandTest extends TestCase
{
private MockObject&Api $apiMock;
private MockObject&Config $configMock;
private WebhookSubscribeCommand $command;
protected function setUp(): void
{
parent::setUp();
$this->apiMock = $this->createMock(Api::class);
$this->configMock = $this->createMock(Config::class);
$this->container->instance(Api::class, $this->apiMock);
$this->container->instance(Config::class, $this->configMock);
$this->command = new WebhookSubscribeCommand();
$this->command->setLaravel($this->container);
$application = new ConsoleApplication();
$application->add($this->command);
$commandInApp = $application->find('maxbot:webhook:subscribe');
$this->tester = new CommandTester($commandInApp);
}
#[Test]
public function handleSuccessfullySubscribesWithAllOptions(): void
{
$url = 'https://example.com/webhook';
$secret = 'my-super-secret';
$types = ['message_created', 'bot_started'];
$expectedUpdateTypes = [UpdateType::MessageCreated, UpdateType::BotStarted];
$this->apiMock
->expects($this->once())
->method('subscribe')
->with($url, $secret, $expectedUpdateTypes)
->willReturn(new Result(true, null));
$this->tester->execute([
'url' => $url,
'--secret' => $secret,
'--types' => $types,
]);
$this->tester->assertCommandIsSuccessful();
$output = $this->tester->getDisplay();
$this->assertStringContainsString('✅ Successfully subscribed to webhook!', $output);
$this->assertStringContainsString("URL: $url", $output);
$this->assertStringContainsString("Secret: ***************", $output);
$this->assertStringContainsString("Update types: message_created, bot_started", $output);
}
#[Test]
public function handleUsesSecretFromConfigWhenOptionIsNotProvided(): void
{
$url = 'https://example.com/webhook';
$configSecret = 'secret-from-config';
$this->configMock
->expects($this->once())
->method('get')
->with('maxbot.webhook_secret')
->willReturn($configSecret);
$this->apiMock
->expects($this->once())
->method('subscribe')
->with($url, $configSecret, null)
->willReturn(new Result(true, null));
$this->tester->execute(['url' => $url]);
$this->tester->assertCommandIsSuccessful();
}
#[Test]
public function handleFailsForInvalidUrl(): void
{
$this->apiMock->expects($this->never())->method('subscribe');
$statusCode = $this->tester->execute(['url' => 'not-a-valid-url']);
$this->assertSame(1, $statusCode);
$this->assertStringContainsString('Invalid URL provided.', $this->tester->getDisplay());
}
#[Test]
public function handleFailsForInvalidUpdateType(): void
{
$this->apiMock->expects($this->never())->method('subscribe');
$statusCode = $this->tester->execute([
'url' => 'https://example.com',
'--types' => ['message_created', 'invalid_type'],
]);
$this->assertSame(1, $statusCode);
$this->assertStringContainsString('Invalid update type: invalid_type', $this->tester->getDisplay());
}
#[Test]
public function handleDisplaysApiErrorMessageOnFailure(): void
{
$url = 'https://example.com/webhook';
$apiErrorMessage = 'URL is already subscribed';
$this->apiMock
->expects($this->once())
->method('subscribe')
->willReturn(new Result(false, $apiErrorMessage));
$statusCode = $this->tester->execute(['url' => $url]);
$this->assertSame(1, $statusCode);
$output = $this->tester->getDisplay();
$this->assertStringContainsString('❌ Failed to subscribe to webhook.', $output);
$this->assertStringContainsString("Response: $apiErrorMessage", $output);
}
#[Test]
public function handleCatchesExceptionAndLogsError(): void
{
$url = 'https://example.com/webhook';
$exceptionMessage = 'Network error';
$exception = new \RuntimeException($exceptionMessage);
$this->apiMock
->expects($this->once())
->method('subscribe')
->willThrowException($exception);
Log::shouldReceive('error')
->once()
->with("Webhook subscription error: $exceptionMessage", ['exception' => $exception]);
$statusCode = $this->tester->execute(['url' => $url]);
$this->assertSame(1, $statusCode);
$output = $this->tester->getDisplay();
$this->assertStringContainsString("❌ Webhook subscription error: $exceptionMessage", $output);
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel\Commands;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookUnsubscribeCommand;
use BushlanovDev\MaxMessengerBot\Models\Result;
use Illuminate\Support\Facades\Log;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Tester\CommandTester;
#[CoversClass(WebhookUnsubscribeCommand::class)]
#[UsesClass(Api::class)]
#[UsesClass(Result::class)]
final class WebhookUnsubscribeCommandTest extends TestCase
{
private MockObject&Api $apiMock;
private WebhookUnsubscribeCommand $command;
protected function setUp(): void
{
parent::setUp();
$this->apiMock = $this->createMock(Api::class);
$this->container->instance(Api::class, $this->apiMock);
$this->command = new WebhookUnsubscribeCommand();
$this->command->setLaravel($this->container);
$application = new ConsoleApplication();
$application->add($this->command);
$commandInApp = $application->find('maxbot:webhook:unsubscribe');
$this->tester = new CommandTester($commandInApp);
}
#[Test]
public function handleSuccessfullyUnsubscribesWithConfirmationFlag(): void
{
$url = 'https://example.com/webhook';
$this->apiMock
->expects($this->once())
->method('unsubscribe')
->with($url)
->willReturn(new Result(true, null));
$this->tester->execute([
'url' => $url,
'--confirm' => true,
]);
$this->tester->assertCommandIsSuccessful();
$output = $this->tester->getDisplay();
$this->assertStringContainsString('✅ Successfully unsubscribed from webhook!', $output);
}
#[Test]
public function handleCancelsWhenNotConfirmed(): void
{
$url = 'https://example.com/webhook';
$this->apiMock->expects($this->never())->method('unsubscribe');
$this->tester->setInputs(['no']);
$this->tester->execute(['url' => $url]);
$this->tester->assertCommandIsSuccessful();
$output = $this->tester->getDisplay();
$this->assertStringContainsString('Are you sure you want to unsubscribe', $output);
$this->assertStringContainsString('Operation cancelled.', $output);
}
#[Test]
public function handleFailsForInvalidUrl(): void
{
$this->apiMock->expects($this->never())->method('unsubscribe');
$statusCode = $this->tester->execute(['url' => 'not-a-valid-url']);
$this->assertSame(1, $statusCode);
$this->assertStringContainsString('Invalid URL provided.', $this->tester->getDisplay());
}
#[Test]
public function handleDisplaysApiErrorMessageOnFailure(): void
{
$url = 'https://example.com/webhook';
$apiErrorMessage = 'Subscription not found';
$this->apiMock
->expects($this->once())
->method('unsubscribe')
->willReturn(new Result(false, $apiErrorMessage));
$statusCode = $this->tester->execute(['url' => $url, '--confirm' => true]);
$this->assertSame(1, $statusCode);
$output = $this->tester->getDisplay();
$this->assertStringContainsString('❌ Failed to unsubscribe from webhook.', $output);
$this->assertStringContainsString("Response: $apiErrorMessage", $output);
}
#[Test]
public function handleCatchesExceptionAndLogsError(): void
{
$url = 'https://example.com/webhook';
$exceptionMessage = 'API connection refused';
$exception = new \RuntimeException($exceptionMessage);
$this->apiMock
->expects($this->once())
->method('unsubscribe')
->willThrowException($exception);
Log::shouldReceive('error')
->once()
->with("Webhook unsubscribe error: $exceptionMessage", ['exception' => $exception]);
$statusCode = $this->tester->execute(['url' => $url, '--confirm' => true]);
$this->assertSame(1, $statusCode);
$output = $this->tester->getDisplay();
$this->assertStringContainsString("❌ Webhook unsubscribe error: $exceptionMessage", $output);
}
}
+340
View File
@@ -0,0 +1,340 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Enums\ChatType;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotManager;
use BushlanovDev\MaxMessengerBot\ModelFactory;
use BushlanovDev\MaxMessengerBot\Models\Message;
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
use BushlanovDev\MaxMessengerBot\Models\Recipient;
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
use BushlanovDev\MaxMessengerBot\WebhookHandler;
use Illuminate\Container\Container;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Facade;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
#[CoversClass(MaxBotManager::class)]
#[UsesClass(Message::class)]
#[UsesClass(MessageBody::class)]
#[UsesClass(Recipient::class)]
#[UsesClass(AbstractUpdate::class)]
#[UsesClass(MessageCreatedUpdate::class)]
#[UsesClass(UpdateDispatcher::class)]
#[UsesClass(WebhookHandler::class)]
final class MaxBotManagerTest extends TestCase
{
private Container $container;
private MockObject&Api $apiMock;
private MockObject&ModelFactory $modelFactoryMock;
private UpdateDispatcher $updateDispatcher;
private MaxBotManager $manager;
protected function setUp(): void
{
parent::setUp();
$this->container = new Container();
Container::setInstance($this->container);
Facade::setFacadeApplication($this->container);
$this->container->singleton('config', function ($app) {
return $app->make(Repository::class);
});
$loggerMock = $this->createMock(LoggerInterface::class);
$this->container->instance(LoggerInterface::class, $loggerMock);
$this->container->instance('log', $loggerMock);
$this->apiMock = $this->createMock(Api::class);
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
$this->updateDispatcher = new UpdateDispatcher($this->apiMock);
$this->container->instance(Api::class, $this->apiMock);
$this->container->instance('maxbot', $this->apiMock);
$this->container->instance(ModelFactory::class, $this->modelFactoryMock);
$this->container->instance(UpdateDispatcher::class, $this->updateDispatcher);
$this->container->singleton(MaxBotManager::class, function ($app) {
return new MaxBotManager(
$app,
$app->make(Api::class),
$app->make(UpdateDispatcher::class),
);
});
$this->manager = $this->container->make(MaxBotManager::class);
Handlers::reset();
}
protected function tearDown(): void
{
Container::setInstance(null);
Facade::clearResolvedInstances();
parent::tearDown();
}
#[Test]
public function handleWebhookReturns200OnSuccess(): void
{
$webhookHandler = new WebhookHandler(
$this->updateDispatcher,
$this->modelFactoryMock,
$this->container->make(\Psr\Log\LoggerInterface::class),
null,
);
$this->container->instance(WebhookHandler::class, $webhookHandler);
$request = Request::create('/webhook', 'POST', content: '{"update_type":"message_created"}');
$realUpdate = new MessageCreatedUpdate(
time(),
$this->createMinimalMessage(),
'ru-RU',
);
$this->modelFactoryMock->method('createUpdate')->willReturn($realUpdate);
$wasDispatched = false;
$this->updateDispatcher->addHandler(UpdateType::MessageCreated, function () use (&$wasDispatched) {
$wasDispatched = true;
});
$response = $this->manager->handleWebhook($request);
$this->assertSame(200, $response->getStatusCode());
$this->assertTrue($wasDispatched, 'The update was not dispatched correctly.');
}
#[Test]
public function handleWebhookReturns403OnSecurityException(): void
{
$webhookHandler = new WebhookHandler(
$this->updateDispatcher,
$this->modelFactoryMock,
$this->container->make(\Psr\Log\LoggerInterface::class),
'real-secret',
);
$this->container->instance(WebhookHandler::class, $webhookHandler);
$request = Request::create('/webhook', 'POST', content: '{}');
$request->headers->set('X-Max-Bot-Api-Secret', 'wrong-secret');
$response = $this->manager->handleWebhook($request);
$this->assertSame(403, $response->getStatusCode());
$this->assertJsonStringEqualsJsonString('{"status":"error","message":"Forbidden"}', $response->getContent());
}
#[Test]
public function handleWebhookReturns400OnSerializationException(): void
{
$webhookHandler = new WebhookHandler(
$this->updateDispatcher,
$this->modelFactoryMock,
$this->container->make(\Psr\Log\LoggerInterface::class),
null
);
$this->container->instance(WebhookHandler::class, $webhookHandler);
$request = Request::create('/webhook', 'POST', content: '{invalid-json');
$response = $this->manager->handleWebhook($request);
$this->assertSame(400, $response->getStatusCode());
$this->assertJsonStringEqualsJsonString('{"status":"error","message":"Bad Request"}', $response->getContent());
}
#[Test]
public function handleWebhookReturns500OnGenericException(): void
{
$webhookHandler = new WebhookHandler(
$this->updateDispatcher,
$this->modelFactoryMock,
$this->container->make(\Psr\Log\LoggerInterface::class),
null
);
$this->container->instance(WebhookHandler::class, $webhookHandler);
$this->modelFactoryMock->method('createUpdate')->willThrowException(new \Exception('DB error'));
$request = Request::create('/webhook', 'POST', content: '{"update_type":"message_created"}');
$response = $this->manager->handleWebhook($request);
$this->assertSame(500, $response->getStatusCode());
$this->assertJsonStringEqualsJsonString(
'{"status":"error","message":"Internal Server Error"}',
$response->getContent(),
);
}
#[Test]
public function resolveHandlerCanResolveCallable(): void
{
$wasCalled = false;
$callable = function () use (&$wasCalled) {
$wasCalled = true;
};
$this->manager->onCommand('test', $callable);
$dispatcher = $this->manager->getDispatcher();
$messageBody = new MessageBody('mid.cmd', 1, 'test', [], []);
$message = new Message(time(), new Recipient(ChatType::Dialog, 1, null), $messageBody, null, null, null, null);
$update = new MessageCreatedUpdate(time(), $message, null);
$dispatcher->dispatch($update);
$this->assertTrue($wasCalled, 'The resolved callable handler was not called.');
}
#[Test]
public function resolveHandlerResolvesClassWithHandleMethod(): void
{
$this->manager->onCommand('test', TestHandlerWithHandleMethod::class);
$this->dispatchCommand('test');
$this->assertTrue(Handlers::$wasCalled, 'Handler with handle() method was not resolved and called.');
}
#[Test]
public function resolveHandlerResolvesInvokableClassFromContainer(): void
{
$this->container->bind(TestHandlerWithInvokeMethod::class);
$this->manager->onCommand('test', TestHandlerWithInvokeMethod::class);
$this->dispatchCommand('test');
$this->assertTrue(Handlers::$wasCalled, 'Invokable handler was not resolved and called.');
}
#[Test]
public function resolveHandlerResolvesClassAtMethodString(): void
{
$this->manager->onCommand('test', TestHandlerWithCustomMethod::class . '@custom');
$this->dispatchCommand('test');
$this->assertTrue(Handlers::$wasCalled, 'Handler with "Class@method" string was not resolved and called.');
}
#[Test]
public function resolveHandlerResolvesBoundClassWithHandleMethod(): void
{
// Шаг 1: Явно регистрируем класс обработчика в контейнере.
// Это гарантирует, что будет выбрана ветка `if ($this->container->bound($handler))`.
$this->container->bind(TestHandlerWithHandleMethod::class);
// Шаг 2: Регистрируем обработчик, используя его имя класса (строку).
$this->manager->onCommand('test', TestHandlerWithHandleMethod::class);
// Шаг 3: Диспетчеризуем команду, которая вызовет обработчик.
$this->dispatchCommand('test');
// Шаг 4: Убеждаемся, что метод `handle` был вызван.
$this->assertTrue(
Handlers::$wasCalled,
'Bound handler with handle() method was not resolved and called via the bound path.'
);
}
#[Test]
public function resolveHandlerThrowsExceptionForUnresolvableString(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Unable to resolve handler: NonExistentClass');
$this->manager->onCommand('test', 'NonExistentClass');
}
#[Test]
public function resolveHandlerThrowsExceptionForClassWithoutHandleOrInvoke(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage(
"Handler class '" . Handlers::class . "' is not callable and doesn't have a handle method."
);
$this->container->bind(Handlers::class);
$this->manager->onCommand('test', Handlers::class);
}
/**
* Helper to dispatch a command to the real UpdateDispatcher.
*/
private function dispatchCommand(string $commandText): void
{
$dispatcher = $this->manager->getDispatcher();
$messageBody = new MessageBody('mid.cmd', 1, $commandText, [], []);
$message = new Message(time(), new Recipient(ChatType::Dialog, 1, null), $messageBody, null, null, null, null);
$update = new MessageCreatedUpdate(time(), $message, null);
$dispatcher->dispatch($update);
}
private function createMinimalMessage(): Message
{
return new Message(
time(),
new Recipient(ChatType::Dialog, 1, null),
new MessageBody('mid.1', 1, 'test', [], []),
null,
null,
null,
null,
);
}
}
class Handlers
{
public static bool $wasCalled = false;
public static function reset(): void
{
self::$wasCalled = false;
}
}
class TestHandlerWithHandleMethod
{
public function handle(MessageCreatedUpdate $update, Api $api): void
{
Handlers::$wasCalled = true;
}
}
class TestHandlerWithInvokeMethod
{
public function __invoke(MessageCreatedUpdate $update, Api $api): void
{
Handlers::$wasCalled = true;
}
}
class TestHandlerWithCustomMethod
{
public function custom(MessageCreatedUpdate $update, Api $api): void
{
Handlers::$wasCalled = true;
}
}
+289
View File
@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Client;
use BushlanovDev\MaxMessengerBot\ClientApiInterface;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\PollingStartCommand;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookListCommand;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookSubscribeCommand;
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookUnsubscribeCommand;
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotManager;
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotServiceProvider;
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
use BushlanovDev\MaxMessengerBot\ModelFactory;
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
use BushlanovDev\MaxMessengerBot\WebhookHandler;
use Illuminate\Support\Facades\Artisan;
use InvalidArgumentException;
use LogicException;
use Orchestra\Testbench\TestCase;
use phpmock\phpunit\PHPMock;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use ReflectionClass;
#[CoversClass(MaxBotServiceProvider::class)]
#[UsesClass(Api::class)]
#[UsesClass(Client::class)]
#[UsesClass(UpdateDispatcher::class)]
#[UsesClass(MaxBotManager::class)]
#[UsesClass(WebhookHandler::class)]
final class MaxBotServiceProviderTest extends TestCase
{
use PHPMock;
protected function getEnvironmentSetUp($app): void
{
$app['config']->set('maxbot.access_token', 'test-token');
$app['config']->set('maxbot.webhook_secret', 'test-secret');
$app['config']->set('maxbot.base_url', 'https://test.max.ru');
$app['config']->set('maxbot.api_version', 'test-version');
}
protected function getPackageProviders($app): array
{
return [MaxBotServiceProvider::class];
}
#[Test]
public function serviceProviderIsLoaded(): void
{
$this->assertInstanceOf(MaxBotServiceProvider::class, $this->app->getProvider(MaxBotServiceProvider::class));
}
#[Test]
public function itThrowsExceptionWhenAccessTokenIsMissingForClient(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(
'Max Bot access token is not configured. Please set MAXBOT_ACCESS_TOKEN in your .env file.'
);
$this->app['config']->set('maxbot.access_token', null);
$this->app->make(ClientApiInterface::class);
}
#[Test]
public function itThrowsExceptionWhenAccessTokenIsMissingForApi(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(
'Max Bot access token is not configured. Please set MAXBOT_ACCESS_TOKEN in your .env file.'
);
$this->app['config']->set('maxbot.access_token', null);
$this->app->make(Api::class);
}
#[Test]
#[RunInSeparateProcess]
#[PreserveGlobalState(false)]
public function itThrowsExceptionWhenGuzzleIsMissing(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage(
'Guzzle HTTP client is required. Please run "composer require guzzlehttp/guzzle".'
);
$classExistsMock = $this->getFunctionMock('BushlanovDev\\MaxMessengerBot\\Laravel', 'class_exists');
$classExistsMock->expects($this->once())->with(\GuzzleHttp\Client::class)->willReturn(false);
(new MaxBotServiceProvider($this->app))->register();
$this->app->make(ClientApiInterface::class);
}
/**
* @return array<string, array{0: string, 1: class-string}>
*/
public static function servicesProvider(): array
{
return [
'Api::class' => [Api::class, Api::class],
'maxbot alias' => ['maxbot', Api::class],
'maxbot.api alias' => ['maxbot.api', Api::class],
'ClientApiInterface::class' => [ClientApiInterface::class, Client::class],
'maxbot.client alias' => ['maxbot.client', Client::class],
'ModelFactory::class' => [ModelFactory::class, ModelFactory::class],
'UpdateDispatcher::class' => [UpdateDispatcher::class, UpdateDispatcher::class],
'maxbot.dispatcher alias' => ['maxbot.dispatcher', UpdateDispatcher::class],
'WebhookHandler::class' => [WebhookHandler::class, WebhookHandler::class],
'maxbot.webhook alias' => ['maxbot.webhook', WebhookHandler::class],
'LongPollingHandler::class' => [LongPollingHandler::class, LongPollingHandler::class],
'maxbot.polling alias' => ['maxbot.polling', LongPollingHandler::class],
'MaxBotManager::class' => [MaxBotManager::class, MaxBotManager::class],
'maxbot.manager alias' => ['maxbot.manager', MaxBotManager::class],
];
}
#[Test]
#[DataProvider('servicesProvider')]
public function allServicesAreRegisteredCorrectly(string $service, string $expectedClass): void
{
$this->assertInstanceOf($expectedClass, $this->app->make($service));
}
/**
* @return array<string, array{0: string}>
*/
public static function singletonsProvider(): array
{
return [
'Api' => [Api::class],
'ClientApiInterface' => [ClientApiInterface::class],
'ModelFactory' => [ModelFactory::class],
'UpdateDispatcher' => [UpdateDispatcher::class],
'MaxBotManager' => [MaxBotManager::class],
];
}
#[Test]
#[DataProvider('singletonsProvider')]
public function servicesAreRegisteredAsSingletons(string $service): void
{
$instance1 = $this->app->make($service);
$instance2 = $this->app->make($service);
$this->assertSame($instance1, $instance2);
}
#[Test]
public function clientIsConfiguredCorrectlyFromConfig(): void
{
/** @var Client $client */
$client = $this->app->make(ClientApiInterface::class);
$this->assertInstanceOf(Client::class, $client);
$reflection = new ReflectionClass($client);
$accessTokenProp = $reflection->getProperty('accessToken');
$baseUrlProp = $reflection->getProperty('baseUrl');
$apiVersionProp = $reflection->getProperty('apiVersion');
$this->assertSame('test-token', $accessTokenProp->getValue($client));
$this->assertSame('https://test.max.ru', $baseUrlProp->getValue($client));
$this->assertSame('test-version', $apiVersionProp->getValue($client));
}
#[Test]
public function clientIsConfiguredWithApplicationLoggerWhenLoggingIsEnabled(): void
{
$this->app['config']->set('maxbot.logging.enabled', true);
$mockLogger = $this->createMock(LoggerInterface::class);
$this->app->instance(LoggerInterface::class, $mockLogger);
/** @var Client $client */
$client = $this->app->make(ClientApiInterface::class);
$reflection = new ReflectionClass($client);
$loggerProp = $reflection->getProperty('logger');
$actualLogger = $loggerProp->getValue($client);
$this->assertSame($mockLogger, $actualLogger);
}
#[Test]
public function clientIsConfiguredWithNullLoggerWhenLoggingIsDisabled(): void
{
$this->app['config']->set('maxbot.logging.enabled', false);
/** @var Client $client */
$client = $this->app->make(ClientApiInterface::class);
$reflection = new ReflectionClass($client);
$loggerProp = $reflection->getProperty('logger');
$actualLogger = $loggerProp->getValue($client);
$this->assertInstanceOf(NullLogger::class, $actualLogger);
}
#[Test]
public function webhookHandlerIsConfiguredWithSecretFromConfig(): void
{
/** @var WebhookHandler $handler */
$handler = $this->app->make(WebhookHandler::class);
$this->assertInstanceOf(WebhookHandler::class, $handler);
$reflection = new ReflectionClass($handler);
$secretProp = $reflection->getProperty('secret');
$this->assertSame('test-secret', $secretProp->getValue($handler));
}
#[Test]
public function apiIsCreatedWithAllDependenciesFromContainer(): void
{
/** @var Api $api */
$api = $this->app->make(Api::class);
$reflection = new ReflectionClass($api);
$clientProp = $reflection->getProperty('client');
$factoryProp = $reflection->getProperty('modelFactory');
$loggerProp = $reflection->getProperty('logger');
$dispatcherProp = $reflection->getProperty('updateDispatcher');
$this->assertSame($this->app->make(ClientApiInterface::class), $clientProp->getValue($api));
$this->assertSame($this->app->make(ModelFactory::class), $factoryProp->getValue($api));
$this->assertSame($this->app->make(LoggerInterface::class), $loggerProp->getValue($api));
$this->assertSame($this->app->make(UpdateDispatcher::class), $dispatcherProp->getValue($api));
}
/**
* @return array<string, array{0: string}>
*/
public static function commandsProvider(): array
{
return [
'WebhookSubscribeCommand' => [WebhookSubscribeCommand::class, 'maxbot:webhook:subscribe'],
'WebhookUnsubscribeCommand' => [WebhookUnsubscribeCommand::class, 'maxbot:webhook:unsubscribe'],
'WebhookListCommand' => [WebhookListCommand::class, 'maxbot:webhook:list'],
'PollingStartCommand' => [PollingStartCommand::class, 'maxbot:polling:start'],
];
}
#[Test]
#[DataProvider('commandsProvider')]
public function bootMethodRegistersCommandsInConsole(string $class, string $signature): void
{
$commands = Artisan::all();
$this->assertArrayHasKey($signature, $commands);
$this->assertInstanceOf($class, $commands[$signature]);
}
#[Test]
public function providesMethod(): void
{
$provides = [
Api::class,
ClientApiInterface::class,
ModelFactory::class,
UpdateDispatcher::class,
WebhookHandler::class,
LongPollingHandler::class,
MaxBotManager::class,
'maxbot',
'maxbot.api',
'maxbot.client',
'maxbot.dispatcher',
'maxbot.webhook',
'maxbot.polling',
'maxbot.manager',
];
$this->assertSame($this->app->getProvider(MaxBotServiceProvider::class)->provides(), $provides);
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\BotStartedUpdate;
use BushlanovDev\MaxMessengerBot\Models\User;
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
use Error;
use Exception;
use phpmock\phpunit\PHPMock;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
#[CoversClass(LongPollingHandler::class)]
#[UsesClass(UpdateDispatcher::class)]
#[UsesClass(UpdateList::class)]
#[UsesClass(AbstractUpdate::class)]
#[UsesClass(BotStartedUpdate::class)]
#[UsesClass(User::class)]
final class LongPollingHandlerTest extends TestCase
{
use PHPMock;
/**
* @param AbstractUpdate[] $updatesToReturn
* @param int $expectedDispatchCount
* @param int|null $expectedMarker
*/
#[Test]
#[DataProvider('processUpdatesProvider')]
public function processUpdates(
array $updatesToReturn,
int $expectedDispatchCount,
?int $expectedMarker,
): void {
$apiMock = $this->createMock(Api::class);
$loggerMock = $this->createMock(LoggerInterface::class);
$dispatcher = new UpdateDispatcher($apiMock);
$updateList = new UpdateList($updatesToReturn, $expectedMarker);
$apiMock->expects($this->once())
->method('getUpdates')
->with($this->isNull(), $this->equalTo(90), $this->isNull())
->willReturn($updateList);
$dispatchCount = 0;
$dispatcher->addHandler(UpdateType::BotStarted, function () use (&$dispatchCount) {
$dispatchCount++;
});
$handler = new LongPollingHandler($apiMock, $dispatcher, $loggerMock);
$returnedMarker = $handler->processUpdates(90, null);
$this->assertSame(
$expectedDispatchCount,
$dispatchCount,
"Dispatcher should be called $expectedDispatchCount times."
);
$this->assertSame($expectedMarker, $returnedMarker, 'Method should return the correct marker.');
}
public static function processUpdatesProvider(): array
{
$user = new User(1, 'Test', null, null, false, time());
$update1 = new BotStartedUpdate(time(), 1, $user, null, null);
$update2 = new BotStartedUpdate(time(), 2, $user, null, null);
return [
'with two updates' => [
'updatesToReturn' => [$update1, $update2],
'expectedDispatchCount' => 2,
'expectedMarker' => 12345,
],
'with no updates' => [
'updatesToReturn' => [],
'expectedDispatchCount' => 0,
'expectedMarker' => 54321,
],
];
}
#[Test]
#[PreserveGlobalState(false)]
#[RunInSeparateProcess]
public function runCatchesNetworkExceptionAndSleeps5Seconds(): void
{
$apiMock = $this->createMock(Api::class);
$loggerMock = $this->createMock(LoggerInterface::class);
$dispatcher = new UpdateDispatcher($apiMock);
$sleepMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'sleep');
$sleepMock->expects($this->once())->with(5);
$apiMock->expects($this->exactly(2))
->method('getUpdates')
->willReturnOnConsecutiveCalls(
$this->throwException(new NetworkException('Connection timeout')),
$this->throwException(new Error('Stop test loop')),
);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('Long-polling network error'), $this->anything());
$handler = new LongPollingHandler($apiMock, $dispatcher, $loggerMock);
try {
$handler->handle();
} catch (Error $e) {
$this->assertSame('Stop test loop', $e->getMessage());
}
}
#[Test]
#[PreserveGlobalState(false)]
#[RunInSeparateProcess]
public function runCatchesGenericExceptionAndSleeps1Second(): void
{
$apiMock = $this->createMock(Api::class);
$loggerMock = $this->createMock(LoggerInterface::class);
$dispatcher = new UpdateDispatcher($apiMock);
$sleepMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'sleep');
$sleepMock->expects($this->once())->with(1);
$apiMock->expects($this->exactly(2))
->method('getUpdates')
->willReturnOnConsecutiveCalls(
$this->throwException(new Exception('Something went wrong')),
$this->throwException(new Error('Stop test loop')),
);
$loggerMock->expects($this->once())
->method('error')
->with($this->stringContains('An error occurred during long-polling'), $this->anything());
$handler = new LongPollingHandler($apiMock, $dispatcher, $loggerMock);
try {
$handler->handle();
} catch (Error $e) {
$this->assertSame('Stop test loop', $e->getMessage());
}
}
#[Test]
public function processUpdatesContinuesAndLogsWhenHandlerThrows(): void
{
$apiMock = $this->createMock(Api::class);
$loggerMock = $this->createMock(LoggerInterface::class);
$dispatcher = new UpdateDispatcher($apiMock, $loggerMock);
$user = new User(1, 'Test', null, null, false, time());
$updateToFail = new BotStartedUpdate(time(), 1, $user, null, null);
$updateToSucceed = new BotStartedUpdate(time(), 2, $user, null, null);
$updateList = new UpdateList([$updateToFail, $updateToSucceed], 12345);
$exception = new Exception('Error inside handler');
$apiMock->expects($this->once())
->method('getUpdates')
->willReturn($updateList);
$handlerCallCount = 0;
$dispatcher->addHandler(UpdateType::BotStarted, function () use (&$handlerCallCount, $exception) {
$currentCall = $handlerCallCount++;
if ($currentCall === 0) {
throw $exception;
}
});
$loggerMock->expects($this->once())
->method('error')
->with('Error dispatching update', ['message' => 'Error inside handler', 'exception' => $exception]);
$handler = new LongPollingHandler($apiMock, $dispatcher, $loggerMock);
$returnedMarker = $handler->processUpdates(90, null);
$this->assertSame(12345, $returnedMarker, 'Method should return marker even if a handler failed.');
$this->assertSame(2, $handlerCallCount, 'Dispatcher should have attempted to process both updates.');
}
}
+408 -6
View File
@@ -8,17 +8,41 @@ use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf;
use BushlanovDev\MaxMessengerBot\Enums\ChatAdminPermission;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\ModelFactory;
use BushlanovDev\MaxMessengerBot\Models\Attachments\AbstractAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\AbstractInlineButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\CallbackButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\ChatButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\LinkButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\RequestContactButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\RequestGeoLocationButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\AbstractReplyButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\SendContactButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\SendMessageButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\DataAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\InlineKeyboardAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\LocationAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\KeyboardPayload;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentPayload;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentRequestPayload;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\ShareAttachmentRequestPayload;
use BushlanovDev\MaxMessengerBot\Models\Attachments\PhotoAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\ReplyKeyboardAttachment;
use BushlanovDev\MaxMessengerBot\Models\Attachments\ShareAttachment;
use BushlanovDev\MaxMessengerBot\Models\BotCommand;
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
use BushlanovDev\MaxMessengerBot\Models\Chat;
use BushlanovDev\MaxMessengerBot\Models\ChatList;
use BushlanovDev\MaxMessengerBot\Models\ChatMember;
use BushlanovDev\MaxMessengerBot\Models\ChatMembersList;
use BushlanovDev\MaxMessengerBot\Models\Image;
use BushlanovDev\MaxMessengerBot\Models\Markup\AbstractMarkup;
use BushlanovDev\MaxMessengerBot\Models\Markup\LinkMarkup;
use BushlanovDev\MaxMessengerBot\Models\Markup\StrongMarkup;
use BushlanovDev\MaxMessengerBot\Models\Message;
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
use BushlanovDev\MaxMessengerBot\Models\Recipient;
use BushlanovDev\MaxMessengerBot\Models\Result;
use BushlanovDev\MaxMessengerBot\Models\Sender;
use BushlanovDev\MaxMessengerBot\Models\User;
use BushlanovDev\MaxMessengerBot\Models\Subscription;
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
use BushlanovDev\MaxMessengerBot\Models\Updates\BotStartedUpdate;
@@ -26,8 +50,12 @@ use BushlanovDev\MaxMessengerBot\Models\Updates\ChatTitleChangedUpdate;
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\UserWithPhoto;
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
use BushlanovDev\MaxMessengerBot\Models\VideoUrls;
use LogicException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
@@ -41,11 +69,11 @@ use PHPUnit\Framework\TestCase;
#[UsesClass(Message::class)]
#[UsesClass(MessageBody::class)]
#[UsesClass(Recipient::class)]
#[UsesClass(Sender::class)]
#[UsesClass(User::class)]
#[UsesClass(UpdateList::class)]
#[UsesClass(BotStartedUpdate::class)]
#[UsesClass(MessageCreatedUpdate::class)]
#[UsesClass(User::class)]
#[UsesClass(UserWithPhoto::class)]
#[UsesClass(UploadEndpoint::class)]
#[UsesClass(Chat::class)]
#[UsesClass(Image::class)]
@@ -53,6 +81,32 @@ use PHPUnit\Framework\TestCase;
#[UsesClass(MessageChatCreatedUpdate::class)]
#[UsesClass(ChatList::class)]
#[UsesClass(ChatMember::class)]
#[UsesClass(ChatMembersList::class)]
#[UsesClass(VideoAttachmentDetails::class)]
#[UsesClass(PhotoAttachmentRequestPayload::class)]
#[UsesClass(VideoUrls::class)]
#[UsesClass(AbstractAttachment::class)]
#[UsesClass(DataAttachment::class)]
#[UsesClass(ShareAttachment::class)]
#[UsesClass(ShareAttachmentRequestPayload::class)]
#[UsesClass(LinkMarkup::class)]
#[UsesClass(AbstractMarkup::class)]
#[UsesClass(StrongMarkup::class)]
#[UsesClass(PhotoAttachmentPayload::class)]
#[UsesClass(PhotoAttachment::class)]
#[UsesClass(AbstractReplyButton::class)]
#[UsesClass(SendContactButton::class)]
#[UsesClass(SendMessageButton::class)]
#[UsesClass(ReplyKeyboardAttachment::class)]
#[UsesClass(AbstractInlineButton::class)]
#[UsesClass(ChatButton::class)]
#[UsesClass(RequestContactButton::class)]
#[UsesClass(CallbackButton::class)]
#[UsesClass(RequestGeoLocationButton::class)]
#[UsesClass(LinkButton::class)]
#[UsesClass(LocationAttachment::class)]
#[UsesClass(InlineKeyboardAttachment::class)]
#[UsesClass(KeyboardPayload::class)]
final class ModelFactoryTest extends TestCase
{
private ModelFactory $factory;
@@ -197,7 +251,7 @@ final class ModelFactoryTest extends TestCase
$this->assertInstanceOf(Message::class, $message);
$this->assertInstanceOf(MessageBody::class, $message->body);
$this->assertInstanceOf(Recipient::class, $message->recipient);
$this->assertInstanceOf(Sender::class, $message->sender);
$this->assertInstanceOf(User::class, $message->sender);
}
#[Test]
@@ -247,7 +301,7 @@ final class ModelFactoryTest extends TestCase
$this->assertInstanceOf(Chat::class, $chat);
$this->assertInstanceOf(Image::class, $chat->icon);
$this->assertInstanceOf(User::class, $chat->dialogWithUser);
$this->assertInstanceOf(UserWithPhoto::class, $chat->dialogWithUser);
}
#[Test]
@@ -386,4 +440,352 @@ final class ModelFactoryTest extends TestCase
$this->assertSame(ChatAdminPermission::Write, $chatMember->permissions[1]);
$this->assertEquals($rawData, $chatMember->toArray());
}
#[Test]
public function createMessagesReturnsArrayOfMessageObjects(): void
{
$data = [
'messages' => [
[
'timestamp' => 1,
'body' => ['mid' => 'mid.1', 'seq' => 1],
'recipient' => ['chat_type' => 'chat', 'chat_id' => 123],
],
[
'timestamp' => 2,
'body' => ['mid' => 'mid.2', 'seq' => 2],
'recipient' => ['chat_type' => 'chat', 'chat_id' => 123],
],
],
];
$messages = $this->factory->createMessages($data);
$this->assertIsArray($messages);
$this->assertCount(2, $messages);
$this->assertInstanceOf(Message::class, $messages[0]);
$this->assertSame('mid.1', $messages[0]->body->mid);
}
#[Test]
public function createMessagesHandlesEmptyOrMissingKey(): void
{
$this->assertEmpty($this->factory->createMessages(['messages' => []]));
$this->assertEmpty($this->factory->createMessages([]));
}
#[Test]
public function createChatMembersListSuccessfully(): void
{
$rawData = [
'members' => [
[
'user_id' => 101,
'first_name' => 'Admin1',
'is_bot' => false,
'last_activity_time' => 1,
'last_access_time' => 2,
'is_owner' => true,
'is_admin' => true,
'join_time' => 0,
],
[
'user_id' => 102,
'first_name' => 'Admin2',
'is_bot' => true,
'last_activity_time' => 3,
'last_access_time' => 4,
'is_owner' => false,
'is_admin' => true,
'join_time' => 5,
],
],
'marker' => 98765,
];
$list = $this->factory->createChatMembersList($rawData);
$this->assertInstanceOf(ChatMembersList::class, $list);
$this->assertCount(2, $list->members);
$this->assertSame(98765, $list->marker);
$this->assertInstanceOf(ChatMember::class, $list->members[0]);
$this->assertSame(101, $list->members[0]->userId);
$this->assertTrue($list->members[0]->isOwner);
$this->assertInstanceOf(ChatMember::class, $list->members[1]);
$this->assertSame(102, $list->members[1]->userId);
$this->assertTrue($list->members[1]->isBot);
}
#[Test]
public function createChatMembersListHandlesEmptyResponse(): void
{
$rawData = [
'members' => [],
'marker' => null,
];
$list = $this->factory->createChatMembersList($rawData);
$this->assertInstanceOf(ChatMembersList::class, $list);
$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);
}
#[Test]
public function createMessageCorrectlyHydratesPolymorphicAttachments(): void
{
$rawData = [
'timestamp' => time(),
'body' => [
'mid' => 'mid.789.def',
'seq' => 102,
'text' => 'Message with data attachment',
'attachments' => [
['type' => 'data', 'data' => 'payload_from_reply_button'],
[
'type' => 'share',
'payload' => ['url' => 'http://a.com'],
'title' => 'Test Share',
'description' => null,
'image_url' => null,
],
['type' => 'image', 'payload' => ['photo_id' => 1, 'token' => 't', 'url' => 'u']]
],
'markup' => null,
],
'recipient' => ['chat_type' => 'dialog', 'user_id' => 123],
];
$message = $this->factory->createMessage($rawData);
$attachments = $message->body->attachments;
$this->assertInstanceOf(Message::class, $message);
$this->assertInstanceOf(MessageBody::class, $message->body);
$this->assertIsArray($attachments);
$this->assertCount(3, $attachments);
$this->assertInstanceOf(DataAttachment::class, $attachments[0]);
$this->assertSame('payload_from_reply_button', $attachments[0]->data);
$this->assertInstanceOf(ShareAttachment::class, $message->body->attachments[1]);
$this->assertSame('Test Share', $attachments[1]->title);
$this->assertInstanceOf(PhotoAttachment::class, $attachments[2]);
$this->assertSame(1, $attachments[2]->payload->photoId);
}
#[Test]
public function createMessageCorrectlyHydratesMarkup(): void
{
// ... (данные теста)
$rawData = [
'timestamp' => time(),
'body' => [
'mid' => 'mid.markup.test',
'seq' => 200,
'text' => 'Hello world! Visit our site.',
'attachments' => null,
'markup' => [
['type' => 'strong', 'from' => 6, 'length' => 5],
['type' => 'link', 'from' => 18, 'length' => 4, 'url' => 'https://dev.max.ru']
]
],
'recipient' => ['chat_type' => 'dialog', 'user_id' => 123],
];
$message = $this->factory->createMessage($rawData);
$markup = $message->body->markup;
$this->assertInstanceOf(StrongMarkup::class, $markup[0]);
$this->assertSame(6, $markup[0]->from);
$this->assertInstanceOf(LinkMarkup::class, $markup[1]);
$this->assertSame('https://dev.max.ru', $markup[1]->url);
}
#[Test]
public function createMarkupElementThrowsExceptionForUnknownType(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('Unknown or unsupported markup type: brand_new_unsupported_type');
$this->factory->createMarkupElement(['type' => 'brand_new_unsupported_type']);
}
#[Test]
public function createAttachmentCorrectlyHydratesReplyKeyboard(): void
{
$data = [
'type' => 'reply_keyboard',
'buttons' => [
[['type' => 'message', 'text' => 'Hello']],
[['type' => 'user_contact', 'text' => 'My Contact']]
]
];
$attachment = $this->factory->createAttachment($data);
$this->assertInstanceOf(ReplyKeyboardAttachment::class, $attachment);
$this->assertCount(2, $attachment->buttons);
$this->assertInstanceOf(SendMessageButton::class, $attachment->buttons[0][0]);
$this->assertInstanceOf(SendContactButton::class, $attachment->buttons[1][0]);
$this->assertSame('My Contact', $attachment->buttons[1][0]->text);
}
#[Test]
public function createReplyButtonThrowsExceptionForUnknownType(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('Unknown or unsupported reply button type: new_fancy_button');
$this->factory->createReplyButton(['type' => 'new_fancy_button']);
}
/**
* Data provider for successful inline button creation.
* @return array<string, array{0: array<string, mixed>, 1: class-string}>
*/
public static function inlineButtonProvider(): array
{
return [
'CallbackButton' => [
['type' => 'callback', 'text' => 'Press', 'payload' => 'p'],
CallbackButton::class,
],
'LinkButton' => [
['type' => 'link', 'text' => 'Visit', 'url' => 'https://a.com'],
LinkButton::class,
],
'RequestContactButton' => [
['type' => 'request_contact', 'text' => 'Share Contact'],
RequestContactButton::class,
],
'RequestGeoLocationButton' => [
['type' => 'request_geo_location', 'text' => 'Share Location', 'quick' => false],
RequestGeoLocationButton::class,
],
'ChatButton' => [
['type' => 'chat', 'text' => 'Join Chat', 'chat_title' => 'My Chat'],
ChatButton::class,
],
];
}
#[Test]
#[DataProvider('inlineButtonProvider')]
public function createInlineButtonSuccessfully(array $data, string $expectedClass): void
{
$button = $this->factory->createInlineButton($data);
$this->assertInstanceOf($expectedClass, $button);
$this->assertSame($data['text'], $button->text);
}
/**
* Data provider for invalid inline button data.
* @return array<string, array{0: array<string, mixed>, 1: string}>
*/
public static function invalidInlineButtonProvider(): array
{
return [
'unknown type' => [
['type' => 'unknown_button_type', 'text' => 'Unknown'],
'Unknown or unsupported inline button type: unknown_button_type',
],
'missing type' => [
['text' => 'No type here'],
'Unknown or unsupported inline button type: none',
],
];
}
#[Test]
#[DataProvider('invalidInlineButtonProvider')]
public function createInlineButtonThrowsExceptionForInvalidType(array $invalidData, string $expectedMessage): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage($expectedMessage);
$this->factory->createInlineButton($invalidData);
}
/**
* Data provider for testing various attachment types in createAttachment.
* @return array<string, array{0: array<string, mixed>, 1: class-string, 2: callable}>
*/
public static function attachmentTypeProvider(): array
{
return [
'Data Attachment' => [
['type' => 'data', 'data' => 'test_payload'],
DataAttachment::class,
function (TestCase $test, DataAttachment $attachment) {
$test->assertSame('test_payload', $attachment->data);
}
],
'Location Attachment' => [
['type' => 'location', 'latitude' => 55.751244, 'longitude' => 37.618423],
LocationAttachment::class,
function (TestCase $test, LocationAttachment $attachment) {
$test->assertSame(55.751244, $attachment->latitude);
}
],
'Inline Keyboard Attachment' => [
[
'type' => 'inline_keyboard',
'payload' => [
'buttons' => [
[['type' => 'callback', 'text' => 'Test', 'payload' => 'p']]
]
]
],
InlineKeyboardAttachment::class,
function (TestCase $test, InlineKeyboardAttachment $attachment) {
$test->assertInstanceOf(KeyboardPayload::class, $attachment->payload);
$test->assertIsArray($attachment->payload->buttons);
$test->assertInstanceOf(CallbackButton::class, $attachment->payload->buttons[0][0]);
$test->assertSame('p', $attachment->payload->buttons[0][0]->payload);
}
],
];
}
#[Test]
#[DataProvider('attachmentTypeProvider')]
public function createAttachmentSuccessfullyCreatesVariousTypes(
array $data,
string $expectedClass,
callable $assertionCallback,
): void {
$attachment = $this->factory->createAttachment($data);
$this->assertInstanceOf($expectedClass, $attachment);
$assertionCallback($this, $attachment);
}
}

Some files were not shown because too many files have changed in this diff Show More