Compare commits

...

43 Commits

Author SHA1 Message Date
Alex 79d406da27 ShareAttachmentRequestPayload & PhotoAttachmentRequestPayload update exception rule (#18) 2025-12-05 13:53:07 +03:00
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
Alex d2aedc090f Documentation 2025-08-21 19:24:38 +03:00
Alex 92c8f4cd94 Fix upload files 2025-08-20 23:22:18 +03:00
Alex fa7875a670 Documentation 2025-08-19 21:26:05 +03:00
Alex 70c290ce0a Documentation 2025-08-18 21:29:43 +03:00
Alex de3a67bee7 Documentation & rename deleteAdmins to deleteAdmin 2025-08-17 22:40:21 +03:00
Alex b58dc8b2ed Documentation 2025-08-16 21:07:17 +03:00
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
43 changed files with 10341 additions and 438 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
+93 -42
View File
@@ -1,9 +1,10 @@
# 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)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE)
> [!CAUTION]
@@ -25,18 +26,43 @@
composer require bushlanov-dev/max-bot-api-client-php
```
Пользователи Laravel могут зарегистрировать сервис провайдер и фасад в `config/app.php`:
```php
'providers' => [
// ...
BushlanovDev\MaxMessengerBot\Laravel\MaxBotServiceProvider::class,
],
// ...
'aliases' => [
// ...
'MaxBot' => BushlanovDev\MaxMessengerBot\Laravel\MaxBotFacade::class,
],
```
### Использование
Отправка сообщения с клавиатурой
```php
$api = new \BushlanovDev\MaxMessengerBot\Api('YOUR_BOT_API_TOKEN');
require __DIR__.'/vendor/autoload.php';
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')],
@@ -46,6 +72,32 @@ $api->sendMessage(
);
```
Отправка сообщения с использованием фасада Laravel
```php
MaxBot::sendUserMessage(123456, 'Привет из Laravel!');
```
Создание универсального обработчика обновлений
```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
@@ -60,66 +112,65 @@ $api->subscribe(
);
```
Обработка хуков
Обработка обновлений
```php
$webhookHandler = $api->createWebhookHandler();
$handler = $api->createWebhookHandler('super_secret'); // Обновления через вебхук
// ИЛИ
$handler = $api->createLongPollingHandler(); // Обновления через лонгполлинг
$webhookHandler->addHandler(UpdateType::BotStarted, function (BotStartedUpdate $update, Api $api) {
$api->sendMessage(
chatId: $update->chatId,
text: 'Я запущен!',
);
});
$handler->handle();
```
> ℹ️ С полной документацией [вы можете ознакомиться тут](./docs/README.md).
## Реализованные методы
#### Bots
- [x] `GET /me` (`getBotInfo`) - *Получение информации о боте.*
- [x] `PATCH /me` (`editBotInfo`) - *Редактирование информации о боте.*
- [x] `GET /me` (`getBotInfo`) - [*Получение информации о боте.*](./docs/README.md#Получение-информации-о-боте)
- [x] `PATCH /me` (`editBotInfo`) - [*Редактирование информации о боте.*](./docs/README.md#Редактирование-информации-о-боте)
#### Chats
- [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`) - *Удаление участника из чата.*
- [x] `GET /chats` (`getChats`) - [*Получение списка всех чатов бота.*](./docs/README.md#Получение-списка-всех-чатов-бота)
- [x] `GET /chats/{chatLink}` (`getChatByLink`) - [*Получение информации о чате по ссылке.*](./docs/README.md#Получение-информации-о-чате-по-ссылке)
- [x] `GET /chats/{chatId}` (`getChat`) - [*Получение информации о чате по ID.*](./docs/README.md#Получение-информации-о-чате-по-ID)
- [x] `PATCH /chats/{chatId}` (`editChat`) - [*Редактирование информации о чате.*](./docs/README.md#Редактирование-информации-о-чате)
- [x] `DELETE /chats/{chatId}` (`deleteChat`) - [*Удаление чата.*](./docs/README.md#Удаление-чата)
- [x] `POST /chats/{chatId}/actions` (`sendAction`) - [*Отправка действия в чат (например, "печатает...").*](./docs/README.md#Отправка-действия-в-чат)
- [x] `GET /chats/{chatId}/pin` (`getPinnedMessage`) - [*Получение закрепленного сообщения.*](./docs/README.md#Получение-закрепленного-сообщения)
- [x] `PUT /chats/{chatId}/pin` (`pinMessage`) - [*Закрепление сообщения.*](./docs/README.md#Закрепление-сообщения)
- [x] `DELETE /chats/{chatId}/pin` (`unpinMessage`) - [*Открепление сообщения.*](./docs/README.md#Открепление-сообщения)
- [x] `GET /chats/{chatId}/members/me` (`getMembership`) - [*Получение информации о членстве бота в чате.*](./docs/README.md#Получение-информации-о-членстве-бота-в-чате)
- [x] `DELETE /chats/{chatId}/members/me` (`leaveChat`) - [*Выход бота из чата.*](./docs/README.md#Выход-бота-из-чата)
- [x] `GET /chats/{chatId}/members/admins` (`getAdmins`) - [*Получение администраторов чата.*](./docs/README.md#Получение-администраторов-чата)
- [x] `POST /chats/{chatId}/members/admins` (`addAdmins`) - [*Назначение администраторов чата.*](./docs/README.md#Назначение-администраторов-чата)
- [x] `DELETE /chats/{chatId}/members/admins/{userId}` (`deleteAdmin`) - [*Снятие прав администратора.*](./docs/README.md#Снятие-прав-администратора)
- [x] `GET /chats/{chatId}/members` (`getMembers`) - [*Получение участников чата.*](./docs/README.md#Получение-участников-чата)
- [x] `POST /chats/{chatId}/members` (`addMembers`) - [*Добавление участников в чат.*](./docs/README.md#Добавление-участников-в-чат)
- [x] `DELETE /chats/{chatId}/members` (`deleteMember`) - [*Удаление участника из чата.*](./docs/README.md#Удаление-участника-из-чата)
#### 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-подписок.*](./docs/README.md#Получение-списка-Webhook-подписок)
- [x] `POST /subscriptions` (`subscribe`) - [*Создание Webhook-подписки.*](./docs/README.md#Создание-Webhook-подписки)
- [x] `DELETE /subscriptions` (`unsubscribe`) - [*Удаление Webhook-подписки.*](./docs/README.md#Удаление-Webhook-подписки)
- [x] `GET /updates` (`getUpdates`) - [*Получение обновлений через Long-Polling.*](./docs/README.md#Получение-обновлений-через-Long-Polling)
#### Upload
- [x] `POST /uploads` (`getUploadUrl`) - *Получение URL для загрузки файла.*
- [x] `POST /uploads` (`getUploadUrl`) - [*Получение URL для загрузки файла.*](./docs/README.md#Получение-URL-для-загрузки-файла)
#### Messages
- [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-кнопки.*
- [x] `GET /messages` (`getMessages`) - [*Получение списка сообщений из чата.*](./docs/README.md#Получение-списка-сообщений-из-чата)
- [x] `POST /messages` (`sendMessage`) - [*Отправка сообщения.*](./docs/README.md#Отправка-сообщения)
- [x] `PUT /messages` (`editMessage`) - [*Редактирование сообщения.*](./docs/README.md#Редактирование-сообщения)
- [x] `DELETE /messages` (`deleteMessage`) - [*Удаление сообщения.*](./docs/README.md#Удаление-сообщения)
- [x] `GET /messages/{messageId}` (`getMessageById`) - [*Получение сообщения по ID.*](./docs/README.md#Получение-сообщения-по-ID)
- [x] `GET /videos/{videoToken}` (`getVideoAttachmentDetails`) - [*Получение детальной информации о видео.*](./docs/README.md#Получение-детальной-информации-о-видео)
- [x] `POST /answers` (`answerOnCallback`) - [*Ответ на нажатие callback-кнопки.*](./docs/README.md#Ответ-на-нажатие-callback-кнопки)
## Лицензия
+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"]
}
}
+616
View File
@@ -0,0 +1,616 @@
- [Быстрый старт](#Быстрый-старт)
- [Получение токена](#Получение-токена)
- [Установка библиотеки](#Установка-библиотеки)
- [Инициализация бота](#Инициализация-бота)
- [Информация о боте](#Информация-о-боте)
- `GET /me` (`getBotInfo`) - [*Получение информации о боте.*](#Получение-информации-о-боте)
- `PATCH /me` (`editBotInfo`) - [*Редактирование информации о боте.*](#Редактирование-информации-о-боте)
- [Чаты](#Чаты)
- `GET /chats` (`getChats`) - [*Получение списка всех чатов бота.*](#Получение-списка-всех-чатов-бота)
- `GET /chats/{chatLink}` (`getChatByLink`) - [*Получение информации о чате по ссылке.*](#Получение-информации-о-чате-по-ссылке)
- `GET /chats/{chatId}` (`getChat`) - [*Получение информации о чате по ID.*](#Получение-информации-о-чате-по-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` (`addAdmin`) - [*Назначение администраторов чата.*](#Назначение-администраторов-чата)
- `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-подписок.*](#Получение-списка-Webhook-подписок)
- `POST /subscriptions` (`subscribe`) - [*Создание Webhook-подписки.*](#Создание-Webhook-подписки)
- `DELETE /subscriptions` (`unsubscribe`) - [*Удаление Webhook-подписки.*](#Удаление-Webhook-подписки)
- `GET /updates` (`getUpdates`) - [*Получение обновлений через Long-Polling.*](#Получение-обновлений-через-Long-Polling)
- [Загрузка файлов](#Загрузка-файлов)
- `POST /uploads` (`getUploadUrl`) - [*Получение URL для загрузки файла.*](#Получение-URL-для-загрузки-файла)
- `uploadAttachment` - [*Загрузка файла.*](#Загрузка-файла)
- [Сообщения](#Сообщения)
- `GET /messages` (`getMessages`) - [*Получение списка сообщений из чата.*](#Получение-списка-сообщений-из-чата)
- `POST /messages` (`sendMessage`) - [*Отправка сообщения.*](#Отправка-сообщения)
- `PUT /messages` (`editMessage`) - [*Редактирование сообщения.*](#Редактирование-сообщения)
- `DELETE /messages` (`deleteMessage`) - [*Удаление сообщения.*](#Удаление-сообщения)
- `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)
- [Тестирование](#Тестирование)
## Быстрый старт
> Если вы новичок, то можете прочитать [официальную документацию](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(),
);
```
## Информация о боте
### Получение информации о боте
Возвращает информацию о текущем боте, который идентифицируется с помощью токена доступа.
Метод возвращает ID бота, его имя и аватар (если есть).
```php
$botInfo = $api->getBotInfo();
```
### Редактирование информации о боте
Обратите внимание, что данный метод отправляется PATCH запросом. Это значит, что будут обновлены только переданные
поля.
В следующем примере мы изменяем только название бота и отчистим его описание. Остальные поля останутся неизменными.
```php
$botInfo = $api->editBotInfo(
new BotPatch(
name: 'Супер бот',
description: null,
)
);
```
## Чаты
### Получение списка всех чатов бота
Возвращает информацию о чатах, в которых участвовал бот.
Результат включает список чатов и маркер для перехода к следующей странице.
```php
$chats = $api->getChats(
count: 10, // Количество запрашиваемых чатов
marker: 2, // Указатель на следующую страницу данных. Для первой страницы передайте null
);
```
### Получение информации о чате по ссылке
Возвращает информацию о чате по его публичной ссылке, либо информацию о диалоге с пользователем по его username.
```php
$chat = $api->getChatByLink('@super_chat'); // Публичная ссылка на чат или username пользователя
```
### Получение информации о чате по ID
```php
$chat = $api->getChat(12345);
```
### Редактирование информации о чате
Позволяет редактировать информацию о чате, включая название, иконку и закреплённое сообщение.
```php
$chat = $api->editChat(
chatId: 12345,
chatPatch: new ChatPatch(
title: 'Новое название чата',
),
);
```
### Удаление чата
```php
$api->deleteChat(12345);
```
### Отправка действия в чат
Позволяет отправлять действия бота в чат, такие как «набор текста» или «отправка фото».
```php
$api->sendAction(
chatId: 12345,
action: SenderAction::SendingVideo,
);
```
### Получение закрепленного сообщения
```php
$message = $api->getPinnedMessage(12345);
```
### Закрепление сообщения
```php
$api->pinMessage(
chatId: 12345,
messageId: 54321,
notify: true,
);
```
### Открепление сообщения
```php
$api->unpinMessage(12345);
```
### Получение информации о членстве бота в чате
```php
$chatMember = $api->getMembership(12345);
```
### Выход бота из чата
```php
$api->leaveChat(12345);
```
### Получение администраторов чата
Возвращает всех администраторов чата. Бот должен быть администратором в запрашиваемом чате.
```php
$adminsChatMemberList = $api->getAdmins(12345);
```
### Назначение администраторов чата
```php
$api->addAdmins(
chatId: 12345,
admins: [
new ChatAdmin(123, [ChatAdminPermission::ReadAllMessages]),
new ChatAdmin(456, [ChatAdminPermission::Write]),
],
);
```
### Снятие прав администратора
```php
$api->deleteAdmin(
chatId: 12345,
userId: 123,
);
```
### Получение участников чата
```php
$chatMemberList = $api->getMembers(12345);
```
### Добавление участников в чат
```php
$api->addMembers(
chatId: 12345,
userIds: [123, 456],
);
```
### Удаление участника из чата
```php
$api->deleteMember(
chatId: 12345,
userId: 123,
block: true, // Пользователь будет заблокирован в чате
);
```
## Получение обновлений
### Получение списка Webhook-подписок
```php
$subscriptions = $api->getSubscriptions();
```
### Создание Webhook-подписки
Подписывает бота на получение обновлений через WebHook.
После вызова этого метода бот будет получать уведомления о новых событиях в чатах на указанный URL.
Ваш сервер должен прослушивать один из следующих портов: 80, 8080, 443, 8443, 16384-32383.
```php
$api->subscribe(
url: 'https://example.com/webhook', // URL на который будут приходить хуки. Должен начинаться с http(s)://
secret: 'super_secret', // Секретная фраза для проверки хуков (необязательно)
updateTypes: [UpdateType::MessageCreated], // Типы хуков которые вы хотите получать (либо ничего не указывать, чтобы получать все)
);
```
### Удаление Webhook-подписки
```php
$api->unsubscribe('https://example.com/webhook');
```
### Получение обновлений через Long-Polling
Этот метод можно использовать для получения обновлений, если ваш бот не подписан на WebHook. Метод использует долгий опрос (long polling).
Каждое обновление имеет свой номер последовательности. Свойство marker в ответе указывает на следующее ожидаемое обновление.
Все предыдущие обновления считаются завершенными после прохождения параметра marker.
Если параметр marker не передан, бот получит все обновления, произошедшие после последнего подтверждения.
```php
$updateList = $api->getUpdates(
limit: 10, // Максимальное количество обновлений для получения [1-1000] (необязательно)
timeout: 10, // Таймаут в секундах [0-90] (необязательно)
marker: 123, // Если передан, бот получит обновления, которые еще не были получены (необязательно)
types: [UpdateType::MessageCreated], // Типы обновлений которые вы хотите получать (необязательно)
);
```
## Загрузка файлов
### Получение URL для загрузки файла
```php
$uploadEndpoint = $api->getUploadUrl(UploadType::Video);
// Далее вы можете загрузить файл по полученному URL самостоятельно
// или воспользоваться методами Client::multipartUpload(), Client::resumableUpload(), Api::uploadFile()
```
### Загрузка файла
Данный метод получит URL для загрузки, отправит файл и вернет готовый аттачмент
```php
$photoAttachmentRequest = $api->uploadAttachment(
type: UploadType::Image,
filePath: __DIR__ . '/test.jpg',
);
```
## Сообщения
### Получение списка сообщений из чата
Возвращает сообщения в чате: страницу с результатами и маркер, указывающий на следующую страницу.
Сообщения возвращаются в обратном порядке, то есть последние сообщения в чате будут первыми в массиве.
Поэтому, если вы используете параметры from и to, то to должно быть меньше, чем from.
```php
$messages = $api->getMessages(
chatId: 12345, // ID чата, чтобы получить сообщения из определённого чата (необязательно)
messageIds: [123, 456], // Список ID сообщений, которые нужно получить (необязательно)
from: 10, // Время начала для запрашиваемых сообщений [Unix timestamp] (необязательно)
to: 20, // Время окончания для запрашиваемых сообщений [Unix timestamp] (необязательно)
count: 10, // Максимальное количество сообщений в ответе [1-100] (необязательно)
);
```
### Отправка сообщения
```php
$fileAttachmentRequest = $api->uploadAttachment(
type: UploadType::File,
filePath: __DIR__ . '/test.pdf',
);
$message = $api->sendMessage(
userId: 12345, // Если вы отправляете сообщение пользователю, укажите его ID (необязательно)
chatId: 54321, // Если сообщение отправляется в чат, укажите его ID (необязательно)
text: 'Привет мир!', // Текст сообщения (необязательно)
attachments: [ // Прикрепленные элементы (необязательно)
$fileAttachmentRequest,
PhotoAttachmentRequest::fromUrl('https://example.com/image.jpg'),
new LocationAttachmentRequest(
latitude: 55.7520233,
longitude: 37.6174994,
),
],
format: MessageFormat::Markdown, // Формат сообщения Markdown или HTML (необязательно)
link: null, // Ссылка на сообщение (необязательно)
notify: true, // Если false, участники чата не будут уведомлены (необязательно)
disableLinkPreview: false, // Если false, сервер не будет генерировать превью для ссылок в тексте сообщения (необязательно)
);
```
### Редактирование сообщения
Редактирует сообщение в чате. Если поле attachments равно null, вложения текущего сообщения не изменяются.
Если в этом поле передан пустой список, все вложения будут удалены.
```php
$api->editMessage(
messageId: 12345,
text: 'Привет мир!',
attachments: null,
format: null,
link: null,
notify: true,
);
```
### Удаление сообщения
```php
$api->deleteMessage(12345);
```
### Получение сообщения по ID
```php
$message = $api->getMessageById(12345);
```
### Получение детальной информации о видео
Возвращает подробную информацию о приклеплённом видео. URL-адреса воспроизведения и дополнительные метаданные.
```php
$videoAttachmentDetails = $api->getVideoAttachmentDetails('some-video-token');
```
### Ответ на нажатие callback-кнопки
Этот метод используется для отправки ответа после того, как пользователь нажал на кнопку.
Ответом может быть обновленное сообщение и/или одноразовое уведомление для пользователя.
```php
$api->answerOnCallback(
callbackId: 'some-callback-id', // Идентификатор кнопки, по которой пользователь кликнул
notification: 'some-notification', // Заполните это, если хотите просто отправить одноразовое уведомление пользователю (необязательно)
text: 'some-text', // Новый текст сообщения (необязательно)
attachments: null, // Вложения сообщения. Если пусто, все вложения будут удалены (необязательно)
link: null, // Ссылка на сообщение (необязательно)
format: null, // Формат сообщения Markdown или HTML (необязательно)
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
}
}
```
+3264
View File
File diff suppressed because it is too large Load Diff
+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>
+88 -30
View File
@@ -33,6 +33,7 @@ use BushlanovDev\MaxMessengerBot\Models\UpdateList;
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
use InvalidArgumentException;
use JsonException;
use LogicException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
@@ -47,11 +48,11 @@ use RuntimeException;
*/
class Api
{
public const string LIBRARY_VERSION = '1.0.0';
public const string LIBRARY_VERSION = '1.4.1';
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';
@@ -74,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;
@@ -85,21 +88,23 @@ class Api
/**
* 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 The model factory.
* @param LoggerInterface|null $logger PSR LoggerInterface.
* @param UpdateDispatcher|null $updateDispatcher The update dispatcher.
*
* @throws InvalidArgumentException
*/
public function __construct(
string $accessToken,
?string $accessToken = null,
?ClientApiInterface $client = null,
?ModelFactory $modelFactory = null,
?LoggerInterface $logger = null,
?UpdateDispatcher $updateDispatcher = 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) {
@@ -123,14 +128,14 @@ class Api
$httpFactory,
$httpFactory,
self::API_BASE_URL,
self::API_VERSION,
null,
$this->logger,
);
}
$this->client = $client;
$this->modelFactory = $modelFactory ?? new ModelFactory();
$this->updateDispatcher = $updateDispatcher ?? new UpdateDispatcher($this);
$this->modelFactory = $modelFactory ?? new ModelFactory($this->logger);
$this->updateDispatcher = new UpdateDispatcher($this);
}
/**
@@ -358,7 +363,7 @@ class Api
$this->buildNewMessageBody($text, $attachments, $format, $link, $notify),
);
return $this->modelFactory->createMessage($response['message']);
return $this->modelFactory->createMessageFromSendResponse($response);
}
/**
@@ -440,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.
*
@@ -468,27 +502,52 @@ class Api
$uploadEndpoint = $this->getUploadUrl($type);
$uploadResult = $this->client->upload(
$uploadEndpoint->url,
$fileHandle,
basename($filePath),
);
// For audio and video, the token is received *before* the upload
// The actual upload response is not JSON and can be ignored on success
if ($type === UploadType::Audio || $type === UploadType::Video) {
if (empty($uploadEndpoint->token)) {
throw new SerializationException(
"API did not return a pre-upload token for type '$type->value'."
);
}
fclose($fileHandle);
$this->uploadFile($uploadEndpoint->url, $fileHandle, basename($filePath));
fclose($fileHandle);
if (!isset($uploadResult['token'])) {
throw new SerializationException('Could not find "token" in upload server response.');
return match ($type) {
UploadType::Audio => new AudioAttachmentRequest($uploadEndpoint->token),
UploadType::Video => new VideoAttachmentRequest($uploadEndpoint->token),
};
}
return match ($type) {
UploadType::Image => PhotoAttachmentRequest::fromToken($uploadResult['token']),
UploadType::Video => new VideoAttachmentRequest($uploadResult['token']),
UploadType::Audio => new AudioAttachmentRequest($uploadResult['token']),
UploadType::File => new FileAttachmentRequest($uploadResult['token']), // @phpstan-ignore-line
default => throw new LogicException(
"Attachment creation for type '$type->value' is not yet implemented."
),
};
// For images and files, the token is in the response *after* the upload.
$responseBody = $this->uploadFile($uploadEndpoint->url, $fileHandle, basename($filePath));
fclose($fileHandle);
try {
$uploadResult = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new SerializationException('Failed to decode upload server response JSON.', 0, $e);
}
// Using switch because match expression arms cannot be code blocks.
switch ($type) {
case UploadType::Image:
$photoData = current($uploadResult['photos'] ?? []); // Get first photo from response
if (!isset($photoData['token'])) {
throw new SerializationException('Could not find "token" in photo upload response.');
}
return PhotoAttachmentRequest::fromToken($photoData['token']);
case UploadType::File:
if (!isset($uploadResult['token'])) {
throw new SerializationException('Could not find "token" in file upload response.');
}
return new FileAttachmentRequest($uploadResult['token']);
}
// @codeCoverageIgnoreStart
throw new LogicException("Attachment creation for type '$type->value' is not yet implemented."); // @phpstan-ignore-line
// @codeCoverageIgnoreEnd
}
/**
@@ -626,7 +685,6 @@ class Api
return null;
}
return $this->modelFactory->createMessage($response['message']);
}
@@ -872,7 +930,7 @@ class Api
* @throws ReflectionException
* @throws SerializationException
*/
public function deleteAdmins(int $chatId, int $userId): Result
public function deleteAdmin(int $chatId, int $userId): Result
{
return $this->modelFactory->createResult(
$this->client->request(
+69 -8
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): array
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 {
@@ -152,13 +155,71 @@ final readonly class Client implements ClientApiInterface
$this->handleErrorResponse($response);
$responseBody = (string)$response->getBody();
return (string)$response->getBody();
}
try {
return json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new SerializationException('Failed to decode upload server response JSON.', 0, $e);
/**
* @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;
}
/**
+26 -2
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
{
@@ -32,10 +33,33 @@ interface ClientApiInterface
* @param resource|string $fileContents File content (stream resource or string).
* @param string $fileName The name of the file that will be sent to the server.
*
* @return array<string, mixed>
* @return string The raw response body from the upload server.
* @throws ClientApiException
* @throws NetworkException
* @throws SerializationException
*/
public function upload(string $uri, mixed $fileContents, string $fileName): array;
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';
}
+1 -1
View File
@@ -64,7 +64,7 @@ use Illuminate\Support\Facades\Facade;
* @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 deleteAdmin(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)
+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 -8
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,12 +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),
null,
$logger,
);
});
@@ -115,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(
@@ -23,9 +23,9 @@ final readonly class PhotoAttachmentRequestPayload extends AbstractAttachmentReq
#[ArrayOf(PhotoToken::class)]
public ?array $photos = null,
) {
if (count(array_filter([$this->url, $this->token, $this->photos])) !== 1) {
if ($this->url === null && $this->token === null && $this->photos === null) {
throw new InvalidArgumentException(
'Provide exactly one of "url", "token", or "photos" for PhotoAttachmentRequestPayload.'
'Provide one of "url", "token", or "photos" for PhotoAttachmentRequestPayload.'
);
}
}
@@ -19,9 +19,9 @@ final readonly class ShareAttachmentRequestPayload extends AbstractAttachmentReq
public ?string $url = null,
public ?string $token = null,
) {
if (count(array_filter([$this->url, $this->token])) !== 1) {
if ($this->url === null && $this->token === null) {
throw new InvalidArgumentException(
'Provide exactly one of "url" or "token" for ShareAttachmentRequestPayload.'
'Provide one of "url" or "token" for ShareAttachmentRequestPayload.'
);
}
}
+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);
-4
View File
@@ -35,15 +35,11 @@ final class ApiFactoryMethodsTest extends TestCase
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);
$apiForDispatcher = $this->createMock(Api::class);
$dispatcher = new UpdateDispatcher($apiForDispatcher);
$this->api = new Api(
'fake-token',
$this->clientMock,
$this->modelFactoryMock,
$this->loggerMock,
$dispatcher,
);
}
+317 -135
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(
@@ -451,24 +466,59 @@ final class ApiTest extends TestCase
}
#[Test]
public function uploadAttachmentSuccessfullyUploadsImageAndReturnsAttachment(): void
public function uploadAttachmentForImage(): void
{
$filePath = tempnam(sys_get_temp_dir(), 'test_upload_');
file_put_contents($filePath, 'fake-image-content');
$filePath = $this->createTempFile('image-content');
$uploadUrl = 'https://upload.server/image';
$uploadResponseJson = '{"photos":{"random_key_123":{"token":"final_image_token"}}}';
$expectedAttachment = PhotoAttachmentRequest::fromToken('final_image_token');
$uploadType = UploadType::Image;
$uploadUrl = 'https://upload.server/gohere';
$uploadToken = 'FINAL_TOKEN_123';
$this->clientMock->method('request')->willReturn(['url' => $uploadUrl]);
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl));
$getUploadUrlResponse = ['url' => $uploadUrl];
$uploadResponse = ['token' => $uploadToken];
$expectedEndpoint = new UploadEndpoint($uploadUrl);
$expectedAttachment = PhotoAttachmentRequest::fromToken($uploadToken);
$this->clientMock->method('multipartUpload')->willReturn($uploadResponseJson);
$result = $this->api->uploadAttachment(UploadType::Image, $filePath);
$this->assertEquals($expectedAttachment, $result);
unlink($filePath);
}
#[Test]
public function uploadAttachmentForFile(): void
{
$filePath = $this->createTempFile('file-content');
$uploadUrl = 'https://upload.server/file';
$uploadResponseJson = '{"token":"final_file_token"}';
$expectedAttachment = new FileAttachmentRequest('final_file_token');
$this->clientMock->method('request')->willReturn(['url' => $uploadUrl]);
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl));
$this->clientMock->method('multipartUpload')->willReturn($uploadResponseJson);
$result = $this->api->uploadAttachment(UploadType::File, $filePath);
$this->assertEquals($expectedAttachment, $result);
unlink($filePath);
}
#[Test]
public function uploadAttachmentForAudio(): void
{
$filePath = $this->createTempFile('audio-content');
$uploadUrl = 'https://upload.server/audio';
$preUploadToken = 'pre_upload_audio_token';
$uploadResponse = '<retval>1</retval>';
$expectedAttachment = new AudioAttachmentRequest($preUploadToken);
$getUploadUrlResponse = ['url' => $uploadUrl, 'token' => $preUploadToken];
$expectedEndpoint = new UploadEndpoint($uploadUrl, $preUploadToken);
$this->clientMock
->expects($this->once())
->method('request')
->with('POST', '/uploads', ['type' => $uploadType->value])
->with('POST', '/uploads', ['type' => UploadType::Audio->value])
->willReturn($getUploadUrlResponse);
$this->modelFactoryMock
@@ -479,11 +529,11 @@ final class ApiTest extends TestCase
$this->clientMock
->expects($this->once())
->method('upload')
->method('multipartUpload')
->with($uploadUrl, $this->isResource(), basename($filePath))
->willReturn($uploadResponse);
$result = $this->api->uploadAttachment($uploadType, $filePath);
$result = $this->api->uploadAttachment(UploadType::Audio, $filePath);
$this->assertEquals($expectedAttachment, $result);
@@ -491,29 +541,49 @@ final class ApiTest extends TestCase
}
#[Test]
public function uploadAttachmentForMultiplePhotosReturnsCorrectAttachment(): void
public function uploadAttachmentForVideo(): void
{
$filePath = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($filePath, 'content');
$filePath = $this->createTempFile('video-content');
$uploadUrl = 'https://upload.server/video';
$preUploadToken = 'pre_upload_video_token';
$uploadResponse = '<retval>1</retval>';
$expectedAttachment = new VideoAttachmentRequest($preUploadToken);
$getUploadUrlResponse = ['url' => 'http://upload.server'];
$expectedEndpoint = new UploadEndpoint('http://upload.server');
$getUploadUrlResponse = ['url' => $uploadUrl, 'token' => $preUploadToken];
$expectedEndpoint = new UploadEndpoint($uploadUrl, $preUploadToken);
$uploadResponse = ['token' => 'token'];
$this->clientMock
->expects($this->once())
->method('request')
->with('POST', '/uploads', ['type' => UploadType::Video->value])
->willReturn($getUploadUrlResponse);
$expectedAttachment = PhotoAttachmentRequest::fromToken('token');
$this->modelFactoryMock
->expects($this->once())
->method('createUploadEndpoint')
->with($getUploadUrlResponse)
->willReturn($expectedEndpoint);
$this->clientMock->method('request')->willReturn($getUploadUrlResponse);
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn($expectedEndpoint);
$this->clientMock->method('upload')->willReturn($uploadResponse);
$this->clientMock
->expects($this->once())
->method('multipartUpload')
->with($uploadUrl, $this->isResource(), basename($filePath))
->willReturn($uploadResponse);
$result = $this->api->uploadAttachment(UploadType::Image, $filePath);
$result = $this->api->uploadAttachment(UploadType::Video, $filePath);
$this->assertEquals($expectedAttachment, $result);
unlink($filePath);
}
private function createTempFile(string $content): string
{
$filePath = tempnam(sys_get_temp_dir(), 'test_upload_');
file_put_contents($filePath, $content);
return $filePath;
}
#[Test]
public function uploadAttachmentThrowsExceptionForNonExistentFile(): void
{
@@ -671,12 +741,12 @@ final class ApiTest extends TestCase
$this->clientMock
->expects($this->once())
->method('upload')
->method('multipartUpload')
->with($uploadUrl, $this->isResource(), basename($filePath))
->willReturn($invalidUploadResponse);
->willReturn(json_encode($invalidUploadResponse));
$this->expectException(SerializationException::class);
$this->expectExceptionMessage('Could not find "token" in upload server response.');
$this->expectExceptionMessage('Could not find "token" in photo upload response.');
try {
$this->api->uploadAttachment($uploadType, $filePath);
@@ -685,86 +755,6 @@ final class ApiTest extends TestCase
}
}
#[Test]
public function uploadAttachmentSuccessfullyUploadsVideoAndReturnsAttachment(): void
{
$filePath = tempnam(sys_get_temp_dir(), 'test_video_');
file_put_contents($filePath, 'fake-video-content');
$uploadType = UploadType::Video;
$uploadUrl = 'https://upload.server/video_path';
$uploadToken = 'VIDEO_TOKEN_XYZ';
$getUploadUrlResponse = ['url' => $uploadUrl];
$uploadResponse = ['token' => $uploadToken];
$expectedEndpoint = new UploadEndpoint($uploadUrl);
$expectedAttachment = new VideoAttachmentRequest($uploadToken);
$this->clientMock
->expects($this->once())
->method('request')
->with('POST', '/uploads', ['type' => $uploadType->value])
->willReturn($getUploadUrlResponse);
$this->modelFactoryMock
->expects($this->once())
->method('createUploadEndpoint')
->with($getUploadUrlResponse)
->willReturn($expectedEndpoint);
$this->clientMock
->expects($this->once())
->method('upload')
->with($uploadUrl, $this->isResource(), basename($filePath))
->willReturn($uploadResponse);
$result = $this->api->uploadAttachment($uploadType, $filePath);
$this->assertEquals($expectedAttachment, $result);
unlink($filePath);
}
#[Test]
public function uploadAttachmentSuccessfullyUploadsAudioAndReturnsAttachment(): void
{
$filePath = tempnam(sys_get_temp_dir(), 'test_audio_');
file_put_contents($filePath, 'fake-audio-content');
$uploadType = UploadType::Audio;
$uploadUrl = 'https://upload.server/audio_path';
$uploadToken = 'AUDIO_TOKEN_ABC';
$getUploadUrlResponse = ['url' => $uploadUrl];
$uploadResponse = ['token' => $uploadToken];
$expectedEndpoint = new UploadEndpoint($uploadUrl);
$expectedAttachment = new AudioAttachmentRequest($uploadToken);
$this->clientMock
->expects($this->once())
->method('request')
->with('POST', '/uploads', ['type' => $uploadType->value])
->willReturn($getUploadUrlResponse);
$this->modelFactoryMock
->expects($this->once())
->method('createUploadEndpoint')
->with($getUploadUrlResponse)
->willReturn($expectedEndpoint);
$this->clientMock
->expects($this->once())
->method('upload')
->with($uploadUrl, $this->isResource(), basename($filePath))
->willReturn($uploadResponse);
$result = $this->api->uploadAttachment($uploadType, $filePath);
$this->assertEquals($expectedAttachment, $result);
unlink($filePath);
}
#[Test]
public function uploadAttachmentSuccessfullyUploadsFileAndReturnsAttachment(): void
{
@@ -794,9 +784,9 @@ final class ApiTest extends TestCase
$this->clientMock
->expects($this->once())
->method('upload')
->method('multipartUpload')
->with($uploadUrl, $this->isResource(), basename($filePath))
->willReturn($uploadResponse);
->willReturn(json_encode($uploadResponse));
$result = $this->api->uploadAttachment($uploadType, $filePath);
@@ -828,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')
@@ -840,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]);
@@ -874,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')
@@ -886,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]);
@@ -920,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')
@@ -932,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]);
@@ -965,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')
@@ -977,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]);
@@ -1595,7 +1598,7 @@ final class ApiTest extends TestCase
->with($rawResponse)
->willReturn($expectedResult);
$result = $this->api->deleteAdmins($chatId, $userId);
$result = $this->api->deleteAdmin($chatId, $userId);
$this->assertSame($expectedResult, $result);
}
@@ -1980,4 +1983,183 @@ final class ApiTest extends TestCase
$this->assertSame($expectedDetails, $result);
}
#[Test]
public function constructorThrowsExceptionWhenNoTokenAndNoClientProvided(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('You must provide either an access token or a client.');
new Api(
accessToken: null,
client: null
);
}
#[Test]
public function uploadAttachmentThrowsSerializationExceptionOnInvalidUploadResponse(): void
{
$this->expectException(SerializationException::class);
$this->expectExceptionMessage('Failed to decode upload server response JSON.');
$filePath = $this->createTempFile('image-content');
$uploadUrl = 'https://upload.server/image';
$invalidJsonResponse = '{not-valid-json';
$this->clientMock->method('request')->willReturn(['url' => $uploadUrl]);
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl));
$this->clientMock
->expects($this->once())
->method('multipartUpload')
->willReturn($invalidJsonResponse);
try {
$this->api->uploadAttachment(UploadType::Image, $filePath);
} finally {
unlink($filePath);
}
}
#[Test]
public function uploadAttachmentForVideoThrowsExceptionOnMissingPreUploadToken(): void
{
$this->expectException(SerializationException::class);
$this->expectExceptionMessage("API did not return a pre-upload token for type 'video'.");
$filePath = $this->createTempFile('video-content');
$uploadUrl = 'https://upload.server/video';
$getUploadUrlResponse = ['url' => $uploadUrl];
$expectedEndpoint = new UploadEndpoint($uploadUrl, null);
$this->clientMock
->expects($this->once())
->method('request')
->with('POST', '/uploads', ['type' => UploadType::Video->value])
->willReturn($getUploadUrlResponse);
$this->modelFactoryMock
->expects($this->once())
->method('createUploadEndpoint')
->with($getUploadUrlResponse)
->willReturn($expectedEndpoint);
$this->clientMock->expects($this->never())->method('multipartUpload');
try {
$this->api->uploadAttachment(UploadType::Video, $filePath);
} finally {
unlink($filePath);
}
}
#[Test]
public function uploadAttachmentForFileThrowsExceptionOnMissingPostUploadToken(): void
{
$this->expectException(SerializationException::class);
$this->expectExceptionMessage('Could not find "token" in file upload response.');
$filePath = $this->createTempFile('file-content');
$uploadUrl = 'https://upload.server/file';
$invalidUploadResponse = json_encode(['status' => 'success', 'file_id' => 123]);
$this->clientMock->method('request')->willReturn(['url' => $uploadUrl]);
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl));
$this->clientMock
->expects($this->once())
->method('multipartUpload')
->willReturn($invalidUploadResponse);
try {
$this->api->uploadAttachment(UploadType::File, $filePath);
} finally {
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');
}
}
+233 -37
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,8 +341,8 @@ 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);
$this->assertSame($responsePayload, $result);
$result = $this->client->multipartUpload($uploadUrl, $fileContents, $fileName);
$this->assertSame(json_encode($responsePayload), $result);
}
#[Test]
@@ -330,15 +358,32 @@ 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($responsePayload, $result);
$this->assertSame(json_encode($responsePayload), $result);
fclose($tmpFileHandle);
}
@@ -358,27 +403,7 @@ final class ClientTest extends TestCase
->with($this->requestMock)
->willThrowException($psrException);
$this->client->upload('http://some.url', 'content', 'file.txt');
}
#[Test]
public function uploadThrowsSerializationExceptionOnInvalidJsonResponse(): void
{
$this->expectException(SerializationException::class);
$this->expectExceptionMessage('Failed to decode upload server response JSON.');
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
$this->requestMock->method('withHeader')->willReturn($this->requestMock);
$this->requestMock->method('withBody')->willReturn($this->requestMock);
$this->httpClientMock
->method('sendRequest')
->with($this->requestMock)
->willReturn($this->responseMock);
$this->responseMock->method('getStatusCode')->willReturn(200);
$this->streamMock->method('__toString')->willReturn('{not-a-valid-json');
$this->client->upload('http://some.url', 'content', 'file.txt');
$this->client->multipartUpload('http://some.url', 'content', 'file.txt');
}
#[Test]
@@ -409,4 +434,175 @@ final class ClientTest extends TestCase
$this->client->request('GET', '/not/found');
}
#[Test]
public function uploadMethodReturnsRawStringResponse(): void
{
$uploadUrl = 'https://upload.server/path';
$fileContents = 'data';
$fileName = 'file.txt';
$rawResponse = '<retval>1</retval>';
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
$this->requestMock->method('withHeader')->willReturn($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($rawResponse);
$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]);
}
}
@@ -9,7 +9,6 @@ use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentRequ
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoToken;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
@@ -77,32 +76,12 @@ final class PhotoAttachmentRequestPayloadTest extends TestCase
$this->assertEquals($expectedArray, $payload->toArray());
}
/**
* Data provider for invalid constructor arguments.
*
* @return array<string, array{0: string|null, 1: string|null, 2: array|null}>
*/
public static function invalidPayloadProvider(): array
{
return [
'all null (no arguments)' => [null, null, null],
'url and token provided' => ['https://a.com', 'token123', null],
'url and photos provided' => ['https://a.com', null, [new PhotoToken('t')]],
'token and photos provided' => [null, 'token123', [new PhotoToken('t')]],
'all three arguments provided' => ['https://a.com', 'token123', [new PhotoToken('t')]],
];
}
#[Test]
#[DataProvider('invalidPayloadProvider')]
public function constructorThrowsExceptionForInvalidArguments(
?string $url,
?string $token,
?array $photos
): void {
public function constructorThrowsExceptionForInvalidArguments(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Provide exactly one of "url", "token", or "photos" for PhotoAttachmentRequestPayload.');
$this->expectExceptionMessage('Provide one of "url", "token", or "photos" for PhotoAttachmentRequestPayload.');
new PhotoAttachmentRequestPayload($url, $token, $photos);
new PhotoAttachmentRequestPayload(null, null, null);
}
}
@@ -38,24 +38,12 @@ final class ShareAttachmentRequestPayloadTest extends TestCase
$this->assertEquals($expectedArray, $payload->toArray());
}
/**
* @return array<string, array{0: ?string, 1: ?string}>
*/
public static function invalidPayloadProvider(): array
{
return [
'both null' => [null, null],
'both set' => ['https://a.com', 'token123'],
];
}
#[Test]
#[DataProvider('invalidPayloadProvider')]
public function constructorThrowsExceptionForInvalidArguments(?string $url, ?string $token): void
public function constructorThrowsExceptionForInvalidArguments(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Provide exactly one of "url" or "token" for ShareAttachmentRequestPayload.');
$this->expectExceptionMessage('Provide one of "url" or "token" for ShareAttachmentRequestPayload.');
new ShareAttachmentRequestPayload($url, $token);
new ShareAttachmentRequestPayload(null, null);
}
}
@@ -98,29 +98,12 @@ final class PhotoAttachmentRequestTest extends TestCase
$this->assertEquals($expectedArray, $request->toArray());
}
/**
* @return array<string, array{0: string|null, 1: string|null, 2: array|null}>
*/
public static function invalidPayloadProvider(): array
{
return [
'no arguments' => [null, null, null],
'url and token' => ['http://a.com', 'token123', null],
'token and photos' => [null, 'token123', [new PhotoToken('t')]],
'all arguments' => ['http://a.com', 'token123', [new PhotoToken('t')]],
];
}
#[Test]
#[DataProvider('invalidPayloadProvider')]
public function payloadThrowsExceptionWhenNotExactlyOneArgumentIsProvided(
?string $url,
?string $token,
?array $photos
): void {
public function payloadThrowsExceptionWhenNotExactlyOneArgumentIsProvided(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Provide exactly one of "url", "token", or "photos" for PhotoAttachmentRequestPayload.');
$this->expectExceptionMessage('Provide one of "url", "token", or "photos" for PhotoAttachmentRequestPayload.');
new PhotoAttachmentRequestPayload($url, $token, $photos);
new PhotoAttachmentRequestPayload(null, null, null);
}
}
@@ -9,7 +9,6 @@ use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\ShareAttachmentRequ
use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\ShareAttachmentRequest;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
@@ -42,21 +41,11 @@ final class ShareAttachmentRequestTest extends TestCase
$this->assertEquals($expected, $request->toArray());
}
/** @return array<string, array{0: ?string, 1: ?string}> */
public static function invalidPayloadProvider(): array
{
return [
'both null' => [null, null],
'both set' => ['https://a.com', 'token123'],
];
}
#[Test]
#[DataProvider('invalidPayloadProvider')]
public function payloadThrowsExceptionForInvalidArguments(?string $url, ?string $token): void
public function payloadThrowsExceptionForInvalidArguments(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Provide exactly one of "url" or "token" for ShareAttachmentRequestPayload.');
new ShareAttachmentRequestPayload($url, $token);
$this->expectExceptionMessage('Provide one of "url" or "token" for ShareAttachmentRequestPayload.');
new ShareAttachmentRequestPayload(null, null);
}
}
+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';