From da40d7df499a02454d3fa7777dc683703c747ea3 Mon Sep 17 00:00:00 2001 From: Kirill Date: Sat, 20 Dec 2025 01:19:56 +0300 Subject: [PATCH] feat(tgbot): add title menu --- modules/bot/back/include/BotToServer.hpp | 4 + modules/bot/back/src/BotToServer.cpp | 99 +++++++++++++++++++ modules/bot/front/include/KeyboardFactory.hpp | 3 + modules/bot/front/include/handlers.hpp | 22 +++++ modules/bot/front/include/structs.hpp | 1 + modules/bot/front/src/handleNavigation.cpp | 70 ++++++++++++- modules/bot/front/src/handlers.cpp | 56 +++++++++-- 7 files changed, 244 insertions(+), 11 deletions(-) diff --git a/modules/bot/back/include/BotToServer.hpp b/modules/bot/back/include/BotToServer.hpp index d4b132b..36e6943 100644 --- a/modules/bot/back/include/BotToServer.hpp +++ b/modules/bot/back/include/BotToServer.hpp @@ -25,6 +25,7 @@ public: // Асинхронный метод: получить список тайтлов пользователя pplx::task> fetchUserTitlesAsync(const std::string& userId); + pplx::task fetchTitleAsync(const std::string& userId, int64_t titleId); private: std::shared_ptr apiconfiguration; @@ -32,4 +33,7 @@ private: std::shared_ptr api; nyanimed::AuthImpersonationClient authClient; + + static BotStructs::Title mapTitleToBotTitle( + const std::shared_ptr& titleModel); }; \ No newline at end of file diff --git a/modules/bot/back/src/BotToServer.cpp b/modules/bot/back/src/BotToServer.cpp index e9d33ff..d403a01 100644 --- a/modules/bot/back/src/BotToServer.cpp +++ b/modules/bot/back/src/BotToServer.cpp @@ -114,4 +114,103 @@ pplx::task> BotToServer::fetchUserTitlesAsync(con throw; } }); +} + +pplx::task BotToServer::fetchTitleAsync(const std::string& userId, int64_t titleId) { + auto impersonationTask = authClient.getImpersonationToken(std::stoi(userId)); + + // Шаг 2: После получения токена — делаем запрос getTitle с этим токеном + return impersonationTask.then([=](pplx::task> tokenTask) { + try { + auto tokenResponse = tokenTask.get(); + if (!tokenResponse) { + throw std::runtime_error("Null response from getImpersonationToken"); + } + + utility::string_t accessToken = utility::conversions::to_string_t(tokenResponse->getAccessToken()); + + // Формируем заголовки с токеном + std::map customHeaders; + customHeaders[U("Cookie")] = U("access_token=") + accessToken; + + // Шаг 3: Выполняем запрос getTitle + return api->getTitle( + titleId, + boost::none, // fields — оставляем по умолчанию (все поля) + customHeaders + ); + + } catch (const std::exception& e) { + std::cerr << "Error obtaining impersonation token in fetchTitleAsync: " << e.what() << std::endl; + throw; + } + }).then([](pplx::task> responseTask) { + try { + auto response = responseTask.get(); + if (!response) { + throw std::runtime_error("Null response from getTitle"); + } + + // Преобразуем модель OpenAPI в внутреннюю структуру бота + BotStructs::Title botTitle = mapTitleToBotTitle(response); + return botTitle; + + } catch (const web::http::http_exception& e) { + std::cerr << "HTTP error in fetchTitleAsync: " << e.what() << std::endl; + throw; + } catch (const std::exception& e) { + std::cerr << "Error in fetchTitleAsync: " << e.what() << std::endl; + throw; + } + }); +} + +BotStructs::Title BotToServer::mapTitleToBotTitle( + const std::shared_ptr& titleModel) +{ + if (!titleModel) { + throw std::invalid_argument("titleModel is null"); + } + + BotStructs::Title botTitle; + botTitle.id = titleModel->getId(); + botTitle.num = 0; + botTitle.description = ""; // Описание недоступно в текущей модели + + // Извлекаем название + std::string titleName; + const auto& titleNames = titleModel->getTitleNames(); + + // Попробуем ru → en → первый попавшийся + std::vector preferredLangs = { U("ru"), U("en") }; + bool found = false; + for (const auto& lang : preferredLangs) { + auto it = titleNames.find(lang); + if (it != titleNames.end() && !it->second.empty()) { + titleName = utility::conversions::to_utf8string(it->second[0]); + found = true; + break; + } + } + + if (!found && !titleNames.empty()) { + // Берём первый язык и первое название + const auto& firstLang = *titleNames.begin(); + if (!firstLang.second.empty()) { + titleName = utility::conversions::to_utf8string(firstLang.second[0]); + } + } + + botTitle.name = titleName; + + // --- Изображение --- + botTitle.imageUrl = ""; + if (titleModel->posterIsSet()) { + auto poster = titleModel->getPoster(); + if (poster && poster->imagePathIsSet()) { + botTitle.imageUrl = utility::conversions::to_utf8string(poster->getImagePath()); + } + } + + return botTitle; } \ No newline at end of file diff --git a/modules/bot/front/include/KeyboardFactory.hpp b/modules/bot/front/include/KeyboardFactory.hpp index f51b5a1..6a2dd52 100644 --- a/modules/bot/front/include/KeyboardFactory.hpp +++ b/modules/bot/front/include/KeyboardFactory.hpp @@ -11,4 +11,7 @@ public: /// Create keyboard for sendError static TgBot::InlineKeyboardMarkup::Ptr createError(const std::string& errorCallback); + + /// Create keyboard for Title page + static TgBot::InlineKeyboardMarkup::Ptr createTitleMenu(int64_t title_id); }; diff --git a/modules/bot/front/include/handlers.hpp b/modules/bot/front/include/handlers.hpp index 327c17b..a86f484 100644 --- a/modules/bot/front/include/handlers.hpp +++ b/modules/bot/front/include/handlers.hpp @@ -3,6 +3,9 @@ #include #include #include +#include +#include +#include #include "BotToServer.hpp" #include "BotUserContext.hpp" @@ -66,6 +69,19 @@ private: /// @param response Параметры ответа: клавиатура и текст void editMessage(int64_t chatId, int64_t messageId, HandlerResult response); + /// @brief Редактирует текущее сообщение в диалоге с пользователем + /// @details Меняет текст сообщения и клавиатуру на те, что передаются + /// в аргументе response, а также прикрепляет картинку + /// @param chatId id чата + /// @param messageId id меняемого сообщения + /// @param imageUrl ссылка на вставляемую картинку (должна быть доступна в публичной сети) + /// @param response Параметры ответа: клавиатура и текст + void editMessageWithPhoto( + int64_t chatId, + int64_t messageId, + const std::string& imageUrl, + HandlerResult response); + /// @brief Отрисовка текущего экрана (соотв. контексту) /// @param ctx - текущий контекст void renderCurrent(TgBot::CallbackQuery::Ptr query); @@ -87,4 +103,10 @@ private: // Форматирование для отображения в сообщении std::string formatTitlesList(const std::vector& titles); + + // Форматирование сообщения на страничке с тайтлом + std::string formatTitle(const BotStructs::Title& title); + + // Парсинг id тайтла из callback'а + int64_t parseId(const std::string& data); }; diff --git a/modules/bot/front/include/structs.hpp b/modules/bot/front/include/structs.hpp index d702163..0db7925 100644 --- a/modules/bot/front/include/structs.hpp +++ b/modules/bot/front/include/structs.hpp @@ -6,5 +6,6 @@ struct Title { std::string name; std::string description; int64_t num; + std::string imageUrl; }; } diff --git a/modules/bot/front/src/handleNavigation.cpp b/modules/bot/front/src/handleNavigation.cpp index ded36b1..41ab3ec 100644 --- a/modules/bot/front/src/handleNavigation.cpp +++ b/modules/bot/front/src/handleNavigation.cpp @@ -95,9 +95,28 @@ void BotHandlers::renderCurrent(TgBot::CallbackQuery::Ptr query) { }); return; - /* + case UserState::VIEWING_TITLE_PAGE: - return returnTitlePage(step.payload); // payload = titleId + server_.fetchTitleAsync(std::to_string(22), step.value().payload) + .then([this, chatId, messageId](pplx::task t) { + try { + auto title = t.get(); + + std::string message = formatTitle(title); + auto keyboard = KeyboardFactory::createTitleMenu(title.id); + + std::string imageUrl = "https://i.pinimg.com/736x/30/2b/49/302b49176e5a74ef43871a462df47e1f.jpg"; + + editMessageWithPhoto(chatId, messageId, imageUrl, {message, keyboard}); + + } catch (const std::exception& e) { + sendError(chatId, messageId, BotConstants::Text::SERVER_ERROR); + // Логирование ошибки (например, в cerr) + } + }); + + return; + /* case UserState::VIEWING_REVIEW: return returnReview(step.payload); // payload = reviewId case UserState::AWAITING_REVIEW: @@ -121,14 +140,14 @@ std::optional BotHandlers::computeNextStep( return NavigationStep{UserState::VIEWING_MY_TITLES, 0}; } break; - /* + case UserState::VIEWING_MY_TITLES: - if (data.starts_with("title_")) { + if (data.starts_with(BotConstants::Callback::CHOICE)) { int64_t titleId = parseId(data); return NavigationStep{UserState::VIEWING_TITLE_PAGE, titleId}; } break; - + /* case UserState::VIEWING_TITLE_PAGE: if (data == BotConstants::Callback::ACTION_ADD_REVIEW) { return NavigationStep{UserState::AWAITING_REVIEW, current.payload}; @@ -156,4 +175,45 @@ std::string BotHandlers::formatTitlesList(const std::vector& msg += std::to_string(i + 1) + ". " + titles[i].name + "\n"; } return msg; +} + +std::string BotHandlers::formatTitle(const BotStructs::Title& title) { + std::string msg; + msg += title.name + "\n"; + + return msg; +} + +int64_t BotHandlers::parseId(const std::string& data) { + const std::string prefix = "choice:"; + if (data.substr(0, prefix.size()) != prefix) { + throw std::invalid_argument("Data does not start with 'choice:'"); + } + + std::string idPart = data.substr(prefix.size()); + + // Проверяем, что остаток состоит только из цифр (и, возможно, знака '-') + if (idPart.empty()) { + throw std::invalid_argument("ID part is empty"); + } + + size_t startPos = 0; + if (idPart[0] == '-') { + if (idPart.size() == 1) { + throw std::invalid_argument("Invalid negative ID"); + } + startPos = 1; + } + + for (size_t i = startPos; i < idPart.size(); ++i) { + if (!std::isdigit(static_cast(idPart[i]))) { + throw std::invalid_argument("ID contains non-digit characters"); + } + } + + try { + return std::stoll(idPart); + } catch (const std::out_of_range&) { + throw std::out_of_range("ID is out of range for int64_t"); + } } \ No newline at end of file diff --git a/modules/bot/front/src/handlers.cpp b/modules/bot/front/src/handlers.cpp index 0555cc3..a6defe9 100644 --- a/modules/bot/front/src/handlers.cpp +++ b/modules/bot/front/src/handlers.cpp @@ -54,6 +54,9 @@ void BotHandlers::processCallbackImpl(TgBot::CallbackQuery::Ptr query) { if (data.starts_with(BotConstants::Callback::NAVIGATION)) { handleNavigation(query); } + else if (data.starts_with(BotConstants::Callback::CHOICE)) { + handleNavigation(query); + } else if (data.starts_with(BotConstants::Callback::ERROR)) { handleError(query); } @@ -89,14 +92,55 @@ void BotHandlers::increasePayload(int64_t& payload, const UserState curState) { } void BotHandlers::editMessage(int64_t chatId, int64_t messageId, HandlerResult response) { - botApi.editMessageText( - response.message, + // botApi.editMessageText быстрее. Реализовать его, где возможно + try { + botApi.deleteMessage(chatId, messageId); + } catch (const std::exception& e) { + std::cerr << "Warning: Failed to delete message " << messageId + << " in chat " << chatId << ": " << e.what() << std::endl; + // Продолжаем отправку нового сообщения даже при ошибке удаления + } + + // 2. Отправляем новое сообщение с правильным порядком параметров + botApi.sendMessage( + chatId, // chatId + response.message, // text + nullptr, // linkPreviewOptions (nullptr = default) + nullptr, // replyParameters (не отвечаем на сообщение) + response.keyboard, // replyMarkup — клавиатура + "HTML", // parseMode — поддержка , и т.д. + false // disableNotification + // остальные параметры по умолчанию + ); +} + +void BotHandlers::editMessageWithPhoto( + int64_t chatId, + int64_t messageId, + const std::string& imageUrl, + HandlerResult response) +{ + // 1. Удаляем старое сообщение + try { + botApi.deleteMessage(chatId, messageId); + } catch (const std::exception& e) { + // Игнорируем ошибку, если сообщение уже удалено или недоступно + std::cerr << "Warning: Failed to delete message: " << e.what() << std::endl; + } + + std::string safeCaption = response.message; + if (safeCaption.length() > 1024) { + safeCaption = safeCaption.substr(0, 1021) + "..."; + } + + // Отправка фото по URL + botApi.sendPhoto( chatId, - messageId, - "", - "", + imageUrl, + safeCaption, nullptr, - response.keyboard + response.keyboard, + "HTML" ); }