Предудыщая часть: Telegram Bot на Kotlin: Введение
Это промежуточная часть туториала о том, как можно создавать телеграм ботов на базе библиотек plagubot и tgbotapi. Конкретно в данной получасти речь пойдет про достаточно простой по-сравнению с планируемыми плагин для регистрации команд на старте и их установке/очистке далее в рантайме.
Стоит сказать, что в этой статье будет представлен упрощенный код. С его помощью можно будет создать аналог того плагина, который в итоге получился, и, тем не менее, в статье он будет чуть более поверхностным. По-возможности я старался добавлять спойлеры там, где код в итоге другой, поэтому проблем возникнуть не должно.
Оглавление
Итак, задача
Как уже говорилось выше, задача у данного плагина очень простая - регистрировать команды бота на старте и иметь возможность менять набор команд в процессе работы. Само собой, хотелось бы для каждой команды иметь возможность уточнять полный спектр параметров, а именно:
BotCommandScopeLanguageCode(в идеале с возможностью использовать весь спектр 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 репозитории. Приятного пользования!
