Telegram Bot на Kotlin: Дратути +1


Это вторая часть из туториалов про ботов для телеграма на kotlin. Первую статью можно найти тут. В рамках данной части мы попробуем создать недостаточно простой welcome плагин, в рамках которого нам придётся работать с базами данных, использовать новые методы в рамках отправки сообщений и способы взаимодействия с пользователями бота.

Очень многие вещи в этой части будут пропущены. Например, тут не будет разбираться вопрос запуска бота, назначения базовых интерфейсов и так далее. Кроме того, не будет подробно рассматриваться настройка API кэширования админов. Узнать большую часть информации можно, посетив первую и полторашную части этого туториала.

Предупреждение для слабонервных

Фактически, функционала этого плагина хватило бы на отдельного бота, поскольку у него есть своя логика и особенности, которые сами по-себе можно развивать бесконечно. Тем не менее, я постараюсь как можно доступней объяснить, что тут происходит и почему

К делу

Итак, задача относительно простая: добавить плагин, который будет предоставлять возможность администраторам чатов настраивать приветственное сообщение для входящих пользователей. Основная логика следующая:

  1. Админ отправляет команду /welcome в чат

  2. Админу в личку приходит сообщение от бота с предложением настройть welcome сообщение

  3. Дальше происходит один из трех вариантов:

    1. Админ отключил welcome сообщение

    2. Админ отменил настройку welcome

    3. Админ отправил сообщение, которое будет служить как welcome

Как всегда, на деле всё сложнее

На самом деле, на каждом шаге может произойти куча всего: пользователь не админ, пользователь не начал диалог с ботом, за время настройки пользователь перестал быть админом и т.д. Часть этих ситуаций мы будем обрабатывать, а часть опустим в данном туториале

Также стоит оговориться, что отправка welcome сообщения будет производится через copy метод для уменьшения расходов как бота, так и самого телеграма

Настройки сообщения и WelcomeTable

Сами настройки для каждого чата состоят всего из трех полей: чат-владелец welcome'а, чат с сообщением welcome'а и идентификатор собщения.

WelcomeTable представляет из себя достаточно простую SQL таблицу с тремя полями для настроек и тремя операциями:

  • Установить сообщение (set)

  • Удалить сообщение (unset)

  • Получить сообщение для чата (get)

Сам плагин

По своей сути, под разделен на две части:

  • Отправка welcome сообщения (пожалуй, самая простая часть)

  • Настройка welcome сообщения

Настройка

Настройка будет состоять из нескольких частей:

  • Регистрация команды от админа

  • Старт управления сообщением

  • Обработка всех веток вариантов:

    • Отключение сообщения

    • Отмена настройки

    • Установка нового сообщения

    • Обработка того факта, что пользователь может перестать быть админом в чате

Каждый из шагов сам по-себе относительно прост, так что будем разделять и властвовать собирать обратно

Регистрация команды админа

onCommand( // Регистрация обработчика
    "welcome", // Установка команды для обработчика
    initialFilter = {
      it.chat is GroupChat // предварительная фильтрация
                           // для работы только с групповыми чатами
    } 
) {
    it.ifCommonGroupContentMessage { groupMessage -> // Если сообщение - обычное
                                                     // то есть не анонимное и не от канала,
                                                     // тогда начинаем обработку
        launch { // Запуск обработки параллельно - чтобы не блокировать обработку новых сообщений
            handleWelcomeCommand( // Вызов метода обработки, работа которого будет описана ниже
              adminsCacheAPI,
              welcomeTable,
              config,
              groupMessage
            )
        }
    }
}
Относительно launch

На самом деле, этот шаг не обязателен. Достаточно было бы указать что-то вроде markerFactory = { it.chat.id to it.messageId }, что спровоцировало бы обработку каждого сообщения отдельно и непоследовательно относительно других. Тем не менее, вариант с launch видится более лаконичным

Проверки и "дратути перед дратути"

Первое, что происходит внутри handleWelcomeCommand - это получение пользователя-отправителя команды и его проверка:

val user = groupMessage.user // Пользователь-отправитель команды

if (adminsCacheAPI.isAdmin(groupMessage.chat.id, user.id)) { // Проверяем, админ ли пользователь
    val previousMessage = welcomeTable.get(groupMessage.chat.id) // Получаем предыдущее welcome-сообщение
    val sentMessage = send( // Отправка сообщения с кнопками управления welcome-сообщением
        user, // Указываем пользователя, а следовательно - его чат, как целевой
        replyMarkup = flatInlineKeyboard { // Создаём одноуровневую клавиатуру
            if (previousMessage != null) { // Если welcome сообщение уже установлено - его можно убрать
                dataButton("Unset", unsetData) // Для этого добавляем соответствующую кнопку
            }
            dataButton("Cancel", cancelData) // Также добавляем кнопку отмены управления welcome-сообщением
        }
    ) { // В этой лямбде будет строится текст сообщения
        regular("Ok, send me the message which should be used as welcome message for chat ")
        underline(groupMessage.chat.title)
    }
            
    // Основная логика
}

Самое страшное

Перед описанием основной логики настройки хотелось бы сразу оговориться о двух недо-DSL, которые далее используются:

  • parallel будучи вызванным в рамках BehaviourContext создаст параллельно запущенную операцию, которая может что-то вернуть (что в целом не обязательно)

  • oneOf получает на вход набор результатов вызовов parallel и ждёт, пока завершится хотя бы одна из полученных операций (остальные отменяются)

Итак, код:

oneOf( // Запуск ожидания завершения хотя бы одного из сценариев
    parallel { // Начало сценария отключения welcome-сообщения
        val query = waitMessageDataCallbackQuery().filter {
            // Ожидаем нажатие кнопки с unsetData и проверяем, что нажатие производится
            // На ту же кнопку, что и sentMessage
            it.data == unsetData && it.message.sameMessage(sentMessage)
        }.first() // Дожидаемся первого такого нажатия

        edit(sentMessage) { // Редактируем отправленное сообщение для отображения результата операции
            if (welcomeTable.unset(groupMessage.chat.id)) { // Убираем сообщение из базы
                // При удачном удалении сообщаем об это пользователю
                regular("Welcome message has been removed for chat ")
                underline(groupMessage.chat.title)
            } else {
                // Также если не получилось удалить сообщение говорим об этом
                regular("Something went wrong on welcome message unsetting for chat ")
                underline(groupMessage.chat.title)
            }
        }

        answer(query) // Говорим Telegram Bot API, что запрос обработан
    },
    parallel { // Начало сценария отмены настройки нового сообщения
        val query = waitMessageDataCallbackQuery().filter {
            // Ожидаем нажатие кнопки с cancelData и проверяем, что нажатие производится
            // На ту же кнопку, что и sentMessage
            it.data == cancelData && it.message.sameMessage(sentMessage)
        }.first() // Дожидаемся первого такого нажатия

        edit(sentMessage) { // Сообщаем пользователю об отмене релактирования
            regular("You have cancelled change of welcome message for chat ")
            underline(groupMessage.chat.title)
        }

        answer(query) // Говорим Telegram Bot API, что запрос обработан
    },
    parallel { // Начало операции регистрации нового сообщения
        val message = waitContentMessage().filter {
            // Проверяем, что полученное сообщение из того же чата
            it.sameChat(sentMessage)
        }.first() // Получаем первое сообщение в чате с пользователем

        val success = welcomeTable.set( // Сохраняем новые настройки со ссылкой на новое сообщение
            ChatSettings(
                groupMessage.chat.id,
                message.chat.id,
                message.messageId
            )
        )

        reply(message) { // Отвечаем на пользовательское сообщение сообщением об успехе/неуспехе
            if (success) {
                regular("Welcome message has been changed for chat ")
                underline(groupMessage.chat.title)
                regular(".\n\n")
                bold("Please, do not delete this message if you want it to work and don't stop this bot to keep welcome message works right")
            } else {
                regular("Something went wrong on welcome message changing for chat ")
                underline(groupMessage.chat.title)
            }
        }
        delete(sentMessage) // Удаляем сообщение с кнопками
    },
    parallel { // Начало операции проверки актуальности админства пользователя
        while (isActive) { // Пока операция не прервана
            delay(config.recheckOfAdmin) // Просто спим :)

            if (adminsCacheAPI.isAdmin(groupMessage.chat.id, user.id)) { // Проверяем админа
                // Если пользователь перестал быть админом - сообщаем об этом и
                // завершаем работу этой и (как следствие) других операций
                edit(sentMessage, "Sorry, but you are not admin in chat ${groupMessage.chat.title} anymore")
                break
            }
        }
    }
)

Ну теперь-то дратути?

Ну и наконец, сама отправка сообщения для новых пользователей:

onNewChatMembers { it: ChatEventMessage<NewChatMembers> -> // Регистрируем триггер входа пользователей
    val chatSettings = welcomeTable.get(it.chat.id) // Получаем настройки чата

    if (chatSettings == null) { // Если для чата нет настроек - отправлять нечего
        return@onNewChatMembers
    }

    reply( // Отправляем ответ на сообщение о входе пользователей
        it, // Используем сообщение - бот поймёт, куда отправлять
            // сообщение и какой messageId использовать для реплая
        chatSettings.sourceChatId, // Чат-источник welcome-сообщения
        chatSettings.sourceMessageId // Идентификатор сообщения welcome-сообщения
    )
}

Поскольку мы использовали идентификаторы чата и сообщения для реплая, бот использует copyMessage метод для отправки сообщения по заданным чату и идентификатору сообщения для копирования.

Что в итоге

В итоге мы сделали плагин для нашего бота, позволяющий настраивать приветственные сообщения для потенциально почти неограниченного количества чатов. Тем не менее, есть много вещей, которые в него можно было бы добавить и я умышленно их пропустил для упрощения кода:

  • Обработка команд, отправленных неадминами

  • Возможность получить welcome-сообщение

  • Уведомления для админов при смене welcome-сообщений

Ради упражнения и пробы вы можете сами добавить этот функционал :)




Комментарии (5):

  1. makapohmgn
    /#24759992

    php)

  2. DeniSix
    /#24760204

    В опросе отстутсвует if err != nil Go. Легко кросскомпилировать, деплоить, легкий рантайм (актуально для всяких Raspberry Pi).

  3. lgorSL
    /#24763544

    Scala. Как котлин, но местами более мощная и красивая