Предудыщая часть: Telegram Bot на Kotlin: Введение

Это промежуточная часть туториала о том, как можно создавать телеграм ботов на базе библиотек plagubot и tgbotapi. Конкретно в данной получасти речь пойдет про достаточно простой по-сравнению с планируемыми плагин для регистрации команд на старте и их установке/очистке далее в рантайме.

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

Итак, задача

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

  • BotCommandScope

  • LanguageCode (в идеале с возможностью использовать весь спектр ietf кодов)

Базовое решение

Поскольку нам нужно на старте откуда-то брать команды, которые бот ставит изначально, самым простым вариантом будет использование DI для получения всех команд от других плагинов и частей приложения. При этом в этих самых других плагинах и частях приложения достаточно будет зарегистрировать команду с привязкой к нужному типу и рандомным идентификатором (чтобы DI не ругался на конфликт типов):

// Примерно так мы будем забирать команды внутри плагина с командами
koin.getAll<CommandType>().distinct()...
// А так команды будут укладываться в DI в других плагинах
single(named(uuid4().toString())) { /* Creating of CommandType */ }

Кроме того, нужно будет обеспечить возможность добавлять/убирать текущие команды бота. Для этого можно будет использовать какой-то простой set/unset интерфейс вроде:

interface CommandsKeeper {
  suspend fun addCommand(command: CommandType)
  suspend fun removeCommand(command: CommandType)
}
О конечном решении

Если вы посмотрите итоговый CommandsKeeper, то увидите много internal. Это возможность языка ограничивать видимость элементов в рамках некоего модуля. Вкратце, например, onScopeChanged будет доступен только для частей плагина

Ну и последнее - сам плагин. По-сути, на старте он должен собирать все команды, зарегистрированные в DI , самостоятельно класть их в CommandsKeeper и как-то слушать изменения команд и их обновлять.

Кусочки пазла

Поскольку в самой Telegram Bot API нет сущности, которая содержала бы сразу и команду с описанием, и её скоуп, и код языка, такую сущность нам придётся сделать самим. По понятным причинам, это будет достаточно простой дата класс с тремя полями:

data class BotCommandFullInfo(
    val command: BotCommand,
    val scope: BotCommandScope = BotCommandScope.Default,
    val languageCode: String? = null
) {
    val key: Pair<BotCommandScope, String?>? = if (scope == BotCommandScope.Default && languageCode == null) {
      	null
    } else {
      	Pair(scope, languageCode)
    }
}
На практике всё получилось немного сложнее

Пришлось добавить value class CommandsKeeperKey для ключей, который как раз включает всю нужную информацию для команды: BotCommandScope и код языка. Как итог, в плагине и его API все вызовы сводятся к вызовам с CommandsKeeperKey

Для CommandsKeeper'а можно сделать простейшую реализацию на базе мапы, в которой ключами будет контекст набора команд - command scope и language code. Пример базовой реализации на основании интерфейса, представленного выше:

class CommandsKeeper(
    // Получаем в конструкторе команды для установки на старте
    val preset: List<BotCommandFullInfo>
) {
    // Этот Flow можно будет использовать для получения обновлений набора команд для каждого ключевого набора
    internal val onScopeChanged = MutableSharedFlow<Pair<BotCommandScope, String?>?>()

    // Тут можно почитать про groupBy: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/group-by.html
    // Создаёт мапу с ключами из scope и languageCode и значениями - списками команд с этими ключами
    private val scopesCommands: MutableMap<Pair<BotCommandScope, String?>?, MutableSet<BotCommand>> = preset.groupBy {
        it.key
    }.mapValues { (_, v) -> // Заменяем значения в мапе
        // Создаём список команд, доставая их из BotCommandFullInfo и превращаем в set для исключения повторений
        v.map { it.command }.toMutableSet()
    }.toMutableMap() // Превращаем в изменяемую мапу

    // Этот Mutex будет использоваться для исключения параллельного доступа к scopesCommands
    private val mutationsMutext = Mutex()

    // Включение информации о команде
    suspend fun addCommand (command: BotCommandFullInfo) {
        val added = mutationsMutex.withLock { // Блокируем изменение набора команд
            // Получаем существующий сет по ключу ЛИБО создаем новый сет, кладем его в мапу и используем этот сет
            val set = scopesCommands.getOrPut(command.key) { mutableSetOf() }
            // Добавляем команду в сет, add возвращает boolean
            set.add(command.command)
        }
        if (added) {
            // Уведомляем об изменении набора команд для ключа
            onScopeChanged.emit(command.key)
        }
    }

    suspend fun removeCommand (command: BotCommandFullInfo) {
        val removed = mutationsMutex.withLock { // Блокируем изменение набора команд
            // Получаем существующий сет по ключу
            // ЛИБО считаем, что команду нельзя удалить и возвращаем из withLock false,
            // который будет установлен в переменную removed
            val set = scopesCommands.get(command.key) ?: return@withLock false
            // Убираем команду из сета, remove возвращает boolean
            set.remove(command.command)
        }
        if (removed) {
            // Уведомляем об изменении набора команд для ключа
            onScopeChanged.emit(command.key)
        }
    }

		internal fun getKeys(): List<Pair<BotCommandScope, String?>?> {
        // Возвращаем ключи
        return scopesCommands.keys.toList()
    }

    internal fun get(key: Pair<BotCommandScope, String?>?): List<BotCommand> {
        // Получаем известные команды, конвертируем в список ЛИБО возвращаем пустой список
        return scopesCommands.get(key) ?.toList() ?: emptyList()
    }
}

По-сути у нас получился достаточно простой по своей сути класс: мы можем добавить (addCommand) или убрать (removeCommand) команду в других плагинах, а внутри проекта командного плагина мы можем получить набор команд для контекста и подписаться на изменения команд. Всё это приправляется синхронизациями в моменты установки/удаления команды

Правда, это не совсем идиоматично :(

А идиоматично было бы сделать sealed interface для типа задачи и пару data class'ов для операций добавления/удаления команд, отправлять это всё в какой-то канал с вложением Deferred, из которого мы будем ждать результата. Как видно, из минусов такого подхода - он очень громоздкий. Из плюсов - у нас больше не будет синхронизаций и потенциально такой код будет проще для переваривания корутинами и, как следствие, в целом для их работы

Самый жирный кусочек

Как можно догадаться, самым жирным кусочком на этом пироге будет сам плагин. Тем не менее, по-сути, он очень простой: создание CommandsKeeper и регистрация в DI , установка команд бота в момент начала его установки и их отслеживание во время работы бота. Далее представлен скелет нашего плагина:

@Serializable
object CommandsPlugin : Plugin {
    override fun Module.setupDI(database: Database, params: JsonObject) {}

    override suspend fun BehaviourContext.setupBotPlugin(koin: Koin) {}
}

То есть в плагине у нас на базовом уровне плагину не нужно ничего, кроме переопределения методов плагина. А теперь добавим создание и регистрацию CommandsKeeper в setupDI:

override fun Module.setupDI(database: Database, params: JsonObject) {
    // Регистрируем единственный CommandsKeeper инстанс
    single {
        // Создаем инстанс CommandsKeeperImpl
        CommandsKeeper(
            // Получаем все зарегистрированные экземпляры BotCommandFullInfo и исключаем повторения
          	getAll<BotCommandFullInfo>().distinct()
        )
    }
}

Больше в DI мы ничего регистрировать не будем. Поскольку мы обозначили актуализацию команд в двух местах, а именно при инициализации бота и изменении набора команд, будет уместно выделить установку команд и удаление команд при их отсутствии в отдельную функцию:

private suspend fun BehaviourContext.setScopeCommands(scope: BotCommandScope, languageCode: String?, commands: List<BotCommand>) {
    if (commands.isEmpty()) {
        // Удаляем команды для scope и languageCode
        deleteMyCommands(
            scope,
            languageCode
        )
    } else {
        // Устанавливаем команды для scope и languageCode
        setMyCommands(
            // Берем только уникальные команды и берем первые 100 команд, если их больше
          	commands.distinctBy { it.command }.take(botCommandsLimit.last + 1),
          	scope,
          	languageCode
        )
    }
}
Как водится, на самом деле всё сложнее, хотя суть та же

Есть несколько нюансов в конечной реализации:

  • Желательно такой код обрамлять в runCatching/trycatch

  • Список команд на входе нуллабельный, поскольку в реализации CommandsKeeper из Github метод get возвращает null когда набора команд нет

Ну и работа в рамках бота:

override suspend fun BehaviourContext.setupBotPlugin(koin: Koin) {
    // Получаем CommandsKeeper, который регистрировали в DI выше
    val commandsKeeper = koin.get<CommandsKeeper>()

    // Подписываемся на изменения набора команд. it тут - Pair<BotCommandScope, String?>?
    commandsKeeper.onScopeChanged.subscribeSafelyWithoutExceptions(scope) {
      // Получаем набор команд по ключам
      val commands = commandsKeeper.getCommands(it)

      // Устанавливаем команды
      setScopeCommands(it, commands)
    }

    // Получаем известные на момент старта бота ключи (пары scope и languageCode) и для каждого актуализируем набор команд
    commandsKeeper.getKeys().forEach {
      // Получаем набор команд по ключам
      val commands = commandsKeeper.getCommands(it)

      // Устанавливаем команды
      setScopeCommands(it, commands)
    }
}

В целом, это всё :)

Итоги

В итоге мы создали достаточно простой плагин, позволяющий централизовано управлять командами бота. Полный код, инструкции по подключению и иная полезная информации лежат в github репозитории. Приятного пользования!

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


  1. Left2
    25.07.2022 08:39

    А почему onScopeChanged это MutableSharedFlow а не MutableStateFlow? Кому-то может быть интересен набор команд который был до настоящего момента?


    1. InsanusMokrassar Автор
      25.07.2022 08:40

      Shared выдает данные, появляющиеся с момента подписки и до момента отписки
      State - это shared flow, но он предназначен для обращения к последнему элементу

      Нам последний элемент не важен, нам важно следить за изменениями :)


      1. Left2
        25.07.2022 09:12

        что значить "следить за изменениями"? Как раз только последний (текущий) элемент и важен, а предыдущие - ну я чесгоря не представляю сценария где они могут быть интересны. Да, по дефолту у MutableSharedFlow replay: Int = 0 - так что "перепроигрывать" прошлые подписки в этом конкретном коде он не будет, и память течь тоже не будет - но ИМХО все же тут типичный сценарий для StateFlow


        1. InsanusMokrassar Автор
          25.07.2022 09:46

          А где мне использовать последнее состояние?:) На данный момент я подписываюсь на поток данных, а вот последнее состояние я нигде не использую


          1. Left2
            25.07.2022 11:14

            У SharedFlow и StateFlow разный паттерн использования. SharedFlow - это значит что нам интересны replay=N последних состояний. StateFlow - это значит что нам интересно только последнее состояние. Грубо говоря, StateFlow - это одновременно И переменная И возможность слушать ее изменения. Т.е. по-хорошему - нужно и scopesCommands и onScopeChanged иметь одной переменной, scopeCommands = MutableStateFlow<...>(emptyMap()). Тогда изменение ее (scopeCommands.value += aaa или scopeCommands.value -= bbb) - будет автоматически emit-ить в нужный flow.


            1. InsanusMokrassar Автор
              25.07.2022 11:33

              Я не знаю, кто вам сказал, что shared flow про replay (если бы он был про replay - последний не имел бы дефолтного значения и уж точно не равнялся 0 по-умолчанию)

              Нам не нужна переменная в рамках работы по прослушиванию потока данных, поэтому нам не нужен StateFlow :)

              Вы пытаетесь смешать мух с котлетами - у нас есть текущее состояние кипера и у нас есть поток его гранулярных изменений. Если я создам StateFlow для всего keeper и буду уведомлять именно об актуальном состоянии набора данных - я во-первых не буду знать, что конкретно изменилось, а во-вторых - буду знать слишком много о том, где/как/в каком виде у нас хранится набор команд. Сейчас же при необходимости и запросе со стороны пользователей получится выделить интерфейс для keeper и делать сохранение команд как нам хочется


  1. md_backend_binance
    25.07.2022 11:34

    Можно тоже самое сделать только не от бота , а от своего аккаунта , со своим логином и паролем?


    1. InsanusMokrassar Автор
      25.07.2022 11:34

      Да, но не с этими либами. Для этого используется клиентский API (который команда Telegram вообще настоятельно просит использовать только для кастомных клиентов, а не ботов)