Compare commits

...

14 Commits

Author SHA1 Message Date
Alex 94ec907506 Added resumable upload tests 2025-08-27 21:26:33 +03:00
Alex 869c254275 Merge pull request #6 from BushlanovDev/resumable-upload
Resumable file upload
2025-08-26 21:10:53 +03:00
Alex cb8ad3f738 Added resumable upload tests 2025-08-26 21:08:07 +03:00
Alex 0f007bf94e Added resumable upload tests 2025-08-26 20:55:45 +03:00
Alex 3127f86c54 Added resumable upload 2025-08-25 23:11:06 +03:00
Alex ed715af3bc Update badger 2025-08-24 20:37:19 +03:00
Alex 008d6f4e0e Documentation 2025-08-22 20:17:28 +03:00
Alex 4784fcc97b Merge pull request #5 from BushlanovDev/fix-upload-files
Fix upload files
2025-08-21 19:31:21 +03:00
Alex d2aedc090f Documentation 2025-08-21 19:24:38 +03:00
Alex 92c8f4cd94 Fix upload files 2025-08-20 23:22:18 +03:00
Alex fa7875a670 Documentation 2025-08-19 21:26:05 +03:00
Alex 70c290ce0a Documentation 2025-08-18 21:29:43 +03:00
Alex de3a67bee7 Documentation & rename deleteAdmins to deleteAdmin 2025-08-17 22:40:21 +03:00
Alex b58dc8b2ed Documentation 2025-08-16 21:07:17 +03:00
11 changed files with 4279 additions and 233 deletions
@@ -5,7 +5,7 @@
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<mask id="a">
<rect width="99" height="20" rx="3" fill="#fff"/>
<rect width="99" height="20" fill="#fff"/>
</mask>
<g mask="url(#a)">
<path fill="#555" d="M0 0h63v20H0z"/>

Before

Width:  |  Height:  |  Size: 902 B

After

Width:  |  Height:  |  Size: 895 B

+60 -33
View File
@@ -1,7 +1,7 @@
# Max Bot API Client library for PHP
# Max Messenger Bot API Client library for PHP
[![Actions status](https://github.com/BushlanovDev/max-bot-api-client-php/actions/workflows/ci.yml/badge.svg?style=flat-square)](https://github.com/BushlanovDev/max-bot-api-client-php/actions)
[![Coverage](https://raw.githubusercontent.com/BushlanovDev/max-bot-api-client-php/refs/heads/master/badge-coverage.svg?v=1)](https://github.com/BushlanovDev/max-bot-api-client-php/actions)
[![Coverage](https://raw.githubusercontent.com/BushlanovDev/max-bot-api-client-php/refs/heads/master/.github/badge-coverage.svg?v=2)](https://github.com/BushlanovDev/max-bot-api-client-php/actions)
[![Packagist Version](https://img.shields.io/packagist/v/bushlanov-dev/max-bot-api-client-php.svg?style=flat-square)](https://packagist.org/packages/bushlanov-dev/max-bot-api-client-php)
[![PHP version](https://img.shields.io/badge/php-%3E%3D%208.3-8892BF.svg?style=flat-square)](https://github.com/BushlanovDev/max-bot-api-client-php)
[![Laravel](https://img.shields.io/badge/%20Laravel%20Package-available-success?logo=laravel&style=flat-square)](https://github.com/BushlanovDev/max-bot-api-client-php)
@@ -26,6 +26,20 @@
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,
],
```
### Использование
Отправка сообщения с клавиатурой
@@ -37,11 +51,18 @@ use BushlanovDev\MaxMessengerBot\Api;
$api = new Api('YOUR_BOT_API_TOKEN');
// Загрузка файла
$fileAttachmentRequest = $api->uploadAttachment(
type: UploadType::File,
filePath: __DIR__ . '/test.pdf',
);
$api->sendMessage(
userId: 123, // ID пользователя получателя сообщения
chatId: 321, // Или ID чата, в который нужно отправить сообщение
text: 'Привет!', // Текст сообщения, вы можете использовать HTML или Markdown
attachments: [
$fileAttachmentRequest,
new InlineKeyboardAttachmentRequest([
[new CallbackButton('Нажми меня!', 'payload_button1')],
[new LinkButton('Нажми меня!', 'https://example.com')],
@@ -51,6 +72,12 @@ $api->sendMessage(
);
```
Отправка сообщения с использованием фасада Laravel
```php
MaxBot::sendUserMessage(123456, 'Привет из Laravel!');
```
Создание универсального обработчика обновлений
```php
@@ -101,49 +128,49 @@ $handler->handle();
#### 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-кнопки)
## Лицензия
+5 -2
View File
@@ -24,7 +24,7 @@
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.77",
"jaschilz/php-coverage-badger": "^2.0",
"bushlanov-dev/php-coverage-badger": "^2.1",
"laravel/framework": "^11.0",
"mikey179/vfsstream": "^1.6",
"mockery/mockery": "^1.6",
@@ -62,6 +62,9 @@
"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"]
"create-coverage-badge": [
"vendor/bin/phpunit --coverage-clover clover.xml",
"vendor/bin/php-coverage-badger --square clover.xml .github/badge-coverage.svg"
]
}
}
+378 -34
View File
@@ -1,43 +1,45 @@
- [Быстрый старт](#Быстрый-старт)
- [Получение токена](#Получение-токена)
- [Установка библиотеки](#Установка-библиотеки)
- [Установка библиотеки в Laravel](#Установка-библиотеки-в-Laravel)
- [Инициализация бота](#Инициализация-бота)
- [Информация о боте](#Информация-о-боте)
- `GET /me` (`getBotInfo`) - [*Получение информации о боте.*](#Получение-информации-о-боте)
- `PATCH /me` (`editBotInfo`) - [*Редактирование информации о боте.*](#Редактирование-информации-о-боте)
- Чаты
- `GET /chats` (`getChats`) - *Получение списка всех чатов бота.*
- `GET /chats/{chatLink}` (`getChatByLink`) - *Получение информации о чате по ссылке.*
- `GET /chats/{chatId}` (`getChat`) - *Получение информации о чате по ID.*
- `PATCH /chats/{chatId}` (`editChat`) - *Редактирование информации о чате.*
- `DELETE /chats/{chatId}` (`deleteChat`) - *Удаление чата.*
- `POST /chats/{chatId}/actions` (`sendAction`) - *Отправка действия в чат (например, "печатает...").*
- `GET /chats/{chatId}/pin` (`getPinnedMessage`) - *Получение закрепленного сообщения.*
- `PUT /chats/{chatId}/pin` (`pinMessage`) - *Закрепление сообщения.*
- `DELETE /chats/{chatId}/pin` (`unpinMessage`) - *Открепление сообщения.*
- `GET /chats/{chatId}/members/me` (`getMembership`) - *Получение информации о членстве бота в чате.*
- `DELETE /chats/{chatId}/members/me` (`leaveChat`) - *Выход бота из чата.*
- `GET /chats/{chatId}/members/admins` (`getAdmins`) - *Получение администраторов чата.*
- `POST /chats/{chatId}/members/admins` (`addAdmins`) - *Назначение администраторов чата.*
- `DELETE /chats/{chatId}/members/admins/{userId}` (`deleteAdmins`) - *Снятие прав администратора.*
- `GET /chats/{chatId}/members` (`getMembers`) - *Получение участников чата.*
- `POST /chats/{chatId}/members` (`addMembers`) - *Добавление участников в чат.*
- `DELETE /chats/{chatId}/members` (`deleteMember`) - *Удаление участника из чата.*
- Получение обновлений
- `GET /subscriptions` (`getSubscriptions`) - *Получение списка Webhook-подписок.*
- `POST /subscriptions` (`subscribe`) - *Создание Webhook-подписки.*
- `DELETE /subscriptions` (`unsubscribe`) - *Удаление Webhook-подписки.*
- `GET /updates` (`getUpdates`) - *Получение обновлений через Long-Polling.*
- Загрузка файлов
- `POST /uploads` (`getUploadUrl`) - *Получение URL для загрузки файла.*
- Сообщения
- `GET /messages` (`getMessages`) - *Получение списка сообщений из чата.*
- `POST /messages` (`sendMessage`) - *Отправка сообщения.*
- `PUT /messages` (`editMessage`) - *Редактирование сообщения.*
- `DELETE /messages` (`deleteMessage`) - *Удаление сообщения.*
- `GET /messages/{messageId}` (`getMessageById`) - *Получение сообщения по ID.*
- `GET /videos/{videoToken}` (`getVideoAttachmentDetails`) - *Получение детальной информации о видео.*
- `POST /answers` (`answerOnCallback`) - *Ответ на нажатие callback-кнопки.*
- [Чаты](#Чаты)
- `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-кнопки)
## Быстрый старт
@@ -54,6 +56,36 @@
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
```
### Инициализация бота
Единственной обязательной настройкой является токен вашего бота.
@@ -82,13 +114,16 @@ $api = new Api(
### Получение информации о боте
Возвращает информацию о текущем боте, который идентифицируется с помощью токена доступа.
Метод возвращает ID бота, его имя и аватар (если есть).
```php
$botInfo = $api->getBotInfo();
```
### Редактирование информации о боте
Обратите внимание что данный метод отправляется PATCH запросом. Это значит, что будут обновлены только переданные
Обратите внимание, что данный метод отправляется PATCH запросом. Это значит, что будут обновлены только переданные
поля.
В следующем примере мы изменяем только название бота и отчистим его описание. Остальные поля останутся неизменными.
@@ -100,3 +135,312 @@ $botInfo = $api->editBotInfo(
)
);
```
## Чаты
### Получение списка всех чатов бота
Возвращает информацию о чатах, в которых участвовал бот.
Результат включает список чатов и маркер для перехода к следующей странице.
```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
View File
File diff suppressed because it is too large Load Diff
+75 -18
View File
@@ -33,6 +33,7 @@ use BushlanovDev\MaxMessengerBot\Models\UpdateList;
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
use InvalidArgumentException;
use JsonException;
use LogicException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
@@ -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;
@@ -442,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.
*
@@ -470,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
}
/**
@@ -874,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
View File
@@ -21,6 +21,7 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use RuntimeException;
/**
* The low-level HTTP client responsible for communicating with the Max Bot API.
@@ -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;
}
/**
+26 -2
View File
@@ -7,6 +7,7 @@ namespace BushlanovDev\MaxMessengerBot;
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
use RuntimeException;
interface ClientApiInterface
{
@@ -32,10 +33,33 @@ interface ClientApiInterface
* @param resource|string $fileContents File content (stream resource or string).
* @param string $fileName The name of the file that will be sent to the server.
*
* @return array<string, mixed>
* @return string The raw response body from the upload server.
* @throws ClientApiException
* @throws NetworkException
* @throws SerializationException
*/
public function upload(string $uri, mixed $fileContents, string $fileName): array;
public function multipartUpload(string $uri, mixed $fileContents, string $fileName): string;
/**
* Uploads a file in chunks using the resumable upload method.
* The caller is responsible for opening and closing the file resource.
*
* @param string $uploadUrl The target URL for the upload.
* @param resource $fileResource A stream resource pointing to the file.
* @param string $fileName The desired file name for the upload.
* @param int<1, max> $fileSize The total size of the file in bytes.
* @param int<1, max> $chunkSize The size of each chunk in bytes.
*
* @return string The body of the final response from the server.
* @throws NetworkException
* @throws ClientApiException
* @throws RuntimeException
*/
public function resumableUpload(
string $uploadUrl,
$fileResource,
string $fileName,
int $fileSize,
int $chunkSize = 1048576,
): string;
}
+1 -1
View File
@@ -64,7 +64,7 @@ use Illuminate\Support\Facades\Facade;
* @method static Result pinMessage(int $chatId, string $messageId, bool $notify = true)
* @method static ChatMembersList getAdmins(int $chatId)
* @method static ChatMembersList getMembers(int $chatId, ?array<int> $userIds = null, ?int $marker = null, ?int $count = null)
* @method static Result deleteAdmins(int $chatId, int $userId)
* @method static Result deleteAdmin(int $chatId, int $userId)
* @method static Result deleteMember(int $chatId, int $userId, bool $block = false)
* @method static Result addAdmins(int $chatId, array<ChatAdmin> $admins)
* @method static Result addMembers(int $chatId, array<int> $userIds)
+252 -110
View File
@@ -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);
}
@@ -1992,4 +1967,171 @@ final class ApiTest extends TestCase
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
View File
@@ -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);
}
}