🎭 Работа с callback_data в Telegram-боте с использованием protobuf + base85
Если Вы когда-либо разрабатывали Telegram-бота, Вы наверняка знаете, что такое callback_data. Если нет, вкратце, это произвольная строка, которая привязывается к кнопкам в чате, при помощи которой на бэкенде Вы определяете, какая именно кнопка была нажата.
Когда Ваш бот масштабируется, скорее всего управление значениями callback_data
превращается в “кашу”. По крайней мере, так произошло у меня. Поэтому сегодня я хочу поделиться с Вами практикой по организации этой всей “каши” в красивый и органичный код.
Что не так с callback_data?
Здесь я рассчитываю, что Вы уже знакомы с Bot API. Чтобы лучше понять проблему, давайте рассмотрим несколько примеров. Везде далее я пишу код на Scala, но в целом, думаю, подход может быть применен с использованием любого ЯП.
Представьте, у Вас есть бот, который может менеджить список чего-либо. Например, список товаров:
- У Вашего приложения есть команда
/ls
, которая выводит сообщение с нумерованным списком товаров, и клавиатуройinline_keyboard
для выбора товара (кнопки “1”, “2”, “3” и так далее). - У каждой кнопки клавиатуры
callback_data
задан в значениеinfo_${id}
, где${id}
это идентификатор товара. - Когда выбирает товар, нажимая кнопку, бот отвечает сообщением, которое содержит информацию о выбранном товаре и клавиатуру
inline_keyboard
с кнопками “Delete”, “Buy”, “Assign a category”. Каждая из кнопок имеетcallback_data
установленный в значенияdelete_${id},
buy_${id},
assign_category_${id}
соответственно. - Когда пользователь нажимает на кнопку “Assign category”, бот отображает захардкоженный нумерованный список категорий, которые можно присвоить товару, опять же, с клавиатурой
inline_keyboard
для выбора категории по номеру. - Каждая из этих кнопок имеет
callback_data
, который уже будет выглядеть какassign_category_${id}_${categoryId}
, где${id}
и${categoryId}
это идентификаторы товара и категории соответственно. - И теперь представьте, что Вы захотели при клике еще и обновлять выведенное ранее сообщение с информацией о товаре, и тогда наш
callback_data
превращается уже во что-то типаassign_category_${id}_${categoryId}_${messageId}
🤡.
Обработка колбэков типа info_${id}
, buy_${id}
, remove_${id}
, выглядит еще нормально, но более сложные сценарии по типу assign_category_${id}_${categoryId}_${messageId}
уже выглядят немножко громоздко и их сложно менеджить, особенно, если Вам необходимо обрабатывать много таких сценариев.
Более того, если среди Ваших пользователей есть злоумышленники, они могут попробовать атаковать Вашего бота, посылая в него произвольные callback_data
. Им будет проще использовать этот вектор атаки, если значения этого параметра никак не будут защищены. Конечно, Вы всегда должны защищаться от этого, вне зависимости от формата обработки этого параметра, но, тем не менее, дополнительная защита никогда не лишняя.
Как пофиксить эту “кашу” с использованием protobuf + base85
В моем боте Advanced Link Saver (небольшая статья про используемый стэк технологий) реализовано достаточно много сложных сценариев взаимодействия и обработка их всех была ну прям затруднена. Поэтому я пришел к этому, немного экзотическому, методу обработки параметра callback_data
:
- Каждый колбэк описывается через protobuf-месседж.
- Значение
callback_data
теперь не просто голая строка по типуinfo_${id}
, а закодированный в base85 protobuf-месседж. - Все обработчики колбэков в коде теперь работают не с голыми строками по регэспу, а ожидают там base85 строку, которую они пытаются задекодить в определенный protobuf-месседж.
- И если получилось уже все задекодить и распарсить в какой-то объект, то уже далее идет типизированный мэтч по возможным значениям этого объекта.
Давайте теперь посмотрим, как это все выглядит в коде. Например, у меня есть колбэки для просмотра информации о сохраненных закладках и категориях. Мои protobuf-месседжи для них выглядят следующим образом:
message InfoCategory {
uint32 categoryId = 1;
}
message InfoLink {
uint32 linkId = 1;
}
message Info {
oneof callbackData {
InfoLink infoLink = 1;
InfoCategory infoCategory = 2;
}
}
Обработчик этих коллбэков выглядит так:
class InfoCallbackHandler() {
override def handle(callback: CallbackQuery) =
(
callback
.data // this variable is a plain `callback_data` string
.flatMap(ProtobufUtils.fromBase85String[Info])
.map(_.callbackData)
) match {
case Some(Info.CallbackData.InfoCategory(InfoCategory(categoryId))) =>
// ...
case Some(Info.CallbackData.InfoLink(InfoLink(linkId))) =>
// ...
case _ =>
ZIO.fail(new IllegalArgumentException())
}
}
А вот так я заполняю значения callback_data
на кнопках:
val infoButton = InlineKeyboardButton(
text = "Info",
callbackData = Some(
ProtobufUtils.toBase85String(
Info(
Info.CallbackData.InfoLink(InfoLink(link.id /* int */))
)
)
)
)
А вот и сам кодек, но, полагаю, это будет понятно только Scala-разработчикам. На самом деле, это просто енкодинг/декодинг protobuf-месседжа в/из base85:
// Using https://github.com/fzakaria/ascii85 to decode/encode base85 data
import com.github.fzakaria.ascii85.Ascii85
// And https://scalapb.github.io to generate Scala classes from protobuf messages
import scalapb.GeneratedMessage
import scalapb.GeneratedMessageCompanion
object ProtobufUtils {
def fromBase85String[Message <: GeneratedMessage](value: String)(implicit
mComp: GeneratedMessageCompanion[Message]
): Option[Message] = mComp.validate(Ascii85.decode(value)).toOption
def toBase85String[Message <: GeneratedMessage](s: Message)(implicit
mComp: GeneratedMessageCompanion[Message]
): String = Ascii85.encode(mComp.toByteArray(s))
}
Заключение
Как Вы можете видеть, теперь все типизировано, красиво разложено по коду, чуть более секьюрно и здесь теперь нет даже спэйса для ошибок. В отличие от обычного подхода, где Вы формируете голые строки и должны следить за тем, чтобы они были корректно сформированы, корректно распарсены и чтобы между парсерами не было конфликтов.
Спасибо за прочтение этой небольшой статьи. Надеюсь, она была полезна.
Посмотрите также статьи на похожие темы: