mirror of
https://github.com/BushlanovDev/max-bot-api-client-php.git
synced 2026-08-19 17:33:04 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 033fa42b79 | |||
| 94ec907506 | |||
| 869c254275 | |||
| cb8ad3f738 | |||
| 0f007bf94e | |||
| 3127f86c54 | |||
| ed715af3bc | |||
| 008d6f4e0e | |||
| 4784fcc97b | |||
| d2aedc090f | |||
| 92c8f4cd94 | |||
| fa7875a670 | |||
| 70c290ce0a | |||
| de3a67bee7 | |||
| b58dc8b2ed | |||
| 00da3af39d | |||
| ea1947239e |
@@ -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 |
@@ -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
-64
@@ -1,67 +1,78 @@
|
||||
{
|
||||
"name": "bushlanov-dev/max-bot-api-client-php",
|
||||
"description": "Max Bot API Client library",
|
||||
"keywords": ["max messenger", "bot", "max", "api", "max bot", "laravel", "laravel max bot"],
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Aleksandr Bushlanov",
|
||||
"email": "alex@bushlanov.dev",
|
||||
"homepage": "https://bushlanov.dev",
|
||||
"role": "Developer"
|
||||
"name": "bushlanov-dev/max-bot-api-client-php",
|
||||
"description": "Max Bot API Client library",
|
||||
"keywords": [
|
||||
"max messenger",
|
||||
"bot",
|
||||
"max",
|
||||
"api",
|
||||
"max bot",
|
||||
"laravel",
|
||||
"laravel max bot"
|
||||
],
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Aleksandr Bushlanov",
|
||||
"email": "alex@bushlanov.dev",
|
||||
"homepage": "https://bushlanov.dev",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.3",
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/guzzle": "^6.5.8||^7.0",
|
||||
"guzzlehttp/psr7": "^1.8||^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/http-message": "^1.0||^2.0",
|
||||
"psr/log": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.77",
|
||||
"bushlanov-dev/php-coverage-badger": "^2.1",
|
||||
"laravel/framework": "^11.0",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench": "^9.0",
|
||||
"php-mock/php-mock-phpunit": "^2.13",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"roave/security-advisories": "dev-latest"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BushlanovDev\\MaxMessengerBot\\": "src"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"BushlanovDev\\MaxMessengerBot\\Tests\\": "tests"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"BushlanovDev\\MaxMessengerBot\\Laravel\\MaxBotServiceProvider"
|
||||
],
|
||||
"aliases": {
|
||||
"MaxBot": "BushlanovDev\\MaxMessengerBot\\Laravel\\MaxBotFacade"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"analyse": "vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=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 --square clover.xml .github/badge-coverage.svg"
|
||||
]
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.3",
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/guzzle": "^6.5.8||^7.0",
|
||||
"guzzlehttp/psr7": "^1.8||^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/http-message": "^1.0||^2.0",
|
||||
"psr/log": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.77",
|
||||
"jaschilz/php-coverage-badger": "^2.0",
|
||||
"laravel/framework": "^11.0",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench": "^9.0",
|
||||
"php-mock/php-mock-phpunit": "^2.13",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"roave/security-advisories": "dev-latest"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BushlanovDev\\MaxMessengerBot\\": "src"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"BushlanovDev\\MaxMessengerBot\\Tests\\": "tests"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"BushlanovDev\\MaxMessengerBot\\Laravel\\MaxBotServiceProvider"
|
||||
],
|
||||
"aliases": {
|
||||
"MaxBot": "BushlanovDev\\MaxMessengerBot\\Laravel\\MaxBotFacade"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"analyse": "vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=256M",
|
||||
"format": "vendor/bin/php-cs-fixer fix --allow-risky=yes src",
|
||||
"test": "vendor/bin/phpunit",
|
||||
"test-coverage": "vendor/bin/phpunit --coverage-html coverage",
|
||||
"create-coverage-badge": ["vendor/bin/phpunit --coverage-clover clover.xml", "vendor/bin/php-coverage-badger clover.xml badge-coverage.svg"]
|
||||
}
|
||||
}
|
||||
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
- [Быстрый старт](#Быстрый-старт)
|
||||
- [Получение токена](#Получение-токена)
|
||||
- [Установка библиотеки](#Установка-библиотеки)
|
||||
- [Установка библиотеки в Laravel](#Установка-библиотеки-в-Laravel)
|
||||
- [Инициализация бота](#Инициализация-бота)
|
||||
- [Информация о боте](#Информация-о-боте)
|
||||
- `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-кнопки)
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
> Если вы новичок, то можете прочитать [официальную документацию](https://dev.max.ru/), написанную разработчиками Max.
|
||||
|
||||
### Получение токена
|
||||
|
||||
Откройте диалог с [MasterBot](https://max.ru/MasterBot), следуйте инструкциям и создайте нового бота. После создания
|
||||
бота MasterBot отправит вам токен.
|
||||
|
||||
### Установка библиотеки
|
||||
|
||||
```bash
|
||||
composer require bushlanov-dev/max-bot-api-client-php
|
||||
```
|
||||
|
||||
### Установка библиотеки в Laravel
|
||||
|
||||
Пользователи Laravel могут зарегистрировать сервис провайдер и фасад в `config/app.php`:
|
||||
|
||||
```php
|
||||
'providers' => [
|
||||
// ...
|
||||
BushlanovDev\MaxMessengerBot\Laravel\MaxBotServiceProvider::class,
|
||||
],
|
||||
// ...
|
||||
'aliases' => [
|
||||
// ...
|
||||
'MaxBot' => BushlanovDev\MaxMessengerBot\Laravel\MaxBotFacade::class,
|
||||
],
|
||||
```
|
||||
|
||||
При не необходимости опубликовать конфиг выполните следующую команду
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --provider="BushlanovDev\MaxMessengerBot\Laravel\MaxBotServiceProvider"
|
||||
```
|
||||
|
||||
Для работы вам потребуется внести следующие настройки в `.env`
|
||||
|
||||
```env
|
||||
MAXBOT_ACCESS_TOKEN=your_bot_access_token_here
|
||||
MAXBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
MAXBOT_LOGGING_ENABLED=true
|
||||
```
|
||||
|
||||
### Инициализация бота
|
||||
|
||||
Единственной обязательной настройкой является токен вашего бота.
|
||||
⚠️ Никогда, и ни при каких обстоятельствах не храните токен в коде. ⚠️
|
||||
Используйте переменные окружения!
|
||||
|
||||
```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, // Заполните это, если хотите просто отправить одноразовое уведомление пользователю (необязательно)
|
||||
);
|
||||
```
|
||||
+3264
File diff suppressed because it is too large
Load Diff
+83
-24
@@ -33,6 +33,7 @@ use BushlanovDev\MaxMessengerBot\Models\UpdateList;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
|
||||
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
use LogicException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
@@ -47,7 +48,7 @@ use RuntimeException;
|
||||
*/
|
||||
class Api
|
||||
{
|
||||
public const string LIBRARY_VERSION = '1.0.0';
|
||||
public const string LIBRARY_VERSION = '1.2.1';
|
||||
|
||||
public const string API_VERSION = '0.0.6';
|
||||
|
||||
@@ -74,6 +75,8 @@ class Api
|
||||
private const string ACTION_ANSWERS = '/answers';
|
||||
private const string ACTION_VIDEO_DETAILS = '/videos/%s';
|
||||
|
||||
private const int RESUMABLE_UPLOAD_THRESHOLD_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
private readonly ClientApiInterface $client;
|
||||
|
||||
private readonly ModelFactory $modelFactory;
|
||||
@@ -85,21 +88,23 @@ class Api
|
||||
/**
|
||||
* Api constructor.
|
||||
*
|
||||
* @param string $accessToken Your bot's access token from @MasterBot.
|
||||
* @param string|null $accessToken Your bot's access token from @MasterBot.
|
||||
* @param ClientApiInterface|null $client Http api client.
|
||||
* @param ModelFactory|null $modelFactory The model factory.
|
||||
* @param LoggerInterface|null $logger PSR LoggerInterface.
|
||||
* @param UpdateDispatcher|null $updateDispatcher The update dispatcher.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function __construct(
|
||||
string $accessToken,
|
||||
?string $accessToken = null,
|
||||
?ClientApiInterface $client = null,
|
||||
?ModelFactory $modelFactory = null,
|
||||
?LoggerInterface $logger = null,
|
||||
?UpdateDispatcher $updateDispatcher = null,
|
||||
) {
|
||||
if (empty($accessToken) && $client === null) {
|
||||
throw new InvalidArgumentException('You must provide either an access token or a client.');
|
||||
}
|
||||
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
|
||||
if ($client === null) {
|
||||
@@ -130,7 +135,7 @@ class Api
|
||||
|
||||
$this->client = $client;
|
||||
$this->modelFactory = $modelFactory ?? new ModelFactory();
|
||||
$this->updateDispatcher = $updateDispatcher ?? new UpdateDispatcher($this);
|
||||
$this->updateDispatcher = new UpdateDispatcher($this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,6 +445,35 @@ class Api
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to the specified URL.
|
||||
*
|
||||
* @param string $uploadUrl The target URL for the upload.
|
||||
* @param resource $fileHandle A stream resource pointing to the file.
|
||||
* @param string $fileName The desired file name for the upload.
|
||||
*
|
||||
* @return string The body of the final response from the server.
|
||||
* @throws ClientApiException
|
||||
* @throws NetworkException
|
||||
* @throws SerializationException
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function uploadFile(string $uploadUrl, mixed $fileHandle, string $fileName): string
|
||||
{
|
||||
$stat = fstat($fileHandle);
|
||||
if (!is_array($stat)) {
|
||||
throw new RuntimeException('File handle is not a valid resource.');
|
||||
}
|
||||
|
||||
rewind($fileHandle);
|
||||
|
||||
if ($stat['size'] < self::RESUMABLE_UPLOAD_THRESHOLD_BYTES) {
|
||||
return $this->client->multipartUpload($uploadUrl, $fileHandle, $fileName);
|
||||
}
|
||||
|
||||
return $this->client->resumableUpload($uploadUrl, $fileHandle, $fileName, $stat['size']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A simplified method for uploading a file and getting the resulting attachment object.
|
||||
*
|
||||
@@ -468,27 +502,52 @@ class Api
|
||||
|
||||
$uploadEndpoint = $this->getUploadUrl($type);
|
||||
|
||||
$uploadResult = $this->client->upload(
|
||||
$uploadEndpoint->url,
|
||||
$fileHandle,
|
||||
basename($filePath),
|
||||
);
|
||||
// For audio and video, the token is received *before* the upload
|
||||
// The actual upload response is not JSON and can be ignored on success
|
||||
if ($type === UploadType::Audio || $type === UploadType::Video) {
|
||||
if (empty($uploadEndpoint->token)) {
|
||||
throw new SerializationException(
|
||||
"API did not return a pre-upload token for type '$type->value'."
|
||||
);
|
||||
}
|
||||
|
||||
fclose($fileHandle);
|
||||
$this->uploadFile($uploadEndpoint->url, $fileHandle, basename($filePath));
|
||||
fclose($fileHandle);
|
||||
|
||||
if (!isset($uploadResult['token'])) {
|
||||
throw new SerializationException('Could not find "token" in upload server response.');
|
||||
return match ($type) {
|
||||
UploadType::Audio => new AudioAttachmentRequest($uploadEndpoint->token),
|
||||
UploadType::Video => new VideoAttachmentRequest($uploadEndpoint->token),
|
||||
};
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
UploadType::Image => PhotoAttachmentRequest::fromToken($uploadResult['token']),
|
||||
UploadType::Video => new VideoAttachmentRequest($uploadResult['token']),
|
||||
UploadType::Audio => new AudioAttachmentRequest($uploadResult['token']),
|
||||
UploadType::File => new FileAttachmentRequest($uploadResult['token']), // @phpstan-ignore-line
|
||||
default => throw new LogicException(
|
||||
"Attachment creation for type '$type->value' is not yet implemented."
|
||||
),
|
||||
};
|
||||
// For images and files, the token is in the response *after* the upload.
|
||||
$responseBody = $this->uploadFile($uploadEndpoint->url, $fileHandle, basename($filePath));
|
||||
fclose($fileHandle);
|
||||
|
||||
try {
|
||||
$uploadResult = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $e) {
|
||||
throw new SerializationException('Failed to decode upload server response JSON.', 0, $e);
|
||||
}
|
||||
|
||||
// Using switch because match expression arms cannot be code blocks.
|
||||
switch ($type) {
|
||||
case UploadType::Image:
|
||||
$photoData = current($uploadResult['photos'] ?? []); // Get first photo from response
|
||||
if (!isset($photoData['token'])) {
|
||||
throw new SerializationException('Could not find "token" in photo upload response.');
|
||||
}
|
||||
return PhotoAttachmentRequest::fromToken($photoData['token']);
|
||||
case UploadType::File:
|
||||
if (!isset($uploadResult['token'])) {
|
||||
throw new SerializationException('Could not find "token" in file upload response.');
|
||||
}
|
||||
return new FileAttachmentRequest($uploadResult['token']);
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
throw new LogicException("Attachment creation for type '$type->value' is not yet implemented."); // @phpstan-ignore-line
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -872,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(
|
||||
|
||||
+64
-6
@@ -21,6 +21,7 @@ use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* The low-level HTTP client responsible for communicating with the Max Bot API.
|
||||
@@ -122,7 +123,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();
|
||||
@@ -152,13 +153,70 @@ 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}");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace BushlanovDev\MaxMessengerBot;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use RuntimeException;
|
||||
|
||||
interface ClientApiInterface
|
||||
{
|
||||
@@ -32,10 +33,33 @@ interface ClientApiInterface
|
||||
* @param resource|string $fileContents File content (stream resource or string).
|
||||
* @param string $fileName The name of the file that will be sent to the server.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
* @return string The raw response body from the upload server.
|
||||
* @throws ClientApiException
|
||||
* @throws NetworkException
|
||||
* @throws SerializationException
|
||||
*/
|
||||
public function upload(string $uri, mixed $fileContents, string $fileName): array;
|
||||
public function multipartUpload(string $uri, mixed $fileContents, string $fileName): string;
|
||||
|
||||
/**
|
||||
* Uploads a file in chunks using the resumable upload method.
|
||||
* The caller is responsible for opening and closing the file resource.
|
||||
*
|
||||
* @param string $uploadUrl The target URL for the upload.
|
||||
* @param resource $fileResource A stream resource pointing to the file.
|
||||
* @param string $fileName The desired file name for the upload.
|
||||
* @param int<1, max> $fileSize The total size of the file in bytes.
|
||||
* @param int<1, max> $chunkSize The size of each chunk in bytes.
|
||||
*
|
||||
* @return string The body of the final response from the server.
|
||||
* @throws NetworkException
|
||||
* @throws ClientApiException
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function resumableUpload(
|
||||
string $uploadUrl,
|
||||
$fileResource,
|
||||
string $fileName,
|
||||
int $fileSize,
|
||||
int $chunkSize = 1048576,
|
||||
): string;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ use Illuminate\Support\Facades\Facade;
|
||||
* @method static Result pinMessage(int $chatId, string $messageId, bool $notify = true)
|
||||
* @method static ChatMembersList getAdmins(int $chatId)
|
||||
* @method static ChatMembersList getMembers(int $chatId, ?array<int> $userIds = null, ?int $marker = null, ?int $count = null)
|
||||
* @method static Result deleteAdmins(int $chatId, int $userId)
|
||||
* @method static Result deleteAdmin(int $chatId, int $userId)
|
||||
* @method static Result deleteMember(int $chatId, int $userId, bool $block = false)
|
||||
* @method static Result addAdmins(int $chatId, array<ChatAdmin> $admins)
|
||||
* @method static Result addMembers(int $chatId, array<int> $userIds)
|
||||
|
||||
@@ -102,7 +102,6 @@ class MaxBotServiceProvider extends ServiceProvider
|
||||
$app->make(ClientApiInterface::class),
|
||||
$app->make(ModelFactory::class),
|
||||
$app->make(LoggerInterface::class),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -35,15 +35,11 @@ final class ApiFactoryMethodsTest extends TestCase
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
$this->loggerMock = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$apiForDispatcher = $this->createMock(Api::class);
|
||||
$dispatcher = new UpdateDispatcher($apiForDispatcher);
|
||||
|
||||
$this->api = new Api(
|
||||
'fake-token',
|
||||
$this->clientMock,
|
||||
$this->modelFactoryMock,
|
||||
$this->loggerMock,
|
||||
$dispatcher,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+264
-110
@@ -451,24 +451,59 @@ final class ApiTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadAttachmentSuccessfullyUploadsImageAndReturnsAttachment(): void
|
||||
public function uploadAttachmentForImage(): void
|
||||
{
|
||||
$filePath = tempnam(sys_get_temp_dir(), 'test_upload_');
|
||||
file_put_contents($filePath, 'fake-image-content');
|
||||
$filePath = $this->createTempFile('image-content');
|
||||
$uploadUrl = 'https://upload.server/image';
|
||||
$uploadResponseJson = '{"photos":{"random_key_123":{"token":"final_image_token"}}}';
|
||||
$expectedAttachment = PhotoAttachmentRequest::fromToken('final_image_token');
|
||||
|
||||
$uploadType = UploadType::Image;
|
||||
$uploadUrl = 'https://upload.server/gohere';
|
||||
$uploadToken = 'FINAL_TOKEN_123';
|
||||
$this->clientMock->method('request')->willReturn(['url' => $uploadUrl]);
|
||||
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl));
|
||||
|
||||
$getUploadUrlResponse = ['url' => $uploadUrl];
|
||||
$uploadResponse = ['token' => $uploadToken];
|
||||
$expectedEndpoint = new UploadEndpoint($uploadUrl);
|
||||
$expectedAttachment = PhotoAttachmentRequest::fromToken($uploadToken);
|
||||
$this->clientMock->method('multipartUpload')->willReturn($uploadResponseJson);
|
||||
|
||||
$result = $this->api->uploadAttachment(UploadType::Image, $filePath);
|
||||
|
||||
$this->assertEquals($expectedAttachment, $result);
|
||||
unlink($filePath);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadAttachmentForFile(): void
|
||||
{
|
||||
$filePath = $this->createTempFile('file-content');
|
||||
$uploadUrl = 'https://upload.server/file';
|
||||
$uploadResponseJson = '{"token":"final_file_token"}';
|
||||
$expectedAttachment = new FileAttachmentRequest('final_file_token');
|
||||
|
||||
$this->clientMock->method('request')->willReturn(['url' => $uploadUrl]);
|
||||
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn(new UploadEndpoint($uploadUrl));
|
||||
|
||||
$this->clientMock->method('multipartUpload')->willReturn($uploadResponseJson);
|
||||
|
||||
$result = $this->api->uploadAttachment(UploadType::File, $filePath);
|
||||
|
||||
$this->assertEquals($expectedAttachment, $result);
|
||||
unlink($filePath);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadAttachmentForAudio(): void
|
||||
{
|
||||
$filePath = $this->createTempFile('audio-content');
|
||||
$uploadUrl = 'https://upload.server/audio';
|
||||
$preUploadToken = 'pre_upload_audio_token';
|
||||
$uploadResponse = '<retval>1</retval>';
|
||||
$expectedAttachment = new AudioAttachmentRequest($preUploadToken);
|
||||
|
||||
$getUploadUrlResponse = ['url' => $uploadUrl, 'token' => $preUploadToken];
|
||||
$expectedEndpoint = new UploadEndpoint($uploadUrl, $preUploadToken);
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('request')
|
||||
->with('POST', '/uploads', ['type' => $uploadType->value])
|
||||
->with('POST', '/uploads', ['type' => UploadType::Audio->value])
|
||||
->willReturn($getUploadUrlResponse);
|
||||
|
||||
$this->modelFactoryMock
|
||||
@@ -479,11 +514,11 @@ final class ApiTest extends TestCase
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('upload')
|
||||
->method('multipartUpload')
|
||||
->with($uploadUrl, $this->isResource(), basename($filePath))
|
||||
->willReturn($uploadResponse);
|
||||
|
||||
$result = $this->api->uploadAttachment($uploadType, $filePath);
|
||||
$result = $this->api->uploadAttachment(UploadType::Audio, $filePath);
|
||||
|
||||
$this->assertEquals($expectedAttachment, $result);
|
||||
|
||||
@@ -491,29 +526,49 @@ final class ApiTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadAttachmentForMultiplePhotosReturnsCorrectAttachment(): void
|
||||
public function uploadAttachmentForVideo(): void
|
||||
{
|
||||
$filePath = tempnam(sys_get_temp_dir(), 'test_');
|
||||
file_put_contents($filePath, 'content');
|
||||
$filePath = $this->createTempFile('video-content');
|
||||
$uploadUrl = 'https://upload.server/video';
|
||||
$preUploadToken = 'pre_upload_video_token';
|
||||
$uploadResponse = '<retval>1</retval>';
|
||||
$expectedAttachment = new VideoAttachmentRequest($preUploadToken);
|
||||
|
||||
$getUploadUrlResponse = ['url' => 'http://upload.server'];
|
||||
$expectedEndpoint = new UploadEndpoint('http://upload.server');
|
||||
$getUploadUrlResponse = ['url' => $uploadUrl, 'token' => $preUploadToken];
|
||||
$expectedEndpoint = new UploadEndpoint($uploadUrl, $preUploadToken);
|
||||
|
||||
$uploadResponse = ['token' => 'token'];
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('request')
|
||||
->with('POST', '/uploads', ['type' => UploadType::Video->value])
|
||||
->willReturn($getUploadUrlResponse);
|
||||
|
||||
$expectedAttachment = PhotoAttachmentRequest::fromToken('token');
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUploadEndpoint')
|
||||
->with($getUploadUrlResponse)
|
||||
->willReturn($expectedEndpoint);
|
||||
|
||||
$this->clientMock->method('request')->willReturn($getUploadUrlResponse);
|
||||
$this->modelFactoryMock->method('createUploadEndpoint')->willReturn($expectedEndpoint);
|
||||
$this->clientMock->method('upload')->willReturn($uploadResponse);
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('multipartUpload')
|
||||
->with($uploadUrl, $this->isResource(), basename($filePath))
|
||||
->willReturn($uploadResponse);
|
||||
|
||||
$result = $this->api->uploadAttachment(UploadType::Image, $filePath);
|
||||
$result = $this->api->uploadAttachment(UploadType::Video, $filePath);
|
||||
|
||||
$this->assertEquals($expectedAttachment, $result);
|
||||
|
||||
unlink($filePath);
|
||||
}
|
||||
|
||||
private function createTempFile(string $content): string
|
||||
{
|
||||
$filePath = tempnam(sys_get_temp_dir(), 'test_upload_');
|
||||
file_put_contents($filePath, $content);
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadAttachmentThrowsExceptionForNonExistentFile(): void
|
||||
{
|
||||
@@ -671,12 +726,12 @@ final class ApiTest extends TestCase
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('upload')
|
||||
->method('multipartUpload')
|
||||
->with($uploadUrl, $this->isResource(), basename($filePath))
|
||||
->willReturn($invalidUploadResponse);
|
||||
->willReturn(json_encode($invalidUploadResponse));
|
||||
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Could not find "token" in upload server response.');
|
||||
$this->expectExceptionMessage('Could not find "token" in photo upload response.');
|
||||
|
||||
try {
|
||||
$this->api->uploadAttachment($uploadType, $filePath);
|
||||
@@ -685,86 +740,6 @@ final class ApiTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadAttachmentSuccessfullyUploadsVideoAndReturnsAttachment(): void
|
||||
{
|
||||
$filePath = tempnam(sys_get_temp_dir(), 'test_video_');
|
||||
file_put_contents($filePath, 'fake-video-content');
|
||||
|
||||
$uploadType = UploadType::Video;
|
||||
$uploadUrl = 'https://upload.server/video_path';
|
||||
$uploadToken = 'VIDEO_TOKEN_XYZ';
|
||||
|
||||
$getUploadUrlResponse = ['url' => $uploadUrl];
|
||||
$uploadResponse = ['token' => $uploadToken];
|
||||
$expectedEndpoint = new UploadEndpoint($uploadUrl);
|
||||
$expectedAttachment = new VideoAttachmentRequest($uploadToken);
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('request')
|
||||
->with('POST', '/uploads', ['type' => $uploadType->value])
|
||||
->willReturn($getUploadUrlResponse);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUploadEndpoint')
|
||||
->with($getUploadUrlResponse)
|
||||
->willReturn($expectedEndpoint);
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('upload')
|
||||
->with($uploadUrl, $this->isResource(), basename($filePath))
|
||||
->willReturn($uploadResponse);
|
||||
|
||||
$result = $this->api->uploadAttachment($uploadType, $filePath);
|
||||
|
||||
$this->assertEquals($expectedAttachment, $result);
|
||||
|
||||
unlink($filePath);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadAttachmentSuccessfullyUploadsAudioAndReturnsAttachment(): void
|
||||
{
|
||||
$filePath = tempnam(sys_get_temp_dir(), 'test_audio_');
|
||||
file_put_contents($filePath, 'fake-audio-content');
|
||||
|
||||
$uploadType = UploadType::Audio;
|
||||
$uploadUrl = 'https://upload.server/audio_path';
|
||||
$uploadToken = 'AUDIO_TOKEN_ABC';
|
||||
|
||||
$getUploadUrlResponse = ['url' => $uploadUrl];
|
||||
$uploadResponse = ['token' => $uploadToken];
|
||||
$expectedEndpoint = new UploadEndpoint($uploadUrl);
|
||||
$expectedAttachment = new AudioAttachmentRequest($uploadToken);
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('request')
|
||||
->with('POST', '/uploads', ['type' => $uploadType->value])
|
||||
->willReturn($getUploadUrlResponse);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUploadEndpoint')
|
||||
->with($getUploadUrlResponse)
|
||||
->willReturn($expectedEndpoint);
|
||||
|
||||
$this->clientMock
|
||||
->expects($this->once())
|
||||
->method('upload')
|
||||
->with($uploadUrl, $this->isResource(), basename($filePath))
|
||||
->willReturn($uploadResponse);
|
||||
|
||||
$result = $this->api->uploadAttachment($uploadType, $filePath);
|
||||
|
||||
$this->assertEquals($expectedAttachment, $result);
|
||||
|
||||
unlink($filePath);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadAttachmentSuccessfullyUploadsFileAndReturnsAttachment(): void
|
||||
{
|
||||
@@ -794,9 +769,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);
|
||||
|
||||
@@ -1595,7 +1570,7 @@ final class ApiTest extends TestCase
|
||||
->with($rawResponse)
|
||||
->willReturn($expectedResult);
|
||||
|
||||
$result = $this->api->deleteAdmins($chatId, $userId);
|
||||
$result = $this->api->deleteAdmin($chatId, $userId);
|
||||
|
||||
$this->assertSame($expectedResult, $result);
|
||||
}
|
||||
@@ -1980,4 +1955,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');
|
||||
}
|
||||
}
|
||||
|
||||
+153
-26
@@ -313,8 +313,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]
|
||||
@@ -332,13 +332,13 @@ final class ClientTest extends TestCase
|
||||
$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->httpClientMock->method('sendRequest')->willReturn($this->responseMock);
|
||||
$this->responseMock->method('getStatusCode')->willReturn(200);
|
||||
$this->streamMock->method('__toString')->willReturn(json_encode($responsePayload));
|
||||
|
||||
$result = $this->client->upload($uploadUrl, $tmpFileHandle, $fileName);
|
||||
$result = $this->client->multipartUpload($uploadUrl, $tmpFileHandle, $fileName);
|
||||
|
||||
$this->assertSame($responsePayload, $result);
|
||||
$this->assertSame(json_encode($responsePayload), $result);
|
||||
fclose($tmpFileHandle);
|
||||
}
|
||||
|
||||
@@ -358,27 +358,7 @@ final class ClientTest extends TestCase
|
||||
->with($this->requestMock)
|
||||
->willThrowException($psrException);
|
||||
|
||||
$this->client->upload('http://some.url', 'content', 'file.txt');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadThrowsSerializationExceptionOnInvalidJsonResponse(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Failed to decode upload server response JSON.');
|
||||
|
||||
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
|
||||
$this->requestMock->method('withHeader')->willReturn($this->requestMock);
|
||||
$this->requestMock->method('withBody')->willReturn($this->requestMock);
|
||||
|
||||
$this->httpClientMock
|
||||
->method('sendRequest')
|
||||
->with($this->requestMock)
|
||||
->willReturn($this->responseMock);
|
||||
|
||||
$this->responseMock->method('getStatusCode')->willReturn(200);
|
||||
$this->streamMock->method('__toString')->willReturn('{not-a-valid-json');
|
||||
$this->client->upload('http://some.url', 'content', 'file.txt');
|
||||
$this->client->multipartUpload('http://some.url', 'content', 'file.txt');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -409,4 +389,151 @@ final class ClientTest extends TestCase
|
||||
|
||||
$this->client->request('GET', '/not/found');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadMethodReturnsRawStringResponse(): void
|
||||
{
|
||||
$uploadUrl = 'https://upload.server/path';
|
||||
$fileContents = 'data';
|
||||
$fileName = 'file.txt';
|
||||
$rawResponse = '<retval>1</retval>';
|
||||
|
||||
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
|
||||
$this->requestMock->method('withHeader')->willReturn($this->requestMock);
|
||||
$this->requestMock->method('withBody')->willReturn($this->requestMock);
|
||||
$this->httpClientMock->method('sendRequest')->willReturn($this->responseMock);
|
||||
|
||||
$this->responseMock->method('getStatusCode')->willReturn(200);
|
||||
$this->streamMock->method('__toString')->willReturn($rawResponse);
|
||||
|
||||
$result = $this->client->multipartUpload($uploadUrl, $fileContents, $fileName);
|
||||
$this->assertSame($rawResponse, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resumableUploadThrowsExceptionForInvalidResource(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('fileResource must be a valid stream resource.');
|
||||
|
||||
$this->client->resumableUpload('http://a.b', 'not-a-resource', 'file.txt', 100);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resumableUploadThrowsExceptionForZeroFileSize(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('File size must be greater than 0.');
|
||||
|
||||
$fileResource = fopen('php://memory', 'r');
|
||||
$this->client->resumableUpload('http://a.b', $fileResource, 'file.txt', 0);
|
||||
fclose($fileResource);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resumableUploadThrowsNetworkExceptionOnChunkUploadFailure(): void
|
||||
{
|
||||
$this->expectException(NetworkException::class);
|
||||
|
||||
$fileResource = fopen('php://memory', 'w+');
|
||||
fwrite($fileResource, 'some data');
|
||||
rewind($fileResource);
|
||||
|
||||
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
|
||||
$this->requestMock->method('withBody')->willReturnSelf();
|
||||
$this->requestMock->method('withHeader')->willReturnSelf();
|
||||
|
||||
$psrException = new class extends \Exception implements ClientExceptionInterface {};
|
||||
$this->httpClientMock
|
||||
->method('sendRequest')
|
||||
->willThrowException($psrException);
|
||||
|
||||
$this->client->resumableUpload('http://a.b', $fileResource, 'file.txt', 9);
|
||||
fclose($fileResource);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resumableUploadSuccessfullyUploadsSingleChunk(): void
|
||||
{
|
||||
$fileContents = 'test-data';
|
||||
$fileResource = fopen('php://memory', 'w+');
|
||||
fwrite($fileResource, $fileContents);
|
||||
rewind($fileResource);
|
||||
|
||||
$uploadUrl = 'http://a.b';
|
||||
$fileName = 'file.txt';
|
||||
$fileSize = strlen($fileContents);
|
||||
|
||||
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
|
||||
$this->requestMock->method('withBody')->willReturnSelf();
|
||||
$this->requestMock->method('withHeader')->willReturnSelf();
|
||||
|
||||
$this->httpClientMock
|
||||
->expects($this->once())
|
||||
->method('sendRequest')
|
||||
->with($this->requestMock)
|
||||
->willReturn($this->responseMock);
|
||||
|
||||
$this->responseMock->method('getStatusCode')->willReturn(200);
|
||||
$this->streamMock->method('__toString')->willReturn('<retval>1</retval>');
|
||||
|
||||
$result = $this->client->resumableUpload($uploadUrl, $fileResource, $fileName, $fileSize);
|
||||
|
||||
$this->assertSame('<retval>1</retval>', $result);
|
||||
fclose($fileResource);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resumableUploadSuccessfullyUploadsMultipleChunks(): void
|
||||
{
|
||||
$fileContents = str_repeat('A', 3 * 1024 * 1024); // 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, 1024 * 1024);
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user