mirror of
https://github.com/BushlanovDev/max-bot-api-client-php.git
synced 2026-08-20 11:42:56 +00:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 576f02efbd | |||
| da7a07145b | |||
| 7cdbfc0f47 | |||
| f29344d2ec | |||
| 3012272497 | |||
| 0dc10bd279 | |||
| 8419a7892d | |||
| ddc57d7ab8 | |||
| d0f044c7bd | |||
| 31f109db7e | |||
| e1375acabb | |||
| 098aa44825 | |||
| 6137b2ea6e | |||
| 122afcc7c0 | |||
| 4d56119fa2 | |||
| ae53f51854 | |||
| b87b41ff8f | |||
| 493be1a9a0 | |||
| 033fa42b79 | |||
| 94ec907506 | |||
| 869c254275 | |||
| cb8ad3f738 | |||
| 0f007bf94e | |||
| 3127f86c54 | |||
| ed715af3bc | |||
| 008d6f4e0e | |||
| 4784fcc97b | |||
| d2aedc090f | |||
| 92c8f4cd94 | |||
| fa7875a670 | |||
| 70c290ce0a | |||
| de3a67bee7 | |||
| b58dc8b2ed | |||
| 00da3af39d | |||
| ea1947239e | |||
| 0bb13b3a7c | |||
| 17acd6de67 | |||
| b37e7b6a73 | |||
| 3b242c1186 | |||
| 59a843609b | |||
| 9d56be2c92 | |||
| 9cd5dd2c48 | |||
| 1b00bae23a | |||
| ca13096ec2 | |||
| 68da594f89 | |||
| 62fbb6cf3b | |||
| 97d016055f | |||
| 13e7c3fa45 |
+4
-5
@@ -1,5 +1,4 @@
|
||||
/.gitattributes export-ignore
|
||||
/.gitignore export-ignore
|
||||
/.github export-ignore
|
||||
/phpunit.xml export-ignore
|
||||
/tests export-ignore
|
||||
/.* export-ignore
|
||||
/phpunit.xml export-ignore
|
||||
/phpstan.neon export-ignore
|
||||
/tests export-ignore
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Max Bot API Client library for PHP
|
||||
# Max Messenger Bot API Client library for PHP
|
||||
|
||||
[](https://github.com/BushlanovDev/max-bot-api-client-php/actions)
|
||||
[](https://github.com/BushlanovDev/max-bot-api-client-php/actions)
|
||||
[](https://github.com/BushlanovDev/max-bot-api-client-php/actions)
|
||||
[](https://packagist.org/packages/bushlanov-dev/max-bot-api-client-php)
|
||||
[](https://github.com/BushlanovDev/max-bot-api-client-php)
|
||||
[](https://github.com/BushlanovDev/max-bot-api-client-php)
|
||||
[](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
-50
@@ -1,53 +1,78 @@
|
||||
{
|
||||
"name": "bushlanov-dev/max-bot-api-client-php",
|
||||
"description": "Max Bot API Client library",
|
||||
"keywords": ["max messenger", "bot", "max", "api"],
|
||||
"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"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.77",
|
||||
"jaschilz/php-coverage-badger": "^2.0",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"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
|
||||
},
|
||||
"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
@@ -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
File diff suppressed because it is too large
Load Diff
+13
-3
@@ -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 & ~E_DEPRECATED"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
+130
-127
@@ -10,7 +10,6 @@ use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UploadType;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\AbstractAttachmentRequest;
|
||||
@@ -31,12 +30,13 @@ use BushlanovDev\MaxMessengerBot\Models\MessageLink;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Result;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Subscription;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
|
||||
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
use LogicException;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use ReflectionException;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -48,9 +48,11 @@ use RuntimeException;
|
||||
*/
|
||||
class Api
|
||||
{
|
||||
public const string LIBRARY_VERSION = '1.3.0';
|
||||
|
||||
public 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 const string METHOD_GET = 'GET';
|
||||
private const string METHOD_POST = 'POST';
|
||||
@@ -73,24 +75,38 @@ 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;
|
||||
|
||||
private readonly LoggerInterface $logger;
|
||||
|
||||
private readonly UpdateDispatcher $updateDispatcher;
|
||||
|
||||
/**
|
||||
* Api constructor.
|
||||
*
|
||||
* @param string $accessToken Your bot's access token from @MasterBot.
|
||||
* @param string|null $accessToken Your bot's access token from @MasterBot.
|
||||
* @param ClientApiInterface|null $client Http api client.
|
||||
* @param ModelFactory|null $modelFactory
|
||||
* @param ModelFactory|null $modelFactory The model factory.
|
||||
* @param LoggerInterface|null $logger PSR LoggerInterface.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function __construct(
|
||||
string $accessToken,
|
||||
?string $accessToken = null,
|
||||
?ClientApiInterface $client = null,
|
||||
?ModelFactory $modelFactory = null,
|
||||
?LoggerInterface $logger = null,
|
||||
) {
|
||||
if (empty($accessToken) && $client === null) {
|
||||
throw new InvalidArgumentException('You must provide either an access token or a client.');
|
||||
}
|
||||
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
|
||||
if ($client === null) {
|
||||
if (!class_exists(\GuzzleHttp\Client::class) || !class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) {
|
||||
throw new LogicException(
|
||||
@@ -99,7 +115,12 @@ class Api
|
||||
);
|
||||
}
|
||||
|
||||
$guzzle = new \GuzzleHttp\Client();
|
||||
$guzzle = new \GuzzleHttp\Client([
|
||||
'timeout' => 10,
|
||||
'connect_timeout' => 5,
|
||||
'read_timeout' => 10,
|
||||
'headers' => ['User-Agent' => 'max-bot-api-client-php/' . self::LIBRARY_VERSION . ' PHP/' . PHP_VERSION],
|
||||
]);
|
||||
$httpFactory = new \GuzzleHttp\Psr7\HttpFactory();
|
||||
$client = new Client(
|
||||
$accessToken,
|
||||
@@ -108,11 +129,13 @@ class Api
|
||||
$httpFactory,
|
||||
self::API_BASE_URL,
|
||||
self::API_VERSION,
|
||||
$this->logger,
|
||||
);
|
||||
}
|
||||
|
||||
$this->client = $client;
|
||||
$this->modelFactory = $modelFactory ?? new ModelFactory();
|
||||
$this->updateDispatcher = new UpdateDispatcher($this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,68 +157,46 @@ class Api
|
||||
return $this->client->request($method, $uri, $queryParams, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the central update dispatcher instance. Use this to register your event and command handlers.
|
||||
*
|
||||
* @return UpdateDispatcher
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function getUpdateDispatcher(): UpdateDispatcher
|
||||
{
|
||||
return $this->updateDispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a WebhookHandler instance, pre-configured with the necessary dependencies.
|
||||
*
|
||||
* @param string|null $secret The secret key for request verification.
|
||||
* Should be the same one you used when calling the subscribe() method.
|
||||
*
|
||||
* @return WebhookHandler
|
||||
*/
|
||||
public function createWebhookHandler(?string $secret = null): WebhookHandler
|
||||
{
|
||||
return new WebhookHandler($this, $this->modelFactory, $secret);
|
||||
return new WebhookHandler(
|
||||
$this->updateDispatcher,
|
||||
$this->modelFactory,
|
||||
$this->logger,
|
||||
$secret,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an incoming webhook request and returns a single Update object.
|
||||
* This is an alternative to the event-driven WebhookHandler::handle() method,
|
||||
* allowing for manual processing of updates.
|
||||
* Creates a LongPollingHandler instance, pre-configured for running a long-polling loop.
|
||||
*
|
||||
* @param string|null $secret The secret key to verify the request signature.
|
||||
* @param ServerRequestInterface|null $request The PSR-7 request object. If null, it's created from globals.
|
||||
*
|
||||
* @return AbstractUpdate The parsed update object (e.g., MessageCreatedUpdate).
|
||||
* @throws \ReflectionException
|
||||
* @throws SecurityException
|
||||
* @throws SerializationException
|
||||
* @throws \LogicException
|
||||
* @return LongPollingHandler
|
||||
*/
|
||||
public function getWebhookUpdate(?string $secret = null, ?ServerRequestInterface $request = null): AbstractUpdate
|
||||
public function createLongPollingHandler(): LongPollingHandler
|
||||
{
|
||||
return $this->createWebhookHandler($secret)->getUpdate($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple way to process a single incoming webhook request using callbacks.
|
||||
* This method creates a WebhookHandler, registers the provided callbacks, and processes the request.
|
||||
*
|
||||
* @param array<string, callable> $handlers An associative array where keys are UpdateType string values
|
||||
* (e.g., UpdateType::MessageCreated->value) and values are handlers.
|
||||
* @param string|null $secret The secret key for request verification.
|
||||
* @param ServerRequestInterface|null $request The PSR-7 request object.
|
||||
*
|
||||
* @throws SecurityException
|
||||
* @throws SerializationException
|
||||
* @throws ReflectionException
|
||||
* @throws LogicException
|
||||
*/
|
||||
public function handleWebhooks(
|
||||
array $handlers,
|
||||
?string $secret = null,
|
||||
?ServerRequestInterface $request = null,
|
||||
): void {
|
||||
$webhookHandler = $this->createWebhookHandler($secret);
|
||||
|
||||
foreach ($handlers as $updateType => $callback) {
|
||||
$updateType = UpdateType::tryFrom($updateType);
|
||||
// @phpstan-ignore-next-line
|
||||
if ($updateType && is_callable($callback)) {
|
||||
$webhookHandler->addHandler($updateType, $callback);
|
||||
}
|
||||
}
|
||||
|
||||
$webhookHandler->handle($request);
|
||||
return new LongPollingHandler(
|
||||
$this,
|
||||
$this->updateDispatcher,
|
||||
$this->logger,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,58 +236,6 @@ class Api
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a long-polling loop to process updates using callbacks.
|
||||
* This method will run indefinitely until the script is terminated.
|
||||
*
|
||||
* @param array<string, callable> $handlers An associative array where keys are UpdateType enums
|
||||
* and values are the corresponding handler functions.
|
||||
* @param int|null $timeout Timeout in seconds for long polling (0-90). Defaults to 90.
|
||||
* @param int|null $marker Pass `null` to get updates you didn't get yet.
|
||||
*/
|
||||
public function handleUpdates(array $handlers, ?int $timeout = null, ?int $marker = null): void
|
||||
{
|
||||
// @phpstan-ignore-next-line
|
||||
while (true) {
|
||||
try {
|
||||
$this->processUpdatesBatch($handlers, $timeout, $marker);
|
||||
} catch (NetworkException $e) {
|
||||
error_log("Network error: " . $e->getMessage());
|
||||
sleep(5);
|
||||
} catch (\Exception $e) {
|
||||
error_log("An error occurred: " . $e->getMessage());
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single batch of updates. This is the core logic used by handleUpdates().
|
||||
* Useful for custom loop implementations or for testing.
|
||||
*
|
||||
* @param array<string, callable> $handlers An associative array of update handlers.
|
||||
* @param int|null $timeout Timeout for the getUpdates call.
|
||||
* @param int|null $marker The marker for which updates to fetch.
|
||||
*
|
||||
* @throws ClientApiException
|
||||
* @throws NetworkException
|
||||
* @throws ReflectionException
|
||||
* @throws SerializationException
|
||||
*/
|
||||
public function processUpdatesBatch(array $handlers, ?int $timeout, ?int &$marker = null): void
|
||||
{
|
||||
$updateList = $this->getUpdates(timeout: $timeout, marker: $marker);
|
||||
|
||||
foreach ($updateList->updates as $update) {
|
||||
$handler = $handlers[$update->updateType->value] ?? null;
|
||||
if ($handler) {
|
||||
$handler($update, $this);
|
||||
}
|
||||
}
|
||||
|
||||
$marker = $updateList->marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about the current bot, identified by an access token.
|
||||
*
|
||||
@@ -414,7 +363,7 @@ class Api
|
||||
$this->buildNewMessageBody($text, $attachments, $format, $link, $notify),
|
||||
);
|
||||
|
||||
return $this->modelFactory->createMessage($response['message']);
|
||||
return $this->modelFactory->createMessageFromSendResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -496,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.
|
||||
*
|
||||
@@ -524,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -899,7 +902,7 @@ class Api
|
||||
int $chatId,
|
||||
?array $userIds = null,
|
||||
?int $marker = null,
|
||||
?int $count = null
|
||||
?int $count = null,
|
||||
): ChatMembersList {
|
||||
$query = [
|
||||
'user_ids' => $userIds !== null ? implode(',', $userIds) : null,
|
||||
@@ -928,7 +931,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(
|
||||
|
||||
+93
-8
@@ -19,6 +19,9 @@ use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Http\Message\RequestFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* The low-level HTTP client responsible for communicating with the Max Bot API.
|
||||
@@ -34,6 +37,7 @@ final readonly class Client implements ClientApiInterface
|
||||
* @param StreamFactoryInterface $streamFactory A PSR-17 factory for creating request body streams.
|
||||
* @param string $baseUrl The base URL for API requests.
|
||||
* @param string|null $apiVersion The API version to use for requests.
|
||||
* @param LoggerInterface $logger
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
@@ -44,6 +48,7 @@ final readonly class Client implements ClientApiInterface
|
||||
private StreamFactoryInterface $streamFactory,
|
||||
private string $baseUrl,
|
||||
private ?string $apiVersion = null,
|
||||
private LoggerInterface $logger = new NullLogger(),
|
||||
) {
|
||||
if (empty($accessToken)) {
|
||||
throw new InvalidArgumentException('Access token cannot be empty.');
|
||||
@@ -55,13 +60,20 @@ 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;
|
||||
}
|
||||
|
||||
$this->logger->debug('Sending API request', [
|
||||
'method' => $method,
|
||||
'url' => $this->baseUrl . $uri,
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$fullUrl = $this->baseUrl . $uri . '?' . http_build_query($queryParams);
|
||||
$request = $this->requestFactory->createRequest($method, $fullUrl);
|
||||
$request = $this->requestFactory
|
||||
->createRequest($method, $fullUrl)
|
||||
->withHeader('Authorization', $this->accessToken);
|
||||
|
||||
if (!empty($body)) {
|
||||
try {
|
||||
@@ -79,6 +91,10 @@ final readonly class Client implements ClientApiInterface
|
||||
$response = $this->httpClient->sendRequest($request);
|
||||
} catch (ClientExceptionInterface $e) {
|
||||
// This catches network errors, DNS failures, timeouts, etc.
|
||||
$this->logger->error('Network exception during API request', [
|
||||
'message' => $e->getMessage(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
throw new NetworkException($e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
|
||||
@@ -86,6 +102,11 @@ final readonly class Client implements ClientApiInterface
|
||||
|
||||
$responseBody = (string)$response->getBody();
|
||||
|
||||
$this->logger->debug('Received API response', [
|
||||
'status' => $response->getStatusCode(),
|
||||
'body' => $responseBody,
|
||||
]);
|
||||
|
||||
// Handle successful but empty responses (e.g., from DELETE endpoints)
|
||||
if (empty($responseBody)) {
|
||||
// The API spec often returns {"success": true}, so we can simulate that
|
||||
@@ -103,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();
|
||||
@@ -123,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 {
|
||||
@@ -133,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +241,11 @@ final readonly class Client implements ClientApiInterface
|
||||
$errorCode = $data['code'] ?? 'unknown';
|
||||
$errorMessage = $data['message'] ?? 'An unknown error occurred.';
|
||||
|
||||
$this->logger->error('API error response received', [
|
||||
'status' => $statusCode,
|
||||
'body' => $responseBody,
|
||||
]);
|
||||
|
||||
throw match ($statusCode) {
|
||||
401 => new UnauthorizedException($errorMessage, $errorCode, $response),
|
||||
403 => new ForbiddenException($errorMessage, $errorCode, $response),
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -26,16 +27,39 @@ interface ClientApiInterface
|
||||
public function request(string $method, string $uri, array $queryParams = [], array $body = []): array;
|
||||
|
||||
/**
|
||||
* Performs a file download at the specified URL.
|
||||
* Performs a file upload at the specified URL.
|
||||
*
|
||||
* @param string $uri URL received from the download API.
|
||||
* @param resource|string $fileContents File content (stream resource or string).
|
||||
* @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;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Enums;
|
||||
|
||||
enum AttachmentType: string
|
||||
enum AttachmentType: string
|
||||
{
|
||||
case Image = 'image';
|
||||
case Video = 'video';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Laravel\Commands;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Artisan command to start processing updates via long polling.
|
||||
*/
|
||||
class PollingStartCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'maxbot:polling:start
|
||||
{--timeout=90 : Timeout in seconds for long polling}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Start the bot to process updates via long polling';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(MaxBotManager $botManager): int
|
||||
{
|
||||
$timeout = (int)$this->option('timeout');
|
||||
|
||||
$this->info("Starting long polling with a timeout of $timeout seconds... Press Ctrl+C to stop.");
|
||||
|
||||
try {
|
||||
$botManager->startLongPolling($timeout);
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
// This part is unreachable as startLongPolling is an infinite loop
|
||||
return self::SUCCESS;
|
||||
// @codeCoverageIgnoreEnd
|
||||
} catch (Throwable $e) {
|
||||
Log::error("Long polling failed to start or crashed: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
]);
|
||||
$this->error("❌ Long polling failed: {$e->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Laravel\Commands;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Artisan command for listing active webhook subscriptions.
|
||||
*/
|
||||
class WebhookListCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'maxbot:webhook:list';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'List all active webhook subscriptions';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(Api $api): int
|
||||
{
|
||||
$this->info('Fetching webhook subscriptions...');
|
||||
|
||||
try {
|
||||
$subscriptions = $api->getSubscriptions();
|
||||
|
||||
if (empty($subscriptions)) {
|
||||
$this->info('No active webhook subscriptions found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Found ' . count($subscriptions) . ' active webhook subscription(s):');
|
||||
$this->newLine();
|
||||
|
||||
$headers = ['URL', 'Update Types', 'Created At'];
|
||||
$rows = [];
|
||||
|
||||
foreach ($subscriptions as $subscription) {
|
||||
$rows[] = [
|
||||
$subscription->url,
|
||||
implode(', ', $subscription->updateTypes ? array_map(fn (UpdateType $updateType) => $updateType->value, $subscription->updateTypes) : ['all']),
|
||||
date('Y-m-d H:i:s', $subscription->time),
|
||||
];
|
||||
}
|
||||
|
||||
$this->table($headers, $rows);
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
Log::error("Webhook list error: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
]);
|
||||
$this->error("❌ Webhook list error: {$e->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Laravel\Commands;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Config\Repository as Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Artisan command for subscribing to webhook updates.
|
||||
*/
|
||||
class WebhookSubscribeCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'maxbot:webhook:subscribe
|
||||
{url : The webhook URL to subscribe to}
|
||||
{--secret= : Secret key for webhook verification (optional)}
|
||||
{--types=* : Update types to subscribe to (optional)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Subscribe bot to webhook updates';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(Api $api, Config $config): int
|
||||
{
|
||||
$url = (string)$this->argument('url'); // @phpstan-ignore-line
|
||||
$secret = $this->option('secret') ?? $config->get('maxbot.webhook_secret');
|
||||
$types = $this->option('types');
|
||||
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$this->error('Invalid URL provided.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$updateTypes = null;
|
||||
if (is_array($types) && !empty($types)) {
|
||||
$updateTypes = [];
|
||||
foreach ($types as $type) {
|
||||
try {
|
||||
$updateTypes[] = UpdateType::from($type);
|
||||
} catch (\ValueError $e) {
|
||||
$this->error("Invalid update type: $type");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Subscribing to webhook...');
|
||||
$this->line("URL: $url");
|
||||
if ($secret) {
|
||||
$this->line("Secret: " . str_repeat('*', strlen($secret)));
|
||||
}
|
||||
if ($updateTypes) {
|
||||
$this->line("Update types: " . implode(', ', array_map(fn($type) => $type->value, $updateTypes)));
|
||||
} else {
|
||||
$this->line("Update types: All (default)");
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $api->subscribe($url, $secret, $updateTypes);
|
||||
|
||||
if ($result->success) {
|
||||
$this->info('✅ Successfully subscribed to webhook!');
|
||||
|
||||
return self::SUCCESS;
|
||||
} else {
|
||||
$this->error('❌ Failed to subscribe to webhook.');
|
||||
$this->line("Response: $result->message");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error("Webhook subscription error: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
]);
|
||||
$this->error("❌ Webhook subscription error: {$e->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Laravel\Commands;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Artisan command for unsubscribing from webhook updates.
|
||||
*/
|
||||
class WebhookUnsubscribeCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'maxbot:webhook:unsubscribe
|
||||
{url : The webhook URL to unsubscribe from}
|
||||
{--confirm : Skip confirmation prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Unsubscribe bot from webhook updates';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(Api $api): int
|
||||
{
|
||||
$url = (string)$this->argument('url'); // @phpstan-ignore-line
|
||||
$confirm = $this->option('confirm');
|
||||
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$this->error('Invalid URL provided.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (!$confirm) {
|
||||
if (!$this->confirm("Are you sure you want to unsubscribe from webhook URL: $url?")) {
|
||||
$this->info('Operation cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Unsubscribing from webhook...');
|
||||
$this->line("URL: $url");
|
||||
|
||||
try {
|
||||
$result = $api->unsubscribe($url);
|
||||
|
||||
if ($result->success) {
|
||||
$this->info('✅ Successfully unsubscribed from webhook!');
|
||||
|
||||
return self::SUCCESS;
|
||||
} else {
|
||||
$this->error('❌ Failed to unsubscribe from webhook.');
|
||||
$this->line("Response: $result->message");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error("Webhook unsubscribe error: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
]);
|
||||
$this->error("❌ Webhook unsubscribe error: {$e->getMessage()}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Laravel;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\MessageFormat;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\SenderAction;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UploadType;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\AbstractAttachmentRequest;
|
||||
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
|
||||
use BushlanovDev\MaxMessengerBot\Models\BotPatch;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Chat;
|
||||
use BushlanovDev\MaxMessengerBot\Models\ChatAdmin;
|
||||
use BushlanovDev\MaxMessengerBot\Models\ChatList;
|
||||
use BushlanovDev\MaxMessengerBot\Models\ChatMember;
|
||||
use BushlanovDev\MaxMessengerBot\Models\ChatMembersList;
|
||||
use BushlanovDev\MaxMessengerBot\Models\ChatPatch;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Message;
|
||||
use BushlanovDev\MaxMessengerBot\Models\MessageLink;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Result;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Subscription;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
|
||||
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
* Laravel Facade for Max Bot API Client.
|
||||
*
|
||||
* Provides static access to the Max Bot API methods through Laravel's facade system.
|
||||
*
|
||||
* @method static array<string, mixed> request(string $method, string $uri, array<string, mixed> $queryParams = [], array<string, mixed> $body = [])
|
||||
* @method static UpdateDispatcher getUpdateDispatcher()
|
||||
* @method static WebhookHandler createWebhookHandler(?string $secret = null)
|
||||
* @method static LongPollingHandler createLongPollingHandler()
|
||||
* @method static UpdateList getUpdates(?int $limit = null, ?int $timeout = null, ?int $marker = null, ?array<UpdateType> $types = null)
|
||||
* @method static BotInfo getBotInfo()
|
||||
* @method static Subscription[] getSubscriptions()
|
||||
* @method static Result subscribe(string $url, ?string $secret = null, ?array<UpdateType> $updateTypes = null)
|
||||
* @method static Result unsubscribe(string $url)
|
||||
* @method static Message sendMessage(?int $userId = null, ?int $chatId = null, ?string $text = null, ?array<AbstractAttachmentRequest> $attachments = null, ?MessageFormat $format = null, ?MessageLink $link = null, bool $notify = true, bool $disableLinkPreview = false)
|
||||
* @method static Message sendUserMessage(?int $userId = null, ?string $text = null, ?array<AbstractAttachmentRequest> $attachments = null, ?MessageFormat $format = null, ?MessageLink $link = null, bool $notify = true, bool $disableLinkPreview = false)
|
||||
* @method static Message sendChatMessage(?int $chatId = null, ?string $text = null, ?array<AbstractAttachmentRequest> $attachments = null, ?MessageFormat $format = null, ?MessageLink $link = null, bool $notify = true, bool $disableLinkPreview = false)
|
||||
* @method static UploadEndpoint getUploadUrl(UploadType $type)
|
||||
* @method static AbstractAttachmentRequest uploadAttachment(UploadType $type, string $filePath)
|
||||
* @method static Chat getChat(int $chatId)
|
||||
* @method static Chat getChatByLink(string $chatLink)
|
||||
* @method static ChatList getChats(?int $count = null, ?int $marker = null)
|
||||
* @method static Result deleteChat(int $chatId)
|
||||
* @method static Result sendAction(int $chatId, SenderAction $action)
|
||||
* @method static Message|null getPinnedMessage(int $chatId)
|
||||
* @method static Result unpinMessage(int $chatId)
|
||||
* @method static ChatMember getMembership(int $chatId)
|
||||
* @method static Result leaveChat(int $chatId)
|
||||
* @method static Message[] getMessages(int $chatId, ?array<string> $messageIds = null, ?int $from = null, ?int $to = null, ?int $count = null)
|
||||
* @method static Result deleteMessage(string $messageId)
|
||||
* @method static Message getMessageById(string $messageId)
|
||||
* @method static Result pinMessage(int $chatId, string $messageId, bool $notify = true)
|
||||
* @method static ChatMembersList getAdmins(int $chatId)
|
||||
* @method static ChatMembersList getMembers(int $chatId, ?array<int> $userIds = null, ?int $marker = null, ?int $count = null)
|
||||
* @method static Result 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)
|
||||
* @method static Result answerOnCallback(string $callbackId, ?string $notification = null, ?string $text = null, ?array<AbstractAttachmentRequest> $attachments = null, ?MessageLink $link = null, ?MessageFormat $format = null, bool $notify = true)
|
||||
* @method static Result editMessage(string $messageId, ?string $text = null, ?array<AbstractAttachmentRequest> $attachments = null, ?MessageFormat $format = null, ?MessageLink $link = null, bool $notify = true)
|
||||
* @method static BotInfo editBotInfo(BotPatch $botPatch)
|
||||
* @method static Chat editChat(int $chatId, ChatPatch $chatPatch)
|
||||
* @method static VideoAttachmentDetails getVideoAttachmentDetails(string $videoToken)
|
||||
*
|
||||
* @see Api
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class MaxBotFacade extends Facade
|
||||
{
|
||||
/**
|
||||
* Get the registered name of the component.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected static function getFacadeAccessor(): string
|
||||
{
|
||||
return 'maxbot';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Laravel;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Max Bot Manager for Laravel integration.
|
||||
*
|
||||
* Provides convenient methods for integrating Max Bot with Laravel applications.
|
||||
* Handles webhook processing, long polling, and event dispatching within Laravel context.
|
||||
*/
|
||||
class MaxBotManager
|
||||
{
|
||||
/**
|
||||
* @param Container $container
|
||||
* @param Api $api
|
||||
* @param UpdateDispatcher $dispatcher
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly Container $container,
|
||||
private readonly Api $api,
|
||||
private readonly UpdateDispatcher $dispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle webhook request in Laravel controller.
|
||||
*
|
||||
* Example usage in a controller:
|
||||
* ```php
|
||||
* public function webhook(Request $request, MaxBotManager $botManager)
|
||||
* {
|
||||
* return $botManager->handleWebhook($request);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
public function handleWebhook(Request $request): JsonResponse|Response
|
||||
{
|
||||
try {
|
||||
/** @var WebhookHandler $webhookHandler */
|
||||
$webhookHandler = $this->container->make(WebhookHandler::class);
|
||||
|
||||
$headers = array_map(function ($values) {
|
||||
return array_filter($values, fn($value) => $value !== null);
|
||||
}, $request->headers->all());
|
||||
|
||||
$webhookHandler->handle(
|
||||
new ServerRequest(
|
||||
$request->getMethod(),
|
||||
$request->getUri(),
|
||||
$headers,
|
||||
$request->getContent(),
|
||||
$request->getProtocolVersion() ?? '1.1',
|
||||
)
|
||||
);
|
||||
|
||||
return new Response('', 200);
|
||||
} catch (SecurityException $e) {
|
||||
Log::warning("Webhook security error: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
'headers' => $request->headers->all(),
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => 'error',
|
||||
'message' => 'Forbidden',
|
||||
], 403);
|
||||
} catch (SerializationException $e) {
|
||||
Log::error("Webhook serialization error: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
'request_content' => $request->getContent(),
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => 'error',
|
||||
'message' => 'Bad Request',
|
||||
], 400);
|
||||
} catch (Throwable $e) {
|
||||
Log::error("Webhook processing error: {$e->getMessage()}", [
|
||||
'exception' => $e,
|
||||
'request_content' => $request->getContent(),
|
||||
'headers' => $request->headers->all(),
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => 'error',
|
||||
'message' => 'Internal Server Error',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start long polling in Laravel context.
|
||||
* This method should be called from a Laravel command or job.
|
||||
* It will run indefinitely until stopped.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function startLongPolling(int $timeout = 90, ?int $marker = null): void
|
||||
{
|
||||
/** @var LongPollingHandler $longPolling */
|
||||
$longPolling = $this->container->make(LongPollingHandler::class);
|
||||
|
||||
$longPolling->handle($timeout, $marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for a specific update type.
|
||||
*
|
||||
* @param UpdateType $type The type of update to handle.
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function addHandler(UpdateType $type, callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->addHandler($type, $this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a command handler.
|
||||
*
|
||||
* @param string $command Command name (without slash)
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onCommand(string $command, callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onCommand($command, $this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a message created handler.
|
||||
*
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageCreated(callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onMessageCreated($this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback handler.
|
||||
*
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageCallback(callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onMessageCallback($this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a message edited handler.
|
||||
*
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageEdited(callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onMessageEdited($this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a message removed handler.
|
||||
*
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageRemoved(callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onMessageRemoved($this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a bot added handler.
|
||||
*
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotAdded(callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onBotAdded($this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a bot removed handler.
|
||||
*
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotRemoved(callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onBotRemoved($this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a user added handler.
|
||||
*
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onUserAdded(callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onUserAdded($this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a user removed handler.
|
||||
*
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onUserRemoved(callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onUserRemoved($this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a bot started handler.
|
||||
*
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotStarted(callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onBotStarted($this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a chat title changed handler.
|
||||
*
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onChatTitleChanged(callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onChatTitleChanged($this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a message chat created handler.
|
||||
*
|
||||
* @param callable|string $handler Can be a closure, callable, or Laravel container binding.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageChatCreated(callable|string $handler): void
|
||||
{
|
||||
$this->dispatcher->onMessageChatCreated($this->resolveHandler($handler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API instance.
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function getApi(): Api
|
||||
{
|
||||
return $this->api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the update dispatcher.
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function getDispatcher(): UpdateDispatcher
|
||||
{
|
||||
return $this->dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a handler that might be a Laravel container binding.
|
||||
*
|
||||
* @param callable|string $handler
|
||||
* @return callable
|
||||
* @phpstan-return callable(AbstractUpdate, Api): void
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
private function resolveHandler(callable|string $handler): callable
|
||||
{
|
||||
if (is_string($handler)) {
|
||||
if ($this->container->bound($handler)) {
|
||||
$resolved = $this->container->make($handler);
|
||||
|
||||
if (is_callable($resolved)) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
if (is_object($resolved) && method_exists($resolved, 'handle')) {
|
||||
/** @var callable */
|
||||
return [$resolved, 'handle'];
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException(
|
||||
"Handler class '$handler' is not callable and doesn't have a handle method."
|
||||
);
|
||||
}
|
||||
|
||||
if (str_contains($handler, '@')) {
|
||||
[$class, $method] = explode('@', $handler, 2);
|
||||
$instance = $this->container->make($class);
|
||||
/** @var callable */
|
||||
return [$instance, $method];
|
||||
}
|
||||
|
||||
if (class_exists($handler)) {
|
||||
$instance = $this->container->make($handler);
|
||||
if (method_exists($instance, 'handle')) {
|
||||
/** @var callable */
|
||||
return [$instance, 'handle'];
|
||||
}
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException("Unable to resolve handler: $handler");
|
||||
}
|
||||
|
||||
return $handler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Laravel;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Client;
|
||||
use BushlanovDev\MaxMessengerBot\ClientApiInterface;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\PollingStartCommand;
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookSubscribeCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookUnsubscribeCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookListCommand;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Contracts\Config\Repository as Config;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* Laravel Service Provider for Max Bot API Client.
|
||||
* Registers all necessary services in the Laravel container.
|
||||
*/
|
||||
class MaxBotServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(
|
||||
__DIR__ . '/config/maxbot.php',
|
||||
'maxbot',
|
||||
);
|
||||
|
||||
$this->app->singleton(ClientApiInterface::class, function (Application $app) {
|
||||
/** @var Config $config */
|
||||
$config = $app->make(Config::class);
|
||||
$accessToken = $config->get('maxbot.access_token');
|
||||
|
||||
if (empty($accessToken)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Max Bot access token is not configured. Please set MAXBOT_ACCESS_TOKEN in your .env file.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!class_exists(\GuzzleHttp\Client::class) || !class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) {
|
||||
throw new \LogicException(
|
||||
'Guzzle HTTP client is required. Please run "composer require guzzlehttp/guzzle".'
|
||||
);
|
||||
}
|
||||
|
||||
$logger = $config->get('maxbot.logging.enabled', false)
|
||||
? $app->make(LoggerInterface::class)
|
||||
: new NullLogger();
|
||||
|
||||
$guzzle = new \GuzzleHttp\Client([
|
||||
'timeout' => (int)$config->get('maxbot.timeout', 10),
|
||||
'connect_timeout' => (int)$config->get('maxbot.connect_timeout', 5),
|
||||
'read_timeout' => (int)$config->get('maxbot.read_timeout', 10),
|
||||
'headers' => [
|
||||
'User-Agent' => 'max-bot-api-client-php/' . Api::LIBRARY_VERSION
|
||||
. ' Laravel/' . $app->version() . ' PHP/' . PHP_VERSION
|
||||
],
|
||||
]);
|
||||
|
||||
$httpFactory = new \GuzzleHttp\Psr7\HttpFactory();
|
||||
|
||||
return new Client(
|
||||
$accessToken,
|
||||
$guzzle,
|
||||
$httpFactory,
|
||||
$httpFactory,
|
||||
$config->get('maxbot.base_url', 'https://botapi.max.ru'),
|
||||
$config->get('maxbot.api_version', Api::API_VERSION),
|
||||
$logger,
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->singleton(ModelFactory::class, function () {
|
||||
return new ModelFactory();
|
||||
});
|
||||
|
||||
$this->app->singleton(Api::class, function (Application $app) {
|
||||
/** @var Config $config */
|
||||
$config = $app->make(Config::class);
|
||||
$accessToken = $config->get('maxbot.access_token');
|
||||
|
||||
if (empty($accessToken)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Max Bot access token is not configured. Please set MAXBOT_ACCESS_TOKEN in your .env file.'
|
||||
);
|
||||
}
|
||||
|
||||
return new Api(
|
||||
$accessToken,
|
||||
$app->make(ClientApiInterface::class),
|
||||
$app->make(ModelFactory::class),
|
||||
$app->make(LoggerInterface::class),
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->singleton(UpdateDispatcher::class, function (Application $app) {
|
||||
return $app->make(Api::class)->getUpdateDispatcher();
|
||||
});
|
||||
|
||||
$this->app->bind(WebhookHandler::class, function (Application $app) {
|
||||
/** @var Config $config */
|
||||
$config = $app->make(Config::class);
|
||||
$secret = $config->get('maxbot.webhook_secret');
|
||||
|
||||
return new WebhookHandler(
|
||||
$app->make(UpdateDispatcher::class),
|
||||
$app->make(ModelFactory::class),
|
||||
$app->make(LoggerInterface::class),
|
||||
$secret,
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->bind(LongPollingHandler::class, function (Application $app) {
|
||||
return new LongPollingHandler(
|
||||
$app->make(Api::class),
|
||||
$app->make(UpdateDispatcher::class),
|
||||
$app->make(LoggerInterface::class),
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->singleton(MaxBotManager::class, function (Application $app) {
|
||||
return new MaxBotManager(
|
||||
$app,
|
||||
$app->make(Api::class),
|
||||
$app->make(UpdateDispatcher::class),
|
||||
);
|
||||
});
|
||||
|
||||
$this->app->alias(Api::class, 'maxbot');
|
||||
$this->app->alias(Api::class, 'maxbot.api');
|
||||
$this->app->alias(ClientApiInterface::class, 'maxbot.client');
|
||||
$this->app->alias(UpdateDispatcher::class, 'maxbot.dispatcher');
|
||||
$this->app->alias(WebhookHandler::class, 'maxbot.webhook');
|
||||
$this->app->alias(LongPollingHandler::class, 'maxbot.polling');
|
||||
$this->app->alias(MaxBotManager::class, 'maxbot.manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->publishes([
|
||||
__DIR__ . '/config/maxbot.php' => $this->app->configPath('maxbot.php'),
|
||||
], 'maxbot-config');
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
WebhookSubscribeCommand::class,
|
||||
WebhookUnsubscribeCommand::class,
|
||||
WebhookListCommand::class,
|
||||
PollingStartCommand::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the services provided by the provider.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function provides(): array
|
||||
{
|
||||
return [
|
||||
Api::class,
|
||||
ClientApiInterface::class,
|
||||
ModelFactory::class,
|
||||
UpdateDispatcher::class,
|
||||
WebhookHandler::class,
|
||||
LongPollingHandler::class,
|
||||
MaxBotManager::class,
|
||||
'maxbot',
|
||||
'maxbot.api',
|
||||
'maxbot.client',
|
||||
'maxbot.dispatcher',
|
||||
'maxbot.webhook',
|
||||
'maxbot.polling',
|
||||
'maxbot.manager',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Max Bot Access Token
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Your bot's access token from @MasterBot. This token is required for
|
||||
| authentication with the Max Bot API. You can obtain it by creating
|
||||
| a bot through @MasterBot in the Max messenger.
|
||||
|
|
||||
*/
|
||||
'access_token' => env('MAXBOT_ACCESS_TOKEN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Webhook Secret
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Secret key for verifying the authenticity of webhook requests.
|
||||
| This is optional but recommended for security. Set this if you're
|
||||
| using webhooks to receive updates.
|
||||
|
|
||||
*/
|
||||
'webhook_secret' => env('MAXBOT_WEBHOOK_SECRET'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for the Max Bot API connection.
|
||||
|
|
||||
*/
|
||||
'base_url' => env('MAXBOT_BASE_URL', 'https://botapi.max.ru'),
|
||||
'api_version' => env('MAXBOT_API_VERSION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Client Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for the HTTP client used to communicate with the API.
|
||||
| All values are in seconds.
|
||||
|
|
||||
*/
|
||||
'timeout' => (int)env('MAXBOT_TIMEOUT', 10),
|
||||
'connect_timeout' => (int)env('MAXBOT_CONNECT_TIMEOUT', 5),
|
||||
'read_timeout' => (int)env('MAXBOT_READ_TIMEOUT', 10),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Logging Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Whether to enable detailed logging of API requests and responses.
|
||||
| This uses Laravel's configured logger.
|
||||
|
|
||||
*/
|
||||
'logging' => [
|
||||
'enabled' => (bool)env('MAXBOT_LOGGING_ENABLED', false),
|
||||
'level' => env('MAXBOT_LOGGING_LEVEL', 'debug'),
|
||||
],
|
||||
];
|
||||
// @codeCoverageIgnoreEnd
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Handles receiving updates via long polling.
|
||||
*/
|
||||
final readonly class LongPollingHandler
|
||||
{
|
||||
/**
|
||||
* @param Api $api
|
||||
* @param UpdateDispatcher $dispatcher The update dispatcher.
|
||||
* @param LoggerInterface $logger PSR LoggerInterface.
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function __construct(
|
||||
private Api $api,
|
||||
private UpdateDispatcher $dispatcher,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
if (!(\PHP_SAPI === 'cli')) {
|
||||
throw new \RuntimeException('LongPollingHandler can only be used in CLI mode.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single batch of updates. Useful for custom loop implementations or for testing.
|
||||
*
|
||||
* @param int $timeout Timeout for the getUpdates call.
|
||||
* @param int|null $marker The marker for which updates to fetch.
|
||||
* @return int|null The new marker to be used for the next iteration.
|
||||
* @throws \Exception Re-throws exceptions from the API or dispatcher.
|
||||
*/
|
||||
public function processUpdates(int $timeout, ?int $marker): ?int
|
||||
{
|
||||
$updateList = $this->api->getUpdates(timeout: $timeout, marker: $marker);
|
||||
|
||||
foreach ($updateList->updates as $update) {
|
||||
try {
|
||||
$this->dispatcher->dispatch($update);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Error dispatching update', [
|
||||
'message' => $e->getMessage(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $updateList->marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a long-polling loop to process updates.
|
||||
* This method will run indefinitely until the script is terminated.
|
||||
*
|
||||
* @param int $timeout Timeout in seconds for long polling (0-90).
|
||||
* @param int|null $marker Initial marker. Pass `null` to get updates you didn't get yet.
|
||||
*/
|
||||
public function handle(int $timeout = 90, ?int $marker = null): void
|
||||
{
|
||||
$this->listenSignals();
|
||||
// @phpstan-ignore-next-line
|
||||
while (true) {
|
||||
try {
|
||||
$marker = $this->processUpdates($timeout, $marker);
|
||||
} catch (NetworkException $e) {
|
||||
$this->logger->error(
|
||||
'Long-polling network error: {message}',
|
||||
['message' => $e->getMessage(), 'exception' => $e],
|
||||
);
|
||||
sleep(5);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error(
|
||||
'An error occurred during long-polling: {message}',
|
||||
['message' => $e->getMessage(), 'exception' => $e],
|
||||
);
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
protected function listenSignals(): void
|
||||
{
|
||||
if (extension_loaded('pcntl')) {
|
||||
pcntl_async_signals(true);
|
||||
|
||||
$kill = static function () {
|
||||
exit(0);
|
||||
};
|
||||
|
||||
pcntl_signal(SIGINT, $kill);
|
||||
pcntl_signal(SIGQUIT, $kill);
|
||||
pcntl_signal(SIGTERM, $kill);
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
-28
@@ -15,6 +15,7 @@ use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\AbstractInlin
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\CallbackButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\ChatButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\LinkButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\OpenAppButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\RequestContactButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\RequestGeoLocationButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\AbstractReplyButton;
|
||||
@@ -47,6 +48,7 @@ use BushlanovDev\MaxMessengerBot\Models\Markup\StrongMarkup;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Markup\UnderlineMarkup;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Markup\UserMentionMarkup;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Message;
|
||||
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Result;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Subscription;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
|
||||
@@ -126,6 +128,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.
|
||||
*
|
||||
@@ -136,23 +165,54 @@ class ModelFactory
|
||||
*/
|
||||
public function createMessage(array $data): Message
|
||||
{
|
||||
if (isset($data['body']['attachments']) && is_array($data['body']['attachments'])) {
|
||||
$data['body']['attachments'] = array_map(
|
||||
[$this, 'createAttachment'],
|
||||
$data['body']['attachments'],
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($data['body']['markup']) && is_array($data['body']['markup'])) {
|
||||
$data['body']['markup'] = array_map(
|
||||
[$this, 'createMarkupElement'],
|
||||
$data['body']['markup'],
|
||||
);
|
||||
if (isset($data['body']) && is_array($data['body'])) {
|
||||
$data['body'] = $this->createMessageBody($data['body']);
|
||||
}
|
||||
|
||||
return Message::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* List of messages.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return Message[]
|
||||
*/
|
||||
public function createMessages(array $data): array
|
||||
{
|
||||
return isset($data['messages']) && is_array($data['messages'])
|
||||
? array_map([$this, 'createMessage'], $data['messages'])
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a MessageBody object from raw API data, handling polymorphic attachments and markup.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return MessageBody
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
private function createMessageBody(array $data): MessageBody
|
||||
{
|
||||
if (isset($data['attachments']) && is_array($data['attachments'])) {
|
||||
$data['attachments'] = array_map(
|
||||
[$this, 'createAttachment'],
|
||||
$data['attachments'],
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($data['markup']) && is_array($data['markup'])) {
|
||||
$data['markup'] = array_map(
|
||||
[$this, 'createMarkupElement'],
|
||||
$data['markup'],
|
||||
);
|
||||
}
|
||||
|
||||
return MessageBody::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a specific Attachment model based on the 'type' field.
|
||||
*
|
||||
@@ -192,7 +252,7 @@ class ModelFactory
|
||||
AttachmentType::InlineKeyboard => InlineKeyboardAttachment::fromArray($data),
|
||||
AttachmentType::ReplyKeyboard => ReplyKeyboardAttachment::fromArray($data),
|
||||
AttachmentType::Location => LocationAttachment::fromArray($data),
|
||||
default => throw new LogicException("Unknown or unsupported attachment type: " . ($data['type'] ?? 'none')),
|
||||
default => throw new LogicException('Unknown or unsupported attachment type: ' . ($data['type'] ?? 'none')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,24 +293,13 @@ class ModelFactory
|
||||
InlineButtonType::RequestContact => RequestContactButton::fromArray($data),
|
||||
InlineButtonType::RequestGeoLocation => RequestGeoLocationButton::fromArray($data),
|
||||
InlineButtonType::Chat => ChatButton::fromArray($data),
|
||||
default => throw new LogicException("Unknown or unsupported inline button type: " . ($data['type'] ?? 'none')),
|
||||
InlineButtonType::OpenApp => OpenAppButton::fromArray($data),
|
||||
default => throw new LogicException(
|
||||
'Unknown or unsupported inline button type: ' . ($data['type'] ?? 'none')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List of messages.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return Message[]
|
||||
*/
|
||||
public function createMessages(array $data): array
|
||||
{
|
||||
return isset($data['messages']) && is_array($data['messages'])
|
||||
? array_map([$this, 'createMessage'], $data['messages'])
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint you should upload to your binaries.
|
||||
*
|
||||
|
||||
@@ -12,7 +12,8 @@ final readonly class InlineKeyboardAttachment extends AbstractAttachment
|
||||
/**
|
||||
* @param KeyboardPayload $payload Keyboard payload.
|
||||
*/
|
||||
public function __construct(public KeyboardPayload $payload) {
|
||||
public function __construct(public KeyboardPayload $payload)
|
||||
{
|
||||
parent::__construct(AttachmentType::InlineKeyboard);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,7 @@ final readonly class PhotoToken extends AbstractModel
|
||||
/**
|
||||
* @param string $token Encoded information of uploaded image.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $token,
|
||||
) {
|
||||
public function __construct(public string $token)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\AbstractReplyButton;
|
||||
|
||||
final readonly class ReplyKeyboardAttachmentRequestPayload extends AbstractAttachmentRequestPayload
|
||||
@@ -15,7 +14,6 @@ final readonly class ReplyKeyboardAttachmentRequestPayload extends AbstractAttac
|
||||
* @param int|null $directUserId If set, reply keyboard will only be shown to this participant.
|
||||
*/
|
||||
public function __construct(
|
||||
#[ArrayOf(AbstractReplyButton::class)]
|
||||
public array $buttons,
|
||||
public bool $direct = false,
|
||||
public ?int $directUserId = null,
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
|
||||
|
||||
/**
|
||||
* Payload for attachments that are uploaded to the server first (video, audio, file).
|
||||
*/
|
||||
final readonly class UploadedInfoAttachmentRequestPayload extends AbstractAttachmentRequestPayload
|
||||
{
|
||||
/**
|
||||
* @param string $token The unique token received after a successful file upload.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $token,
|
||||
) {
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
|
||||
|
||||
/**
|
||||
* Payload for attachments that are uploaded to the server first (video, audio, file).
|
||||
*/
|
||||
final readonly class UploadedInfoAttachmentRequestPayload extends AbstractAttachmentRequestPayload
|
||||
{
|
||||
/**
|
||||
* @param string $token The unique token received after a successful file upload.
|
||||
*/
|
||||
public function __construct(public string $token)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ final readonly class ReplyKeyboardAttachment extends AbstractAttachment
|
||||
/**
|
||||
* @param AbstractReplyButton[][] $buttons
|
||||
*/
|
||||
public function __construct(public array $buttons) {
|
||||
public function __construct(public array $buttons)
|
||||
{
|
||||
parent::__construct(AttachmentType::ReplyKeyboard);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ final readonly class Image extends AbstractModel
|
||||
/**
|
||||
* @param string $url URL of image.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $url,
|
||||
) {
|
||||
public function __construct(public string $url)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Models;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\AbstractAttachment;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Markup\AbstractMarkup;
|
||||
|
||||
@@ -24,9 +23,7 @@ final readonly class MessageBody extends AbstractModel
|
||||
public string $mid,
|
||||
public int $seq,
|
||||
public ?string $text,
|
||||
#[ArrayOf(AbstractAttachment::class)]
|
||||
public ?array $attachments,
|
||||
#[ArrayOf(AbstractMarkup::class)]
|
||||
public ?array $markup,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
|
||||
|
||||
/**
|
||||
* Dispatches updates to registered handlers. Supports handling specific update types and text commands.
|
||||
*/
|
||||
final class UpdateDispatcher
|
||||
{
|
||||
/**
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
private array $handlers = [];
|
||||
|
||||
/**
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
private array $commandHandlers = [];
|
||||
|
||||
/**
|
||||
* @param Api $api
|
||||
*/
|
||||
public function __construct(private readonly Api $api)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for a specific update type.
|
||||
*
|
||||
* @param UpdateType $type The type of update to handle.
|
||||
* @param callable $handler The function to execute when the update is received.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function addHandler(UpdateType $type, callable $handler): self
|
||||
{
|
||||
$this->handlers[$type->value] = $handler;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for a text command without a command prefix "/" (e.g., "start").
|
||||
* The command must be the first word in a message.
|
||||
*
|
||||
* @param string $command The command string (e.g., "start").
|
||||
* @param callable(MessageCreatedUpdate, Api): void $handler The handler to execute.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function onCommand(string $command, callable $handler): self
|
||||
{
|
||||
$this->commandHandlers[$command] = $handler;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a parsed Update object to its registered handler.
|
||||
* Command handlers are prioritized over generic message handlers.
|
||||
*
|
||||
* @param AbstractUpdate $update The update object to dispatch.
|
||||
*/
|
||||
public function dispatch(AbstractUpdate $update): void
|
||||
{
|
||||
if ($update instanceof MessageCreatedUpdate && $update->message->body?->text) {
|
||||
$text = $update->message->body->text;
|
||||
$parts = explode(' ', trim($text));
|
||||
$command = $parts[0];
|
||||
|
||||
if (isset($this->commandHandlers[$command])) {
|
||||
$this->commandHandlers[$command]($update, $this->api);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$handler = $this->handlers[$update->updateType->value] ?? null;
|
||||
if ($handler) {
|
||||
$handler($update, $this->api);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageCreated, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageCreatedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageCreated(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageCreated, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageCallback, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageCallbackUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageCallback(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageCallback, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageEdited, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageEditedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageEdited(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageEdited, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageRemovedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotAdded, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotAddedToChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotAdded(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotAdded, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotRemovedFromChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::UserAdded, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\UserAddedToChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onUserAdded(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::UserAdded, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::UserRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\UserRemovedFromChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onUserRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::UserRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotStarted, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotStartedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotStarted(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotStarted, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::ChatTitleChanged, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\ChatTitleChangedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onChatTitleChanged(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::ChatTitleChanged, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageChatCreated, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageChatCreatedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageChatCreated(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageChatCreated, $handler);
|
||||
}
|
||||
}
|
||||
+22
-226
@@ -4,201 +4,37 @@ declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* A class designed to process incoming webhook requests from the Max API.
|
||||
* It verifies the request's authenticity, parses it, and dispatches it
|
||||
* to the appropriate registered event handler.
|
||||
* It verifies the request's authenticity, parses it, and uses an UpdateDispatcher
|
||||
* to route it to the appropriate handler.
|
||||
*/
|
||||
final class WebhookHandler
|
||||
final readonly class WebhookHandler
|
||||
{
|
||||
/**
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
private array $handlers = [];
|
||||
|
||||
/**
|
||||
* @param Api $api An instance of the Api to be passed to handlers for immediate responses.
|
||||
* @param ModelFactory $modelFactory An instance of the model factory to create Update objects.
|
||||
* @param string|null $secret The secret key provided during webhook subscription to verify requests.
|
||||
* @param UpdateDispatcher $dispatcher The update dispatcher.
|
||||
* @param ModelFactory $modelFactory The model factory.
|
||||
* @param LoggerInterface $logger PSR LoggerInterface.
|
||||
* @param string|null $secret The secret key for request verification.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly Api $api,
|
||||
private readonly ModelFactory $modelFactory,
|
||||
private readonly ?string $secret = null,
|
||||
private UpdateDispatcher $dispatcher,
|
||||
private ModelFactory $modelFactory,
|
||||
private LoggerInterface $logger,
|
||||
private ?string $secret,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for a specific update type.
|
||||
*
|
||||
* @param UpdateType $type The type of update to handle.
|
||||
* @param callable $handler The function to execute when the update is received.
|
||||
* The handler will receive the specific Update object (e.g., MessageCreatedUpdate) and the Api instance.
|
||||
*
|
||||
* @return WebhookHandler
|
||||
*/
|
||||
public function addHandler(UpdateType $type, callable $handler): self
|
||||
{
|
||||
$this->handlers[$type->value] = $handler;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageCreated, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageCreatedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
*/
|
||||
public function onMessageCreated(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageCreated, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageCallback, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageCallbackUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageCallback(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageCallback, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageEdited, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageEditedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageEdited(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageEdited, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageRemovedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotAdded, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotAddedToChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotAdded(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotAdded, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotRemovedFromChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::UserAdded, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\UserAddedToChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onUserAdded(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::UserAdded, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::UserRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\UserRemovedFromChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onUserRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::UserRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotStarted, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotStartedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotStarted(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotStarted, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::ChatTitleChanged, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\ChatTitleChangedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onChatTitleChanged(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::ChatTitleChanged, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageChatCreated, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageChatCreatedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageChatCreated(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageChatCreated, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an incoming webhook request.
|
||||
* This is the main entry point. It reads the HTTP request body and headers,
|
||||
* verifies the signature, parses the update, and calls the appropriate handler.
|
||||
* It automatically sends the correct HTTP response code.
|
||||
* It reads the HTTP request, verifies, parses, and dispatches the update.
|
||||
*
|
||||
* @param ServerRequestInterface|null $request The Psr7 HTTP request to process.
|
||||
* @param ServerRequestInterface|null $request The PSR-7 HTTP request. If null, created from globals.
|
||||
*
|
||||
* @throws \ReflectionException
|
||||
* @throws SecurityException
|
||||
@@ -206,24 +42,6 @@ final class WebhookHandler
|
||||
* @throws \LogicException
|
||||
*/
|
||||
public function handle(?ServerRequestInterface $request = null): void
|
||||
{
|
||||
$this->dispatch($this->getUpdate($request));
|
||||
|
||||
http_response_code(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw request data and returns a typed Update object.
|
||||
*
|
||||
* @param ServerRequestInterface|null $request The Psr7 HTTP request to process.
|
||||
*
|
||||
* @return AbstractUpdate
|
||||
* @throws \ReflectionException
|
||||
* @throws SecurityException
|
||||
* @throws SerializationException
|
||||
* @throws \LogicException
|
||||
*/
|
||||
public function getUpdate(?ServerRequestInterface $request = null): AbstractUpdate
|
||||
{
|
||||
if ($request === null) {
|
||||
if (!class_exists(\GuzzleHttp\Psr7\ServerRequest::class)) {
|
||||
@@ -235,51 +53,28 @@ final class WebhookHandler
|
||||
$request = \GuzzleHttp\Psr7\ServerRequest::fromGlobals();
|
||||
}
|
||||
|
||||
return $this->parseUpdate($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw request data and returns a typed Update object.
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
*
|
||||
* @return AbstractUpdate
|
||||
* @throws \ReflectionException
|
||||
* @throws SecurityException
|
||||
* @throws SerializationException
|
||||
* @throws \LogicException
|
||||
*/
|
||||
public function parseUpdate(ServerRequestInterface $request): AbstractUpdate
|
||||
{
|
||||
$payload = (string)$request->getBody();
|
||||
$signature = $request->getHeaderLine('X-Max-Bot-Api-Secret');
|
||||
$this->logger->debug('Received webhook payload', ['body' => $payload]);
|
||||
|
||||
if (empty($payload)) {
|
||||
throw new SerializationException('Webhook body is empty.');
|
||||
}
|
||||
|
||||
$this->verifySignature($signature);
|
||||
$this->verifySignature($request->getHeaderLine('X-Max-Bot-Api-Secret'));
|
||||
|
||||
try {
|
||||
$data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
$this->logger->error('Failed to decode webhook JSON', ['payload' => $payload, 'exception' => $e]);
|
||||
throw new SerializationException('Failed to decode webhook body as JSON.', 0, $e);
|
||||
}
|
||||
|
||||
return $this->modelFactory->createUpdate($data);
|
||||
}
|
||||
$update = $this->modelFactory->createUpdate($data);
|
||||
|
||||
/**
|
||||
* Dispatches a parsed Update object to its registered handler.
|
||||
*
|
||||
* @param AbstractUpdate $update
|
||||
*/
|
||||
public function dispatch(AbstractUpdate $update): void
|
||||
{
|
||||
$handler = $this->handlers[$update->updateType->value] ?? null;
|
||||
$this->dispatcher->dispatch($update);
|
||||
|
||||
if ($handler) {
|
||||
$handler($update, $this->api);
|
||||
if (!headers_sent()) {
|
||||
http_response_code(200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +92,7 @@ final class WebhookHandler
|
||||
}
|
||||
|
||||
if (!hash_equals($this->secret, $signature)) {
|
||||
$this->logger->warning('Webhook signature verification failed', ['received_signature' => $signature]);
|
||||
throw new SecurityException('Signature verification failed.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\ClientApiInterface;
|
||||
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
|
||||
#[CoversClass(Api::class)]
|
||||
#[UsesClass(UpdateDispatcher::class)]
|
||||
#[UsesClass(WebhookHandler::class)]
|
||||
#[UsesClass(LongPollingHandler::class)]
|
||||
final class ApiFactoryMethodsTest extends TestCase
|
||||
{
|
||||
private MockObject&ClientApiInterface $clientMock;
|
||||
private MockObject&ModelFactory $modelFactoryMock;
|
||||
private MockObject&LoggerInterface $loggerMock;
|
||||
private Api $api;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clientMock = $this->createMock(ClientApiInterface::class);
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
$this->loggerMock = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->api = new Api(
|
||||
'fake-token',
|
||||
$this->clientMock,
|
||||
$this->modelFactoryMock,
|
||||
$this->loggerMock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createWebhookHandlerReturnsCorrectlyConfiguredInstance(): void
|
||||
{
|
||||
$secret = 'my-test-secret';
|
||||
|
||||
$webhookHandler = $this->api->createWebhookHandler($secret);
|
||||
|
||||
$this->assertInstanceOf(WebhookHandler::class, $webhookHandler);
|
||||
|
||||
$this->assertSame(
|
||||
$this->getPrivateProperty($this->api, 'updateDispatcher'),
|
||||
$this->getPrivateProperty($webhookHandler, 'dispatcher'),
|
||||
);
|
||||
$this->assertSame(
|
||||
$this->getPrivateProperty($this->api, 'modelFactory'),
|
||||
$this->getPrivateProperty($webhookHandler, 'modelFactory'),
|
||||
);
|
||||
$this->assertSame(
|
||||
$this->getPrivateProperty($this->api, 'logger'),
|
||||
$this->getPrivateProperty($webhookHandler, 'logger'),
|
||||
);
|
||||
$this->assertSame(
|
||||
$secret,
|
||||
$this->getPrivateProperty($webhookHandler, 'secret'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createLongPollingHandlerReturnsCorrectlyConfiguredInstance(): void
|
||||
{
|
||||
$longPollingHandler = $this->api->createLongPollingHandler();
|
||||
|
||||
$this->assertInstanceOf(LongPollingHandler::class, $longPollingHandler);
|
||||
|
||||
$this->assertSame(
|
||||
$this->api,
|
||||
$this->getPrivateProperty($longPollingHandler, 'api')
|
||||
);
|
||||
$this->assertSame(
|
||||
$this->getPrivateProperty($this->api, 'updateDispatcher'),
|
||||
$this->getPrivateProperty($longPollingHandler, 'dispatcher')
|
||||
);
|
||||
$this->assertSame(
|
||||
$this->getPrivateProperty($this->api, 'logger'),
|
||||
$this->getPrivateProperty($longPollingHandler, 'logger')
|
||||
);
|
||||
}
|
||||
|
||||
private function getPrivateProperty(object $object, string $propertyName): mixed
|
||||
{
|
||||
$reflection = new ReflectionClass($object);
|
||||
$property = $reflection->getProperty($propertyName);
|
||||
|
||||
return $property->getValue($object);
|
||||
}
|
||||
}
|
||||
+323
-372
@@ -56,8 +56,8 @@ use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
|
||||
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
|
||||
use BushlanovDev\MaxMessengerBot\Models\VideoUrls;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use org\bovigo\vfs\vfsStream;
|
||||
@@ -70,6 +70,7 @@ use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\Exception;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -116,12 +117,15 @@ use RuntimeException;
|
||||
#[UsesClass(ChatPatch::class)]
|
||||
#[UsesClass(VideoAttachmentDetails::class)]
|
||||
#[UsesClass(VideoUrls::class)]
|
||||
#[UsesClass(UpdateDispatcher::class)]
|
||||
final class ApiTest extends TestCase
|
||||
{
|
||||
use PHPMock;
|
||||
|
||||
private MockObject&ClientApiInterface $clientMock;
|
||||
private MockObject&ModelFactory $modelFactoryMock;
|
||||
private MockObject&LoggerInterface $loggerMock;
|
||||
|
||||
private Api $api;
|
||||
|
||||
/**
|
||||
@@ -133,8 +137,9 @@ final class ApiTest extends TestCase
|
||||
|
||||
$this->clientMock = $this->createMock(ClientApiInterface::class);
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
$this->loggerMock = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock);
|
||||
$this->api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock, $this->loggerMock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -331,7 +336,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,
|
||||
@@ -343,9 +348,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())
|
||||
@@ -355,8 +369,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(
|
||||
@@ -411,12 +425,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())
|
||||
@@ -431,8 +450,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(
|
||||
@@ -446,24 +465,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
|
||||
@@ -474,11 +528,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);
|
||||
|
||||
@@ -486,29 +540,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
|
||||
{
|
||||
@@ -609,201 +683,6 @@ final class ApiTest extends TestCase
|
||||
$this->api->getUpdates();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createWebhookHandlerReturnsInstanceWithProvidedSecret(): void
|
||||
{
|
||||
$secret = 'my-test-secret-key';
|
||||
$webhookHandler = $this->api->createWebhookHandler($secret);
|
||||
|
||||
$this->assertInstanceOf(WebhookHandler::class, $webhookHandler);
|
||||
|
||||
$reflection = new ReflectionClass($webhookHandler);
|
||||
|
||||
$apiProperty = $reflection->getProperty('api');
|
||||
$this->assertSame($this->api, $apiProperty->getValue($webhookHandler));
|
||||
|
||||
$factoryProperty = $reflection->getProperty('modelFactory');
|
||||
$this->assertSame($this->modelFactoryMock, $factoryProperty->getValue($webhookHandler));
|
||||
|
||||
$secretProperty = $reflection->getProperty('secret');
|
||||
$this->assertSame($secret, $secretProperty->getValue($webhookHandler));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getWebhookUpdateCreatesHandlerAndReturnsUpdate(): void
|
||||
{
|
||||
$api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock);
|
||||
|
||||
$payload = '{"update_type":"bot_started","timestamp":123,"chat_id":1,"user":{"user_id":1,"first_name":"Test","is_bot":false,"last_activity_time":123}}';
|
||||
$request = new ServerRequest('POST', '/webhook', [], $payload);
|
||||
|
||||
$expectedUpdate = new BotStartedUpdate(
|
||||
123,
|
||||
1,
|
||||
new User(1, 'Test', null, null, false, 123, null, null, null),
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->with(json_decode($payload, true))
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$result = $api->getWebhookUpdate(null, $request);
|
||||
|
||||
$this->assertSame($expectedUpdate, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleWebhooksDispatchesCorrectHandler(): void
|
||||
{
|
||||
$api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock);
|
||||
$secret = 'my-secret';
|
||||
|
||||
$payload = '{"update_type":"message_created","timestamp":123,"message":{"timestamp":1,"body":{"mid":"m1","seq":1},"recipient":{"chat_type":"dialog"}}}';
|
||||
$request = new \GuzzleHttp\Psr7\ServerRequest(
|
||||
'POST',
|
||||
'/webhook',
|
||||
['X-Max-Bot-Api-Secret' => $secret],
|
||||
$payload,
|
||||
);
|
||||
|
||||
$expectedUpdate = new MessageCreatedUpdate(
|
||||
123,
|
||||
Message::fromArray(
|
||||
['timestamp' => 1, 'body' => ['mid' => 'm1', 'seq' => 1], 'recipient' => ['chat_type' => 'dialog']]
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->with(json_decode($payload, true))
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$messageHandlerCallCount = 0;
|
||||
$messageHandlerCapturedUpdate = null;
|
||||
|
||||
$messageHandler = function (MessageCreatedUpdate $update, Api $receivedApi) use (
|
||||
&$messageHandlerCallCount,
|
||||
&$messageHandlerCapturedUpdate,
|
||||
) {
|
||||
$messageHandlerCallCount++;
|
||||
$messageHandlerCapturedUpdate = $update;
|
||||
};
|
||||
|
||||
$botStartedHandlerCallCount = 0;
|
||||
$botStartedHandler = function () use (&$botStartedHandlerCallCount) {
|
||||
$botStartedHandlerCallCount++;
|
||||
};
|
||||
|
||||
$handlers = [
|
||||
UpdateType::MessageCreated->value => $messageHandler,
|
||||
UpdateType::BotStarted->value => $botStartedHandler,
|
||||
];
|
||||
|
||||
$api->handleWebhooks($handlers, $secret, $request);
|
||||
|
||||
$this->assertSame(1, $messageHandlerCallCount, 'MessageCreated handler should be called once.');
|
||||
$this->assertSame(0, $botStartedHandlerCallCount, 'BotStarted handler should not be called.');
|
||||
$this->assertSame($expectedUpdate, $messageHandlerCapturedUpdate);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function processUpdatesBatchDispatchesHandlersAndUpdatesMarker(): void
|
||||
{
|
||||
$messageUpdate = new MessageCreatedUpdate(
|
||||
1,
|
||||
Message::fromArray(
|
||||
['timestamp' => 1, 'body' => ['mid' => 'm1', 'seq' => 1], 'recipient' => ['chat_type' => 'dialog']]
|
||||
),
|
||||
null,
|
||||
);
|
||||
$botStartedUpdate = new BotStartedUpdate(
|
||||
2, 123,
|
||||
User::fromArray(['user_id' => 1, 'first_name' => 'Test', 'is_bot' => false, 'last_activity_time' => 1]),
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
$messageHandlerCallCount = 0;
|
||||
$botStartedHandlerCallCount = 0;
|
||||
$handlers = [
|
||||
UpdateType::MessageCreated->value => function () use (&$messageHandlerCallCount) {
|
||||
$messageHandlerCallCount++;
|
||||
},
|
||||
UpdateType::BotStarted->value => function () use (&$botStartedHandlerCallCount) {
|
||||
$botStartedHandlerCallCount++;
|
||||
},
|
||||
];
|
||||
|
||||
$apiMock = $this->getMockBuilder(Api::class)
|
||||
->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock])
|
||||
->onlyMethods(['getUpdates'])
|
||||
->getMock();
|
||||
|
||||
$apiMock->expects($this->once())
|
||||
->method('getUpdates')
|
||||
->willReturn(new UpdateList([$messageUpdate, $botStartedUpdate], 12345));
|
||||
|
||||
$marker = null;
|
||||
|
||||
$apiMock->processUpdatesBatch($handlers, 90, $marker);
|
||||
|
||||
$this->assertSame(1, $messageHandlerCallCount, 'MessageCreated handler should have been called once.');
|
||||
$this->assertSame(1, $botStartedHandlerCallCount, 'BotStarted handler should have been called once.');
|
||||
$this->assertSame(12345, $marker, 'Marker should have been updated to the new value.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var int Counter for processUpdatesBatch method calls.
|
||||
*/
|
||||
private int $processUpdatesBatchCallCount = 0;
|
||||
|
||||
/**
|
||||
* @throws \Throwable We catch a base \Error, so we need to declare it here.
|
||||
*/
|
||||
#[Test]
|
||||
public function handleUpdatesLoopContinuesAfterException(): void
|
||||
{
|
||||
$this->processUpdatesBatchCallCount = 0;
|
||||
$handlers = [UpdateType::MessageCreated->value => fn() => null];
|
||||
|
||||
$apiMock = $this->getMockBuilder(Api::class)
|
||||
->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock])
|
||||
->onlyMethods(['processUpdatesBatch'])
|
||||
->getMock();
|
||||
|
||||
$apiMock->expects($this->any())
|
||||
->method('processUpdatesBatch')
|
||||
->willReturnCallback(function () {
|
||||
switch ($this->processUpdatesBatchCallCount++) {
|
||||
case 0:
|
||||
return;
|
||||
case 1:
|
||||
throw new \BushlanovDev\MaxMessengerBot\Exceptions\NetworkException("Simulated network error");
|
||||
default:
|
||||
throw new \Error("LoopBreak");
|
||||
}
|
||||
});
|
||||
|
||||
$this->expectOutputRegex('/Network error: Simulated network error/');
|
||||
|
||||
try {
|
||||
$apiMock->handleUpdates($handlers);
|
||||
} catch (\Error $e) {
|
||||
$this->assertSame('LoopBreak', $e->getMessage());
|
||||
$this->assertSame(
|
||||
3,
|
||||
$this->processUpdatesBatchCallCount,
|
||||
'processUpdatesBatch should have been called 3 times.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[RunInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
@@ -821,46 +700,6 @@ final class ApiTest extends TestCase
|
||||
new Api('some-token');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleUpdatesLoopCatchesGenericExceptionAndContinues(): void
|
||||
{
|
||||
$this->processUpdatesBatchCallCount = 0;
|
||||
$handlers = [UpdateType::MessageCreated->value => fn() => null];
|
||||
|
||||
$apiMock = $this->getMockBuilder(Api::class)
|
||||
->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock])
|
||||
->onlyMethods(['processUpdatesBatch'])
|
||||
->getMock();
|
||||
|
||||
$apiMock->expects($this->any())
|
||||
->method('processUpdatesBatch')
|
||||
->willReturnCallback(function () {
|
||||
switch ($this->processUpdatesBatchCallCount++) {
|
||||
case 0:
|
||||
return;
|
||||
case 1:
|
||||
throw new \BushlanovDev\MaxMessengerBot\Exceptions\SerializationException(
|
||||
"Simulated JSON error"
|
||||
);
|
||||
default:
|
||||
throw new \Error("LoopBreak");
|
||||
}
|
||||
});
|
||||
|
||||
$this->expectOutputRegex('/An error occurred: Simulated JSON error/');
|
||||
|
||||
try {
|
||||
$apiMock->handleUpdates($handlers);
|
||||
} catch (\Error $e) {
|
||||
$this->assertSame('LoopBreak', $e->getMessage());
|
||||
$this->assertSame(
|
||||
3,
|
||||
$this->processUpdatesBatchCallCount,
|
||||
'processUpdatesBatch should have been called 3 times, indicating the loop continued after the exception.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadAttachmentThrowsRuntimeExceptionWhenPathIsADirectory(): void
|
||||
{
|
||||
@@ -901,12 +740,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);
|
||||
@@ -915,86 +754,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
|
||||
{
|
||||
@@ -1024,9 +783,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);
|
||||
|
||||
@@ -1058,11 +817,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')
|
||||
@@ -1070,8 +833,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]);
|
||||
@@ -1104,11 +867,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')
|
||||
@@ -1116,8 +882,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]);
|
||||
@@ -1150,11 +916,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')
|
||||
@@ -1162,8 +931,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]);
|
||||
@@ -1195,11 +964,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')
|
||||
@@ -1207,8 +979,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]);
|
||||
@@ -1825,7 +1597,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);
|
||||
}
|
||||
@@ -2210,4 +1982,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');
|
||||
}
|
||||
}
|
||||
|
||||
+251
-22
@@ -28,13 +28,14 @@ use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[CoversClass(Client::class)]
|
||||
final class ClientTest extends TestCase
|
||||
{
|
||||
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;
|
||||
@@ -42,6 +43,7 @@ final class ClientTest extends TestCase
|
||||
private MockObject&RequestInterface $requestMock;
|
||||
private MockObject&ResponseInterface $responseMock;
|
||||
private MockObject&StreamInterface $streamMock;
|
||||
private MockObject&LoggerInterface $loggerMock;
|
||||
|
||||
private Client $client;
|
||||
|
||||
@@ -61,6 +63,7 @@ final class ClientTest extends TestCase
|
||||
$this->requestMock = $this->createMock(RequestInterface::class);
|
||||
$this->responseMock = $this->createMock(ResponseInterface::class);
|
||||
$this->streamMock = $this->createMock(StreamInterface::class);
|
||||
$this->loggerMock = $this->createMock(LoggerInterface::class);
|
||||
|
||||
// Common mock setups
|
||||
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
|
||||
@@ -75,6 +78,7 @@ final class ClientTest extends TestCase
|
||||
$this->streamFactory,
|
||||
self::API_BASE_URL,
|
||||
self::API_VERSION,
|
||||
$this->loggerMock,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,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'];
|
||||
@@ -104,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')
|
||||
@@ -134,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
|
||||
@@ -153,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));
|
||||
@@ -294,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())
|
||||
@@ -309,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]
|
||||
@@ -326,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);
|
||||
}
|
||||
|
||||
@@ -354,26 +403,206 @@ final class ClientTest extends TestCase
|
||||
->with($this->requestMock)
|
||||
->willThrowException($psrException);
|
||||
|
||||
$this->client->upload('http://some.url', 'content', 'file.txt');
|
||||
$this->client->multipartUpload('http://some.url', 'content', 'file.txt');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadThrowsSerializationExceptionOnInvalidJsonResponse(): void
|
||||
public function requestLogsRequestAndResponseOnDebugLevel(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Failed to decode upload server response JSON.');
|
||||
$this->responseMock->method('getStatusCode')->willReturn(200);
|
||||
$this->streamMock->method('__toString')->willReturn('{"success":true}');
|
||||
|
||||
$this->loggerMock
|
||||
->expects($this->exactly(2))
|
||||
->method('debug');
|
||||
|
||||
$this->client->request('GET', '/me');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleErrorResponseLogsWarning(): void
|
||||
{
|
||||
$this->responseMock->method('getStatusCode')->willReturn(404);
|
||||
$this->streamMock->method('__toString')->willReturn('{"code":"not.found","message":"Not Found"}');
|
||||
|
||||
$this->loggerMock
|
||||
->expects($this->once())
|
||||
->method('error')
|
||||
->with('API error response received', $this->anything());
|
||||
|
||||
$this->expectException(NotFoundException::class);
|
||||
|
||||
$this->client->request('GET', '/not/found');
|
||||
}
|
||||
|
||||
#[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('{not-a-valid-json');
|
||||
$this->client->upload('http://some.url', 'content', 'file.txt');
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel\Commands;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\PollingStartCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotManager;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
#[CoversClass(PollingStartCommand::class)]
|
||||
#[UsesClass(MaxBotManager::class)]
|
||||
final class PollingStartCommandTest extends TestCase
|
||||
{
|
||||
private MockObject&MaxBotManager $botManagerMock;
|
||||
private PollingStartCommand $command;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->botManagerMock = $this->createMock(MaxBotManager::class);
|
||||
$this->container->instance(MaxBotManager::class, $this->botManagerMock);
|
||||
$this->container->alias(MaxBotManager::class, 'maxbot.manager');
|
||||
|
||||
$this->command = new PollingStartCommand();
|
||||
$this->command->setLaravel($this->container);
|
||||
|
||||
$application = new ConsoleApplication();
|
||||
$application->add($this->command);
|
||||
$commandInApp = $application->find('maxbot:polling:start');
|
||||
$this->tester = new CommandTester($commandInApp);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleSuccessfullyCallsManagerWithCustomTimeout(): void
|
||||
{
|
||||
$timeout = 60;
|
||||
|
||||
$this->botManagerMock
|
||||
->expects($this->once())
|
||||
->method('startLongPolling')
|
||||
->with($timeout);
|
||||
|
||||
$this->tester->execute(['--timeout' => $timeout]);
|
||||
$this->tester->assertCommandIsSuccessful();
|
||||
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString("Starting long polling with a timeout of $timeout seconds...", $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleSuccessfullyUsesDefaultTimeout(): void
|
||||
{
|
||||
$defaultTimeout = 90;
|
||||
|
||||
$this->botManagerMock
|
||||
->expects($this->once())
|
||||
->method('startLongPolling')
|
||||
->with($defaultTimeout);
|
||||
|
||||
$this->tester->execute([]);
|
||||
$this->tester->assertCommandIsSuccessful();
|
||||
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString(
|
||||
"Starting long polling with a timeout of $defaultTimeout seconds...",
|
||||
$output
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleCatchesExceptionAndLogsError(): void
|
||||
{
|
||||
$exceptionMessage = 'Something went wrong';
|
||||
$exception = new \RuntimeException($exceptionMessage);
|
||||
|
||||
$this->botManagerMock
|
||||
->expects($this->once())
|
||||
->method('startLongPolling')
|
||||
->willThrowException($exception);
|
||||
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with(
|
||||
"Long polling failed to start or crashed: $exceptionMessage",
|
||||
['exception' => $exception],
|
||||
);
|
||||
|
||||
$statusCode = $this->tester->execute([]);
|
||||
|
||||
$this->assertSame(1, $statusCode, 'Command should return a failure exit code.');
|
||||
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString("❌ Long polling failed: $exceptionMessage", $output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel\Commands;
|
||||
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use PHPUnit\Framework\TestCase as TestCaseOriginal;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
abstract class TestCase extends TestCaseOriginal
|
||||
{
|
||||
protected Container $container;
|
||||
protected CommandTester $tester;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->container = new TestApplicationContainer();
|
||||
Container::setInstance($this->container);
|
||||
Facade::setFacadeApplication($this->container);
|
||||
|
||||
$loggerMock = $this->createMock(LoggerInterface::class);
|
||||
$this->container->instance('log', $loggerMock);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Container::setInstance(null);
|
||||
Facade::clearResolvedInstances();
|
||||
|
||||
if (class_exists(\Mockery::class)) {
|
||||
\Mockery::close();
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
}
|
||||
|
||||
class TestApplicationContainer extends Container
|
||||
{
|
||||
public function runningUnitTests(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel\Commands;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookListCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Subscription;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
#[CoversClass(WebhookListCommand::class)]
|
||||
#[UsesClass(Api::class)]
|
||||
#[UsesClass(Subscription::class)]
|
||||
final class WebhookListCommandTest extends TestCase
|
||||
{
|
||||
private MockObject&Api $apiMock;
|
||||
private WebhookListCommand $command;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->apiMock = $this->createMock(Api::class);
|
||||
$this->container->instance(Api::class, $this->apiMock);
|
||||
|
||||
$this->command = new WebhookListCommand();
|
||||
$this->command->setLaravel($this->container);
|
||||
|
||||
$application = new ConsoleApplication();
|
||||
$application->add($this->command);
|
||||
$commandInApp = $application->find('maxbot:webhook:list');
|
||||
$this->tester = new CommandTester($commandInApp);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleDisplaysTableWithActiveSubscriptions(): void
|
||||
{
|
||||
$timestamp = 1678886400; // 2023-03-15 13:20:00 UTC
|
||||
$subscriptions = [
|
||||
new Subscription(
|
||||
'https://example.com/hook1',
|
||||
$timestamp,
|
||||
[UpdateType::MessageCreated, UpdateType::BotStarted],
|
||||
'0.0.6'
|
||||
),
|
||||
new Subscription(
|
||||
'https://example.com/hook2',
|
||||
$timestamp + 3600,
|
||||
null, // Should be rendered as 'all'
|
||||
'0.0.6'
|
||||
),
|
||||
];
|
||||
|
||||
$this->apiMock
|
||||
->expects($this->once())
|
||||
->method('getSubscriptions')
|
||||
->willReturn($subscriptions);
|
||||
|
||||
$this->tester->execute([]);
|
||||
$this->tester->assertCommandIsSuccessful();
|
||||
|
||||
$output = $this->tester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('https://example.com/hook1', $output);
|
||||
$this->assertStringContainsString('https://example.com/hook2', $output);
|
||||
$this->assertStringContainsString('message_created, bot_started', $output);
|
||||
$this->assertStringContainsString('all', $output);
|
||||
$this->assertStringContainsString(date('Y-m-d H:i:s', $timestamp), $output);
|
||||
$this->assertStringContainsString(date('Y-m-d H:i:s', $timestamp + 3600), $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleDisplaysMessageWhenNoSubscriptionsExist(): void
|
||||
{
|
||||
$this->apiMock
|
||||
->expects($this->once())
|
||||
->method('getSubscriptions')
|
||||
->willReturn([]);
|
||||
|
||||
$this->tester->execute([]);
|
||||
$this->tester->assertCommandIsSuccessful();
|
||||
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString('No active webhook subscriptions found.', $output);
|
||||
$this->assertStringNotContainsString('URL', $output, 'Table headers should not be displayed.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleCatchesExceptionAndLogsError(): void
|
||||
{
|
||||
$exceptionMessage = 'API is down';
|
||||
$exception = new \RuntimeException($exceptionMessage);
|
||||
|
||||
$this->apiMock
|
||||
->expects($this->once())
|
||||
->method('getSubscriptions')
|
||||
->willThrowException($exception);
|
||||
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with("Webhook list error: $exceptionMessage", ['exception' => $exception]);
|
||||
|
||||
$statusCode = $this->tester->execute([]);
|
||||
|
||||
$this->assertSame(1, $statusCode, 'Command should return a failure exit code.');
|
||||
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString("❌ Webhook list error: $exceptionMessage", $output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel\Commands;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookSubscribeCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Result;
|
||||
use Illuminate\Contracts\Config\Repository as Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
#[CoversClass(WebhookSubscribeCommand::class)]
|
||||
#[UsesClass(Api::class)]
|
||||
#[UsesClass(Result::class)]
|
||||
final class WebhookSubscribeCommandTest extends TestCase
|
||||
{
|
||||
private MockObject&Api $apiMock;
|
||||
private MockObject&Config $configMock;
|
||||
private WebhookSubscribeCommand $command;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->apiMock = $this->createMock(Api::class);
|
||||
$this->configMock = $this->createMock(Config::class);
|
||||
$this->container->instance(Api::class, $this->apiMock);
|
||||
$this->container->instance(Config::class, $this->configMock);
|
||||
|
||||
$this->command = new WebhookSubscribeCommand();
|
||||
$this->command->setLaravel($this->container);
|
||||
|
||||
$application = new ConsoleApplication();
|
||||
$application->add($this->command);
|
||||
$commandInApp = $application->find('maxbot:webhook:subscribe');
|
||||
$this->tester = new CommandTester($commandInApp);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleSuccessfullySubscribesWithAllOptions(): void
|
||||
{
|
||||
$url = 'https://example.com/webhook';
|
||||
$secret = 'my-super-secret';
|
||||
$types = ['message_created', 'bot_started'];
|
||||
$expectedUpdateTypes = [UpdateType::MessageCreated, UpdateType::BotStarted];
|
||||
|
||||
$this->apiMock
|
||||
->expects($this->once())
|
||||
->method('subscribe')
|
||||
->with($url, $secret, $expectedUpdateTypes)
|
||||
->willReturn(new Result(true, null));
|
||||
|
||||
$this->tester->execute([
|
||||
'url' => $url,
|
||||
'--secret' => $secret,
|
||||
'--types' => $types,
|
||||
]);
|
||||
$this->tester->assertCommandIsSuccessful();
|
||||
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString('✅ Successfully subscribed to webhook!', $output);
|
||||
$this->assertStringContainsString("URL: $url", $output);
|
||||
$this->assertStringContainsString("Secret: ***************", $output);
|
||||
$this->assertStringContainsString("Update types: message_created, bot_started", $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleUsesSecretFromConfigWhenOptionIsNotProvided(): void
|
||||
{
|
||||
$url = 'https://example.com/webhook';
|
||||
$configSecret = 'secret-from-config';
|
||||
|
||||
$this->configMock
|
||||
->expects($this->once())
|
||||
->method('get')
|
||||
->with('maxbot.webhook_secret')
|
||||
->willReturn($configSecret);
|
||||
|
||||
$this->apiMock
|
||||
->expects($this->once())
|
||||
->method('subscribe')
|
||||
->with($url, $configSecret, null)
|
||||
->willReturn(new Result(true, null));
|
||||
|
||||
$this->tester->execute(['url' => $url]);
|
||||
$this->tester->assertCommandIsSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleFailsForInvalidUrl(): void
|
||||
{
|
||||
$this->apiMock->expects($this->never())->method('subscribe');
|
||||
|
||||
$statusCode = $this->tester->execute(['url' => 'not-a-valid-url']);
|
||||
|
||||
$this->assertSame(1, $statusCode);
|
||||
$this->assertStringContainsString('Invalid URL provided.', $this->tester->getDisplay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleFailsForInvalidUpdateType(): void
|
||||
{
|
||||
$this->apiMock->expects($this->never())->method('subscribe');
|
||||
|
||||
$statusCode = $this->tester->execute([
|
||||
'url' => 'https://example.com',
|
||||
'--types' => ['message_created', 'invalid_type'],
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $statusCode);
|
||||
$this->assertStringContainsString('Invalid update type: invalid_type', $this->tester->getDisplay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleDisplaysApiErrorMessageOnFailure(): void
|
||||
{
|
||||
$url = 'https://example.com/webhook';
|
||||
$apiErrorMessage = 'URL is already subscribed';
|
||||
|
||||
$this->apiMock
|
||||
->expects($this->once())
|
||||
->method('subscribe')
|
||||
->willReturn(new Result(false, $apiErrorMessage));
|
||||
|
||||
$statusCode = $this->tester->execute(['url' => $url]);
|
||||
|
||||
$this->assertSame(1, $statusCode);
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString('❌ Failed to subscribe to webhook.', $output);
|
||||
$this->assertStringContainsString("Response: $apiErrorMessage", $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleCatchesExceptionAndLogsError(): void
|
||||
{
|
||||
$url = 'https://example.com/webhook';
|
||||
$exceptionMessage = 'Network error';
|
||||
$exception = new \RuntimeException($exceptionMessage);
|
||||
|
||||
$this->apiMock
|
||||
->expects($this->once())
|
||||
->method('subscribe')
|
||||
->willThrowException($exception);
|
||||
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with("Webhook subscription error: $exceptionMessage", ['exception' => $exception]);
|
||||
|
||||
$statusCode = $this->tester->execute(['url' => $url]);
|
||||
|
||||
$this->assertSame(1, $statusCode);
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString("❌ Webhook subscription error: $exceptionMessage", $output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel\Commands;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookUnsubscribeCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Result;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
#[CoversClass(WebhookUnsubscribeCommand::class)]
|
||||
#[UsesClass(Api::class)]
|
||||
#[UsesClass(Result::class)]
|
||||
final class WebhookUnsubscribeCommandTest extends TestCase
|
||||
{
|
||||
private MockObject&Api $apiMock;
|
||||
private WebhookUnsubscribeCommand $command;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->apiMock = $this->createMock(Api::class);
|
||||
$this->container->instance(Api::class, $this->apiMock);
|
||||
|
||||
$this->command = new WebhookUnsubscribeCommand();
|
||||
$this->command->setLaravel($this->container);
|
||||
|
||||
$application = new ConsoleApplication();
|
||||
$application->add($this->command);
|
||||
$commandInApp = $application->find('maxbot:webhook:unsubscribe');
|
||||
$this->tester = new CommandTester($commandInApp);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleSuccessfullyUnsubscribesWithConfirmationFlag(): void
|
||||
{
|
||||
$url = 'https://example.com/webhook';
|
||||
|
||||
$this->apiMock
|
||||
->expects($this->once())
|
||||
->method('unsubscribe')
|
||||
->with($url)
|
||||
->willReturn(new Result(true, null));
|
||||
|
||||
$this->tester->execute([
|
||||
'url' => $url,
|
||||
'--confirm' => true,
|
||||
]);
|
||||
$this->tester->assertCommandIsSuccessful();
|
||||
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString('✅ Successfully unsubscribed from webhook!', $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleCancelsWhenNotConfirmed(): void
|
||||
{
|
||||
$url = 'https://example.com/webhook';
|
||||
|
||||
$this->apiMock->expects($this->never())->method('unsubscribe');
|
||||
$this->tester->setInputs(['no']);
|
||||
$this->tester->execute(['url' => $url]);
|
||||
$this->tester->assertCommandIsSuccessful();
|
||||
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString('Are you sure you want to unsubscribe', $output);
|
||||
$this->assertStringContainsString('Operation cancelled.', $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleFailsForInvalidUrl(): void
|
||||
{
|
||||
$this->apiMock->expects($this->never())->method('unsubscribe');
|
||||
|
||||
$statusCode = $this->tester->execute(['url' => 'not-a-valid-url']);
|
||||
|
||||
$this->assertSame(1, $statusCode);
|
||||
$this->assertStringContainsString('Invalid URL provided.', $this->tester->getDisplay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleDisplaysApiErrorMessageOnFailure(): void
|
||||
{
|
||||
$url = 'https://example.com/webhook';
|
||||
$apiErrorMessage = 'Subscription not found';
|
||||
|
||||
$this->apiMock
|
||||
->expects($this->once())
|
||||
->method('unsubscribe')
|
||||
->willReturn(new Result(false, $apiErrorMessage));
|
||||
|
||||
$statusCode = $this->tester->execute(['url' => $url, '--confirm' => true]);
|
||||
|
||||
$this->assertSame(1, $statusCode);
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString('❌ Failed to unsubscribe from webhook.', $output);
|
||||
$this->assertStringContainsString("Response: $apiErrorMessage", $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleCatchesExceptionAndLogsError(): void
|
||||
{
|
||||
$url = 'https://example.com/webhook';
|
||||
$exceptionMessage = 'API connection refused';
|
||||
$exception = new \RuntimeException($exceptionMessage);
|
||||
|
||||
$this->apiMock
|
||||
->expects($this->once())
|
||||
->method('unsubscribe')
|
||||
->willThrowException($exception);
|
||||
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with("Webhook unsubscribe error: $exceptionMessage", ['exception' => $exception]);
|
||||
|
||||
$statusCode = $this->tester->execute(['url' => $url, '--confirm' => true]);
|
||||
|
||||
$this->assertSame(1, $statusCode);
|
||||
$output = $this->tester->getDisplay();
|
||||
$this->assertStringContainsString("❌ Webhook unsubscribe error: $exceptionMessage", $output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\ChatType;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotManager;
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Message;
|
||||
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Recipient;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[CoversClass(MaxBotManager::class)]
|
||||
#[UsesClass(Message::class)]
|
||||
#[UsesClass(MessageBody::class)]
|
||||
#[UsesClass(Recipient::class)]
|
||||
#[UsesClass(AbstractUpdate::class)]
|
||||
#[UsesClass(MessageCreatedUpdate::class)]
|
||||
#[UsesClass(UpdateDispatcher::class)]
|
||||
#[UsesClass(WebhookHandler::class)]
|
||||
final class MaxBotManagerTest extends TestCase
|
||||
{
|
||||
private Container $container;
|
||||
private MockObject&Api $apiMock;
|
||||
private MockObject&ModelFactory $modelFactoryMock;
|
||||
private UpdateDispatcher $updateDispatcher;
|
||||
private MaxBotManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->container = new Container();
|
||||
Container::setInstance($this->container);
|
||||
Facade::setFacadeApplication($this->container);
|
||||
|
||||
$this->container->singleton('config', function ($app) {
|
||||
return $app->make(Repository::class);
|
||||
});
|
||||
|
||||
$loggerMock = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->container->instance(LoggerInterface::class, $loggerMock);
|
||||
$this->container->instance('log', $loggerMock);
|
||||
|
||||
$this->apiMock = $this->createMock(Api::class);
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
|
||||
$this->updateDispatcher = new UpdateDispatcher($this->apiMock);
|
||||
|
||||
$this->container->instance(Api::class, $this->apiMock);
|
||||
$this->container->instance('maxbot', $this->apiMock);
|
||||
$this->container->instance(ModelFactory::class, $this->modelFactoryMock);
|
||||
$this->container->instance(UpdateDispatcher::class, $this->updateDispatcher);
|
||||
|
||||
$this->container->singleton(MaxBotManager::class, function ($app) {
|
||||
return new MaxBotManager(
|
||||
$app,
|
||||
$app->make(Api::class),
|
||||
$app->make(UpdateDispatcher::class),
|
||||
);
|
||||
});
|
||||
$this->manager = $this->container->make(MaxBotManager::class);
|
||||
|
||||
Handlers::reset();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Container::setInstance(null);
|
||||
Facade::clearResolvedInstances();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleWebhookReturns200OnSuccess(): void
|
||||
{
|
||||
$webhookHandler = new WebhookHandler(
|
||||
$this->updateDispatcher,
|
||||
$this->modelFactoryMock,
|
||||
$this->container->make(\Psr\Log\LoggerInterface::class),
|
||||
null,
|
||||
);
|
||||
$this->container->instance(WebhookHandler::class, $webhookHandler);
|
||||
|
||||
$request = Request::create('/webhook', 'POST', content: '{"update_type":"message_created"}');
|
||||
|
||||
$realUpdate = new MessageCreatedUpdate(
|
||||
time(),
|
||||
$this->createMinimalMessage(),
|
||||
'ru-RU',
|
||||
);
|
||||
|
||||
$this->modelFactoryMock->method('createUpdate')->willReturn($realUpdate);
|
||||
|
||||
$wasDispatched = false;
|
||||
$this->updateDispatcher->addHandler(UpdateType::MessageCreated, function () use (&$wasDispatched) {
|
||||
$wasDispatched = true;
|
||||
});
|
||||
|
||||
$response = $this->manager->handleWebhook($request);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
$this->assertTrue($wasDispatched, 'The update was not dispatched correctly.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleWebhookReturns403OnSecurityException(): void
|
||||
{
|
||||
$webhookHandler = new WebhookHandler(
|
||||
$this->updateDispatcher,
|
||||
$this->modelFactoryMock,
|
||||
$this->container->make(\Psr\Log\LoggerInterface::class),
|
||||
'real-secret',
|
||||
);
|
||||
$this->container->instance(WebhookHandler::class, $webhookHandler);
|
||||
|
||||
$request = Request::create('/webhook', 'POST', content: '{}');
|
||||
$request->headers->set('X-Max-Bot-Api-Secret', 'wrong-secret');
|
||||
|
||||
$response = $this->manager->handleWebhook($request);
|
||||
|
||||
$this->assertSame(403, $response->getStatusCode());
|
||||
$this->assertJsonStringEqualsJsonString('{"status":"error","message":"Forbidden"}', $response->getContent());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleWebhookReturns400OnSerializationException(): void
|
||||
{
|
||||
$webhookHandler = new WebhookHandler(
|
||||
$this->updateDispatcher,
|
||||
$this->modelFactoryMock,
|
||||
$this->container->make(\Psr\Log\LoggerInterface::class),
|
||||
null
|
||||
);
|
||||
$this->container->instance(WebhookHandler::class, $webhookHandler);
|
||||
|
||||
$request = Request::create('/webhook', 'POST', content: '{invalid-json');
|
||||
|
||||
$response = $this->manager->handleWebhook($request);
|
||||
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
$this->assertJsonStringEqualsJsonString('{"status":"error","message":"Bad Request"}', $response->getContent());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleWebhookReturns500OnGenericException(): void
|
||||
{
|
||||
$webhookHandler = new WebhookHandler(
|
||||
$this->updateDispatcher,
|
||||
$this->modelFactoryMock,
|
||||
$this->container->make(\Psr\Log\LoggerInterface::class),
|
||||
null
|
||||
);
|
||||
$this->container->instance(WebhookHandler::class, $webhookHandler);
|
||||
|
||||
$this->modelFactoryMock->method('createUpdate')->willThrowException(new \Exception('DB error'));
|
||||
|
||||
$request = Request::create('/webhook', 'POST', content: '{"update_type":"message_created"}');
|
||||
|
||||
$response = $this->manager->handleWebhook($request);
|
||||
|
||||
$this->assertSame(500, $response->getStatusCode());
|
||||
$this->assertJsonStringEqualsJsonString(
|
||||
'{"status":"error","message":"Internal Server Error"}',
|
||||
$response->getContent(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveHandlerCanResolveCallable(): void
|
||||
{
|
||||
$wasCalled = false;
|
||||
$callable = function () use (&$wasCalled) {
|
||||
$wasCalled = true;
|
||||
};
|
||||
|
||||
$this->manager->onCommand('test', $callable);
|
||||
$dispatcher = $this->manager->getDispatcher();
|
||||
|
||||
$messageBody = new MessageBody('mid.cmd', 1, 'test', [], []);
|
||||
$message = new Message(time(), new Recipient(ChatType::Dialog, 1, null), $messageBody, null, null, null, null);
|
||||
$update = new MessageCreatedUpdate(time(), $message, null);
|
||||
|
||||
$dispatcher->dispatch($update);
|
||||
|
||||
$this->assertTrue($wasCalled, 'The resolved callable handler was not called.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveHandlerResolvesClassWithHandleMethod(): void
|
||||
{
|
||||
$this->manager->onCommand('test', TestHandlerWithHandleMethod::class);
|
||||
|
||||
$this->dispatchCommand('test');
|
||||
|
||||
$this->assertTrue(Handlers::$wasCalled, 'Handler with handle() method was not resolved and called.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveHandlerResolvesInvokableClassFromContainer(): void
|
||||
{
|
||||
$this->container->bind(TestHandlerWithInvokeMethod::class);
|
||||
|
||||
$this->manager->onCommand('test', TestHandlerWithInvokeMethod::class);
|
||||
|
||||
$this->dispatchCommand('test');
|
||||
|
||||
$this->assertTrue(Handlers::$wasCalled, 'Invokable handler was not resolved and called.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveHandlerResolvesClassAtMethodString(): void
|
||||
{
|
||||
$this->manager->onCommand('test', TestHandlerWithCustomMethod::class . '@custom');
|
||||
|
||||
$this->dispatchCommand('test');
|
||||
|
||||
$this->assertTrue(Handlers::$wasCalled, 'Handler with "Class@method" string was not resolved and called.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveHandlerResolvesBoundClassWithHandleMethod(): void
|
||||
{
|
||||
// Шаг 1: Явно регистрируем класс обработчика в контейнере.
|
||||
// Это гарантирует, что будет выбрана ветка `if ($this->container->bound($handler))`.
|
||||
$this->container->bind(TestHandlerWithHandleMethod::class);
|
||||
|
||||
// Шаг 2: Регистрируем обработчик, используя его имя класса (строку).
|
||||
$this->manager->onCommand('test', TestHandlerWithHandleMethod::class);
|
||||
|
||||
// Шаг 3: Диспетчеризуем команду, которая вызовет обработчик.
|
||||
$this->dispatchCommand('test');
|
||||
|
||||
// Шаг 4: Убеждаемся, что метод `handle` был вызван.
|
||||
$this->assertTrue(
|
||||
Handlers::$wasCalled,
|
||||
'Bound handler with handle() method was not resolved and called via the bound path.'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveHandlerThrowsExceptionForUnresolvableString(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Unable to resolve handler: NonExistentClass');
|
||||
|
||||
$this->manager->onCommand('test', 'NonExistentClass');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resolveHandlerThrowsExceptionForClassWithoutHandleOrInvoke(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage(
|
||||
"Handler class '" . Handlers::class . "' is not callable and doesn't have a handle method."
|
||||
);
|
||||
|
||||
$this->container->bind(Handlers::class);
|
||||
|
||||
$this->manager->onCommand('test', Handlers::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to dispatch a command to the real UpdateDispatcher.
|
||||
*/
|
||||
private function dispatchCommand(string $commandText): void
|
||||
{
|
||||
$dispatcher = $this->manager->getDispatcher();
|
||||
$messageBody = new MessageBody('mid.cmd', 1, $commandText, [], []);
|
||||
$message = new Message(time(), new Recipient(ChatType::Dialog, 1, null), $messageBody, null, null, null, null);
|
||||
$update = new MessageCreatedUpdate(time(), $message, null);
|
||||
|
||||
$dispatcher->dispatch($update);
|
||||
}
|
||||
|
||||
private function createMinimalMessage(): Message
|
||||
{
|
||||
return new Message(
|
||||
time(),
|
||||
new Recipient(ChatType::Dialog, 1, null),
|
||||
new MessageBody('mid.1', 1, 'test', [], []),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Handlers
|
||||
{
|
||||
public static bool $wasCalled = false;
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$wasCalled = false;
|
||||
}
|
||||
}
|
||||
|
||||
class TestHandlerWithHandleMethod
|
||||
{
|
||||
public function handle(MessageCreatedUpdate $update, Api $api): void
|
||||
{
|
||||
Handlers::$wasCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
class TestHandlerWithInvokeMethod
|
||||
{
|
||||
public function __invoke(MessageCreatedUpdate $update, Api $api): void
|
||||
{
|
||||
Handlers::$wasCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
class TestHandlerWithCustomMethod
|
||||
{
|
||||
public function custom(MessageCreatedUpdate $update, Api $api): void
|
||||
{
|
||||
Handlers::$wasCalled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests\Laravel;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Client;
|
||||
use BushlanovDev\MaxMessengerBot\ClientApiInterface;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\PollingStartCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookListCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookSubscribeCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\Commands\WebhookUnsubscribeCommand;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotManager;
|
||||
use BushlanovDev\MaxMessengerBot\Laravel\MaxBotServiceProvider;
|
||||
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use phpmock\phpunit\PHPMock;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use ReflectionClass;
|
||||
|
||||
#[CoversClass(MaxBotServiceProvider::class)]
|
||||
#[UsesClass(Api::class)]
|
||||
#[UsesClass(Client::class)]
|
||||
#[UsesClass(UpdateDispatcher::class)]
|
||||
#[UsesClass(MaxBotManager::class)]
|
||||
#[UsesClass(WebhookHandler::class)]
|
||||
final class MaxBotServiceProviderTest extends TestCase
|
||||
{
|
||||
use PHPMock;
|
||||
|
||||
protected function getEnvironmentSetUp($app): void
|
||||
{
|
||||
$app['config']->set('maxbot.access_token', 'test-token');
|
||||
$app['config']->set('maxbot.webhook_secret', 'test-secret');
|
||||
$app['config']->set('maxbot.base_url', 'https://test.max.ru');
|
||||
$app['config']->set('maxbot.api_version', 'test-version');
|
||||
}
|
||||
|
||||
protected function getPackageProviders($app): array
|
||||
{
|
||||
return [MaxBotServiceProvider::class];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function serviceProviderIsLoaded(): void
|
||||
{
|
||||
$this->assertInstanceOf(MaxBotServiceProvider::class, $this->app->getProvider(MaxBotServiceProvider::class));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionWhenAccessTokenIsMissingForClient(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage(
|
||||
'Max Bot access token is not configured. Please set MAXBOT_ACCESS_TOKEN in your .env file.'
|
||||
);
|
||||
|
||||
$this->app['config']->set('maxbot.access_token', null);
|
||||
|
||||
$this->app->make(ClientApiInterface::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionWhenAccessTokenIsMissingForApi(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage(
|
||||
'Max Bot access token is not configured. Please set MAXBOT_ACCESS_TOKEN in your .env file.'
|
||||
);
|
||||
|
||||
$this->app['config']->set('maxbot.access_token', null);
|
||||
|
||||
$this->app->make(Api::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[RunInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
public function itThrowsExceptionWhenGuzzleIsMissing(): void
|
||||
{
|
||||
error_reporting(E_ALL & ~E_DEPRECATED);
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage(
|
||||
'Guzzle HTTP client is required. Please run "composer require guzzlehttp/guzzle".'
|
||||
);
|
||||
|
||||
$classExistsMock = $this->getFunctionMock('BushlanovDev\\MaxMessengerBot\\Laravel', 'class_exists');
|
||||
$classExistsMock->expects($this->once())->with(\GuzzleHttp\Client::class)->willReturn(false);
|
||||
|
||||
(new MaxBotServiceProvider($this->app))->register();
|
||||
|
||||
$this->app->make(ClientApiInterface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: string, 1: class-string}>
|
||||
*/
|
||||
public static function servicesProvider(): array
|
||||
{
|
||||
return [
|
||||
'Api::class' => [Api::class, Api::class],
|
||||
'maxbot alias' => ['maxbot', Api::class],
|
||||
'maxbot.api alias' => ['maxbot.api', Api::class],
|
||||
'ClientApiInterface::class' => [ClientApiInterface::class, Client::class],
|
||||
'maxbot.client alias' => ['maxbot.client', Client::class],
|
||||
'ModelFactory::class' => [ModelFactory::class, ModelFactory::class],
|
||||
'UpdateDispatcher::class' => [UpdateDispatcher::class, UpdateDispatcher::class],
|
||||
'maxbot.dispatcher alias' => ['maxbot.dispatcher', UpdateDispatcher::class],
|
||||
'WebhookHandler::class' => [WebhookHandler::class, WebhookHandler::class],
|
||||
'maxbot.webhook alias' => ['maxbot.webhook', WebhookHandler::class],
|
||||
'LongPollingHandler::class' => [LongPollingHandler::class, LongPollingHandler::class],
|
||||
'maxbot.polling alias' => ['maxbot.polling', LongPollingHandler::class],
|
||||
'MaxBotManager::class' => [MaxBotManager::class, MaxBotManager::class],
|
||||
'maxbot.manager alias' => ['maxbot.manager', MaxBotManager::class],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('servicesProvider')]
|
||||
public function allServicesAreRegisteredCorrectly(string $service, string $expectedClass): void
|
||||
{
|
||||
$this->assertInstanceOf($expectedClass, $this->app->make($service));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: string}>
|
||||
*/
|
||||
public static function singletonsProvider(): array
|
||||
{
|
||||
return [
|
||||
'Api' => [Api::class],
|
||||
'ClientApiInterface' => [ClientApiInterface::class],
|
||||
'ModelFactory' => [ModelFactory::class],
|
||||
'UpdateDispatcher' => [UpdateDispatcher::class],
|
||||
'MaxBotManager' => [MaxBotManager::class],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('singletonsProvider')]
|
||||
public function servicesAreRegisteredAsSingletons(string $service): void
|
||||
{
|
||||
$instance1 = $this->app->make($service);
|
||||
$instance2 = $this->app->make($service);
|
||||
|
||||
$this->assertSame($instance1, $instance2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clientIsConfiguredCorrectlyFromConfig(): void
|
||||
{
|
||||
/** @var Client $client */
|
||||
$client = $this->app->make(ClientApiInterface::class);
|
||||
$this->assertInstanceOf(Client::class, $client);
|
||||
|
||||
$reflection = new ReflectionClass($client);
|
||||
|
||||
$accessTokenProp = $reflection->getProperty('accessToken');
|
||||
$baseUrlProp = $reflection->getProperty('baseUrl');
|
||||
$apiVersionProp = $reflection->getProperty('apiVersion');
|
||||
|
||||
$this->assertSame('test-token', $accessTokenProp->getValue($client));
|
||||
$this->assertSame('https://test.max.ru', $baseUrlProp->getValue($client));
|
||||
$this->assertSame('test-version', $apiVersionProp->getValue($client));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clientIsConfiguredWithApplicationLoggerWhenLoggingIsEnabled(): void
|
||||
{
|
||||
$this->app['config']->set('maxbot.logging.enabled', true);
|
||||
|
||||
$mockLogger = $this->createMock(LoggerInterface::class);
|
||||
$this->app->instance(LoggerInterface::class, $mockLogger);
|
||||
|
||||
/** @var Client $client */
|
||||
$client = $this->app->make(ClientApiInterface::class);
|
||||
|
||||
$reflection = new ReflectionClass($client);
|
||||
$loggerProp = $reflection->getProperty('logger');
|
||||
$actualLogger = $loggerProp->getValue($client);
|
||||
|
||||
$this->assertSame($mockLogger, $actualLogger);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clientIsConfiguredWithNullLoggerWhenLoggingIsDisabled(): void
|
||||
{
|
||||
$this->app['config']->set('maxbot.logging.enabled', false);
|
||||
|
||||
/** @var Client $client */
|
||||
$client = $this->app->make(ClientApiInterface::class);
|
||||
|
||||
$reflection = new ReflectionClass($client);
|
||||
$loggerProp = $reflection->getProperty('logger');
|
||||
$actualLogger = $loggerProp->getValue($client);
|
||||
|
||||
$this->assertInstanceOf(NullLogger::class, $actualLogger);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function webhookHandlerIsConfiguredWithSecretFromConfig(): void
|
||||
{
|
||||
/** @var WebhookHandler $handler */
|
||||
$handler = $this->app->make(WebhookHandler::class);
|
||||
$this->assertInstanceOf(WebhookHandler::class, $handler);
|
||||
|
||||
$reflection = new ReflectionClass($handler);
|
||||
$secretProp = $reflection->getProperty('secret');
|
||||
|
||||
$this->assertSame('test-secret', $secretProp->getValue($handler));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function apiIsCreatedWithAllDependenciesFromContainer(): void
|
||||
{
|
||||
/** @var Api $api */
|
||||
$api = $this->app->make(Api::class);
|
||||
|
||||
$reflection = new ReflectionClass($api);
|
||||
|
||||
$clientProp = $reflection->getProperty('client');
|
||||
$factoryProp = $reflection->getProperty('modelFactory');
|
||||
$loggerProp = $reflection->getProperty('logger');
|
||||
$dispatcherProp = $reflection->getProperty('updateDispatcher');
|
||||
|
||||
$this->assertSame($this->app->make(ClientApiInterface::class), $clientProp->getValue($api));
|
||||
$this->assertSame($this->app->make(ModelFactory::class), $factoryProp->getValue($api));
|
||||
$this->assertSame($this->app->make(LoggerInterface::class), $loggerProp->getValue($api));
|
||||
$this->assertSame($this->app->make(UpdateDispatcher::class), $dispatcherProp->getValue($api));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: string}>
|
||||
*/
|
||||
public static function commandsProvider(): array
|
||||
{
|
||||
return [
|
||||
'WebhookSubscribeCommand' => [WebhookSubscribeCommand::class, 'maxbot:webhook:subscribe'],
|
||||
'WebhookUnsubscribeCommand' => [WebhookUnsubscribeCommand::class, 'maxbot:webhook:unsubscribe'],
|
||||
'WebhookListCommand' => [WebhookListCommand::class, 'maxbot:webhook:list'],
|
||||
'PollingStartCommand' => [PollingStartCommand::class, 'maxbot:polling:start'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('commandsProvider')]
|
||||
public function bootMethodRegistersCommandsInConsole(string $class, string $signature): void
|
||||
{
|
||||
$commands = Artisan::all();
|
||||
$this->assertArrayHasKey($signature, $commands);
|
||||
$this->assertInstanceOf($class, $commands[$signature]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function providesMethod(): void
|
||||
{
|
||||
$provides = [
|
||||
Api::class,
|
||||
ClientApiInterface::class,
|
||||
ModelFactory::class,
|
||||
UpdateDispatcher::class,
|
||||
WebhookHandler::class,
|
||||
LongPollingHandler::class,
|
||||
MaxBotManager::class,
|
||||
'maxbot',
|
||||
'maxbot.api',
|
||||
'maxbot.client',
|
||||
'maxbot.dispatcher',
|
||||
'maxbot.webhook',
|
||||
'maxbot.polling',
|
||||
'maxbot.manager',
|
||||
];
|
||||
|
||||
$this->assertSame($this->app->getProvider(MaxBotServiceProvider::class)->provides(), $provides);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
|
||||
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\BotStartedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\User;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use Error;
|
||||
use Exception;
|
||||
use phpmock\phpunit\PHPMock;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[CoversClass(LongPollingHandler::class)]
|
||||
#[UsesClass(UpdateDispatcher::class)]
|
||||
#[UsesClass(UpdateList::class)]
|
||||
#[UsesClass(AbstractUpdate::class)]
|
||||
#[UsesClass(BotStartedUpdate::class)]
|
||||
#[UsesClass(User::class)]
|
||||
final class LongPollingHandlerTest extends TestCase
|
||||
{
|
||||
use PHPMock;
|
||||
|
||||
/**
|
||||
* @param AbstractUpdate[] $updatesToReturn
|
||||
* @param int $expectedDispatchCount
|
||||
* @param int|null $expectedMarker
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('processUpdatesProvider')]
|
||||
public function processUpdates(
|
||||
array $updatesToReturn,
|
||||
int $expectedDispatchCount,
|
||||
?int $expectedMarker,
|
||||
): void {
|
||||
$apiMock = $this->createMock(Api::class);
|
||||
$loggerMock = $this->createMock(LoggerInterface::class);
|
||||
$dispatcher = new UpdateDispatcher($apiMock);
|
||||
|
||||
$updateList = new UpdateList($updatesToReturn, $expectedMarker);
|
||||
|
||||
$apiMock->expects($this->once())
|
||||
->method('getUpdates')
|
||||
->with($this->isNull(), $this->equalTo(90), $this->isNull())
|
||||
->willReturn($updateList);
|
||||
|
||||
$dispatchCount = 0;
|
||||
$dispatcher->addHandler(UpdateType::BotStarted, function () use (&$dispatchCount) {
|
||||
$dispatchCount++;
|
||||
});
|
||||
|
||||
$handler = new LongPollingHandler($apiMock, $dispatcher, $loggerMock);
|
||||
|
||||
$returnedMarker = $handler->processUpdates(90, null);
|
||||
|
||||
$this->assertSame(
|
||||
$expectedDispatchCount,
|
||||
$dispatchCount,
|
||||
"Dispatcher should be called $expectedDispatchCount times."
|
||||
);
|
||||
$this->assertSame($expectedMarker, $returnedMarker, 'Method should return the correct marker.');
|
||||
}
|
||||
|
||||
public static function processUpdatesProvider(): array
|
||||
{
|
||||
$user = new User(1, 'Test', null, null, false, time());
|
||||
$update1 = new BotStartedUpdate(time(), 1, $user, null, null);
|
||||
$update2 = new BotStartedUpdate(time(), 2, $user, null, null);
|
||||
|
||||
return [
|
||||
'with two updates' => [
|
||||
'updatesToReturn' => [$update1, $update2],
|
||||
'expectedDispatchCount' => 2,
|
||||
'expectedMarker' => 12345,
|
||||
],
|
||||
'with no updates' => [
|
||||
'updatesToReturn' => [],
|
||||
'expectedDispatchCount' => 0,
|
||||
'expectedMarker' => 54321,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[PreserveGlobalState(false)]
|
||||
#[RunInSeparateProcess]
|
||||
public function runCatchesNetworkExceptionAndSleeps5Seconds(): void
|
||||
{
|
||||
$apiMock = $this->createMock(Api::class);
|
||||
$loggerMock = $this->createMock(LoggerInterface::class);
|
||||
$dispatcher = new UpdateDispatcher($apiMock);
|
||||
|
||||
$sleepMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'sleep');
|
||||
$sleepMock->expects($this->once())->with(5);
|
||||
|
||||
$apiMock->expects($this->exactly(2))
|
||||
->method('getUpdates')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
$this->throwException(new NetworkException('Connection timeout')),
|
||||
$this->throwException(new Error('Stop test loop')),
|
||||
);
|
||||
|
||||
$loggerMock->expects($this->once())
|
||||
->method('error')
|
||||
->with($this->stringContains('Long-polling network error'), $this->anything());
|
||||
|
||||
$handler = new LongPollingHandler($apiMock, $dispatcher, $loggerMock);
|
||||
|
||||
try {
|
||||
$handler->handle();
|
||||
} catch (Error $e) {
|
||||
$this->assertSame('Stop test loop', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[PreserveGlobalState(false)]
|
||||
#[RunInSeparateProcess]
|
||||
public function runCatchesGenericExceptionAndSleeps1Second(): void
|
||||
{
|
||||
$apiMock = $this->createMock(Api::class);
|
||||
$loggerMock = $this->createMock(LoggerInterface::class);
|
||||
$dispatcher = new UpdateDispatcher($apiMock);
|
||||
|
||||
$sleepMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'sleep');
|
||||
$sleepMock->expects($this->once())->with(1);
|
||||
|
||||
$apiMock->expects($this->exactly(2))
|
||||
->method('getUpdates')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
$this->throwException(new Exception('Something went wrong')),
|
||||
$this->throwException(new Error('Stop test loop')),
|
||||
);
|
||||
|
||||
$loggerMock->expects($this->once())
|
||||
->method('error')
|
||||
->with($this->stringContains('An error occurred during long-polling'), $this->anything());
|
||||
|
||||
$handler = new LongPollingHandler($apiMock, $dispatcher, $loggerMock);
|
||||
|
||||
try {
|
||||
$handler->handle();
|
||||
} catch (Error $e) {
|
||||
$this->assertSame('Stop test loop', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function processUpdatesContinuesAndLogsWhenHandlerThrows(): void
|
||||
{
|
||||
$apiMock = $this->createMock(Api::class);
|
||||
$loggerMock = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$dispatcher = new UpdateDispatcher($apiMock, $loggerMock);
|
||||
|
||||
$user = new User(1, 'Test', null, null, false, time());
|
||||
$updateToFail = new BotStartedUpdate(time(), 1, $user, null, null);
|
||||
$updateToSucceed = new BotStartedUpdate(time(), 2, $user, null, null);
|
||||
|
||||
$updateList = new UpdateList([$updateToFail, $updateToSucceed], 12345);
|
||||
$exception = new Exception('Error inside handler');
|
||||
|
||||
$apiMock->expects($this->once())
|
||||
->method('getUpdates')
|
||||
->willReturn($updateList);
|
||||
|
||||
$handlerCallCount = 0;
|
||||
$dispatcher->addHandler(UpdateType::BotStarted, function () use (&$handlerCallCount, $exception) {
|
||||
$currentCall = $handlerCallCount++;
|
||||
if ($currentCall === 0) {
|
||||
throw $exception;
|
||||
}
|
||||
});
|
||||
|
||||
$loggerMock->expects($this->once())
|
||||
->method('error')
|
||||
->with('Error dispatching update', ['message' => 'Error inside handler', 'exception' => $exception]);
|
||||
|
||||
$handler = new LongPollingHandler($apiMock, $dispatcher, $loggerMock);
|
||||
|
||||
$returnedMarker = $handler->processUpdates(90, null);
|
||||
|
||||
$this->assertSame(12345, $returnedMarker, 'Method should return marker even if a handler failed.');
|
||||
$this->assertSame(2, $handlerCallCount, 'Dispatcher should have attempted to process both updates.');
|
||||
}
|
||||
}
|
||||
@@ -220,6 +220,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
|
||||
{
|
||||
|
||||
@@ -29,4 +29,23 @@ final class OpenAppButtonTest extends TestCase
|
||||
|
||||
$this->assertSame($expectedArray, $resultArray);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fromArrayHydratesCorrectly(): void
|
||||
{
|
||||
$data = [
|
||||
'type' => 'open_app',
|
||||
'text' => 'Launch',
|
||||
'web_app' => 'SomeApp',
|
||||
'contact_id' => 456,
|
||||
];
|
||||
|
||||
$button = OpenAppButton::fromArray($data);
|
||||
|
||||
$this->assertInstanceOf(OpenAppButton::class, $button);
|
||||
$this->assertSame(InlineButtonType::OpenApp, $button->type);
|
||||
$this->assertSame('Launch', $button->text);
|
||||
$this->assertSame('SomeApp', $button->webApp);
|
||||
$this->assertSame(456, $button->contactId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,35 @@ declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests\Models;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\AbstractAttachment;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\ContactAttachment;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\ContactAttachmentPayload;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentPayload;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\PhotoAttachment;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Markup\AbstractMarkup;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Markup\StrongMarkup;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Message;
|
||||
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Recipient;
|
||||
use BushlanovDev\MaxMessengerBot\Models\User;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(MessageBody::class)]
|
||||
#[UsesClass(AbstractAttachment::class)]
|
||||
#[UsesClass(ContactAttachment::class)]
|
||||
#[UsesClass(ContactAttachmentPayload::class)]
|
||||
#[UsesClass(PhotoAttachmentPayload::class)]
|
||||
#[UsesClass(PhotoAttachment::class)]
|
||||
#[UsesClass(ModelFactory::class)]
|
||||
#[UsesClass(AbstractMarkup::class)]
|
||||
#[UsesClass(StrongMarkup::class)]
|
||||
#[UsesClass(Message::class)]
|
||||
#[UsesClass(Recipient::class)]
|
||||
#[UsesClass(User::class)]
|
||||
final class MessageBodyTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
@@ -59,4 +82,57 @@ final class MessageBodyTest extends TestCase
|
||||
$this->assertIsArray($array);
|
||||
$this->assertSame($data, $array);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createMessageCorrectlyHydratesComplexMessageBody(): void
|
||||
{
|
||||
$messageData = [
|
||||
'timestamp' => time(),
|
||||
'body' => [
|
||||
'mid' => 'mid.poly.test',
|
||||
'seq' => 200,
|
||||
'text' => 'Message with mixed content',
|
||||
'attachments' => [
|
||||
[
|
||||
'type' => 'contact',
|
||||
'payload' => [
|
||||
'vcf_info' => 'vcf...',
|
||||
'max_info' => [
|
||||
'user_id' => 1111,
|
||||
'first_name' => 'aaaaa',
|
||||
'is_bot' => false,
|
||||
'last_activity_time' => 1754385571000,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'image',
|
||||
'payload' => ['photo_id' => 1, 'token' => 't', 'url' => 'u'],
|
||||
]
|
||||
],
|
||||
'markup' => [
|
||||
['type' => 'strong', 'from' => 0, 'length' => 7],
|
||||
]
|
||||
],
|
||||
'recipient' => ['chat_type' => 'dialog', 'user_id' => 123],
|
||||
];
|
||||
|
||||
$factory = new ModelFactory();
|
||||
$message = $factory->createMessage($messageData);
|
||||
|
||||
$this->assertInstanceOf(Message::class, $message);
|
||||
$this->assertInstanceOf(MessageBody::class, $message->body);
|
||||
|
||||
$attachments = $message->body->attachments;
|
||||
$this->assertIsArray($attachments);
|
||||
$this->assertCount(2, $attachments);
|
||||
$this->assertInstanceOf(ContactAttachment::class, $attachments[0]);
|
||||
$this->assertInstanceOf(PhotoAttachment::class, $attachments[1]);
|
||||
|
||||
$markup = $message->body->markup;
|
||||
$this->assertIsArray($markup);
|
||||
$this->assertCount(1, $markup);
|
||||
$this->assertInstanceOf(StrongMarkup::class, $markup[0]);
|
||||
$this->assertSame(0, $markup[0]->from);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\ChatType;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Message;
|
||||
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Recipient;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\BotStartedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\User;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(UpdateDispatcher::class)]
|
||||
#[UsesClass(User::class)]
|
||||
#[UsesClass(BotStartedUpdate::class)]
|
||||
#[UsesClass(Message::class)]
|
||||
#[UsesClass(MessageBody::class)]
|
||||
#[UsesClass(Recipient::class)]
|
||||
#[UsesClass(MessageCreatedUpdate::class)]
|
||||
final class UpdateDispatcherTest extends TestCase
|
||||
{
|
||||
private Api $apiMock;
|
||||
private UpdateDispatcher $dispatcher;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiMock = $this->createMock(Api::class);
|
||||
$this->dispatcher = new UpdateDispatcher($this->apiMock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function addHandlerAndDispatch(): void
|
||||
{
|
||||
$wasCalled = false;
|
||||
|
||||
$user = new User(100, 'Test', 'User', 'testuser', false, time());
|
||||
$update = new BotStartedUpdate(time(), 12345, $user, null, 'ru-RU');
|
||||
|
||||
$this->dispatcher->addHandler(
|
||||
UpdateType::BotStarted,
|
||||
function ($receivedUpdate, $receivedApi) use (&$wasCalled, $update) {
|
||||
$this->assertSame($update, $receivedUpdate);
|
||||
$this->assertSame($this->apiMock, $receivedApi);
|
||||
$wasCalled = true;
|
||||
}
|
||||
);
|
||||
|
||||
$this->dispatcher->dispatch($update);
|
||||
|
||||
$this->assertTrue($wasCalled, 'Handler for BotStarted update was not called.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function onCommandDispatch(): void
|
||||
{
|
||||
$commandCalled = false;
|
||||
$messageHandlerCalled = false;
|
||||
|
||||
$messageBody = new MessageBody('mid1', 1, '/start with args', null, null);
|
||||
$sender = new User(101, 'Cmd', 'Sender', 'cmdsender', false, time());
|
||||
$recipient = new Recipient(ChatType::Dialog, 101, null);
|
||||
$message = new Message(time(), $recipient, $messageBody, $sender, null, null, null);
|
||||
$update = new MessageCreatedUpdate(time(), $message, 'ru-RU');
|
||||
|
||||
$this->dispatcher->onCommand('/start', function ($receivedUpdate) use (&$commandCalled, $update) {
|
||||
$this->assertSame($update, $receivedUpdate);
|
||||
$commandCalled = true;
|
||||
});
|
||||
|
||||
$this->dispatcher->onMessageCreated(function () use (&$messageHandlerCalled) {
|
||||
$messageHandlerCalled = true;
|
||||
});
|
||||
|
||||
$this->dispatcher->dispatch($update);
|
||||
|
||||
$this->assertTrue($commandCalled, 'onCommand handler was not called.');
|
||||
$this->assertFalse(
|
||||
$messageHandlerCalled,
|
||||
'onMessageCreated handler should not be called when a command matches.',
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function messageWithoutCommandTriggersGenericHandler(): void
|
||||
{
|
||||
$commandCalled = false;
|
||||
$messageHandlerCalled = false;
|
||||
|
||||
$messageBody = new MessageBody('mid2', 2, 'Hello world', null, null);
|
||||
$sender = new User(102, 'Msg', 'Sender', 'msgsender', false, time());
|
||||
$recipient = new Recipient(ChatType::Dialog, 102, null);
|
||||
$message = new Message(time(), $recipient, $messageBody, $sender, null, null, null);
|
||||
$update = new MessageCreatedUpdate(time(), $message, 'en-US');
|
||||
|
||||
$this->dispatcher->onCommand('/start', function () use (&$commandCalled) {
|
||||
$commandCalled = true;
|
||||
});
|
||||
|
||||
$this->dispatcher->onMessageCreated(function ($receivedUpdate) use (&$messageHandlerCalled, $update) {
|
||||
$this->assertSame($update, $receivedUpdate);
|
||||
$messageHandlerCalled = true;
|
||||
});
|
||||
|
||||
$this->dispatcher->dispatch($update);
|
||||
|
||||
$this->assertFalse($commandCalled, 'onCommand handler should not be called for a regular message.');
|
||||
$this->assertTrue($messageHandlerCalled, 'onMessageCreated handler was not called.');
|
||||
}
|
||||
}
|
||||
+90
-166
@@ -6,6 +6,7 @@ namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\ChatType;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
@@ -14,19 +15,24 @@ use BushlanovDev\MaxMessengerBot\Models\MessageBody;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Recipient;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use LogicException;
|
||||
use phpmock\phpunit\PHPMock;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[CoversClass(WebhookHandler::class)]
|
||||
#[UsesClass(UpdateDispatcher::class)]
|
||||
#[UsesClass(Message::class)]
|
||||
#[UsesClass(MessageBody::class)]
|
||||
#[UsesClass(Recipient::class)]
|
||||
@@ -36,229 +42,147 @@ final class WebhookHandlerTest extends TestCase
|
||||
{
|
||||
use PHPMock;
|
||||
|
||||
private const string SECRET = 'my-secret-key';
|
||||
|
||||
private MockObject&Api $apiMock;
|
||||
private MockObject&ModelFactory $modelFactoryMock;
|
||||
private const string SECRET = 'my-super-secret-key';
|
||||
private UpdateDispatcher $dispatcher;
|
||||
private MockObject&LoggerInterface $loggerMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->apiMock = $this->createMock(Api::class);
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
$this->loggerMock = $this->createMock(LoggerInterface::class);
|
||||
$this->dispatcher = new UpdateDispatcher($this->apiMock);
|
||||
}
|
||||
|
||||
private function createValidUpdatePayload(): string
|
||||
private function createValidUpdate(): MessageCreatedUpdate
|
||||
{
|
||||
return json_encode([
|
||||
'update_type' => 'message_created',
|
||||
'timestamp' => 1678886400,
|
||||
'message' => [
|
||||
'timestamp' => 1678886400,
|
||||
'body' => ['mid' => 'm.123', 'seq' => 1, 'text' => 'Hello World'],
|
||||
'recipient' => ['chat_type' => 'dialog', 'chat_id' => 101, 'user_id' => 101],
|
||||
'sender' => null,
|
||||
'url' => null,
|
||||
],
|
||||
'user_locale' => 'ru-RU',
|
||||
]);
|
||||
$messageBody = new MessageBody('m.1', 1, 'Hi', null, null);
|
||||
$recipient = new Recipient(ChatType::Dialog, 1, null);
|
||||
$message = new Message(time(), $recipient, $messageBody, null, null, null, null);
|
||||
|
||||
return new MessageCreatedUpdate(time(), $message, 'ru-RU');
|
||||
}
|
||||
|
||||
private function createRealUpdateObject(array $data): MessageCreatedUpdate
|
||||
private function createMockRequest(string $body, string $signature): ServerRequestInterface
|
||||
{
|
||||
$messageBody = new MessageBody(
|
||||
$data['message']['body']['mid'],
|
||||
$data['message']['body']['seq'],
|
||||
$data['message']['body']['text'],
|
||||
null,
|
||||
null,
|
||||
);
|
||||
$recipient = new Recipient(
|
||||
ChatType::from($data['message']['recipient']['chat_type']),
|
||||
$data['message']['recipient']['user_id'],
|
||||
$data['message']['recipient']['chat_id']
|
||||
);
|
||||
$message = new Message(
|
||||
$data['message']['timestamp'],
|
||||
$recipient,
|
||||
$messageBody,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
$streamMock = $this->createMock(StreamInterface::class);
|
||||
$streamMock->method('__toString')->willReturn($body);
|
||||
|
||||
return new MessageCreatedUpdate(
|
||||
$data['timestamp'],
|
||||
$message,
|
||||
$data['user_locale']
|
||||
);
|
||||
$requestMock = $this->createMock(ServerRequestInterface::class);
|
||||
$requestMock->method('getBody')->willReturn($streamMock);
|
||||
$requestMock->method('getHeaderLine')->with('X-Max-Bot-Api-Secret')->willReturn($signature);
|
||||
|
||||
return $requestMock;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleMethodProcessesPsr7RequestAndDispatches(): void
|
||||
#[DataProvider('successfulRequestProvider')]
|
||||
public function handleSuccessfulRequest(?string $secret, string $signatureHeader): void
|
||||
{
|
||||
$payload = $this->createValidUpdatePayload();
|
||||
$signature = self::SECRET;
|
||||
$payload = '{"update_type":"message_created","timestamp":123}';
|
||||
$updateData = json_decode($payload, true);
|
||||
$expectedUpdate = $this->createRealUpdateObject($updateData);
|
||||
$expectedUpdate = $this->createValidUpdate();
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => $signature], $payload
|
||||
);
|
||||
$request = $this->createMockRequest($payload, $signatureHeader);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
$this->modelFactoryMock->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->with($updateData)
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$handlerWasCalled = false;
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, self::SECRET);
|
||||
$webhookHandler->onMessageCreated(function (AbstractUpdate $update) use (&$handlerWasCalled, $expectedUpdate) {
|
||||
$this->assertSame($expectedUpdate, $update);
|
||||
$this->dispatcher->addHandler(UpdateType::MessageCreated, function () use (&$handlerWasCalled) {
|
||||
$handlerWasCalled = true;
|
||||
});
|
||||
|
||||
$webhookHandler->handle($request);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, $secret);
|
||||
|
||||
$this->assertTrue($handlerWasCalled, 'The registered handler was not dispatched from handle() method.');
|
||||
$handler->handle($request);
|
||||
|
||||
$this->assertTrue($handlerWasCalled, 'Dispatcher was not called on successful request.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dispatchCallsCorrectHandlerForRegisteredEvent(): void
|
||||
public static function successfulRequestProvider(): array
|
||||
{
|
||||
$handlerWasCalled = false;
|
||||
$updateData = json_decode($this->createValidUpdatePayload(), true);
|
||||
$testUpdate = $this->createRealUpdateObject($updateData);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
|
||||
$webhookHandler->onMessageCreated(
|
||||
function (MessageCreatedUpdate $update, Api $api) use (&$handlerWasCalled, $testUpdate) {
|
||||
$this->assertSame($testUpdate, $update);
|
||||
$this->assertSame($this->apiMock, $api);
|
||||
$handlerWasCalled = true;
|
||||
}
|
||||
);
|
||||
|
||||
$webhookHandler->dispatch($testUpdate);
|
||||
|
||||
$this->assertTrue($handlerWasCalled, 'The registered handler for onMessageCreated was not called.');
|
||||
return [
|
||||
'with correct secret' => [self::SECRET, self::SECRET],
|
||||
'with no secret configured' => [null, 'any-signature'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dispatchDoesNothingForUnregisteredEvent(): void
|
||||
public function handleThrowsSecurityExceptionOnInvalidSignature(): void
|
||||
{
|
||||
$updateData = json_decode($this->createValidUpdatePayload(), true);
|
||||
$testUpdate = $this->createRealUpdateObject($updateData);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
|
||||
$webhookHandler->dispatch($testUpdate);
|
||||
|
||||
$this->expectNotToPerformAssertions();
|
||||
$this->expectException(SecurityException::class);
|
||||
$request = $this->createMockRequest('{}', 'wrong-signature');
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
$handler->handle($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForEmptyPayload(): void
|
||||
public function handleLogsWarningOnSignatureFailure(): void
|
||||
{
|
||||
$this->loggerMock->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Webhook signature verification failed', ['received_signature' => 'wrong-signature']);
|
||||
|
||||
$request = $this->createMockRequest('{}', 'wrong-signature');
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
|
||||
try {
|
||||
$handler->handle($request);
|
||||
} catch (SecurityException) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleThrowsSerializationExceptionOnEmptyBody(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Webhook body is empty.');
|
||||
|
||||
$request = new ServerRequest('POST', '/webhook', [], '');
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
$request = $this->createMockRequest('', self::SECRET);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
$handler->handle($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForInvalidJson(): void
|
||||
public function handleThrowsSerializationExceptionOnInvalidJson(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Failed to decode webhook body as JSON.');
|
||||
|
||||
$request = new ServerRequest('POST', '/webhook', [], '{invalid-json');
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForInvalidSignature(): void
|
||||
{
|
||||
$this->expectException(SecurityException::class);
|
||||
$this->expectExceptionMessage('Signature verification failed.');
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => 'wrong-signature'], $this->createValidUpdatePayload()
|
||||
);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, self::SECRET);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function signatureVerificationIsSkippedWhenNoSecretIsConfigured(): void
|
||||
{
|
||||
$payload = $this->createValidUpdatePayload();
|
||||
$updateData = json_decode($payload, true);
|
||||
$expectedUpdate = $this->createRealUpdateObject($updateData);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => 'any-signature-or-empty'], $payload
|
||||
);
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, null);
|
||||
|
||||
$result = $webhookHandler->parseUpdate($request);
|
||||
|
||||
$this->assertSame($expectedUpdate, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getUpdateParsesRequestAndReturnsUpdateObject(): void
|
||||
{
|
||||
$payload = $this->createValidUpdatePayload();
|
||||
$updateData = json_decode($payload, true);
|
||||
$expectedUpdate = $this->createRealUpdateObject($updateData);
|
||||
|
||||
$request = new ServerRequest('POST', '/webhook', [], $payload);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->with($updateData)
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$result = $webhookHandler->getUpdate($request);
|
||||
|
||||
$this->assertSame($expectedUpdate, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleWithoutRequestWhenGuzzleIsPresent(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Webhook body is empty.');
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->handle(null);
|
||||
$request = $this->createMockRequest('{invalid-json', self::SECRET);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
$handler->handle($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[RunInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
public function handleWithoutRequestWhenGuzzleIsMissing(): void
|
||||
public function handleWithoutRequestThrowsLogicExceptionWhenGuzzleIsMissing(): void
|
||||
{
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessageMatches('/No ServerRequest was provided and "guzzlehttp\/psr7" is not found/');
|
||||
|
||||
$classExistsMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'class_exists');
|
||||
$classExistsMock->expects($this->once())
|
||||
->with(\GuzzleHttp\Psr7\ServerRequest::class)
|
||||
->willReturn(false);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, null);
|
||||
$handler->handle(null);
|
||||
}
|
||||
|
||||
$classExistsMock->expects($this->once())->with('GuzzleHttp\Psr7\ServerRequest')->willReturn(false);
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->handle(null);
|
||||
#[Test]
|
||||
public function handleWithoutRequestWhenGuzzleIsPresent(): void
|
||||
{
|
||||
if (!class_exists(\GuzzleHttp\Psr7\ServerRequest::class)) {
|
||||
$this->markTestSkipped('guzzlehttp/psr7 is not installed, cannot run this test.');
|
||||
}
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Webhook body is empty.');
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, null);
|
||||
$handler->handle(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
error_reporting(E_ALL & ~E_DEPRECATED);
|
||||
|
||||
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
||||
Reference in New Issue
Block a user