Compare commits

..

8 Commits

Author SHA1 Message Date
Alex d2aedc090f Documentation 2025-08-21 19:24:38 +03:00
Alex 92c8f4cd94 Fix upload files 2025-08-20 23:22:18 +03:00
Alex fa7875a670 Documentation 2025-08-19 21:26:05 +03:00
Alex 70c290ce0a Documentation 2025-08-18 21:29:43 +03:00
Alex de3a67bee7 Documentation & rename deleteAdmins to deleteAdmin 2025-08-17 22:40:21 +03:00
Alex b58dc8b2ed Documentation 2025-08-16 21:07:17 +03:00
Alex 00da3af39d Documentation and some fix 2025-08-15 20:17:30 +03:00
Alex ea1947239e Added badge laravel support 2025-08-14 14:34:40 +03:00
11 changed files with 4018 additions and 210 deletions
+84 -40
View File
@@ -4,6 +4,7 @@
[![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)
[![Packagist Version](https://img.shields.io/packagist/v/bushlanov-dev/max-bot-api-client-php.svg?style=flat-square)](https://packagist.org/packages/bushlanov-dev/max-bot-api-client-php)
[![PHP version](https://img.shields.io/badge/php-%3E%3D%208.3-8892BF.svg?style=flat-square)](https://github.com/BushlanovDev/max-bot-api-client-php)
[![Laravel](https://img.shields.io/badge/%20Laravel%20Package-available-success?logo=laravel&style=flat-square)](https://github.com/BushlanovDev/max-bot-api-client-php)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE)
> [!CAUTION]
@@ -25,12 +26,30 @@
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');
$api->sendMessage(
userId: 123, // ID пользователя получателя сообщения
@@ -46,6 +65,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 +105,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-кнопки)
## Лицензия
+413
View File
@@ -0,0 +1,413 @@
- [Быстрый старт](#Быстрый-старт)
- [Получение токена](#Получение-токена)
- [Установка библиотеки](#Установка-библиотеки)
- [Установка библиотеки в 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(),
);
```
## Информация о боте
### Получение информации о боте
```php
$botInfo = $api->getBotInfo();
```
### Редактирование информации о боте
Обратите внимание что данный метод отправляется PATCH запросом. Это значит, что будут обновлены только переданные
поля.
В следующем примере мы изменяем только название бота и отчистим его описание. Остальные поля останутся неизменными.
```php
$botInfo = $api->editBotInfo(
new BotPatch(
name: 'Супер бот',
description: null,
)
);
```
## Чаты
### Получение списка всех чатов бота
```php
$chats = $api->getChats(
count: 10, // Количество запрашиваемых чатов
marker: 2, // Указатель на следующую страницу данных. Для первой страницы передайте null
);
```
### Получение информации о чате по ссылке
```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
$chatMemberList = $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-подписки
```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
```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::upload()
```
### Загрузка файла
Данный метод получит URL для загрузки, отправит файл и вернет готовый аттачмент
```php
$photoAttachmentRequest = $api->uploadAttachment(
type: UploadType::Image,
filePath: __DIR__ . '/test.jpg',
);
```
## Сообщения
### Получение списка сообщений из чата
```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, сервер не будет генерировать превью для ссылок в тексте сообщения (необязательно)
);
```
### Редактирование сообщения
```php
$api->editMessage(
messageId: 12345,
text: 'Привет мир!',
attachments: null,
format: null,
link: null,
notify: true,
);
```
### Удаление сообщения
```php
$api->deleteMessage(12345);
```
### Получение сообщения по ID
```php
$message = $api->getMessageById(12345);
```
### Получение детальной информации о видео
```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
+52 -25
View File
@@ -33,6 +33,7 @@ use BushlanovDev\MaxMessengerBot\Models\UpdateList;
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
use InvalidArgumentException;
use JsonException;
use LogicException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
@@ -47,7 +48,7 @@ use RuntimeException;
*/
class Api
{
public const string LIBRARY_VERSION = '1.0.0';
public const string LIBRARY_VERSION = '1.0.1';
public const string API_VERSION = '0.0.6';
@@ -85,21 +86,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 +133,7 @@ class Api
$this->client = $client;
$this->modelFactory = $modelFactory ?? new ModelFactory();
$this->updateDispatcher = $updateDispatcher ?? new UpdateDispatcher($this);
$this->updateDispatcher = new UpdateDispatcher($this);
}
/**
@@ -468,27 +471,51 @@ 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'."
);
}
$this->client->upload($uploadEndpoint->url, $fileHandle, basename($filePath));
fclose($fileHandle);
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->client->upload($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 +899,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(
+2 -8
View File
@@ -122,7 +122,7 @@ final readonly class Client implements ClientApiInterface
/**
* @inheritDoc
*/
public function upload(string $uri, mixed $fileContents, string $fileName): array
public function upload(string $uri, mixed $fileContents, string $fileName): string
{
$boundary = '--------------------------' . microtime(true);
$bodyStream = $this->streamFactory->createStream();
@@ -152,13 +152,7 @@ final readonly class Client implements ClientApiInterface
$this->handleErrorResponse($response);
$responseBody = (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);
}
return (string)$response->getBody();
}
/**
+2 -2
View File
@@ -32,10 +32,10 @@ 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 upload(string $uri, mixed $fileContents, string $fileName): 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)
-1
View File
@@ -102,7 +102,6 @@ class MaxBotServiceProvider extends ServiceProvider
$app->make(ClientApiInterface::class),
$app->make(ModelFactory::class),
$app->make(LoggerInterface::class),
null,
);
});
-4
View File
@@ -35,15 +35,11 @@ final class ApiFactoryMethodsTest extends TestCase
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);
$apiForDispatcher = $this->createMock(Api::class);
$dispatcher = new UpdateDispatcher($apiForDispatcher);
$this->api = new Api(
'fake-token',
$this->clientMock,
$this->modelFactoryMock,
$this->loggerMock,
$dispatcher,
);
}
+178 -107
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('upload')->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('upload')->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
@@ -483,7 +518,7 @@ final class ApiTest extends TestCase
->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('upload')
->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
{
@@ -673,10 +728,10 @@ final class ApiTest extends TestCase
->expects($this->once())
->method('upload')
->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
{
@@ -796,7 +771,7 @@ final class ApiTest extends TestCase
->expects($this->once())
->method('upload')
->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,100 @@ 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('upload')
->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('upload');
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('upload')
->willReturn($invalidUploadResponse);
try {
$this->api->uploadAttachment(UploadType::File, $filePath);
} finally {
unlink($filePath);
}
}
}
+22 -22
View File
@@ -314,7 +314,7 @@ final class ClientTest extends TestCase
$this->streamMock->method('__toString')->willReturn(json_encode($responsePayload));
$result = $this->client->upload($uploadUrl, $fileContents, $fileName);
$this->assertSame($responsePayload, $result);
$this->assertSame(json_encode($responsePayload), $result);
}
#[Test]
@@ -338,7 +338,7 @@ final class ClientTest extends TestCase
$result = $this->client->upload($uploadUrl, $tmpFileHandle, $fileName);
$this->assertSame($responsePayload, $result);
$this->assertSame(json_encode($responsePayload), $result);
fclose($tmpFileHandle);
}
@@ -361,26 +361,6 @@ final class ClientTest extends TestCase
$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');
}
#[Test]
public function requestLogsRequestAndResponseOnDebugLevel(): void
{
@@ -409,4 +389,24 @@ 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->upload($uploadUrl, $fileContents, $fileName);
$this->assertSame($rawResponse, $result);
}
}