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

в 11:16, , рубрики: c++, C++20, open source, sqlite, telegram, кэширование, модерирование, рефакторинг

Привет!

В этой статье я расскажу об эволюции моего проекта — 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) {
    	}
    };

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

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

В начале была только одна команда /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 и (. . . ), для удобного логирования. Это обеспечивает полную защиту от исключений из обёрнутой функции, а также автоматическое логирование вызванных команд и возникающих ошибок.

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

    Также в 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.

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

Автор: H-D-OWL

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js