Краткое содержание предыдущих серий

Скрытый текст

В прошлый раз мы рассматривали загрузку файлов в 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. Позже доделаю и пример по вебхукам.

Комментарии (0)