Зачем мы написали библиотеку для создания телеграм ботов на С++?


Сейчас телеграм боты крайне популярны, вкратце что они из себя представляют: чтобы создать своего бота нужно получить токен у @BotFather, а потом используя его обращаться в HTTP API для получения обновлений (Update)

Есть два способа получения апдейтов:

  • getUpdates: обновления пачками приходят через механизм long poll

  • setWebhook: обновления по одному приходят на какой-то ваш адрес в виде http запроса из телеграма

Теоретически, setWebhook должен быть эффективнее, но на практике long справляется не хуже и не создаёт лишних сложностей в реализации и запуске бота.

Когда апдейт получен, бот просто использует апи, например sendMessage, в общем выполняет свою бизнес-логику.

Требования

Казалось бы, если всё так просто и есть спрос, то наверняка уже сотни библиотек для удобного создания ботов?

Вкратце — нет, если вы захотите написать телеграм бота, то вы (были) вынуждены делать это на python. Библиотеки на других языках непопулярны и зачастую не выполняют даже минимальных требований

Кстати, про минимальные требования. Для создания чего-то серьёзного хотелось бы по крайней мере:

  • асинхронное обращение к апи

  • легкое в подключение библиотеки и лёгкость в использовании

  • http2, по причинам схожим с теми, по которым хочется иметь асинхронность (переиспользование соединения)

    Итак, по доброй традиции С++, мы не нашли под свои требования существующих библиотек и потому приступили к написанию своей.

TGBM

в TGBM (telegram-bot motherlib) нужно было создать три главных компонента:

  • генерация api методов и классов на основе документации

  • json — парсинг ответов и сериализация запросов

  • http2 и ssl (телеграм требует ssl соединение для работы)

И так как это С++, то перед тем как приступать к коду нужно решить главную проблему — как будут подключать вашу библиотеку. Конечно, CMake вне конкуренции и он точно будет. Но одного его недостаточно для подключения библиотеки с такими зависимостями как openssl (порой поражаешься, насколько сложной в подключении можно сделать библиотеку из кучки .c файлов) и boost.

Все до этого существующие библиотеки телеграм ботов на С++ требовали установить зависимости вручную.

Почему не vcpkg: эта система сборки похожа больше на шутку. Сидит какое-то количество программистов microsoft и вручную добавляет все библиотеки, потом вручную их обновляет, никакой расширяемости. Этот «пакетный менеджер» не умеет в версии библиотек. В него нельзя добавить свою библиотеку. Всё через какие-то странные костыли. И главное, последние несколько лет эта штука даже не развивается.

Они владеют github, у них целая операционная систем и они выпускают вот такие релизы:

74b6b2daeca96ae742056b07afc454f7.png

Почему не conan: не просто так существует conan2. Очень сложная в использовании система, которая по моему личному мнению проиграет конкуренцию, так что уже сейчас использовать её не нужно. В конце концов, не для того мы пишем на С++ чтобы писать билд скрипты на питоне

Так что мы продолжили поиски пакетного менеджера. Оказалось, что решение есть, но оно (пока) не так популярно

CPM

CPM (cmake package manager). Этот пакетный менеджер как можно понять из названия использует cmake и основан на механизме cmake fetch content. При использовании CPM подключение библиотеки с любыми сложными зависимостями (openssl, boost, HPACK) выглядит просто:

CPMAddPackage(
  NAME TGBM
  GIT_REPOSITORY https://github.com/bot-motherlib/TGBM
  GIT_TAG        v1.0.1
  OPTIONS "TGBM_ENABLE_EXAMPLES ON"
)

target_link_libraries(MyTargetName tgbmlib)

Думаю, для многих С++ программистов станет открытием, что не обязательно круглосуточно страдать при подключении зависимостей

И только теперь, после решения главной проблемы С++ можно приступать к коду.

Генерация апи

Первой неожиданностью стало то, что телеграм не предоставляет какой-то формальной схемы своего апи. Есть по сути только человекочитаемый текст, из-за чего парсить его и генерировать что-то на его основе не просто мука, а минное поле, учитывая что меняется апи примерно раз в 2 недели.

Так или иначе, с этим можно побороться, всего пару недель парсинга html глазами для выявления закономерностей =)

После этого ещё нужно узнать то, что в документации не написано: какой формат ответа у телеграма, например он не просто присылает status + body, вместо этого там в зависимости от запроса и вероятно расположения духа того кто в тот день это писал может быть
{ «ok» : true, result: «то что ты действительно хотел получить», «description»:», «error_code» }, а иногда оно вместо json может вообще в ответ прислать html, в общем там много мест для исследования методом проб, ошибок и мечтаний о том чтобы тг разраб это написал в документации.

6d4d9853468975c4909d4062b30b3b80.png

После того как закономерности выявлены, пайплайн генерации таков:

  • html документация телеграма

  • скрипт, который создаёт файлы на С++ с структурами и функциями

  • С++ рефлексия (boost pfr) и несколько шаблонов генерирующие из С++ структур json парсинг и сериализацию запросов

Дальше остаётся «всего лишь» сформировать корректный json, отправить его по сети, получить обратно и распарсить.

Здесь достаточно упомянуть то что вышло в итоге: json парсится потоково, насколько это возможно эффективно, внутри используется boost json. Сериализация возложена на rapid json.

Насчёт http2… В С++ огромное множество библиотек на любую ситуацию. Именно из-за этого наивного мифа в итоге для http2 есть только nghttp2, после взгляда на которую было решено, что легче будет написать с нуля. Ну и написали реализацию http2 с нуля.

echobot

наконец можно посмотреть на какого-то работающего бота. По традиции это будет echobot, который отправляет в ответ то, что ему написал пользователь:

#include 

dd::task main_task(tgbm::bot& bot);

int main() {
  tgbm::bot bot{/* YOUR BOT TOKEN */};

  main_task(bot).start_and_detach();
  bot.run();

  return 0;
}

dd::task main_task(tgbm::bot& bot) {
  using namespace tgbm::api;

  auto updates = bot.updates();
  while (Update* u = co_await updates.next()) {
    Message* m = u->get_message();
    if (!m || !m->text)
      continue;
    bot.api.sendMessage({.chat_id = m->chat->id, .text = *m->text})
           .start_and_detach();
  }
}

Первое что бросается в глаза — то что это даже проще в коде, чем аналогичные боты на питоне.

Разберём по строкам то что тут происходит:

  • создаём бота, передавая токен от BotFather

  • создаём цикл обработки апдейтов (тут он назван main_task) и запускаем его не блокируясь (start_and_detach)

  • и запускаем бота (bot.run ())

bot.updates () возвращает асинхронный генератор апдейтов, из которого мы их получаем по одному (за этим скрыт один из способов получения апдейтов, long-poll или webHooks). Нам показалась эта схема наиболее гибкой и понятной.

В данном случае, мы просто отправляем в ответ sendMessage с тем же текстом, что прислал юзер (если это вообще был текст), при этом не блокируем ни корутину, ни поток, чтобы тут же начать обрабатывать следующий апдейт (.start_and_detach).

В целом, телеграм апи полностью повторяется в bot.api с такими же именами, причём везде используются структуры для имитации именованных аргументов. В будущем планируется добавить билдеры запросов, но это лишь микрооптимизация.

Команды

Команды это важная часть телеграм бота, каждый бот (по негласной конвенции) должен поддерживать команду /start и вот как добавление команд выглядит в tgbm (это тот же самый бот, но с командой send_cat отправляющей фото кота).


#include 

dd::task main_task(tgbm::bot& bot);

int main() {
  tgbm::bot bot{/* YOUR BOT TOKEN */};

  bot.commands.add("send_cat", [&bot](tgbm::api::Message msg) {
    bot.api.sendPhoto({
            .chat_id = msg.chat->id,
            .photo = tgbm::api::InputFile::from_file("path/to/cat", "image/jpeg"),
        })
        .start_and_detach();
  });

  main_task(bot).start_and_detach();
  bot.run();

  return 0;
}

dd::task main_task(tgbm::bot& bot) {
  using namespace tgbm::api;

  auto updates = bot.updates();
  while (Update* u = co_await updates.next()) {
    Message* m = u->get_message();
    if (!m || !m->text)
      continue;
    bot.api.sendMessage({.chat_id = m->chat->id, .text = *m->text})
           .start_and_detach();
  }
}

Теперь неплохо бы поговорить о том, что в действительности происходит внутри. Мы старались обойтись без неявностей, так как важно понимать что твой код делает.

Во время обработки цикла (co_await updates.next ()) если апдейт был командой (сообщением /send_cat), то вместо того чтобы разбудить корутину ожидающую Update, вызывается обработчик команды.

Внутри запросов api тоже всё прозрачно: формируется json запрос, в зависимости от содержимого запроса либо application/json либо multipart data по требованиям телеграма, кодируется в http2 + ssl, отправляется по сети, потом когда-то асинхронно читается и возвращает управление в этот цикл. Всё это (в данном случае) в одном потоке, никаких скрытых тредпулов.

Вот и всё, наконец-то на С++ можно просто взять и написать телеграм бота, пользуйтесь) https://github.com/bot-motherlib/TGBM

© Habrahabr.ru