Краткое содержание предыдущих серий
Скрытый текст
В прошлый раз мы рассматривали загрузку файлов в MAX и отправку сообщений с ними. На этом занятии я рассчитывал рассмотреть редактирование сообщений. Но возникла мысль, что правильнее будет закрепить полученные знания на прикладной задаче. Ну и задача внезапно подвернулась; точнее, неожиданный способ её решения.
Как мы увидели в прошлый раз, отправка запроса SendMessage возвращает объект класса SendMessageResponse, в котором находится объект Message. Такой же объект Message приходит боту в апдейте, говорящем о поступлении входящего сообщения в "личку" или "администрируемый" канал или чат. Посмотрим, что есть в этом объекте, если в отправленном сообщении есть какие-то медиаобъекты, будь то картинки или видео (приведу урезанный результат print_r со своими комментариями).
Скрытый текст
[message] => lubezniy\yii2max\entity\Message Object ( [sender] => lubezniy\yii2max\entity\User Object ( [userId] => id бота [firstName] => название бота [lastName] => [username] => публичное имя бота [isBot] => 1 [lastActivityTime] => 1784487964972 [name] => название бота ) [recipient] => lubezniy\yii2max\entity\Recipient Object ( [chatId] => id чата [chatType] => dialog [userId] => id пользователя-получателя ) [timestamp] => 1784487964917 [link] => [body] => lubezniy\yii2max\entity\MessageBody Object ( [mid] => id сообщения [seq] => id последовательности [text] => Сообщение с двумя картинками и видео [attachments] => Array ( [0] => lubezniy\yii2max\entity\Attachment Object ( [type] => image [payload] => lubezniy\yii2max\entity\AttachmentPayload Object ( [fileId] => [photoId] => id фото [id] => [token] => токен фото [url] => https://ссылка на фото (постоянная?) [vcfInfo] => [maxInfo] => [buttons] => [title] => [description] => [imageUrl] => [latitude] => [longitude] => ) [thumbnail] => [width] => [height] => [duration] => [transcription] => [filename] => [size] => ) [1] => lubezniy\yii2max\entity\Attachment Object ( [type] => image [payload] => lubezniy\yii2max\entity\AttachmentPayload Object ( [fileId] => [photoId] => id фото [id] => [token] => токен фото [url] => https://ссылка на фото (постоянная?) [vcfInfo] => [maxInfo] => [buttons] => [title] => [description] => [imageUrl] => [latitude] => [longitude] => ) [thumbnail] => [width] => [height] => [duration] => [transcription] => [filename] => [size] => ) [2] => lubezniy\yii2max\entity\Attachment Object ( [type] => video [payload] => lubezniy\yii2max\entity\AttachmentPayload Object ( [fileId] => [photoId] => [id] => id видео [token] => токен видео [url] => https://ссылка на видео (содержит expires) [vcfInfo] => [maxInfo] => [buttons] => [title] => [description] => [imageUrl] => [latitude] => [longitude] => ) [thumbnail] => lubezniy\yii2max\entity\VideoThumbnail Object ( [url] => https://ссылка на картинку-превью (постоянная?) ) [width] => [height] => [duration] => 8 [transcription] => [filename] => [size] => ) ) [markup] => ) [stat] => [url] => )
Как видно, у каждого медиавложения есть ссылка, по которой можно скачать файл. На картинки, может, и постоянная; в документации про ограничение срока скачивания не сказано ничего (на видео ссылка содержит GET-параметр expires, поэтому вряд ли).
Возникла идея: почему бы не попытаться сделать бота, который при поступлении себе "в личку" сообщения с картинкой выдаст в ответ полученную из API ссылку на эту картинку или даже embed-код, и использовать таким образом инфраструктуру MAX в качестве фотохостинга. Для этого нужно будет освоить базовую обработку апдейтов и работу с сообщениями. Использовать для этого мы будем метод long polling, а к следующему разу я добавлю ещё и вебхуки.
Понятно, что, как ни крути, а по всей вероятности посетителям сайтов, использующих такой вот "фотохостинг", сейчас или позже придётся ставить корневой и промежуточный сертификаты Минцифры из-за свершившегося или предстоящего отзыва глобальных. И постоянное хранение фоток, очевидно, разработчики не гарантируют. Поэтому так использовать MAX можно, разве что, для каких-то некритичных и ненагруженных вещей, и вряд ли есть смысл брать деньги за такой вот хостинг. А для учебного примера такая задача будет вполне себе прикладной и пригодной. Если заморочиться работой с БД, можно попробовать даже ловить редактирование фотосообщений и соответствующим образом редактировать ответы, меняя ссылки. Но это на любителя; мы сейчас так увлекаться не будем.
Для получения апдейтов методом long polling мы будем использовать запрос класса lubezniy\yii2max\request\GetUpdates и выполнять его в цикле с учётом тонкостей, упомянутых в первой части. Как я писал в прошлый раз, для корректной работы на машинку, где всё это крутится, нужно установить корневые и промежуточные сертификаты Минцифры. Срок уже прошёл, так что ставим сертификаты и обновляем библиотеку (как, см. прошлую часть). Обновление важно, т. к. там ещё исправлен баг, выловленный в процессе работы над описываемым примером; известен ещё один, с ним пока сложновато, но до него обязательно доберусь. В работе этой части он нам точно не помешает.
Но, прежде чем получать апдейты, нам нужно разобраться, как их обрабатывать. Для этого, конечно, будем писать обработчик. Его целесообразно вынести в отдельный класс, где реализовать код обработки в статическом методе, куда параметрами подаются объекты собственно апдейта (для чтения данных) и модуля бота (для ответных действий). Это делается потому, что при работе через вебхуки контроллер отдаст апдейт в анонимную функцию, прописанную в конфиге модуля. Если логику сейчас прописать в контроллере командной строки, то придётся либо переносить всю иногда весьма непростую логику обработки в эту анонимную функцию, что для конфига не есть хорошо, либо просто прописать в эту функцию вызов статического метода нашего отдельного класса, который будет уже оттестирован на long polling.
В какое пространство имён мы поместим наш класс, наверное, не так принципиально; здесь, если я не ошибаюсь, у каждого yii-шника свои предпочтения. Из того, что видел я, кто-то делает классы в app\components, кто-то в app\services, кто-то где-то ещё. Мы, раз у нас класс является обработчиком, обзовём его процессором и соответственно поместим в app\processors . А название дадим MaxBot. Идём в каталог приложения, создаём каталог под выбранный namespace и создаём файл.
cd /var/maxbot mkdir processors nano processors/MaxBot.php
Нам исходно известно, что в классе будет минимум один статический метод, собственно обрабатывающий апдейты; о нём и его параметрах я писал выше. Его параметры известны, и нам ничто не мешает сразу написать его скелет в файле. Также, сорян, я немного спойлерну и помещу в начало файла директивы use со всеми объектами yii и библиотеки, которые потребуются нам для написания обработчика. Так удобнее в реальной работе, тем более некоторые имена классов нам придётся задействовать несколько раз.
<?php namespace app\processors; use lubezniy\yii2max\MaxModule; use lubezniy\yii2max\entity\AttachmentRequest; use lubezniy\yii2max\entity\InlineKeyboardAttachmentRequestPayload; use lubezniy\yii2max\entity\KeyboardButton; use lubezniy\yii2max\entity\NewMessageBody; use lubezniy\yii2max\entity\NewMessageLink; use lubezniy\yii2max\entity\Update; use lubezniy\yii2max\request\SendMessage; use yii\helpers\Html; /** * Класс обработчика апдейтов бота MAX */ class MaxBot { // здесь опишем дополнительные методы, тоже статические /** * Метод обработки апдейтов * @param lubezniy\yii2max\entity\Update $update объект апдейта * @param lubezniy\yii2max\MaxModule $module объект модуля * @return void */ public static function processUpdate(Update $update, MaxModule $module) { // сюда пишем код метода обработки } }
Теперь распишем задачу детально. На вход обработчика в теории могут поступать апдейты любого из пятнадцати типов, описанных в документации по API (в официальной библиотеке для TypeScript видел ещё парочку, но описания нигде не нашёл). Нас по задаче интересует только один из них; это message_created (получено сообщение). Но, как я писал в первой части, есть ещё интересный тип апдейта bot_started (пользователь начал переписку с ботом). Приличному разработчику грех не сделать приветственное сообщение для пользователя, в котором написано, что и как пользователь может может делать с этим ботом. Так поступим и мы, а остальные типы апдейтов проигнорируем. Приветственное сообщение для удобства опишем в виде константы класса; так его проще будет увидеть.
Дальше. Помним, что пользователь, какие инструкции ему ни выдавай, вправе пробовать отправлять боту всё, что угодно; в том числе сообщения вообще без вложений и без картинок. А мы по задаче можем обрабатывать только сообщения с одной или несколькими картинками. Для таких мы заготовим ругательное сообщение. Тоже опишем его константой класса. Больше констант вроде не потребуется. В общем, перед описанием метода-обработчика ставим наши константы.
public const INSTRUCTION_MESSAGE = 'Привет. Для размещения картинки просто отправь её мне в личку как картинку (не файл); я в ответ пришлю ссылку и кнопки для копирования её и embed-кода.'; public const NO_IMAGE_ATTACH_ERROR = 'В сообщении нет картинок.';
В прошлой части мы видели, что для отправки сообщения нужно написать довольно много строк кода (для простого текстового 9 штук). А у нас только сообщения из констант нужно отправлять сразу в двух местах, плюс надо ещё отправлять ответ со ссылкой (там, если вы прочитали тексты сообщений, будет ещё и аттачмент с клавиатурой, да и ответ у нас будет в ответ на конкретное сообщение). Так что вынесем коды отправки текстового сообщения и отправки результирующего сообщения в отдельные, тоже статические, методы нашего класса. Между константами и описанием нашего метода вставляем следующий код:
/** * Метод отправки текстового сообщения * @param lubezniy\yii2max\MaxModule $module объект модуля * @param integer $userId id пользователя-получателя * @param string $text текст сообщения * @return void */ private static function sendText(MaxModule $module, int $userId, string $text) { $module->createRequest([ 'class' => SendMessage::class, 'userId' => $userId, 'messageBody' => new NewMessageBody([ 'text' => $text, 'format' => 'html', ]), ])->send(); } /** * Метод отправки сообщения с результатом-ссылкой * @param lubezniy\yii2max\MaxModule $module объект модуля * @param integer $userId id пользователя-получателя * @param string $replyMid id сообщения, на которое идёт ответ * @param string $url ссылка на картинку * @return void */ private static function sendLink(MaxModule $module, int $userId, string $replyMid, string $url) { $req = $module->createRequest([ 'class' => SendMessage::class, 'userId' => $userId, 'messageBody' => new NewMessageBody([ 'text' => $url, 'format' => 'html', 'link' => new NewMessageLink([ 'type' => 'reply', 'mid' => $replyMid, ]), 'attachments' => [ new AttachmentRequest([ 'type' => 'inline_keyboard', 'payload' => new InlineKeyboardAttachmentRequestPayload([ 'buttons' => [ [ new KeyboardButton([ 'type' => 'clipboard', 'text' => 'Копировать ссылку', 'payload' => $url, ]), ], [ new KeyboardButton([ 'type' => 'clipboard', 'text' => 'Копировать embed-код', 'payload' => Html::img($url), ]), ], ], ]), ]), ], ]), ])->send(); }
Осталось расписать сам код обработчика. Для простоты логику сделаем такой: если картинок в сообщении по апдейту будет несколько, то дадим несколько ответных сообщений, каждое из которых будет относиться к конкретной картинке. Внутри фигурных скобок метода класса, где выше мы оставили коммент, пишем такой вот код:
// тип апдейта bot_started; пользователь "подписался" на бота // выдаём инструкцию по действиям и заканчиваем if ($update->updateType == 'bot_started') { self::sendText( $module, $update->user->userId, self::INSTRUCTION_MESSAGE ); return; } // тип апдейта не message_created; просто игнорим и выходим if ($update->updateType != 'message_created') { return; } // сообщение не "в личку" боту, просто выходим if ($update->message->recipient->chatType != 'dialog') { return; } /** @var boolean $hasPics флаг наличия картинок в сообщении */ $hasPics = false; // В сообщениях без вложений attachments может быть null if (is_array($update->message->body->attachments)) { // перебираем вложения в поисках картинок foreach ($update->message->body->attachments as $attachment) { if ($attachment->type == 'image') { // наш клиент; обрабатываем вложение, а другие типы игнорим $hasPics = true; // отправляем ответ со ссылкой self::sendLink( $module, $update->message->sender->userId, $update->message->body->mid, $attachment->payload->url ); } } } // если картинок в переборе не попалось, ругаемся if (!$hasPics) { self::sendText( $module, $update->message->sender->userId, self::NO_IMAGE_ATTACH_ERROR ); return; }
По параметру $update->message->recipient->chatType мы определяем источник сообщения. Значение dialog говорит нам о том, что это личка. Если мы увидим значение chat или channel , то это означает, что владелец какого-то чата или канала зачем-то добавил бота в админы этих канала или чата, а сообщение является пользовательским сообщением в чате или постом в канале. Возможно, в будущем появится какое-нибудь значение и для коммента в канале; как я писал во внеочередной прошлой публикации, сейчас средств для автоматизации отслеживания комментов к постам в каналах у Bot API нет. Так или иначе, вряд ли есть смысл засорять чат или канал какими-то сообщениями от нашего бота, когда он работает только с личкой. Поэтому такое сообщение в апдейте мы просто деликатно проигнорируем.
С процессором всё; сохраняем файл и выходим. Теперь можно писать циклическую обработку апдейтов. Предварительно рекомендую вспомнить написанное в первой части. Открываем наш учебный контроллер.
cd ../ nano commands/MaxLearnController.php
И, как всегда, между двумя последними закрывающими фигурными скобками добавляем новый action.
/** * Шаг 4. Обработка апдейтов методом long polling * @return int */ public function actionStep4(): int { // получаем модуль /** @var \lubezniy\yii2max\MaxModule $module */ $module = Yii::$app->getModule('maxbot', true); /** @var int|null $marker Маркер последнего апдейта */ $marker = null; // цикл обработки do { // создаём запрос $request = $module->createRequest([ 'class' => \lubezniy\yii2max\request\GetUpdates::class, // Заполним нужные нам свойства класса. 'limit' => 1, 'timeout' => 1, 'marker' => $marker, ]); // отправляем и получаем ответ /** @var \lubezniy\yii2max\response\GetUpdatesResponse $response */ $response = $request->send(); // парсим ответ if (is_array($response->updates)) { // обрабатываем апдейты процессором в цикле foreach ($response->updates as $update) { \app\processors\MaxBot::processUpdate($update, $module); } } // устанавливаем marker $marker = $response->marker; // освобождаем память unset($request); } while (true); }
Параметр запроса marker, по-видимому, относится к последнему апдейту (конкретно что это, документация не раскрывает, поэтому не могу утверждать). Если его не передавать, то апдейты могут отдаваться одни и те же. Поэтому его обработка крайне необходима для корректной работы бота.
Параметр запроса timeout - это время в секундах, в течение которого http-соединение с сервером MAX будет "висеть" в ожидании апдейта (при наличии в очереди хотя бы одного апдейта этого зависания не будет, апдейты отдадутся сразу). По умолчанию timeout составляет 30 секунд, что, на мой взгляд, многовато. Поэтому поставил 1; и при опросах нескольких ботов по очереди рекомендую делать примерно так же. В общей сложности ограничение MAX по периодичности запросов с одной точки составляет 30 запросов в секунду максимум.
В остальном всё просто: получаем сконфигуренный ранее модуль библиотеки бота, формируем и отправляем запрос GetUpdates, перебираем полученные апдейты в цикле и вызываем метод процессора, передавая туда объекты модуля и апдейта.
Сохраняем файл, выходим из редактора, и можно проверять:
php yii max-learn/step4
Работать это всё будет до нажатия Ctrl + C. Для длительной работы, чтобы не мешать пользователю, можно пользовать механизмы запуска, например, под screen . А мы для проверки скормим боту сообщение с картинкой. Если всё нормально, то код должен стоически и молча держаться в консоли, но выдавать ответы в личку обратившимся пользователям.
Итог
В этом материале, надеюсь, получилось расписать базовую работу с апдейтами в режиме long polling. Позже доделаю и пример по вебхукам.