🎭 Работа с 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-месседж.
  • И если получилось уже все задекодить и распарсить в какой-то объект, то уже далее идет типизированный мэтч по возможным значениям этого объекта.

base85 (также известный как ASCII85) это просто алгоритм энкодинга голых байтов в строку. Вы также можете использовать старый-добрый base64, но base85, хоть и менее распространен, является чуть более эффективным в плане размера выходной строки. Это имеет значение, так как размер значения callback_data ограничен 64 байтами.

Давайте теперь посмотрим, как это все выглядит в коде. Например, у меня есть колбэки для просмотра информации о сохраненных закладках и категориях. Мои 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))

}

Заключение

Как Вы можете видеть, теперь все типизировано, красиво разложено по коду, чуть более секьюрно и здесь теперь нет даже спэйса для ошибок. В отличие от обычного подхода, где Вы формируете голые строки и должны следить за тем, чтобы они были корректно сформированы, корректно распарсены и чтобы между парсерами не было конфликтов.

Спасибо за прочтение этой небольшой статьи. Надеюсь, она была полезна.

Посмотрите также статьи на похожие темы: