Compare commits

..

34 Commits

Author SHA1 Message Date
Alex ef3b76394c tag 1.4.0 2025-12-04 08:44:10 +03:00
Alex 2ecb5c7b04 Merge pull request #17 from BushlanovDev/i15
Silent mode for errors of unsupported event types #15
2025-12-04 08:41:54 +03:00
Alex 2e7e9d987f Added DialogUnmuted & DialogCleared & DialogRemoved update types 2025-12-03 23:05:09 +03:00
Alex cf415b70e9 Added DialogMuted & BotStopped update types 2025-12-02 20:00:34 +03:00
Alex a94dfe0df4 Silent mode for errors of unsupported event types #15 2025-12-01 20:11:47 +03:00
Alex 03b1f158d9 Disabled api version #16 2025-11-30 17:10:00 +03:00
Alex d853faacf7 tag 1.3.2 2025-10-21 09:05:51 +03:00
Alex 576f02efbd Merge pull request #14 from BushlanovDev/i12
fix 12
2025-10-21 09:02:44 +03:00
Alex da7a07145b Fix send message response object 2025-10-20 21:27:54 +03:00
Alex 7cdbfc0f47 Update Message model 2025-10-08 23:14:06 +03:00
Alex f29344d2ec Update Message model 2025-10-07 23:56:57 +03:00
Alex 3012272497 Merge pull request #11 from BushlanovDev/fix10
Fix authorization with header #10
2025-10-04 21:54:19 +03:00
Alex 0dc10bd279 Fix authorization with header #10 2025-10-04 21:51:14 +03:00
Alex 8419a7892d Merge remote-tracking branch 'origin/master' 2025-09-29 19:28:41 +03:00
Alex ddc57d7ab8 Change api domain and authorization with header 2025-09-29 19:28:20 +03:00
Alex d0f044c7bd Change api domain and authorization with header 2025-09-29 19:25:28 +03:00
Alex 31f109db7e Merge pull request #8 from BushlanovDev/docs
Update documentation
2025-09-16 21:25:38 +03:00
Alex e1375acabb Update documentation 2025-09-16 21:22:04 +03:00
Alex 098aa44825 Update documentation 2025-09-15 23:04:15 +03:00
Alex 6137b2ea6e Merge pull request #7 from BushlanovDev/docs
Update documentation
2025-09-14 15:55:33 +03:00
Alex 122afcc7c0 Merge branch 'master' into docs 2025-09-14 15:44:19 +03:00
Alex 4d56119fa2 Disable deprecation error for unit test 2025-09-13 19:53:43 +03:00
Alex ae53f51854 Disable deprecation error for unit test 2025-09-13 19:26:13 +03:00
Alex b87b41ff8f Update documentation 2025-09-12 22:06:23 +03:00
Alex 493be1a9a0 Add php8.5 to github action 2025-09-10 21:25:52 +03:00
Alex 033fa42b79 Update library version 2025-09-02 21:32:26 +03:00
Alex 94ec907506 Added resumable upload tests 2025-08-27 21:26:33 +03:00
Alex 869c254275 Merge pull request #6 from BushlanovDev/resumable-upload
Resumable file upload
2025-08-26 21:10:53 +03:00
Alex cb8ad3f738 Added resumable upload tests 2025-08-26 21:08:07 +03:00
Alex 0f007bf94e Added resumable upload tests 2025-08-26 20:55:45 +03:00
Alex 3127f86c54 Added resumable upload 2025-08-25 23:11:06 +03:00
Alex ed715af3bc Update badger 2025-08-24 20:37:19 +03:00
Alex 008d6f4e0e Documentation 2025-08-22 20:17:28 +03:00
Alex 4784fcc97b Merge pull request #5 from BushlanovDev/fix-upload-files
Fix upload files
2025-08-21 19:31:21 +03:00
34 changed files with 6354 additions and 198 deletions
@@ -5,7 +5,7 @@
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<mask id="a">
<rect width="99" height="20" rx="3" fill="#fff"/>
<rect width="99" height="20" fill="#fff"/>
</mask>
<g mask="url(#a)">
<path fill="#555" d="M0 0h63v20H0z"/>

Before

Width:  |  Height:  |  Size: 902 B

After

Width:  |  Height:  |  Size: 895 B

+7 -13
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php-versions: [ "8.3", "8.4" ]
php-versions: [ "8.3", "8.4", "8.5" ]
name: PHP ${{ matrix.php-versions }} Test on ubuntu-latest
steps:
@@ -43,19 +43,13 @@ jobs:
php-version: ${{ matrix.php-versions }}
coverage: xdebug
- name: Get composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer dependencies
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install composer dependencies
run: composer install --prefer-dist --no-interaction
run: |
if [[ "${{ matrix.php-versions }}" == "8.5" ]]; then
composer install --prefer-dist --no-interaction --ignore-platform-req=php
else
composer install --prefer-dist --no-interaction
fi
- name: PHPUnit
run: ./vendor/bin/phpunit --configuration=phpunit.xml --coverage-text
+9 -2
View File
@@ -1,7 +1,7 @@
# Max Bot API Client library for PHP
# Max Messenger Bot API Client library for PHP
[![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)
[![Coverage](https://raw.githubusercontent.com/BushlanovDev/max-bot-api-client-php/refs/heads/master/.github/badge-coverage.svg?v=2)](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)
@@ -51,11 +51,18 @@ use BushlanovDev\MaxMessengerBot\Api;
$api = new Api('YOUR_BOT_API_TOKEN');
// Загрузка файла
$fileAttachmentRequest = $api->uploadAttachment(
type: UploadType::File,
filePath: __DIR__ . '/test.pdf',
);
$api->sendMessage(
userId: 123, // ID пользователя получателя сообщения
chatId: 321, // Или ID чата, в который нужно отправить сообщение
text: 'Привет!', // Текст сообщения, вы можете использовать HTML или Markdown
attachments: [
$fileAttachmentRequest,
new InlineKeyboardAttachmentRequest([
[new CallbackButton('Нажми меня!', 'payload_button1')],
[new LinkButton('Нажми меня!', 'https://example.com')],
+75 -64
View File
@@ -1,67 +1,78 @@
{
"name": "bushlanov-dev/max-bot-api-client-php",
"description": "Max Bot API Client library",
"keywords": ["max messenger", "bot", "max", "api", "max bot", "laravel", "laravel max bot"],
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Aleksandr Bushlanov",
"email": "alex@bushlanov.dev",
"homepage": "https://bushlanov.dev",
"role": "Developer"
"name": "bushlanov-dev/max-bot-api-client-php",
"description": "Max Bot API Client library",
"keywords": [
"max messenger",
"bot",
"max",
"api",
"max bot",
"laravel",
"laravel max bot"
],
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Aleksandr Bushlanov",
"email": "alex@bushlanov.dev",
"homepage": "https://bushlanov.dev",
"role": "Developer"
}
],
"require": {
"php": ">=8.3",
"ext-json": "*",
"guzzlehttp/guzzle": "^6.5.8||^7.0",
"guzzlehttp/psr7": "^1.8||^2.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0||^2.0",
"psr/log": "^3.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.77",
"bushlanov-dev/php-coverage-badger": "^2.1",
"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",
"roave/security-advisories": "dev-latest"
},
"autoload": {
"psr-4": {
"BushlanovDev\\MaxMessengerBot\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"BushlanovDev\\MaxMessengerBot\\Tests\\": "tests"
}
},
"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=512M",
"format": "vendor/bin/php-cs-fixer fix --allow-risky=yes src",
"test": "vendor/bin/phpunit",
"test-coverage": "vendor/bin/phpunit --coverage-html coverage",
"create-coverage-badge": [
"vendor/bin/phpunit --coverage-clover clover.xml",
"vendor/bin/php-coverage-badger --square clover.xml .github/badge-coverage.svg"
]
}
],
"require": {
"php": ">=8.3",
"ext-json": "*",
"guzzlehttp/guzzle": "^6.5.8||^7.0",
"guzzlehttp/psr7": "^1.8||^2.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.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",
"roave/security-advisories": "dev-latest"
},
"autoload": {
"psr-4": {
"BushlanovDev\\MaxMessengerBot\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"BushlanovDev\\MaxMessengerBot\\Tests\\": "tests"
}
},
"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",
"test": "vendor/bin/phpunit",
"test-coverage": "vendor/bin/phpunit --coverage-html coverage",
"create-coverage-badge": ["vendor/bin/phpunit --coverage-clover clover.xml", "vendor/bin/php-coverage-badger clover.xml badge-coverage.svg"]
}
}
+237 -34
View File
@@ -1,7 +1,6 @@
- [Быстрый старт](#Быстрый-старт)
- [Получение токена](#Получение-токена)
- [Установка библиотеки](#Установка-библиотеки)
- [Установка библиотеки в Laravel](#Установка-библиотеки-в-Laravel)
- [Инициализация бота](#Инициализация-бота)
- [Информация о боте](#Информация-о-боте)
- `GET /me` (`getBotInfo`) - [*Получение информации о боте.*](#Получение-информации-о-боте)
@@ -40,6 +39,18 @@
- `GET /messages/{messageId}` (`getMessageById`) - [*Получение сообщения по ID.*](#Получение-сообщения-по-ID)
- `GET /videos/{videoToken}` (`getVideoAttachmentDetails`) - [*Получение детальной информации о видео.*](#Получение-детальной-информации-о-видео)
- `POST /answers` (`answerOnCallback`) - [*Ответ на нажатие callback-кнопки.*](#Ответ-на-нажатие-callback-кнопки)
- [Laravel](#Laravel)
- [Регистрация пакета](#Регистрация-пакета)
- [Настройка](#Настройка)
- [Использование](#Использование)
- [Artisan команды](#Artisan-команды)
- [Подписка на Webhook](#Подписка-на-Webhook)
- [Удаление подписки](#Удаление-подписки)
- [Список подписок](#Список-подписок)
- [Обработка хуков](#Обработка-хуков)
- [Long Polling](#Long-Polling)
- [Handler Classes](#Handler-Classes)
- [Тестирование](#Тестирование)
## Быстрый старт
@@ -56,36 +67,6 @@
composer require bushlanov-dev/max-bot-api-client-php
```
### Установка библиотеки в Laravel
Пользователи Laravel могут зарегистрировать сервис провайдер и фасад в `config/app.php`:
```php
'providers' => [
// ...
BushlanovDev\MaxMessengerBot\Laravel\MaxBotServiceProvider::class,
],
// ...
'aliases' => [
// ...
'MaxBot' => BushlanovDev\MaxMessengerBot\Laravel\MaxBotFacade::class,
],
```
При не необходимости опубликовать конфиг выполните следующую команду
```bash
php artisan vendor:publish --provider="BushlanovDev\MaxMessengerBot\Laravel\MaxBotServiceProvider"
```
Для работы вам потребуется внести следующие настройки в `.env`
```env
MAXBOT_ACCESS_TOKEN=your_bot_access_token_here
MAXBOT_WEBHOOK_SECRET=your_webhook_secret_here
MAXBOT_LOGGING_ENABLED=true
```
### Инициализация бота
Единственной обязательной настройкой является токен вашего бота.
@@ -114,13 +95,16 @@ $api = new Api(
### Получение информации о боте
Возвращает информацию о текущем боте, который идентифицируется с помощью токена доступа.
Метод возвращает ID бота, его имя и аватар (если есть).
```php
$botInfo = $api->getBotInfo();
```
### Редактирование информации о боте
Обратите внимание что данный метод отправляется PATCH запросом. Это значит, что будут обновлены только переданные
Обратите внимание, что данный метод отправляется PATCH запросом. Это значит, что будут обновлены только переданные
поля.
В следующем примере мы изменяем только название бота и отчистим его описание. Остальные поля останутся неизменными.
@@ -137,6 +121,9 @@ $botInfo = $api->editBotInfo(
### Получение списка всех чатов бота
Возвращает информацию о чатах, в которых участвовал бот.
Результат включает список чатов и маркер для перехода к следующей странице.
```php
$chats = $api->getChats(
count: 10, // Количество запрашиваемых чатов
@@ -146,6 +133,8 @@ $chats = $api->getChats(
### Получение информации о чате по ссылке
Возвращает информацию о чате по его публичной ссылке, либо информацию о диалоге с пользователем по его username.
```php
$chat = $api->getChatByLink('@super_chat'); // Публичная ссылка на чат или username пользователя
```
@@ -158,6 +147,8 @@ $chat = $api->getChat(12345);
### Редактирование информации о чате
Позволяет редактировать информацию о чате, включая название, иконку и закреплённое сообщение.
```php
$chat = $api->editChat(
chatId: 12345,
@@ -175,6 +166,8 @@ $api->deleteChat(12345);
### Отправка действия в чат
Позволяет отправлять действия бота в чат, такие как «набор текста» или «отправка фото».
```php
$api->sendAction(
chatId: 12345,
@@ -218,6 +211,8 @@ $api->leaveChat(12345);
### Получение администраторов чата
Возвращает всех администраторов чата. Бот должен быть администратором в запрашиваемом чате.
```php
$adminsChatMemberList = $api->getAdmins(12345);
```
@@ -225,7 +220,7 @@ $adminsChatMemberList = $api->getAdmins(12345);
### Назначение администраторов чата
```php
$chatMemberList = $api->addAdmins(
$api->addAdmins(
chatId: 12345,
admins: [
new ChatAdmin(123, [ChatAdminPermission::ReadAllMessages]),
@@ -278,6 +273,10 @@ $subscriptions = $api->getSubscriptions();
### Создание Webhook-подписки
Подписывает бота на получение обновлений через WebHook.
После вызова этого метода бот будет получать уведомления о новых событиях в чатах на указанный URL.
Ваш сервер должен прослушивать один из следующих портов: 80, 8080, 443, 8443, 16384-32383.
```php
$api->subscribe(
url: 'https://example.com/webhook', // URL на который будут приходить хуки. Должен начинаться с http(s)://
@@ -294,6 +293,11 @@ $api->unsubscribe('https://example.com/webhook');
### Получение обновлений через Long-Polling
Этот метод можно использовать для получения обновлений, если ваш бот не подписан на WebHook. Метод использует долгий опрос (long polling).
Каждое обновление имеет свой номер последовательности. Свойство marker в ответе указывает на следующее ожидаемое обновление.
Все предыдущие обновления считаются завершенными после прохождения параметра marker.
Если параметр marker не передан, бот получит все обновления, произошедшие после последнего подтверждения.
```php
$updateList = $api->getUpdates(
limit: 10, // Максимальное количество обновлений для получения [1-1000] (необязательно)
@@ -309,7 +313,8 @@ $updateList = $api->getUpdates(
```php
$uploadEndpoint = $api->getUploadUrl(UploadType::Video);
// Далее вы можете загрузить файл по полученному URL самостоятельно или воспользоваться методом Client::upload()
// Далее вы можете загрузить файл по полученному URL самостоятельно
// или воспользоваться методами Client::multipartUpload(), Client::resumableUpload(), Api::uploadFile()
```
### Загрузка файла
@@ -327,6 +332,10 @@ $photoAttachmentRequest = $api->uploadAttachment(
### Получение списка сообщений из чата
Возвращает сообщения в чате: страницу с результатами и маркер, указывающий на следующую страницу.
Сообщения возвращаются в обратном порядке, то есть последние сообщения в чате будут первыми в массиве.
Поэтому, если вы используете параметры from и to, то to должно быть меньше, чем from.
```php
$messages = $api->getMessages(
chatId: 12345, // ID чата, чтобы получить сообщения из определённого чата (необязательно)
@@ -366,6 +375,9 @@ $message = $api->sendMessage(
### Редактирование сообщения
Редактирует сообщение в чате. Если поле attachments равно null, вложения текущего сообщения не изменяются.
Если в этом поле передан пустой список, все вложения будут удалены.
```php
$api->editMessage(
messageId: 12345,
@@ -391,6 +403,8 @@ $message = $api->getMessageById(12345);
### Получение детальной информации о видео
Возвращает подробную информацию о приклеплённом видео. URL-адреса воспроизведения и дополнительные метаданные.
```php
$videoAttachmentDetails = $api->getVideoAttachmentDetails('some-video-token');
```
@@ -411,3 +425,192 @@ $api->answerOnCallback(
notify: true, // Заполните это, если хотите просто отправить одноразовое уведомление пользователю (необязательно)
);
```
## Laravel
### Регистрация пакета
Пакет будет автоматически обнаружен Laravel.
Если автоматическое обнаружение отключено можно зарегистрировать сервис провайдер и фасад в `config/app.php`:
```php
'providers' => [
// ...
BushlanovDev\MaxMessengerBot\Laravel\MaxBotServiceProvider::class,
],
// ...
'aliases' => [
// ...
'MaxBot' => BushlanovDev\MaxMessengerBot\Laravel\MaxBotFacade::class,
],
```
### Настройка
При не необходимости опубликовать конфиг выполните следующую команду:
```bash
php artisan vendor:publish --provider="BushlanovDev\MaxMessengerBot\Laravel\MaxBotServiceProvider"
```
Для работы вам потребуется добавить ваш токен в файл `.env`
```env
MAXBOT_ACCESS_TOKEN=your_bot_access_token_here
MAXBOT_WEBHOOK_SECRET=your_webhook_secret_here
```
### Использование
Все методы бота доступны через фасад MaxBot, например:
```php
use MaxBot;
// Отправка сообщения пользователю
MaxBot::sendUserMessage(123456, 'Hello from Laravel!');
// Получение обновления
$updates = MaxBot::getUpdates();
```
### Artisan команды
#### Подписка на Webhook
```bash
# Подписка на получение обновлений
php artisan maxbot:webhook:subscribe https://yourapp.com/bot/webhook
# С верификацией
php artisan maxbot:webhook:subscribe https://yourapp.com/bot/webhook --secret=your_secret_key
# Подписка только на определенные типы событий
php artisan maxbot:webhook:subscribe https://yourapp.com/bot/webhook --types=message_created --types=message_callback
```
#### Удаление подписки
```bash
php artisan maxbot:webhook:unsubscribe https://yourapp.com/bot/webhook
```
#### Список подписок
```bash
php artisan maxbot:webhook:list
```
### Обработка хуков
Создайте контроллер для обработчика:
```php
use Illuminate\Http\Request;
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotManager;
class WebhookController extends Controller
{
public function handle(Request $request, MaxBotManager $botManager)
{
// Обработчик сообщений
$botManager->onMessageCreated(function (MessageCreatedUpdate $update) {
$message = $update->message;
// ...
});
// Обработчик команды /start
$botManager->onCommand('start', function (MessageCreatedUpdate $update) {
// ...
});
// Using Laravel container bindings
$botManager->onMessageCreated(MessageHandler::class);
$botManager->onCommand('help', HelpCommandHandler::class);
return $botManager->handleWebhook($request);
}
}
```
Добавьте маршрут в `routes/web.php`:
```php
Route::post('/bot/webhook', [WebhookController::class, 'handle']);
```
### Long Polling
Создайте artisan команду для получения long polling обновлений:
```php
use Illuminate\Console\Command;
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotManager;
class BotPollingCommand extends Command
{
protected $signature = 'bot:polling';
protected $description = 'Start bot long polling';
public function handle(MaxBotManager $botManager)
{
$this->info('Starting bot polling...');
$botManager->startLongPolling();
}
}
```
### Handler Classes
Примеры классов обработчиков обновлений:
```php
class MessageHandler
{
public function handle(MessageCreatedUpdate $update)
{
$message = $update->message;
$text = $message->body?->text;
if ($text) {
app(Api::class)->sendMessage(
userId: $message->sender->userId,
text: "You said: $text",
);
}
}
}
class HelpCommandHandler
{
public function handle(MessageCreatedUpdate $update)
{
app(Api::class)->sendMessage(
userId: $update->message->sender->userId,
text: "This is help message",
);
}
}
```
### Тестирование
Вы можете использовать мок API для тестирования:
```php
use BushlanovDev\MaxMessengerBot\Api;
class BotTest extends TestCase
{
public function test_bot_sends_message()
{
$apiMock = $this->createMock(Api::class);
$apiMock->expects($this->once())
->method('sendUserMessage')
->with(123456, 'Hello!');
$this->app->instance(Api::class, $apiMock);
// Your test code here
}
}
```
+4659
View File
File diff suppressed because one or more lines are too long
+13 -3
View File
@@ -1,14 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
bootstrap="tests/bootstrap.php"
testdox="false"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
displayDetailsOnPhpunitDeprecations="true"
failOnPhpunitDeprecation="true"
failOnDeprecation="false"
failOnPhpunitDeprecation="false"
displayDetailsOnPhpunitDeprecations="false"
displayDetailsOnTestsThatTriggerDeprecations="false"
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
@@ -25,4 +31,8 @@
<directory suffix=".php">src/Exceptions</directory>
</exclude>
</source>
<php>
<ini name="error_reporting" value="E_ALL &amp; ~E_DEPRECATED"/>
</php>
</phpunit>
+40 -9
View File
@@ -48,11 +48,11 @@ use RuntimeException;
*/
class Api
{
public const string LIBRARY_VERSION = '1.0.1';
public const string LIBRARY_VERSION = '1.4.0';
public const string API_VERSION = '0.0.6';
public const string API_VERSION = '1.2.5';
private const string API_BASE_URL = 'https://botapi.max.ru';
private const string API_BASE_URL = 'https://platform-api.max.ru';
private const string METHOD_GET = 'GET';
private const string METHOD_POST = 'POST';
@@ -75,6 +75,8 @@ class Api
private const string ACTION_ANSWERS = '/answers';
private const string ACTION_VIDEO_DETAILS = '/videos/%s';
private const int RESUMABLE_UPLOAD_THRESHOLD_BYTES = 10 * 1024 * 1024; // 10 MB
private readonly ClientApiInterface $client;
private readonly ModelFactory $modelFactory;
@@ -126,13 +128,13 @@ class Api
$httpFactory,
$httpFactory,
self::API_BASE_URL,
self::API_VERSION,
null,
$this->logger,
);
}
$this->client = $client;
$this->modelFactory = $modelFactory ?? new ModelFactory();
$this->modelFactory = $modelFactory ?? new ModelFactory($this->logger);
$this->updateDispatcher = new UpdateDispatcher($this);
}
@@ -361,7 +363,7 @@ class Api
$this->buildNewMessageBody($text, $attachments, $format, $link, $notify),
);
return $this->modelFactory->createMessage($response['message']);
return $this->modelFactory->createMessageFromSendResponse($response);
}
/**
@@ -443,6 +445,35 @@ class Api
);
}
/**
* Uploads a file to the specified URL.
*
* @param string $uploadUrl The target URL for the upload.
* @param resource $fileHandle A stream resource pointing to the file.
* @param string $fileName The desired file name for the upload.
*
* @return string The body of the final response from the server.
* @throws ClientApiException
* @throws NetworkException
* @throws SerializationException
* @throws RuntimeException
*/
public function uploadFile(string $uploadUrl, mixed $fileHandle, string $fileName): string
{
$stat = fstat($fileHandle);
if (!is_array($stat)) {
throw new RuntimeException('File handle is not a valid resource.');
}
rewind($fileHandle);
if ($stat['size'] < self::RESUMABLE_UPLOAD_THRESHOLD_BYTES) {
return $this->client->multipartUpload($uploadUrl, $fileHandle, $fileName);
}
return $this->client->resumableUpload($uploadUrl, $fileHandle, $fileName, $stat['size']);
}
/**
* A simplified method for uploading a file and getting the resulting attachment object.
*
@@ -479,7 +510,8 @@ class Api
"API did not return a pre-upload token for type '$type->value'."
);
}
$this->client->upload($uploadEndpoint->url, $fileHandle, basename($filePath));
$this->uploadFile($uploadEndpoint->url, $fileHandle, basename($filePath));
fclose($fileHandle);
return match ($type) {
@@ -489,7 +521,7 @@ class Api
}
// For images and files, the token is in the response *after* the upload.
$responseBody = $this->client->upload($uploadEndpoint->url, $fileHandle, basename($filePath));
$responseBody = $this->uploadFile($uploadEndpoint->url, $fileHandle, basename($filePath));
fclose($fileHandle);
try {
@@ -653,7 +685,6 @@ class Api
return null;
}
return $this->modelFactory->createMessage($response['message']);
}
+70 -3
View File
@@ -21,6 +21,7 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use RuntimeException;
/**
* The low-level HTTP client responsible for communicating with the Max Bot API.
@@ -59,7 +60,6 @@ final readonly class Client implements ClientApiInterface
*/
public function request(string $method, string $uri, array $queryParams = [], array $body = []): array
{
$queryParams['access_token'] = $this->accessToken;
if (!empty($this->apiVersion)) {
$queryParams['v'] = $this->apiVersion;
}
@@ -71,7 +71,9 @@ final readonly class Client implements ClientApiInterface
]);
$fullUrl = $this->baseUrl . $uri . '?' . http_build_query($queryParams);
$request = $this->requestFactory->createRequest($method, $fullUrl);
$request = $this->requestFactory
->createRequest($method, $fullUrl)
->withHeader('Authorization', $this->accessToken);
if (!empty($body)) {
try {
@@ -122,7 +124,7 @@ final readonly class Client implements ClientApiInterface
/**
* @inheritDoc
*/
public function upload(string $uri, mixed $fileContents, string $fileName): string
public function multipartUpload(string $uri, mixed $fileContents, string $fileName): string
{
$boundary = '--------------------------' . microtime(true);
$bodyStream = $this->streamFactory->createStream();
@@ -142,6 +144,7 @@ final readonly class Client implements ClientApiInterface
$request = $this->requestFactory
->createRequest('POST', $uri)
->withHeader('Content-Type', 'multipart/form-data; boundary=' . $boundary)
->withHeader('Authorization', $this->accessToken)
->withBody($bodyStream);
try {
@@ -155,6 +158,70 @@ final readonly class Client implements ClientApiInterface
return (string)$response->getBody();
}
/**
* @inheritDoc
*/
public function resumableUpload(
string $uploadUrl,
mixed $fileResource,
string $fileName,
int $fileSize,
int $chunkSize = 1048576,
): string {
if (!is_resource($fileResource) || get_resource_type($fileResource) !== 'stream') {
throw new InvalidArgumentException('fileResource must be a valid stream resource.');
}
// @phpstan-ignore-next-line
if ($fileSize <= 0) {
throw new InvalidArgumentException('File size must be greater than 0.');
}
$startByte = 0;
$finalResponseBody = '';
while (!feof($fileResource)) {
$chunk = fread($fileResource, $chunkSize);
if ($chunk === false) {
// @codeCoverageIgnoreStart
throw new RuntimeException('Failed to read chunk from file stream.');
// @codeCoverageIgnoreEnd
}
$chunkLength = strlen($chunk);
if ($chunkLength === 0) {
break;
}
$endByte = $startByte + $chunkLength - 1;
$chunkStream = $this->streamFactory->createStream($chunk);
$request = $this->requestFactory->createRequest('POST', $uploadUrl)
->withBody($chunkStream)
->withHeader('Content-Type', 'application/octet-stream')
->withHeader('Content-Disposition', 'attachment; filename="' . $fileName . '"')
->withHeader('Content-Range', "bytes {$startByte}-{$endByte}/{$fileSize}")
->withHeader('Authorization', $this->accessToken);
try {
$response = $this->httpClient->sendRequest($request);
} catch (ClientExceptionInterface $e) {
throw new NetworkException($e->getMessage(), $e->getCode(), $e);
}
$this->handleErrorResponse($response);
// The final response might contain the retval
$finalResponseBody = (string)$response->getBody();
$startByte += $chunkLength;
}
// According to docs, for video/audio the token is sent separately,
// and the upload response contains 'retval'. We return the body of the last response.
return $finalResponseBody;
}
/**
* Checks the response for an error status code and throws a corresponding typed exception.
*
+25 -1
View File
@@ -7,6 +7,7 @@ namespace BushlanovDev\MaxMessengerBot;
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
use RuntimeException;
interface ClientApiInterface
{
@@ -37,5 +38,28 @@ interface ClientApiInterface
* @throws NetworkException
* @throws SerializationException
*/
public function upload(string $uri, mixed $fileContents, string $fileName): string;
public function multipartUpload(string $uri, mixed $fileContents, string $fileName): string;
/**
* Uploads a file in chunks using the resumable upload method.
* The caller is responsible for opening and closing the file resource.
*
* @param string $uploadUrl The target URL for the upload.
* @param resource $fileResource A stream resource pointing to the file.
* @param string $fileName The desired file name for the upload.
* @param int<1, max> $fileSize The total size of the file in bytes.
* @param int<1, max> $chunkSize The size of each chunk in bytes.
*
* @return string The body of the final response from the server.
* @throws NetworkException
* @throws ClientApiException
* @throws RuntimeException
*/
public function resumableUpload(
string $uploadUrl,
$fileResource,
string $fileName,
int $fileSize,
int $chunkSize = 1048576,
): string;
}
+5
View File
@@ -15,9 +15,14 @@ enum UpdateType: string
case MessageRemoved = 'message_removed';
case BotAdded = 'bot_added';
case BotRemoved = 'bot_removed';
case DialogMuted = 'dialog_muted';
case DialogUnmuted = 'dialog_unmuted';
case DialogCleared = 'dialog_cleared';
case DialogRemoved = 'dialog_removed';
case UserAdded = 'user_added';
case UserRemoved = 'user_removed';
case BotStarted = 'bot_started';
case BotStopped = 'bot_stopped';
case ChatTitleChanged = 'chat_title_changed';
case MessageChatCreated = 'message_chat_created';
}
+69 -4
View File
@@ -27,7 +27,7 @@ use Throwable;
* Provides convenient methods for integrating Max Bot with Laravel applications.
* Handles webhook processing, long polling, and event dispatching within Laravel context.
*/
class MaxBotManager
readonly class MaxBotManager
{
/**
* @param Container $container
@@ -35,9 +35,9 @@ class MaxBotManager
* @param UpdateDispatcher $dispatcher
*/
public function __construct(
private readonly Container $container,
private readonly Api $api,
private readonly UpdateDispatcher $dispatcher,
private Container $container,
private Api $api,
private UpdateDispatcher $dispatcher,
) {
}
@@ -229,6 +229,58 @@ class MaxBotManager
$this->dispatcher->onBotRemoved($this->resolveHandler($handler));
}
/**
* Register a dialog mute handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onDialogMuted(callable|string $handler): void
{
$this->dispatcher->onDialogMuted($this->resolveHandler($handler));
}
/**
* Register a dialog unmute handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onDialogUnmuted(callable|string $handler): void
{
$this->dispatcher->onDialogUnmuted($this->resolveHandler($handler));
}
/**
* Register a dialog cleared handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onDialogCleared(callable|string $handler): void
{
$this->dispatcher->onDialogCleared($this->resolveHandler($handler));
}
/**
* Register a dialog removed handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onDialogRemoved(callable|string $handler): void
{
$this->dispatcher->onDialogRemoved($this->resolveHandler($handler));
}
/**
* Register a user added handler.
*
@@ -268,6 +320,19 @@ class MaxBotManager
$this->dispatcher->onBotStarted($this->resolveHandler($handler));
}
/**
* Register a bot stopped handler.
*
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
*
* @throws BindingResolutionException
* @codeCoverageIgnore
*/
public function onBotStopped(callable|string $handler): void
{
$this->dispatcher->onBotStopped($this->resolveHandler($handler));
}
/**
* Register a chat title changed handler.
*
+28 -7
View File
@@ -76,14 +76,20 @@ class MaxBotServiceProvider extends ServiceProvider
$guzzle,
$httpFactory,
$httpFactory,
$config->get('maxbot.base_url', 'https://botapi.max.ru'),
$config->get('maxbot.api_version', Api::API_VERSION),
$config->get('maxbot.base_url', 'https://platform-api.max.ru'),
$config->get('maxbot.api_version'),
$logger,
);
});
$this->app->singleton(ModelFactory::class, function () {
return new ModelFactory();
$this->app->singleton(ModelFactory::class, function (Application $app) {
/** @var Config $config */
$config = $app->make(Config::class);
$logger = $config->get('maxbot.logging.enabled', false)
? $app->make(LoggerInterface::class)
: new NullLogger();
return new ModelFactory($logger);
});
$this->app->singleton(Api::class, function (Application $app) {
@@ -97,11 +103,15 @@ class MaxBotServiceProvider extends ServiceProvider
);
}
$logger = $config->get('maxbot.logging.enabled', false)
? $app->make(LoggerInterface::class)
: new NullLogger();
return new Api(
$accessToken,
$app->make(ClientApiInterface::class),
$app->make(ModelFactory::class),
$app->make(LoggerInterface::class),
$logger,
);
});
@@ -114,19 +124,30 @@ class MaxBotServiceProvider extends ServiceProvider
$config = $app->make(Config::class);
$secret = $config->get('maxbot.webhook_secret');
$logger = $config->get('maxbot.logging.enabled', false)
? $app->make(LoggerInterface::class)
: new NullLogger();
return new WebhookHandler(
$app->make(UpdateDispatcher::class),
$app->make(ModelFactory::class),
$app->make(LoggerInterface::class),
$logger,
$secret,
);
});
$this->app->bind(LongPollingHandler::class, function (Application $app) {
/** @var Config $config */
$config = $app->make(Config::class);
$logger = $config->get('maxbot.logging.enabled', false)
? $app->make(LoggerInterface::class)
: new NullLogger();
return new LongPollingHandler(
$app->make(Api::class),
$app->make(UpdateDispatcher::class),
$app->make(LoggerInterface::class),
$logger,
);
});
+58 -3
View File
@@ -56,7 +56,12 @@ use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\BotAddedToChatUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\BotRemovedFromChatUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\BotStartedUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\BotStoppedUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\ChatTitleChangedUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\DialogClearedUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\DialogMutedUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\DialogRemovedUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\DialogUnmutedUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCallbackUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageChatCreatedUpdate;
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
@@ -67,13 +72,25 @@ use BushlanovDev\MaxMessengerBot\Models\Updates\UserRemovedFromChatUpdate;
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
use LogicException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use ReflectionException;
/**
* Creates DTOs from raw associative arrays returned by the API client.
*/
class ModelFactory
readonly class ModelFactory
{
private LoggerInterface $logger;
/**
* @param LoggerInterface|null $logger PSR LoggerInterface.
*/
public function __construct(?LoggerInterface $logger = null)
{
$this->logger = $logger ?? new NullLogger();
}
/**
* Simple response to request.
*
@@ -128,6 +145,33 @@ class ModelFactory
: [];
}
/**
* Creates a Message from the specific response structure of the sendMessage endpoint.
*
* @param array<string, mixed> $data The raw response from the client.
*
* @return Message
* @throws ReflectionException
*/
public function createMessageFromSendResponse(array $data): Message
{
$messageData = $data['message'];
$topLevelData = [
'chat_id' => $data['chat_id'] ?? null,
'recipient_id' => $data['recipient_id'] ?? null,
'message_id' => $data['message_id'] ?? null,
];
$messageData = array_merge($messageData, array_filter($topLevelData, fn($value) => $value !== null));
if (isset($messageData['message']) && is_array($messageData['message'])) {
$messageData['body'] = $messageData['message'];
unset($messageData['message']);
}
return $this->createMessage($messageData);
}
/**
* Message.
*
@@ -267,7 +311,9 @@ class ModelFactory
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')),
default => throw new LogicException(
'Unknown or unsupported inline button type: ' . ($data['type'] ?? 'none')
),
};
}
@@ -312,7 +358,11 @@ class ModelFactory
if (isset($data['updates']) && is_array($data['updates'])) {
foreach ($data['updates'] as $updateData) {
// Here we delegate the creation of a specific update to another factory method
$updateObjects[] = $this->createUpdate($updateData);
try {
$updateObjects[] = $this->createUpdate($updateData);
} catch (LogicException $e) {
$this->logger->debug($e->getMessage(), ['payload' => $updateData, 'exception' => $e]);
}
}
}
@@ -340,9 +390,14 @@ class ModelFactory
UpdateType::MessageRemoved => MessageRemovedUpdate::fromArray($data),
UpdateType::BotAdded => BotAddedToChatUpdate::fromArray($data),
UpdateType::BotRemoved => BotRemovedFromChatUpdate::fromArray($data),
UpdateType::DialogMuted => DialogMutedUpdate::fromArray($data),
UpdateType::DialogUnmuted => DialogUnmutedUpdate::fromArray($data),
UpdateType::DialogCleared => DialogClearedUpdate::fromArray($data),
UpdateType::DialogRemoved => DialogRemovedUpdate::fromArray($data),
UpdateType::UserAdded => UserAddedToChatUpdate::fromArray($data),
UpdateType::UserRemoved => UserRemovedFromChatUpdate::fromArray($data),
UpdateType::BotStarted => BotStartedUpdate::fromArray($data),
UpdateType::BotStopped => BotStoppedUpdate::fromArray($data),
UpdateType::ChatTitleChanged => ChatTitleChangedUpdate::fromArray($data),
UpdateType::MessageChatCreated => MessageChatCreatedUpdate::fromArray($data),
default => throw new LogicException(
+6
View File
@@ -17,6 +17,9 @@ final readonly class Message extends AbstractModel
* @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.
* @param int|null $chatId Chat identifier.
* @param int|null $recipientId User identifier, if message was sent to user.
* @param string|null $messageId Unique identifier of message.
*/
public function __construct(
public int $timestamp,
@@ -26,6 +29,9 @@ final readonly class Message extends AbstractModel
public ?string $url,
public ?LinkedMessage $link,
public ?MessageStat $stat,
public ?int $chatId = null,
public ?int $recipientId = null,
public ?string $messageId = null,
) {
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Updates;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\User;
/**
* The bot receives this type of update as soon as the user stops the bot.
*/
final readonly class BotStoppedUpdate extends AbstractUpdate
{
/**
* @param int $timestamp Unix-time when event has occurred.
* @param int $chatId Dialog identifier where event has occurred.
* @param User $user User pressed the 'Start' button.
* @param string|null $userLocale Current user locale in IETF BCP 47 format.
*/
public function __construct(
int $timestamp,
public int $chatId,
public User $user,
public ?string $userLocale,
) {
parent::__construct(UpdateType::BotStopped, $timestamp);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Updates;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\User;
/**
* Event clearing dialog history.
*/
final readonly class DialogClearedUpdate extends AbstractUpdate
{
/**
* @param int $timestamp Unix-time when event has occurred.
* @param int $chatId Dialog identifier where event has occurred.
* @param User $user User pressed the 'Start' button.
* @param string|null $userLocale Current user locale in IETF BCP 47 format.
*/
public function __construct(
int $timestamp,
public int $chatId,
public User $user,
public ?string $userLocale,
) {
parent::__construct(UpdateType::DialogCleared, $timestamp);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Updates;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\User;
/**
* Event when a user mutes a conversation with a bot.
*/
final readonly class DialogMutedUpdate extends AbstractUpdate
{
/**
* @param int $timestamp Unix-time when event has occurred.
* @param int $chatId Dialog identifier where event has occurred.
* @param User $user User pressed the 'Start' button.
* @param int|null $mutedUntil The time in Unix format before which the dialog was disabled.
* @param string|null $userLocale Current user locale in IETF BCP 47 format.
*/
public function __construct(
int $timestamp,
public int $chatId,
public User $user,
public ?int $mutedUntil,
public ?string $userLocale,
) {
parent::__construct(UpdateType::DialogMuted, $timestamp);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Updates;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\User;
/**
* Event deleting a chat.
*/
final readonly class DialogRemovedUpdate extends AbstractUpdate
{
/**
* @param int $timestamp Unix-time when event has occurred.
* @param int $chatId Dialog identifier where event has occurred.
* @param User $user User pressed the 'Start' button.
* @param string|null $userLocale Current user locale in IETF BCP 47 format.
*/
public function __construct(
int $timestamp,
public int $chatId,
public User $user,
public ?string $userLocale,
) {
parent::__construct(UpdateType::DialogRemoved, $timestamp);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models\Updates;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\User;
/**
* Event of enabling notifications in a dialog.
*/
final readonly class DialogUnmutedUpdate extends AbstractUpdate
{
/**
* @param int $timestamp Unix-time when event has occurred.
* @param int $chatId Dialog identifier where event has occurred.
* @param User $user User pressed the 'Start' button.
* @param string|null $userLocale Current user locale in IETF BCP 47 format.
*/
public function __construct(
int $timestamp,
public int $chatId,
public User $user,
public ?string $userLocale,
) {
parent::__construct(UpdateType::DialogUnmuted, $timestamp);
}
}
+65
View File
@@ -164,6 +164,58 @@ final class UpdateDispatcher
return $this->addHandler(UpdateType::BotRemoved, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::DialogMuted, $handler).
*
* @param callable(Models\Updates\DialogMutedUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onDialogMuted(callable $handler): self
{
return $this->addHandler(UpdateType::DialogMuted, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::DialogUnmuted, $handler).
*
* @param callable(Models\Updates\DialogUnmutedUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onDialogUnmuted(callable $handler): self
{
return $this->addHandler(UpdateType::DialogUnmuted, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::DialogCleared, $handler).
*
* @param callable(Models\Updates\DialogClearedUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onDialogCleared(callable $handler): self
{
return $this->addHandler(UpdateType::DialogCleared, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::DialogRemoved, $handler).
*
* @param callable(Models\Updates\DialogRemovedUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onDialogRemoved(callable $handler): self
{
return $this->addHandler(UpdateType::DialogRemoved, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::UserAdded, $handler).
*
@@ -203,6 +255,19 @@ final class UpdateDispatcher
return $this->addHandler(UpdateType::BotStarted, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::BotStopped, $handler).
*
* @param callable(Models\Updates\BotStoppedUpdate, Api): void $handler
*
* @return $this
* @codeCoverageIgnore
*/
public function onBotStopped(callable $handler): self
{
return $this->addHandler(UpdateType::BotStopped, $handler);
}
/**
* A convenient alias for addHandler(UpdateType::ChatTitleChanged, $handler).
*
+6 -3
View File
@@ -69,9 +69,12 @@ final readonly class WebhookHandler
throw new SerializationException('Failed to decode webhook body as JSON.', 0, $e);
}
$update = $this->modelFactory->createUpdate($data);
$this->dispatcher->dispatch($update);
try {
$update = $this->modelFactory->createUpdate($data);
$this->dispatcher->dispatch($update);
} catch (\LogicException $e) {
$this->logger->debug($e->getMessage(), ['payload' => $payload, 'exception' => $e]);
}
if (!headers_sent()) {
http_response_code(200);
+145 -34
View File
@@ -118,6 +118,7 @@ use RuntimeException;
#[UsesClass(VideoAttachmentDetails::class)]
#[UsesClass(VideoUrls::class)]
#[UsesClass(UpdateDispatcher::class)]
#[UsesClass(ModelFactory::class)]
final class ApiTest extends TestCase
{
use PHPMock;
@@ -336,7 +337,7 @@ final class ApiTest extends TestCase
$apiResponse = [
'message' => [
'timestamp' => time(),
'body' => ['mid' => 'mid.456.xyz', 'seq' => 101, 'text' => $text],
'message' => ['mid' => 'mid.456.xyz', 'seq' => 101, 'text' => $text],
'recipient' => ['chat_type' => 'dialog', 'user_id' => 123, 'chat_id' => null],
'sender' => [
'user_id' => 123,
@@ -348,9 +349,18 @@ final class ApiTest extends TestCase
],
'url' => 'https://max.ru/message/123',
],
'chat_id' => 20414985,
'recipient_id' => 4328369,
'message_id' => 'mid.456.xyz',
];
$expectedMessageObject = Message::fromArray($apiResponse['message']);
$finalMessageData = $apiResponse['message'];
$finalMessageData['body'] = $finalMessageData['message'];
unset($finalMessageData['message']);
$finalMessageData['chat_id'] = $apiResponse['chat_id'];
$finalMessageData['recipient_id'] = $apiResponse['recipient_id'];
$finalMessageData['message_id'] = $apiResponse['message_id'];
$expectedMessageObject = Message::fromArray($finalMessageData);
$this->clientMock
->expects($this->once())
@@ -360,8 +370,8 @@ final class ApiTest extends TestCase
$this->modelFactoryMock
->expects($this->once())
->method('createMessage')
->with($apiResponse['message'])
->method('createMessageFromSendResponse')
->with($apiResponse)
->willReturn($expectedMessageObject);
$result = $this->api->sendMessage(
@@ -416,12 +426,17 @@ final class ApiTest extends TestCase
$apiResponse = [
'message' => [
'timestamp' => time(),
'body' => ['mid' => 'mid.test.123', 'seq' => 1, 'text' => $text],
'message' => ['mid' => 'mid.test.123', 'seq' => 1, 'text' => $text],
'recipient' => ['chat_type' => 'dialog', 'user_id' => 123, 'chat_id' => null],
]
],
'message_id' => 'mid.test.123',
];
$expectedMessageObject = Message::fromArray($apiResponse['message']);
$finalMessageData = $apiResponse['message'];
$finalMessageData['body'] = $finalMessageData['message'];
unset($finalMessageData['message']);
$finalMessageData['message_id'] = $apiResponse['message_id'];
$expectedMessageObject = Message::fromArray($finalMessageData);
$this->clientMock
->expects($this->once())
@@ -436,8 +451,8 @@ final class ApiTest extends TestCase
$this->modelFactoryMock
->expects($this->once())
->method('createMessage')
->with($apiResponse['message'])
->method('createMessageFromSendResponse')
->with($apiResponse)
->willReturn($expectedMessageObject);
$result = $this->api->sendMessage(
@@ -461,7 +476,7 @@ final class ApiTest extends TestCase
$this->clientMock->method('request')->willReturn(['url' => $uploadUrl]);
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl));
$this->clientMock->method('upload')->willReturn($uploadResponseJson);
$this->clientMock->method('multipartUpload')->willReturn($uploadResponseJson);
$result = $this->api->uploadAttachment(UploadType::Image, $filePath);
@@ -480,7 +495,7 @@ final class ApiTest extends TestCase
$this->clientMock->method('request')->willReturn(['url' => $uploadUrl]);
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl));
$this->clientMock->method('upload')->willReturn($uploadResponseJson);
$this->clientMock->method('multipartUpload')->willReturn($uploadResponseJson);
$result = $this->api->uploadAttachment(UploadType::File, $filePath);
@@ -514,7 +529,7 @@ final class ApiTest extends TestCase
$this->clientMock
->expects($this->once())
->method('upload')
->method('multipartUpload')
->with($uploadUrl, $this->isResource(), basename($filePath))
->willReturn($uploadResponse);
@@ -551,7 +566,7 @@ final class ApiTest extends TestCase
$this->clientMock
->expects($this->once())
->method('upload')
->method('multipartUpload')
->with($uploadUrl, $this->isResource(), basename($filePath))
->willReturn($uploadResponse);
@@ -726,7 +741,7 @@ final class ApiTest extends TestCase
$this->clientMock
->expects($this->once())
->method('upload')
->method('multipartUpload')
->with($uploadUrl, $this->isResource(), basename($filePath))
->willReturn(json_encode($invalidUploadResponse));
@@ -769,7 +784,7 @@ final class ApiTest extends TestCase
$this->clientMock
->expects($this->once())
->method('upload')
->method('multipartUpload')
->with($uploadUrl, $this->isResource(), basename($filePath))
->willReturn(json_encode($uploadResponse));
@@ -803,11 +818,15 @@ final class ApiTest extends TestCase
$apiResponse = [
'message' => [
'timestamp' => time(),
'body' => ['mid' => 'mid.sticker.1', 'seq' => 10],
'message' => ['mid' => 'mid.sticker.1', 'seq' => 10],
'recipient' => ['chat_type' => 'dialog', 'user_id' => $chatId],
]
];
$expectedMessageObject = Message::fromArray($apiResponse['message']);
$finalMessageData = $apiResponse['message'];
$finalMessageData['body'] = $finalMessageData['message'];
unset($finalMessageData['message']);
$expectedMessageObject = Message::fromArray($finalMessageData);
$this->clientMock->expects($this->once())
->method('request')
@@ -815,8 +834,8 @@ final class ApiTest extends TestCase
->willReturn($apiResponse);
$this->modelFactoryMock->expects($this->once())
->method('createMessage')
->with($apiResponse['message'])
->method('createMessageFromSendResponse')
->with($apiResponse)
->willReturn($expectedMessageObject);
$result = $this->api->sendMessage(chatId: $chatId, attachments: [$stickerRequest]);
@@ -849,11 +868,14 @@ final class ApiTest extends TestCase
$apiResponse = [
'message' => [
'timestamp' => time(),
'body' => ['mid' => 'mid.contact.1', 'seq' => 11],
'message' => ['mid' => 'mid.contact.1', 'seq' => 11],
'recipient' => ['chat_type' => 'dialog', 'user_id' => $chatId],
]
];
$expectedMessageObject = Message::fromArray($apiResponse['message']);
$finalMessageData = $apiResponse['message'];
$finalMessageData['body'] = $finalMessageData['message'];
unset($finalMessageData['message']);
$expectedMessageObject = Message::fromArray($finalMessageData);
$this->clientMock->expects($this->once())
->method('request')
@@ -861,8 +883,8 @@ final class ApiTest extends TestCase
->willReturn($apiResponse);
$this->modelFactoryMock->expects($this->once())
->method('createMessage')
->with($apiResponse['message'])
->method('createMessageFromSendResponse')
->with($apiResponse)
->willReturn($expectedMessageObject);
$result = $this->api->sendMessage(chatId: $chatId, attachments: [$contactRequest]);
@@ -895,11 +917,14 @@ final class ApiTest extends TestCase
$apiResponse = [
'message' => [
'timestamp' => time(),
'body' => ['mid' => 'mid.location.1', 'seq' => 12],
'message' => ['mid' => 'mid.location.1', 'seq' => 12],
'recipient' => ['chat_type' => 'dialog', 'user_id' => $chatId],
]
];
$expectedMessageObject = Message::fromArray($apiResponse['message']);
$finalMessageData = $apiResponse['message'];
$finalMessageData['body'] = $finalMessageData['message'];
unset($finalMessageData['message']);
$expectedMessageObject = Message::fromArray($finalMessageData);
$this->clientMock->expects($this->once())
->method('request')
@@ -907,8 +932,8 @@ final class ApiTest extends TestCase
->willReturn($apiResponse);
$this->modelFactoryMock->expects($this->once())
->method('createMessage')
->with($apiResponse['message'])
->method('createMessageFromSendResponse')
->with($apiResponse)
->willReturn($expectedMessageObject);
$result = $this->api->sendMessage(chatId: $chatId, attachments: [$locationRequest]);
@@ -940,11 +965,14 @@ final class ApiTest extends TestCase
$apiResponse = [
'message' => [
'timestamp' => time(),
'body' => ['mid' => 'mid.share.1', 'seq' => 13],
'message' => ['mid' => 'mid.share.1', 'seq' => 13],
'recipient' => ['chat_type' => 'dialog', 'user_id' => $chatId],
]
];
$expectedMessageObject = Message::fromArray($apiResponse['message']);
$finalMessageData = $apiResponse['message'];
$finalMessageData['body'] = $finalMessageData['message'];
unset($finalMessageData['message']);
$expectedMessageObject = Message::fromArray($finalMessageData);
$this->clientMock->expects($this->once())
->method('request')
@@ -952,8 +980,8 @@ final class ApiTest extends TestCase
->willReturn($apiResponse);
$this->modelFactoryMock->expects($this->once())
->method('createMessage')
->with($apiResponse['message'])
->method('createMessageFromSendResponse')
->with($apiResponse)
->willReturn($expectedMessageObject);
$result = $this->api->sendMessage(chatId: $chatId, attachments: [$shareRequest]);
@@ -1983,7 +2011,7 @@ final class ApiTest extends TestCase
$this->clientMock
->expects($this->once())
->method('upload')
->method('multipartUpload')
->willReturn($invalidJsonResponse);
try {
@@ -2017,7 +2045,7 @@ final class ApiTest extends TestCase
->with($getUploadUrlResponse)
->willReturn($expectedEndpoint);
$this->clientMock->expects($this->never())->method('upload');
$this->clientMock->expects($this->never())->method('multipartUpload');
try {
$this->api->uploadAttachment(UploadType::Video, $filePath);
@@ -2042,7 +2070,7 @@ final class ApiTest extends TestCase
$this->clientMock
->expects($this->once())
->method('upload')
->method('multipartUpload')
->willReturn($invalidUploadResponse);
try {
@@ -2051,4 +2079,87 @@ final class ApiTest extends TestCase
unlink($filePath);
}
}
#[Test]
#[RunInSeparateProcess]
#[PreserveGlobalState(false)]
public function uploadFileUsesMultipartForSmallFiles(): void
{
$uploadUrl = 'https://upload.server/path';
$fileName = 'small.txt';
$fileContents = 'content';
$fileHandle = fopen('php://memory', 'w+');
fwrite($fileHandle, $fileContents);
rewind($fileHandle);
$smallFileSize = strlen($fileContents);
$expectedResponse = 'multipart-response';
$fstatMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'fstat');
$fstatMock->expects($this->once())->with($fileHandle)->willReturn(['size' => $smallFileSize]);
$this->clientMock
->expects($this->once())
->method('multipartUpload')
->with($uploadUrl, $fileHandle, $fileName)
->willReturn($expectedResponse);
$this->clientMock
->expects($this->never())
->method('resumableUpload');
$result = $this->api->uploadFile($uploadUrl, $fileHandle, $fileName);
$this->assertSame($expectedResponse, $result);
fclose($fileHandle);
}
#[Test]
#[RunInSeparateProcess]
#[PreserveGlobalState(false)]
public function uploadFileUsesResumableForLargeFiles(): void
{
$uploadUrl = 'https://upload.server/path';
$fileName = 'large.zip';
$fileHandle = fopen('php://memory', 'w+');
rewind($fileHandle);
$largeFileSize = 10 * 1024 * 1024;
$expectedResponse = 'resumable-response';
$fstatMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'fstat');
$fstatMock->expects($this->once())->with($fileHandle)->willReturn(['size' => $largeFileSize]);
$this->clientMock
->expects($this->once())
->method('resumableUpload')
->with($uploadUrl, $fileHandle, $fileName, $largeFileSize)
->willReturn($expectedResponse);
$this->clientMock
->expects($this->never())
->method('multipartUpload');
$result = $this->api->uploadFile($uploadUrl, $fileHandle, $fileName);
$this->assertSame($expectedResponse, $result);
fclose($fileHandle);
}
#[Test]
#[RunInSeparateProcess]
#[PreserveGlobalState(false)]
public function uploadFileThrowsExceptionWhenFstatFails(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('File handle is not a valid resource.');
$fileHandle = fopen('php://memory', 'r');
$fstatMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'fstat');
$fstatMock->expects($this->once())->with($fileHandle)->willReturn(false);
$this->api->uploadFile('http://a.b', $fileHandle, 'file.txt');
}
}
+212 -16
View File
@@ -35,7 +35,7 @@ final class ClientTest extends TestCase
{
private const string FAKE_TOKEN = '12345:abcdef';
private const string API_VERSION = '0.0.6';
private const string API_BASE_URL = 'https://botapi.max.ru';
private const string API_BASE_URL = 'https://platform-api.max.ru';
private MockObject&ClientInterface $httpClientMock;
private MockObject&RequestFactoryInterface $requestFactoryMock;
@@ -96,7 +96,6 @@ final class ClientTest extends TestCase
{
$uri = '/me';
$expectedUrl = self::API_BASE_URL . $uri . '?' . http_build_query([
'access_token' => self::FAKE_TOKEN,
'v' => self::API_VERSION,
]);
$responsePayload = ['id' => 987, 'name' => 'TestBot'];
@@ -108,6 +107,12 @@ final class ClientTest extends TestCase
->with('GET', $expectedUrl)
->willReturn($this->requestMock);
$this->requestMock
->expects($this->once())
->method('withHeader')
->with('Authorization', self::FAKE_TOKEN)
->willReturn($this->requestMock);
$this->httpClientMock
->expects($this->once())
->method('sendRequest')
@@ -138,8 +143,7 @@ final class ClientTest extends TestCase
];
$responsePayload = ['success' => true];
$expectedUrl = self::API_BASE_URL . $uri . '?' . http_build_query([
'access_token' => self::FAKE_TOKEN,
'v' => self::API_VERSION
'v' => self::API_VERSION,
]);
$this->requestFactoryMock
@@ -157,11 +161,23 @@ final class ClientTest extends TestCase
}))
->willReturn($this->requestMock);
$headerCallCount = 0;
$this->requestMock
->expects($this->once())
->expects($this->exactly(2))
->method('withHeader')
->with('Content-Type', 'application/json; charset=utf-8')
->willReturn($this->requestMock);
->willReturnCallback(function (string $header, string $value) use (&$headerCallCount) {
if ($headerCallCount === 0) {
$this->assertSame('Authorization', $header);
$this->assertSame(self::FAKE_TOKEN, $value);
} elseif ($headerCallCount === 1) {
$this->assertSame('Content-Type', $header);
$this->assertSame('application/json; charset=utf-8', $value);
}
$headerCallCount++;
return $this->requestMock;
});
$this->responseMock->method('getStatusCode')->willReturn(200);
$this->streamMock->method('__toString')->willReturn(json_encode($responsePayload));
@@ -298,11 +314,23 @@ final class ClientTest extends TestCase
)
->willReturn($this->requestMock);
$headerCallCount = 0;
$this->requestMock
->expects($this->once())
->expects($this->exactly(2))
->method('withHeader')
->with($this->stringStartsWith('Content-Type'), $this->stringStartsWith('multipart/form-data'))
->willReturn($this->requestMock);
->willReturnCallback(function (string $header, string $value) use (&$headerCallCount) {
if ($headerCallCount === 0) {
$this->assertSame('Content-Type', $header);
$this->assertStringStartsWith('multipart/form-data; boundary=', $value);
} elseif ($headerCallCount === 1) {
$this->assertSame('Authorization', $header);
$this->assertSame(self::FAKE_TOKEN, $value);
}
$headerCallCount++;
return $this->requestMock;
});
$this->requestFactoryMock
->expects($this->once())
@@ -313,7 +341,7 @@ final class ClientTest extends TestCase
$this->responseMock->method('getStatusCode')->willReturn(200);
$this->streamMock->method('__toString')->willReturn(json_encode($responsePayload));
$result = $this->client->upload($uploadUrl, $fileContents, $fileName);
$result = $this->client->multipartUpload($uploadUrl, $fileContents, $fileName);
$this->assertSame(json_encode($responsePayload), $result);
}
@@ -330,13 +358,30 @@ final class ClientTest extends TestCase
rewind($tmpFileHandle);
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
$this->requestMock->method('withHeader')->willReturn($this->requestMock);
$headerCallCount = 0;
$this->requestMock
->expects($this->exactly(2))
->method('withHeader')
->willReturnCallback(function (string $header, string $value) use (&$headerCallCount) {
if ($headerCallCount === 0) {
$this->assertSame('Content-Type', $header);
$this->assertStringStartsWith('multipart/form-data; boundary=', $value);
} elseif ($headerCallCount === 1) {
$this->assertSame('Authorization', $header);
$this->assertSame(self::FAKE_TOKEN, $value);
}
$headerCallCount++;
return $this->requestMock;
});
$this->requestMock->method('withBody')->willReturn($this->requestMock);
$this->httpClientMock->method('sendRequest')->willReturn($this->responseMock);
$this->responseMock->method('getStatusCode')->willReturn(200);
$this->streamMock->method('__toString')->willReturn(json_encode($responsePayload));
$result = $this->client->upload($uploadUrl, $tmpFileHandle, $fileName);
$result = $this->client->multipartUpload($uploadUrl, $tmpFileHandle, $fileName);
$this->assertSame(json_encode($responsePayload), $result);
fclose($tmpFileHandle);
@@ -358,7 +403,7 @@ final class ClientTest extends TestCase
->with($this->requestMock)
->willThrowException($psrException);
$this->client->upload('http://some.url', 'content', 'file.txt');
$this->client->multipartUpload('http://some.url', 'content', 'file.txt');
}
#[Test]
@@ -406,7 +451,158 @@ final class ClientTest extends TestCase
$this->responseMock->method('getStatusCode')->willReturn(200);
$this->streamMock->method('__toString')->willReturn($rawResponse);
$result = $this->client->upload($uploadUrl, $fileContents, $fileName);
$result = $this->client->multipartUpload($uploadUrl, $fileContents, $fileName);
$this->assertSame($rawResponse, $result);
}
#[Test]
public function resumableUploadThrowsExceptionForInvalidResource(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('fileResource must be a valid stream resource.');
$this->client->resumableUpload('http://a.b', 'not-a-resource', 'file.txt', 100);
}
#[Test]
public function resumableUploadThrowsExceptionForZeroFileSize(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('File size must be greater than 0.');
$fileResource = fopen('php://memory', 'r');
$this->client->resumableUpload('http://a.b', $fileResource, 'file.txt', 0);
fclose($fileResource);
}
#[Test]
public function resumableUploadThrowsNetworkExceptionOnChunkUploadFailure(): void
{
$this->expectException(NetworkException::class);
$fileResource = fopen('php://memory', 'w+');
fwrite($fileResource, 'some data');
rewind($fileResource);
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
$this->requestMock->method('withBody')->willReturnSelf();
$this->requestMock->method('withHeader')->willReturnSelf();
$psrException = new class extends \Exception implements ClientExceptionInterface {};
$this->httpClientMock
->method('sendRequest')
->willThrowException($psrException);
$this->client->resumableUpload('http://a.b', $fileResource, 'file.txt', 9);
fclose($fileResource);
}
#[Test]
public function resumableUploadSuccessfullyUploadsSingleChunk(): void
{
$fileContents = 'test-data';
$fileResource = fopen('php://memory', 'w+');
fwrite($fileResource, $fileContents);
rewind($fileResource);
$uploadUrl = 'http://a.b';
$fileName = 'file.txt';
$fileSize = strlen($fileContents);
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
$this->requestMock->method('withBody')->willReturnSelf();
$headerCallCount = 0;
$this->requestMock
->expects($this->exactly(4))
->method('withHeader')
->willReturnCallback(function (string $header, string $value) use (&$headerCallCount, $fileName, $fileSize) {
if ($headerCallCount === 0) {
$this->assertSame('Content-Type', $header);
$this->assertSame('application/octet-stream', $value);
} elseif ($headerCallCount === 1) {
$this->assertSame('Content-Disposition', $header);
$this->assertSame('attachment; filename="' . $fileName . '"', $value);
} elseif ($headerCallCount === 2) {
$this->assertSame('Content-Range', $header);
$this->assertSame("bytes 0-8/{$fileSize}", $value);
} elseif ($headerCallCount === 3) {
$this->assertSame('Authorization', $header);
$this->assertSame(self::FAKE_TOKEN, $value);
}
$headerCallCount++;
return $this->requestMock;
});
$this->httpClientMock
->expects($this->once())
->method('sendRequest')
->with($this->requestMock)
->willReturn($this->responseMock);
$this->responseMock->method('getStatusCode')->willReturn(200);
$this->streamMock->method('__toString')->willReturn('<retval>1</retval>');
$result = $this->client->resumableUpload($uploadUrl, $fileResource, $fileName, $fileSize);
$this->assertSame('<retval>1</retval>', $result);
fclose($fileResource);
}
#[Test]
public function resumableUploadSuccessfullyUploadsMultipleChunks(): void
{
$chunkSize = 1024 * 1024;
$fileContents = str_repeat('A', 3 * $chunkSize); // 3 MB
$fileResource = fopen('php://memory', 'w+');
fwrite($fileResource, $fileContents);
rewind($fileResource);
$uploadUrl = 'http://a.b';
$fileName = 'bigfile.bin';
$fileSize = strlen($fileContents);
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
$this->requestMock->method('withBody')->willReturnSelf();
$this->requestMock->method('withHeader')->willReturnSelf();
$this->httpClientMock
->expects($this->exactly(3))
->method('sendRequest')
->willReturnOnConsecutiveCalls(
$this->responseMock,
$this->responseMock,
$this->responseMock,
);
$this->responseMock->method('getStatusCode')->willReturn(200);
$this->streamMock
->method('__toString')
->willReturnOnConsecutiveCalls('', '', '<retval>1</retval>');
$result = $this->client->resumableUpload($uploadUrl, $fileResource, $fileName, $fileSize, $chunkSize);
$this->assertSame('<retval>1</retval>', $result);
fclose($fileResource);
}
#[Test]
public function resumableUploadStopsOnEmptyChunk(): void
{
$fileResource = fopen('php://memory', 'w+');
rewind($fileResource);
$uploadUrl = 'http://a.b';
$fileName = 'empty.txt';
$this->requestFactoryMock->expects($this->never())->method('createRequest');
$this->httpClientMock->expects($this->never())->method('sendRequest');
$result = $this->client->resumableUpload($uploadUrl, $fileResource, $fileName, 100);
$this->assertSame('', $result);
fclose($fileResource);
}
}
+121 -1
View File
@@ -38,6 +38,7 @@ use ReflectionClass;
#[UsesClass(UpdateDispatcher::class)]
#[UsesClass(MaxBotManager::class)]
#[UsesClass(WebhookHandler::class)]
#[UsesClass(ModelFactory::class)]
final class MaxBotServiceProviderTest extends TestCase
{
use PHPMock;
@@ -92,6 +93,8 @@ final class MaxBotServiceProviderTest extends TestCase
#[PreserveGlobalState(false)]
public function itThrowsExceptionWhenGuzzleIsMissing(): void
{
error_reporting(E_ALL & ~E_DEPRECATED);
$this->expectException(LogicException::class);
$this->expectExceptionMessage(
'Guzzle HTTP client is required. Please run "composer require guzzlehttp/guzzle".'
@@ -210,6 +213,105 @@ final class MaxBotServiceProviderTest extends TestCase
$this->assertInstanceOf(NullLogger::class, $actualLogger);
}
#[Test]
public function modelFactoryIsConfiguredWithApplicationLoggerWhenLoggingIsEnabled(): void
{
$this->app['config']->set('maxbot.logging.enabled', true);
$mockLogger = $this->createMock(LoggerInterface::class);
$this->app->instance(LoggerInterface::class, $mockLogger);
/** @var ModelFactory $factory */
$factory = $this->app->make(ModelFactory::class);
$reflection = new ReflectionClass($factory);
$loggerProp = $reflection->getProperty('logger');
$actualLogger = $loggerProp->getValue($factory);
$this->assertSame($mockLogger, $actualLogger);
}
#[Test]
public function modelFactoryIsConfiguredWithNullLoggerWhenLoggingIsDisabled(): void
{
$this->app['config']->set('maxbot.logging.enabled', false);
/** @var ModelFactory $factory */
$factory = $this->app->make(ModelFactory::class);
$reflection = new ReflectionClass($factory);
$loggerProp = $reflection->getProperty('logger');
$actualLogger = $loggerProp->getValue($factory);
$this->assertInstanceOf(NullLogger::class, $actualLogger);
}
#[Test]
public function webhookHandlerIsConfiguredWithApplicationLoggerWhenLoggingIsEnabled(): void
{
$this->app['config']->set('maxbot.logging.enabled', true);
$mockLogger = $this->createMock(LoggerInterface::class);
$this->app->instance(LoggerInterface::class, $mockLogger);
/** @var WebhookHandler $handler */
$handler = $this->app->make(WebhookHandler::class);
$reflection = new ReflectionClass($handler);
$loggerProp = $reflection->getProperty('logger');
$actualLogger = $loggerProp->getValue($handler);
$this->assertSame($mockLogger, $actualLogger);
}
#[Test]
public function webhookHandlerIsConfiguredWithNullLoggerWhenLoggingIsDisabled(): void
{
$this->app['config']->set('maxbot.logging.enabled', false);
/** @var WebhookHandler $handler */
$handler = $this->app->make(WebhookHandler::class);
$reflection = new ReflectionClass($handler);
$loggerProp = $reflection->getProperty('logger');
$actualLogger = $loggerProp->getValue($handler);
$this->assertInstanceOf(NullLogger::class, $actualLogger);
}
#[Test]
public function longPollingHandlerIsConfiguredWithApplicationLoggerWhenLoggingIsEnabled(): void
{
$this->app['config']->set('maxbot.logging.enabled', true);
$mockLogger = $this->createMock(LoggerInterface::class);
$this->app->instance(LoggerInterface::class, $mockLogger);
/** @var LongPollingHandler $handler */
$handler = $this->app->make(LongPollingHandler::class);
$reflection = new ReflectionClass($handler);
$loggerProp = $reflection->getProperty('logger');
$actualLogger = $loggerProp->getValue($handler);
$this->assertSame($mockLogger, $actualLogger);
}
#[Test]
public function longPollingHandlerIsConfiguredWithNullLoggerWhenLoggingIsDisabled(): void
{
$this->app['config']->set('maxbot.logging.enabled', false);
/** @var LongPollingHandler $handler */
$handler = $this->app->make(LongPollingHandler::class);
$reflection = new ReflectionClass($handler);
$loggerProp = $reflection->getProperty('logger');
$actualLogger = $loggerProp->getValue($handler);
$this->assertInstanceOf(NullLogger::class, $actualLogger);
}
#[Test]
public function webhookHandlerIsConfiguredWithSecretFromConfig(): void
{
@@ -238,10 +340,28 @@ final class MaxBotServiceProviderTest extends TestCase
$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->assertInstanceOf(NullLogger::class, $loggerProp->getValue($api));
$this->assertSame($this->app->make(UpdateDispatcher::class), $dispatcherProp->getValue($api));
}
#[Test]
public function apiIsConfiguredWithApplicationLoggerWhenLoggingIsEnabled(): void
{
$this->app['config']->set('maxbot.logging.enabled', true);
$mockLogger = $this->createMock(LoggerInterface::class);
$this->app->instance(LoggerInterface::class, $mockLogger);
/** @var Api $api */
$api = $this->app->make(Api::class);
$reflection = new ReflectionClass($api);
$loggerProp = $reflection->getProperty('logger');
$actualLogger = $loggerProp->getValue($api);
$this->assertSame($mockLogger, $actualLogger);
}
/**
* @return array<string, array{0: string}>
*/
+82
View File
@@ -59,6 +59,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
#[CoversClass(ModelFactory::class)]
#[UsesClass(BotInfo::class)]
@@ -220,6 +221,36 @@ final class ModelFactoryTest extends TestCase
$this->assertSame(UpdateType::MessageCreated, $subscriptions[0]->updateTypes[0]);
}
#[Test]
public function createMessageFromSendResponseCorrectlyTransformsData(): void
{
$apiResponse = [
'message' => [
'recipient' => ['chat_id' => 20414985, 'chat_type' => 'dialog', 'user_id' => 4328369],
'timestamp' => 1760089962345,
'sender' => [
'user_id' => 24480184,
'first_name' => 'Autobot',
'is_bot' => true,
'last_activity_time' => 1760089962354,
],
'message' => ['mid' => 'mid.xyz', 'seq' => 1153, 'text' => '123123'],
],
'chat_id' => 20414985,
'recipient_id' => 4328369,
'message_id' => 'mid.xyz',
];
$message = $this->factory->createMessageFromSendResponse($apiResponse);
$this->assertInstanceOf(Message::class, $message);
$this->assertInstanceOf(MessageBody::class, $message->body);
$this->assertSame('mid.xyz', $message->body->mid);
$this->assertSame(20414985, $message->chatId);
$this->assertSame(4328369, $message->recipientId);
$this->assertSame('mid.xyz', $message->messageId);
}
#[Test]
public function createMessage(): void
{
@@ -788,4 +819,55 @@ final class ModelFactoryTest extends TestCase
$assertionCallback($this, $attachment);
}
#[Test]
public function createUpdateListCatchesAndLogsLogicException(): void
{
$loggerMock = $this->createMock(LoggerInterface::class);
$factory = $this->getMockBuilder(ModelFactory::class)
->setConstructorArgs([$loggerMock])
->onlyMethods(['createUpdate'])
->getMock();
$validUpdateData = [
'update_type' => 'bot_started',
'timestamp' => 2,
'chat_id' => 123,
'user' => [
'user_id' => 123,
'first_name' => 'John',
'is_bot' => false,
'last_activity_time' => 2,
],
'payload' => 'start_payload',
'user_locale' => 'ru-RU',
];
$invalidUpdateData = ['update_type' => 'unknown_type'];
$rawData = [
'updates' => [$validUpdateData, $invalidUpdateData],
'marker' => 123,
];
$exception = new LogicException('Unknown or unsupported update type received: unknown_type');
$factory->expects($this->exactly(2))
->method('createUpdate')
->willReturnCallback(function ($data) use ($validUpdateData, $invalidUpdateData, $exception) {
if ($data === $validUpdateData) {
return BotStartedUpdate::fromArray($data);
}
if ($data === $invalidUpdateData) {
throw $exception;
}
return null;
});
$loggerMock->expects($this->once())
->method('debug')
->with($exception->getMessage(), ['payload' => $invalidUpdateData, 'exception' => $exception]);
$updateList = $factory->createUpdateList($rawData);
$this->assertCount(1, $updateList->updates);
$this->assertInstanceOf(BotStartedUpdate::class, $updateList->updates[0]);
}
}
+3
View File
@@ -64,6 +64,9 @@ final class MessageTest extends TestCase
'stat' => [
'views' => 500,
],
'chat_id' => null,
'recipient_id' => null,
'message_id' => null,
];
$message = Message::fromArray($data);
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Models\Updates;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\Updates\BotStoppedUpdate;
use BushlanovDev\MaxMessengerBot\Models\User;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(BotStoppedUpdate::class)]
#[UsesClass(User::class)]
final class BotStoppedUpdateTest extends TestCase
{
#[Test]
public function canBeCreatedFromArray(): void
{
$data = [
'update_type' => UpdateType::BotStopped->value,
'timestamp' => 1678886400000,
'chat_id' => 123,
'user' => [
'user_id' => 123,
'first_name' => 'John',
'last_name' => 'Doe',
'is_bot' => false,
'last_activity_time' => 1678886400000,
],
'user_locale' => 'ru-ru',
];
$update = BotStoppedUpdate::fromArray($data);
$this->assertInstanceOf(BotStoppedUpdate::class, $update);
$this->assertSame(UpdateType::BotStopped, $update->updateType);
$this->assertSame(123, $update->user->userId);
$this->assertSame('John', $update->user->firstName);
$this->assertSame('Doe', $update->user->lastName);
$this->assertSame('ru-ru', $update->userLocale);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Models\Updates;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\Updates\DialogClearedUpdate;
use BushlanovDev\MaxMessengerBot\Models\User;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(DialogClearedUpdate::class)]
#[UsesClass(User::class)]
final class DialogClearedUpdateTest extends TestCase
{
#[Test]
public function canBeCreatedFromArray(): void
{
$data = [
'update_type' => UpdateType::DialogCleared->value,
'timestamp' => 1678886400000,
'chat_id' => 123,
'user' => [
'user_id' => 123,
'first_name' => 'John',
'last_name' => 'Doe',
'is_bot' => false,
'last_activity_time' => 1678886400000,
],
'user_locale' => 'ru-ru',
];
$update = DialogClearedUpdate::fromArray($data);
$this->assertInstanceOf(DialogClearedUpdate::class, $update);
$this->assertSame(UpdateType::DialogCleared, $update->updateType);
$this->assertSame(123, $update->user->userId);
$this->assertSame('John', $update->user->firstName);
$this->assertSame('Doe', $update->user->lastName);
$this->assertSame('ru-ru', $update->userLocale);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Models\Updates;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\Updates\DialogMutedUpdate;
use BushlanovDev\MaxMessengerBot\Models\User;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(DialogMutedUpdate::class)]
#[UsesClass(User::class)]
final class DialogMutedUpdateTest extends TestCase
{
#[Test]
public function canBeCreatedFromArray(): void
{
$data = [
'update_type' => UpdateType::DialogMuted->value,
'timestamp' => 1678886400000,
'chat_id' => 123,
'user' => [
'user_id' => 123,
'first_name' => 'John',
'last_name' => 'Doe',
'is_bot' => false,
'last_activity_time' => 1678886400000,
],
'muted_until' => 1678886400000,
'user_locale' => 'ru-ru',
];
$update = DialogMutedUpdate::fromArray($data);
$this->assertInstanceOf(DialogMutedUpdate::class, $update);
$this->assertSame(UpdateType::DialogMuted, $update->updateType);
$this->assertSame(123, $update->user->userId);
$this->assertSame('John', $update->user->firstName);
$this->assertSame('Doe', $update->user->lastName);
$this->assertSame(1678886400000, $update->mutedUntil);
$this->assertSame('ru-ru', $update->userLocale);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Models\Updates;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\Updates\DialogRemovedUpdate;
use BushlanovDev\MaxMessengerBot\Models\User;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(DialogRemovedUpdate::class)]
#[UsesClass(User::class)]
final class DialogRemovedUpdateTest extends TestCase
{
#[Test]
public function canBeCreatedFromArray(): void
{
$data = [
'update_type' => UpdateType::DialogRemoved->value,
'timestamp' => 1678886400000,
'chat_id' => 123,
'user' => [
'user_id' => 123,
'first_name' => 'John',
'last_name' => 'Doe',
'is_bot' => false,
'last_activity_time' => 1678886400000,
],
'user_locale' => 'ru-ru',
];
$update = DialogRemovedUpdate::fromArray($data);
$this->assertInstanceOf(DialogRemovedUpdate::class, $update);
$this->assertSame(UpdateType::DialogRemoved, $update->updateType);
$this->assertSame(123, $update->user->userId);
$this->assertSame('John', $update->user->firstName);
$this->assertSame('Doe', $update->user->lastName);
$this->assertSame('ru-ru', $update->userLocale);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Models\Updates;
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
use BushlanovDev\MaxMessengerBot\Models\Updates\DialogUnmutedUpdate;
use BushlanovDev\MaxMessengerBot\Models\User;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(DialogUnmutedUpdate::class)]
#[UsesClass(User::class)]
final class DialogUnmutedUpdateTest extends TestCase
{
#[Test]
public function canBeCreatedFromArray(): void
{
$data = [
'update_type' => UpdateType::DialogUnmuted->value,
'timestamp' => 1678886400000,
'chat_id' => 123,
'user' => [
'user_id' => 123,
'first_name' => 'John',
'last_name' => 'Doe',
'is_bot' => false,
'last_activity_time' => 1678886400000,
],
'user_locale' => 'ru-ru',
];
$update = DialogUnmutedUpdate::fromArray($data);
$this->assertInstanceOf(DialogUnmutedUpdate::class, $update);
$this->assertSame(UpdateType::DialogUnmuted, $update->updateType);
$this->assertSame(123, $update->user->userId);
$this->assertSame('John', $update->user->firstName);
$this->assertSame('Doe', $update->user->lastName);
$this->assertSame('ru-ru', $update->userLocale);
}
}
+39
View File
@@ -185,4 +185,43 @@ final class WebhookHandlerTest extends TestCase
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, null);
$handler->handle(null);
}
#[Test]
public function handleCatchesAndLogsLogicExceptionFromModelFactory(): void
{
$payload = '{"update_type":"unknown_type","timestamp":123}';
$updateData = json_decode($payload, true);
$exception = new LogicException('Unknown or unsupported update type received: unknown_type');
$request = $this->createMockRequest($payload, self::SECRET);
$this->modelFactoryMock->expects($this->once())
->method('createUpdate')
->with($updateData)
->willThrowException($exception);
$callIndex = 0;
$this->loggerMock->expects($this->exactly(2))
->method('debug')
->willReturnCallback(
function (string $message, array $context = []) use (&$callIndex, $payload, $exception) {
if ($callIndex === 0) {
$this->assertSame('Received webhook payload', $message);
$this->assertArrayHasKey('body', $context);
$this->assertSame($payload, $context['body']);
} elseif ($callIndex === 1) {
$this->assertSame('Unknown or unsupported update type received: unknown_type', $message);
$this->assertArrayHasKey('payload', $context);
$this->assertArrayHasKey('exception', $context);
$this->assertSame($payload, $context['payload']);
$this->assertSame($exception, $context['exception']);
}
$callIndex++;
}
);
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
$handler->handle($request);
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
error_reporting(E_ALL & ~E_DEPRECATED);
require_once dirname(__DIR__) . '/vendor/autoload.php';