Telegram Bot на Kotlin: Командуем

от автора

Предудыщая часть: 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 репозитории. Приятного пользования!


ссылка на оригинал статьи https://habr.com/ru/post/677624/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *