Эволюция Telegram‑бота на C++: от «лапши» в main() до ООП, in‑memory кэша и мутов по Фибоначчи

от автора

Привет, Хабр!

В этой статье я расскажу об эволюции моего проекта — GroupModerBot, бота для модерации Telegram‑групп. Я покажу, как проект прошел путь от первой версии «всё в одном файле» до продуманной архитектуры с ООП, in‑memory кэшированием, безопасным выполнением команд и нестандартными алгоритмами наказаний пользователей.

Предыстория

Закончив свой прошлый проект, я сразу решил взяться за новый: «Нельзя сидеть без дела, всё забудется!». Сначала хотел написать полноценный калькулятор с парсингом строки, работающий с тангенсами и корнями. Создал проект, что‑то написал, но быстро понял: либо я буду постоянно подсматривать код из туториалов, либо погрязну в написании неоптимального велосипеда.

Решил я взяться за что‑то другое, то, что недавно попалось мне на глаза на YouTube — Telegram‑бота. Увидел я это на канале «Максим С++». Он единственный кто сделал полноценный гайд о создании бота на C++ на YouTube. Эту тему в принципе мало кто ещё поднимал. Значит, это нишевая тема для которой есть основа (в виде гайда «Телеграм бот на С++»), а вот, что и как делать дальше мне никто не подскажет.

Отличная задача, чтобы научиться новому и создать что‑то довольно уникальное.

Как всё начиналось

Так как за основу я взял гайд «Телеграм бот на С++» от «Максим С++». Основные библиотеки были выбран такие же как и у него. И стек технологий получился таким:

• Язык: C++ 20

• Библиотеки: tgbot-cpp — отвечает за взаимодействие с Telegram API и SQLiteCpp — обертка над базой данных SQLite.

Как и многие проекты, первая версия моего бота писалась по принципу «лишь бы работало». Вся логика программы концентрировалась в одном файле TestTGBot.cpp внутри огромной функции main(). Однако даже в этой ранней версии были заложены правильные решения:

  1. Я сразу же подумал о том, что заставлять пользователя лезть в код, для изменения токена бота или пути к базе данных не удобно. К тому же это заставило бы делать перекомпиляцию .exe. Поэтому было сделано простое решение со считыванием из файла DataForBot.txt первой строки как пути к базе данных, а второй строки как токена бота:

    Первоначальный парсинг файла конфигурации
    ifstream fileDataForBot("DataForBot.txt", ios_base::in);. . .for (int i = 0; fileDataForBot.good() && i < 2; ++i){    string fileLine{};    getline(fileDataForBot, fileLine);    switch (i)    {    case 0:        if (!fileLine.empty())            pathToDatabase = fileLine;        break;    case 1:        if (!fileLine.empty())            botToken = fileLine;        break;    }}fileDataForBot.close();
  2. Чтобы нормально работать с базой данных, нужно быть уверенным в наличии у неё всех нужных таблиц и столбцов. Иначе, например, можно отправить SQL‑запрос к несуществующей таблице, из‑за чего будет вызвано исключение и программа упадёт.

    Чтобы это предотвратить я создал const unordered_map<string, vector<string>> dataBasesAndColumnsNames (я знаю, что название не правильное) — который хранит названия таблиц и столбцов этих таблиц, которые должны быть в базе данных. И цикл, проходящий по dataBasesAndColumnsNames, в котором с помощью уже встроенной функции tableExists я проверяю наличие таблицы и если она есть, начинается новый цикл. В котором, уже с помощью мной написанной функции DataBaseHasColumn, я проверяю наличие столбцов в таблицах. Если же таблицы или столбца нет — будет выброшено исключение класса SQLite::Exception об отсутствии конкретного элемента.

  3. Для определения владельца бота было сделано несколько вещей. Сперва в базе данных была создана таблица Managers со столбцами IdManager, FirstNameManager, LastNameManager. Потом была сделана функция isTableEmpty, которая внутри себя вызывает SQL‑запрос: "SELECT 1 FROM " + tableName + " LIMIT 1". Этот SQL‑запрос проверяет, есть ли в таблице хотя бы одна запись. Если записей нет, значит и владельца быть не может.

    Если владельца нет, генерируется confirmation code (64 символьная строка из цифр) и выводится в консоль. Первый пользователь, который отправлял боту команду /start [confirmation code], записывался в базу данных как владелец бота. А confirmation code затирается, и больше не считается валидным кодом. Это простая, но железобетонная защита от перехвата управления ботом посторонними лицами.

Несмотря на эти неплохие решения, монолитный main()становился трудно читаемым, хоть я и пытался делать разделители в коде. Да и кроме назначения владельца бота с помощью команды /start никакого взаимодействия с Telegram не было.

Но это ведь ерунда. Я молодой, у меня времени много. Да и за сколько времени я всё это сделал? Всего лишь за 5 месяцев!? Блин.

Момент осознания

5 месяцев. Конечно, не всё это время я занимался ботом. Половину этого времени я работал на работе. Но разве это оправдание? Нет. Я недооценил проект. Не продумал его. Я без плана просто писал, то, что, наверное, понадобится. Нужно сделать план, утвердить то, что именно я делаю.

Так. Через 3 месяца я собираюсь, увольняется с работы. Этого времени должно быть достаточно — за следующие 3 месяца я должен закончить бота.

Со временем определился. Но какого именно бота я буду делать? Хмм. Нужно что‑то простое, но и полезное. Бот‑игра — точно нет. Создание игры — это отдельный процесс, требующий графики и изучения множества вещей, не связанных с Telegram. Бот‑чат с ИИ — я без понятия, как работать в C++ с ИИ. К тому же, тут особо негде использовать базу данных. Так что нет. Бот‑модератор — вроде неплохой вариант. Для создания его функционала уже есть стандартные функции (banChatMember, unbanChatMember, restrictChatMember). А базу данных можно использовать для хранения админов, групп, предупреждений и данных с ними связанных. Так что выберу делать его.

С назначением бота разобрался. Осталось лишь придумать ему название. Оно должно быть лёгким, информативным, уникальным и пусть в его названии будет написана его функция. Значит «ModerBot»? Ну, нет, не понятно чего именно он модер. Да и в Telegram это имя занято. Так, а если по перебирать варианты. О «@group_moder_bot» не занято. В принципе понятное и короткое название. Пусть будет. Значит, теперь мой бот будет называться — «GroupModerBot».

Архитектурный прыжок

3 месяца должно быть достаточно для завершения проекта. Но это всё же ограничение по времени. Мне нужно поменять своё отношение к архитектуре проекта. Нужно перейти к чему осмысленному, чётко структурировать проблемы. Решить их так чтобы добиться удобства в использовании, безопасности и поддерживаемости. Иначе я могу не успеть в срок.

Общие изменения

Весь код находился в main(). Из‑за чего получалась каша и код становилось трудно читать. Также main() выполняет и инициализацию, и работу с базой данных и ботом.

Чтобы решить эти проблемы в проекте появились три основных архитектурных столпа:

  • GroupModerBot.cpp — точка входа (main()). В которой происходит только инициализация базы данных и бота, и обработка фатальных исключений.

  • BotDatabase — класс (в BotDatabase.h и BotDatabase.cpp), полностью изолирующий работу с базой данных и реализующий кэширование.

  • BotController — класс (в BotController.h и BotController.cpp) являющийся «мозгом» бота. Он связывает Telegram API, бизнес‑логику команд и базу данных.

А также два дополнительных:

  • Logging — файлы Logging.h и Logging.cpp, содержащие функцию логирования и всё с ней связанное.

  • Constants.h — файл содержащий текстовые константы. Для избавления от «магических» данных и дублирования кода.

Я также подумал о необходимости визуального и практичного разделения моего и чужого кода. Убрал все using namespace из кода и создал основной namespace проекта — gmb (GroupModerBot). Который содержал весь мой код.

Также были добавлены дополнительные namespace:

  • logging (Logging.h и Logging.cpp) — содержит код, связанный с логированием.

  • consts (Constants.h) — содержит общие константы.

  • msg содержащий log и chat (Constants.h) — содержат константы для логов и ответов пользователю.

Улучшение отдельных систем

  1. Чтение файла конфигурации: Изначально я сделал это просто считыванием первых двух строк. Однако такой подход непрактичен. Пользователь не имеет визуальных ориентиров и может легко перепутать порядок полей.

    Для разработчика это тоже проблема: при добавлении новых параметров в середину файла структура сломается, из‑за чего пользователям обязательно придется править файл конфигурации, а разработчику — переписывать логику парсинга.

    Я придумал идеальный способ решения этих проблем.

    Изначальный способ — это vector, в котором данные расположены друг за другом. Что в моём случае неудобно.

    Я сделал как в unordered_map. Данные теперь ищутся по специальным ключам (DbPath=, BotToken=). Благодаря этому теперь точно понятно, где какие данные. Кроме того, их порядок в файле больше не имеет значения:

    Улучшенный парсинг файла конфигурации
    std::ifstream fileDataForBot(std::string(gmb::consts::configFile), std::ios_base::in);. . .while (fileDataForBot.good()){    std::string fileLine{};    getline(fileDataForBot, fileLine);      fileLine.erase(std::remove(fileLine.begin(), fileLine.end(), '\r'), fileLine.end());      if (const size_t offDbPath = fileLine.find(gmb::consts::dbPathKey); offDbPath != std::string::npos)    {        dbPath = fileLine.substr(offDbPath + gmb::consts::dbPathKey.size());    }    else if (const size_t offBotToken = fileLine.find(gmb::consts::botTokenKey); offBotToken != std::string::npos)    {      botToken = fileLine.substr(offBotToken + gmb::consts::botTokenKey.size());    }}fileDataForBot.close();
  2. Работа с базой данных: Изначально мной был сделан const unordered_map<string, vector<string>> dataBasesAndColumnsNames который хранил названия таблиц и столбцов базы данных. Если бы я просто так это оставил, то любое изменение названия, добавление таблицы или столбца в базе данных — приводило бы ручному переписыванию множества SQL‑запросов. Что стало бы адом. Так что я создал специальную структуру TableName.

    Структура TableName — является базовой и нужна для лёгкого создания структур описывающих структуру конкретной таблицы (например BotAdminsTableName, GroupsTableName и т.д). Она содержит два поля: const std::string_view nameTable и const std::vector<std::string_view> columnNames. Которые являются структурой таблицы. И три функции: std::string GetColumnNamesBetweenCommas() const, std::string GetPlaceholders() const и std::string GetColumnsEqualPlaceholders() const. Данные функции работают с полями вне зависимости от объёма их содержимого. Они предназначены для упрощения и автоматизации формирования SQL‑запросов:

    struct BotAdminsTableName и пример её использования
    struct BotAdminsTableName : TableName{    static constexpr std::string_view idColumnName = "Id";static constexpr std::string_view firstNameColumnName = "FirstName";static constexpr std::string_view lastNameColumnName = "LastName";static constexpr std::string_view usernameColumnName = "Username";static constexpr std::string_view isBotColumnName = "IsBot";static constexpr std::string_view isPremiumColumnName = "IsPremium";static constexpr std::string_view isBotOwnerColumnName = "IsBotOwner";BotAdminsTableName() : TableName{ "BotAdmins", {idColumnName, firstNameColumnName, lastNameColumnName, usernameColumnName, isBotColumnName, isPremiumColumnName, isBotOwnerColumnName} } {};};void BotDatabase::AddAdmin(const Admin& user){if (user.username.empty())throw std::runtime_error{ "The user must have a Telegram username (with @)" };if (IsAdmin(user.id))throw std::runtime_error{ "TgBot::User " + user.username + " is already an administrator" };SQLite::Statement query{ *botDatabase,"INSERT INTO "+ std::string(botAdminsTableName.nameTable)+ " ("+ botAdminsTableName.GetColumnNamesBetweenCommas()+ ") VALUES("+ botAdminsTableName.GetPlaceholders()+ ')' };query.bind(1, user.id);query.bind(2, user.firstName);query.bind(3, user.lastName);    query.bind(4, user.username);query.bind(5, static_cast<int64_t>(user.isBot));query.bind(6, static_cast<int64_t>(user.isPremium));query.bind(7, static_cast<int64_t>(user.isBotOwner));query.exec();UpsertCache(user);}

    Для хранения и использования данный из таблиц, я создал структуры: Admin, Group и GroupSettings. Они различаются лишь своими полями. Имея одинаковую структуру:

    struct Admin
    struct Admin{int64_t id{};std::string firstName{}, lastName{}, username{};bool isBot{}, isPremium{}, isBotOwner{};auto operator<=>(const Admin&) const = default;Admin() = default;Admin(int64_t id, std::string firstName, std::string lastName, std::string username, bool isBot, bool isPremium, bool isBotOwner): id(id), firstName(firstName), lastName(lastName), username(username), isBot(isBot), isPremium(isPremium), isBotOwner(isBotOwner) {}};

Бизнес‑логика

В начале была только одна команда /start, способная только назначить владельца бота. Теперь я сделал полноценную warn систему в которой есть роль владельца бота, администратора бота и гостя.

Всего есть 14 команд. Они подразделяются на группы:

• Информационные:

  • /start — Рассказывает о доступных пользователю командах в зависимости от его роли.

• Работа с ботом:

  • /botActive — Активирует бота. Бот начинает выполнять команды в группе.

  • /botDeactive — Деактивирует бота. Бот перестает выполнять команды в группе.

• Работа с группами:

  • /groups — Показывает список всех групп, содержащих бота.

  • /setGroupUniqueTitle — Изменяет uniqueTitle группы (uniqueTitle нужен для правильной идентификации групп).

• Работа с админами:

  • /admins — Показывает список всех администраторов бота.

  • /addAdmin — Создаёт код подтверждения администратора, если отправивший команду — владелец бота. Иначе принимает код подтверждения прав владельца бота.

  • /removeAdmin — Удаляет администратора, используя номер индекса из /admins.

• Настройки warns:

  • /setWarnBanSettings — Устанавливает количество предупреждений перед баном члена группы. По умолчанию: 5.

  • /setWarnMuteSettings — Устанавливает количество предупреждений, после которого член группы будет отправлен в мут. По умолчанию: 3.

Вместо хардкода времени блокировки (например, всегда банить на день) было решено сделать расчет длительности мута на основе чисел Фибоначчи. Продолжительность мута (в днях) вычисляется по формуле: Продолжительность мута = Fibonacci(UserWarns-QuantityWarnToMute):

Функция Fibonacci
int64_t BotController::Fibonacci(const size_t numberOfNumber) const{int64_t num = 1, previousNum = 1;for (size_t i = 1; i < numberOfNumber; ++i){const int64_t temp = previousNum;previousNum = num;num += temp;}return num;}

• Работа с warn:

  • /addWarn — Добавляет указанное количество предупреждений пользователю. По умолчанию: 1.

  • /removeWarn — Убирает указанное количество предупреждений у пользователя. По умолчанию: 1.

  • /setWarn — Устанавливает указанное количество предупреждений пользователю.

  • /viewWarn — Показывает текущее количество предупреждений у пользователя.

С помощью этих команд можно легко модерировать сразу несколько групп. А если потребуется помощь. Можно будет назначить админа. Который сможет следить за порядком, но при этом, не будет иметь всех полномочий владельца бота.

Фишки

  1. In‑memory кэш: В новой версии был реализован внутренний in‑memory кэш на базе std::unordered_map с помощью структуры Cache. При запуске бот полностью выгружает нужные данные (список админов и групп, настройки групп) в оперативную память. Теперь данные читаются не из базы данных напрямую, а из std::unordered_map за константное время O(1). Что снижает общую нагрузки и укоряет работу бота:

    struct Cache и пример её использования
    struct Cache{inline static std::unordered_map<int64_t, Admin> admins{};inline static std::unordered_map<int64_t, Group> groups{};inline static std::unordered_map<std::string, int64_t> groupIdsByUniqueTitle{};inline static std::unordered_map<int64_t, GroupSettings> groupsSettings{};};const BotDatabase::Group* BotDatabase::GetGroup(const int64_t id) const{const auto it = Cache.groups.find(id);if (it != Cache.groups.end()){return &it->second;}else{return nullptr;}}
  2. Отказоустойчивость: В коде в стиле «лишь бы работало» безопасности и надёжности места нет. Но этот этап позади. Так что я всерьёз взялся за надёжность бота. У меня почти весь код работает с библиотеками tgbot и SQLiteCpp. Их функции в любой момент могут бросить исключение. Поэтому я поступил так:

    • Инициализация базы данных и бота: Код обёрнут обычным try-catch. Если исключение бросается до или во время их инициализации — это считается фатальной ошибкой. Причина логируется и программа останавливает свою работу, так как без инициализации базы и бота работа невозможна.

    • Обработка команд: Для экономии времени и сил мной была написана шаблонная функция SafeExecute. Она принимает logging::ContextLog и const Func func (template). Внутри себя она содержит try-catch. Там есть catch на каждый особый exception из библиотек, std::exception и (. . . ), для удобного логирования. Это обеспечивает полную защиту от исключений из обёрнутой функции, а также автоматическое логирование вызванных команд и возникающих ошибок.

    Также в SafeExecute есть лямбда функция SafelySendMessage. Она пытается, отправить лог ошибки пользователю, который её вызвал. Если это не получается, ничего другого не происходит, в лог ничего не пишется. Так как если бы оно писало о своём провале в лог, при отсутствии интернета, лог бы заполнился мусором:

    Функция SafeExecute
    template<typename Func>void SafeExecute(const logging::ContextLog& contextLog, const Func func) noexcept{auto SafelySendMessage = [this](const std::string& id, const std::string& textMessage) noexcept{try{bot.getApi().sendMessage(id, textMessage);}catch (...){//}};try{const logging::OnEventResult onEventResult = func();if (!onEventResult.logMsg.empty())logging::Log(logging::LogSource::Program, logging::LogType::Event, contextLog, onEventResult.logMsg);if (!onEventResult.chatMsg.empty())SafelySendMessage(contextLog.userId, (contextLog.title.empty() ? "" : contextLog.title + ": ") + onEventResult.chatMsg);if (!onEventResult.groupMsg.empty())SafelySendMessage(std::string(contextLog.chatId), std::string(onEventResult.groupMsg));}catch (const SQLite::Exception& e){logging::Log(logging::LogSource::Database, logging::LogType::Error, contextLog, e.what());SafelySendMessage(contextLog.userId, "Database error: " + std::string{ e.what() });}catch (const TgBot::TgException& e){        . . .      }

    • TgBot::TgLongPoll: TgBot::TgLongPoll — это класс реализующий механизм Long Polling для получения обновлений от серверов Telegram. Поломка серверов Telegram или отсутствие интернета, вызовет исключение именно из TgBot::TgLongPoll. Для обеспечения полной отказоустойчивости (кроме случаев отсутствия электричества или памяти) осталось лишь защитить экземпляр TgBot::TgLongPoll longPoll. Для этого я создал функцию Run. В которой происходит инициализация longPoll, после чего запускается вечный цикл с longPoll.start() в SafeExecute:

    Функция Run
    void BotController::Run(){TgBot::TgLongPoll longPoll(bot);while (true){SafeExecute(logging::ContextLog{}, [&]() -> logging::OnEventResult {while (true) { longPoll.start(); }return { "", "" }; });std::this_thread::sleep_for(std::chrono::seconds(5));}}
  3. Автоматическое создание базы данных: Для работы бота обязательно нужна база данных SQLite. Но получается, что для запуска GroupModerBot пользователю обязательно нужно будет скачивать программу для работы с SQLite, разбираться, как с ней работать, и создавать необходимые таблицы. Это очень неудобно и долго.

    Поэтому если пользователь хочет, он может сам создать и назвать базу, где и как угодно. Потом просто указав путь к ней в файле конфигурации. Но если он этим заниматься не хочет: Можно просто стереть ключ DbPath= из файла конфигурации DataForBot.txt и при запуске .exe в папке с ним, автоматически создастся настроенная база данных GroupModerBotDatabase.db.

    Это было сделано простой проверкой на наличие ключа DbPath= в файле конфигурации. Если ключ отсутствует, вызывается функция gmb::BotDatabase::InitStandardDB(). Она создаёт базу и заполняет её всему нужными таблицами, после чего возвращает к ней путь:

    Функция InitStandardDB и пример её использования
    std::string BotDatabase::InitStandardDB(){SQLite::Database db(consts::standardDBFile, SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE);const std::unordered_map<std::string_view, const std::string> queries{              . . .          };for (const auto& table : tables){assert(tables.size() == queries.size() && queries.contains(table->nameTable) && "Table standardDB desync");if (!db.tableExists(std::string(table->nameTable))){SQLite::Statement query{ db, queries.at(table->nameTable)};query.exec();}}return consts::standardDBFile;}

Итог

История GroupModerBot — это наглядный пример того, во что может превратиться проект, если взяться за него всерьёз. Сесть и продумать архитектуру с функционалом, поставив ограничение во времени.

Благодаря ему я научился не только работать с библиотеками tgbot и SQLiteCpp, но и создавать легко расширяемый, универсальный и надёжный код на современном стандарте C++. Конечно код и к текущей версии не совершенен: поддержка лишь Windows, однопоточный код, небольшой функционал. Но это лишь сейчас.

Я собираюсь и дальше работать над GroupModerBot. Добавляя новые возможности и улучшая уже имеющиеся.

Хотите посмотреть полный код GroupModerBot, поддержать проект или использовать его в своих целях? Проект находится в свободном доступе на GitHub.

Спасибо, что прочитали мою статью. Я открыт к вопросам и конструктивной критике.

ссылка на оригинал статьи https://habr.com/ru/articles/1039564/