Предудыщая часть: 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)
md_backend_binance
25.07.2022 11:34Можно тоже самое сделать только не от бота , а от своего аккаунта , со своим логином и паролем?
InsanusMokrassar Автор
25.07.2022 11:34Да, но не с этими либами. Для этого используется клиентский API (который команда Telegram вообще настоятельно просит использовать только для кастомных клиентов, а не ботов)
Left2
А почему onScopeChanged это MutableSharedFlow а не MutableStateFlow? Кому-то может быть интересен набор команд который был до настоящего момента?
InsanusMokrassar Автор
Shared выдает данные, появляющиеся с момента подписки и до момента отписки
State - это shared flow, но он предназначен для обращения к последнему элементу
Нам последний элемент не важен, нам важно следить за изменениями :)
Left2
что значить "следить за изменениями"? Как раз только последний (текущий) элемент и важен, а предыдущие - ну я чесгоря не представляю сценария где они могут быть интересны. Да, по дефолту у MutableSharedFlow replay: Int = 0 - так что "перепроигрывать" прошлые подписки в этом конкретном коде он не будет, и память течь тоже не будет - но ИМХО все же тут типичный сценарий для StateFlow
InsanusMokrassar Автор
А где мне использовать последнее состояние?:) На данный момент я подписываюсь на поток данных, а вот последнее состояние я нигде не использую
Left2
У SharedFlow и StateFlow разный паттерн использования. SharedFlow - это значит что нам интересны replay=N последних состояний. StateFlow - это значит что нам интересно только последнее состояние. Грубо говоря, StateFlow - это одновременно И переменная И возможность слушать ее изменения. Т.е. по-хорошему - нужно и scopesCommands и onScopeChanged иметь одной переменной, scopeCommands = MutableStateFlow<...>(emptyMap()). Тогда изменение ее (scopeCommands.value += aaa или scopeCommands.value -= bbb) - будет автоматически emit-ить в нужный flow.
InsanusMokrassar Автор
Я не знаю, кто вам сказал, что shared flow про replay (если бы он был про replay - последний не имел бы дефолтного значения и уж точно не равнялся 0 по-умолчанию)
Нам не нужна переменная в рамках работы по прослушиванию потока данных, поэтому нам не нужен StateFlow :)
Вы пытаетесь смешать мух с котлетами - у нас есть текущее состояние кипера и у нас есть поток его гранулярных изменений. Если я создам StateFlow для всего keeper и буду уведомлять именно об актуальном состоянии набора данных - я во-первых не буду знать, что конкретно изменилось, а во-вторых - буду знать слишком много о том, где/как/в каком виде у нас хранится набор команд. Сейчас же при необходимости и запросе со стороны пользователей получится выделить интерфейс для keeper и делать сохранение команд как нам хочется