Привет!
В этой статье я расскажу об эволюции моего проекта — GroupModerBot, бота для модерации Telegram‑групп. Я покажу, как проект прошел путь от первой версии «всё в одном файле» до продуманной архитектуры с ООП, in‑memory кэшированием, безопасным выполнением команд и нестандартными алгоритмами наказаний пользователей.
Предыстория
Закончив свой прошлый проект, я сразу решил взяться за новый: «Нельзя сидеть без дела, всё забудется!». Сначала хотел написать полноценный калькулятор с парсингом строки, работающий с тангенсами и корнями. Создал проект, что‑то написал, но быстро понял: либо я буду постоянно подсматривать код из туториалов, либо погрязну в написании неоптимального велосипеда.
Решил я взяться за что‑то другое, то, что недавно попалось мне на глаза на YouTube — Telegram‑бота. Увидел я это на канале «Максим С++». Он единственный кто сделал полноценный гайд о создании бота на C++ на YouTube. Эту тему в принципе мало кто ещё поднимал. Значит, это нишевая тема для которой есть основа (в виде гайда «Телеграм бот на С++»), а вот, что и как делать дальше мне никто не подскажет.
Отличная задача, чтобы научиться новому и создать что‑то довольно уникальное.
Как всё начиналось
Так как за основу я взял гайд «Телеграм бот на С++» от «Максим С++». Основные библиотеки были выбран такие же как и у него. И стек технологий получился таким:
• Язык: C++ 20
• Библиотеки: tgbot-cpp — отвечает за взаимодействие с Telegram API и SQLiteCpp — обертка над базой данных SQLite.
Как и многие проекты, первая версия моего бота писалась по принципу «лишь бы работало». Вся логика программы концентрировалась в одном файле TestTGBot.cpp внутри огромной функции main(). Однако даже в этой ранней версии были заложены правильные решения:
-
Я сразу же подумал о том, что заставлять пользователя лезть в код, для изменения токена бота или пути к базе данных не удобно. К тому же это заставило бы делать перекомпиляцию
.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(); -
Чтобы нормально работать с базой данных, нужно быть уверенным в наличии у неё всех нужных таблиц и столбцов. Иначе, например, можно отправить SQL‑запрос к несуществующей таблице, из‑за чего будет вызвано исключение и программа упадёт.
Чтобы это предотвратить я создал
const unordered_map<string, vector<string>> dataBasesAndColumnsNames(я знаю, что название не правильное) — который хранит названия таблиц и столбцов этих таблиц, которые должны быть в базе данных. И цикл, проходящий поdataBasesAndColumnsNames, в котором с помощью уже встроенной функцииtableExistsя проверяю наличие таблицы и если она есть, начинается новый цикл. В котором, уже с помощью мной написанной функцииDataBaseHasColumn, я проверяю наличие столбцов в таблицах. Если же таблицы или столбца нет — будет выброшено исключение классаSQLite::Exceptionоб отсутствии конкретного элемента. -
Для определения владельца бота было сделано несколько вещей. Сперва в базе данных была создана таблица
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) — содержат константы для логов и ответов пользователю.
Улучшение отдельных систем
-
Чтение файла конфигурации: Изначально я сделал это просто считыванием первых двух строк. Однако такой подход непрактичен. Пользователь не имеет визуальных ориентиров и может легко перепутать порядок полей.
Для разработчика это тоже проблема: при добавлении новых параметров в середину файла структура сломается, из‑за чего пользователям обязательно придется править файл конфигурации, а разработчику — переписывать логику парсинга.
Я придумал идеальный способ решения этих проблем.
Изначальный способ — это
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(); -
Работа с базой данных: Изначально мной был сделан
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— Показывает текущее количество предупреждений у пользователя.
С помощью этих команд можно легко модерировать сразу несколько групп. А если потребуется помощь. Можно будет назначить админа. Который сможет следить за порядком, но при этом, не будет иметь всех полномочий владельца бота.
Фишки
-
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; } } -
Отказоустойчивость: В коде в стиле «лишь бы работало» безопасности и надёжности места нет. Но этот этап позади. Так что я всерьёз взялся за надёжность бота. У меня почти весь код работает с библиотеками
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)); } } -
Автоматическое создание базы данных: Для работы бота обязательно нужна база данных 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.
Спасибо, что прочитали мою статью. Я открыт к вопросам и конструктивной критике.
Автор: H-D-OWL
