From 0de99a97e88b19a27f73756e11001f6c5c79b79e Mon Sep 17 00:00:00 2001 From: Kirill Kirilenko Date: Thu, 27 Oct 2022 18:50:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B4=D0=B8=D0=BD=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B4=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B2=D1=81=D0=B5=D0=BC=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0=D0=BC=20=D0=B8=D0=B7=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=84=D0=B8=D0=B3=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .clang-format | 118 ++++++++++++++++++++++++ .gitignore | 3 + .gitmodules | 12 +++ Asio.cpp | 11 +++ Asio.h | 13 +++ CMakeLists.txt | 26 ++++++ CourtApi.cpp | 107 +++++++++++++++++++++ CourtApi.h | 12 +++ Storage.cpp | 84 +++++++++++++++++ Storage.h | 31 +++++++ external/CMakeLists.txt | 8 ++ external/banana | 1 + external/certify | 1 + external/fmt | 1 + external/nlohmann_json | 1 + main.cpp | 200 ++++++++++++++++++++++++++++++++++++++++ 16 files changed, 629 insertions(+) create mode 100644 .clang-format create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Asio.cpp create mode 100644 Asio.h create mode 100644 CMakeLists.txt create mode 100644 CourtApi.cpp create mode 100644 CourtApi.h create mode 100644 Storage.cpp create mode 100644 Storage.h create mode 100644 external/CMakeLists.txt create mode 160000 external/banana create mode 160000 external/certify create mode 160000 external/fmt create mode 160000 external/nlohmann_json create mode 100644 main.cpp diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..8e54c19 --- /dev/null +++ b/.clang-format @@ -0,0 +1,118 @@ +--- +BasedOnStyle: WebKit +AlignAfterOpenBracket: Align +AlignConsecutiveAssignments: false +AlignConsecutiveBitFields: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: DontAlign +AlignOperands: Align +AlignTrailingComments: true +AllowAllArgumentsOnNextLine: true +AllowAllConstructorInitializersOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: Never +AllowShortCaseLabelsOnASingleLine: false +AllowShortEnumsOnASingleLine: false +AllowShortFunctionsOnASingleLine: InlineOnly +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: Inline +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: true +BinPackParameters: false +BraceWrapping: + AfterCaseLabel: true + AfterClass: true + AfterControlStatement: Always + AfterEnum: true + AfterFunction: true + AfterNamespace: false + AfterStruct: true + AfterUnion: true + AfterExternBlock: false + BeforeCatch: true + BeforeElse: true + BeforeLambdaBody: true + BeforeWhile: true + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakBeforeBinaryOperators: None +BreakBeforeBraces: Custom +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeColon +BreakInheritanceList: BeforeColon +BreakStringLiterals: true +ColumnLimit: 100 +CompactNamespaces: false +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DeriveLineEnding: true +DerivePointerAlignment: false +DisableFormat: false +FixNamespaceComments: true +IncludeBlocks: Regroup +IncludeCategories: + - Regex: '^.*Private[.]h"' + Priority: 0 + - Regex: '^".*[.]pb[.]h"' + Priority: 2 + - Regex: '^".*[.]h"' + Priority: 1 + - Regex: '^' + Priority: 5 + - Regex: '^<.*[.](h|hpp|hxx)>' + Priority: 3 + - Regex: '^' + Priority: 6 + - Regex: '^' + Priority: 4 + - Regex: '^<.*>' + Priority: 6 +IndentCaseBlocks: false +IndentCaseLabels: false +IndentExternBlock: NoIndent +IndentGotoLabels: false +IndentPPDirectives: None +IndentWidth: 4 +IndentWrappedFunctionNames: false +KeepEmptyLinesAtTheStartOfBlocks: false +Language: Cpp +MaxEmptyLinesToKeep: 2 +NamespaceIndentation: None +PenaltyExcessCharacter: 10 +PointerAlignment: Left +ReflowComments: true +SortIncludes: true +SortUsingDeclarations: true +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: true +SpaceBeforeAssignmentOperators: true +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 1 +SpacesInAngles: false +SpacesInCStyleCastParentheses: false +SpacesInConditionalStatement: false +SpacesInContainerLiterals: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: c++14 +StatementMacros: ['Q_UNUSED'] +TabWidth: 4 +UseCRLF: false +UseTab: ForIndentation + +... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02dd064 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +cmake-build-* +storage.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..7d55186 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "external/fmt"] + path = external/fmt + url = https://github.com/fmtlib/fmt.git +[submodule "external/nlohmann_json"] + path = external/nlohmann_json + url = https://github.com/nlohmann/json.git +[submodule "external/banana"] + path = external/banana + url = https://github.com/UltraCoderRU/banana.git +[submodule "external/certify"] + path = external/certify + url = https://github.com/djarek/certify.git diff --git a/Asio.cpp b/Asio.cpp new file mode 100644 index 0000000..75f1ccb --- /dev/null +++ b/Asio.cpp @@ -0,0 +1,11 @@ +#include "Asio.h" + +#include + +void initSSL() +{ + sslContext.set_verify_mode(boost::asio::ssl::verify_peer | + boost::asio::ssl::verify_fail_if_no_peer_cert); + sslContext.set_default_verify_paths(); + boost::certify::enable_native_https_server_verification(sslContext); +} diff --git a/Asio.h b/Asio.h new file mode 100644 index 0000000..f95f0cc --- /dev/null +++ b/Asio.h @@ -0,0 +1,13 @@ +#ifndef COURT_MONITOR_ASIO_H +#define COURT_MONITOR_ASIO_H + +#include +#include + +static boost::asio::io_context asioContext; + +static boost::asio::ssl::context sslContext(boost::asio::ssl::context::tls_client); + +void initSSL(); + +#endif // COURT_MONITOR_ASIO_H diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e4a4166 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.5) +project(court_monitor) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED TRUE) + +find_package(OpenSSL REQUIRED) + +find_package(Boost COMPONENTS system REQUIRED) + +add_subdirectory(external) + +add_executable(court_monitor + Asio.cpp + CourtApi.cpp + Storage.cpp + main.cpp + ) +target_link_libraries(court_monitor + banana-beast + fmt::fmt + nlohmann_json::nlohmann_json + certify::core + Boost::system + ${OPENSSL_LIBRARIES} + ) diff --git a/CourtApi.cpp b/CourtApi.cpp new file mode 100644 index 0000000..221d584 --- /dev/null +++ b/CourtApi.cpp @@ -0,0 +1,107 @@ +#include "CourtApi.h" + +#include "Asio.h" + +#include +#include + +#include +#include +#include +#include + +#include +#include + +const char* serverDomain = "mirsud.spb.ru"; + +using ssl_stream = boost::asio::ssl::stream; + +ssl_stream connect(const std::string& hostname) +{ + ssl_stream stream(asioContext, sslContext); + + static boost::asio::ip::tcp::resolver resolver(asioContext); + auto const results = resolver.resolve(hostname, "https"); + + boost::certify::set_server_hostname(stream, hostname); + boost::certify::sni_hostname(stream, hostname); + boost::beast::get_lowest_layer(stream).connect(results); + stream.handshake(boost::asio::ssl::stream_base::client); + + return stream; +} + +std::pair get(ssl_stream& stream, + const std::string& hostname, + const std::string& url, + std::optional payload = {}) +{ + // Создать HTTP-запрос + boost::beast::http::request request; + request.method(boost::beast::http::verb::get); + request.target(url); + request.keep_alive(true); + request.set(boost::beast::http::field::host, hostname); + + if (payload) + { + request.set(boost::beast::http::field::content_type, "application/json"); + request.body() = std::move(*payload); + } + + std::cout << "tx: " << request << std::endl; + + boost::beast::http::write(stream, request); + + boost::beast::http::response response; + boost::beast::flat_buffer buffer; + boost::beast::http::read(stream, buffer, response); + std::cout << "rx: " << response << std::endl; + + return {response.base().result_int(), response.body()}; +} + +nlohmann::json getResults(ssl_stream& stream, const std::string_view& uuid) +{ + int status; + std::string result; + std::tie(status, result) = + get(stream, serverDomain, fmt::format("/cases/api/results/?id={}", uuid)); + if (status == 200) + { + return nlohmann::json::parse(result); + } + else + throw std::runtime_error( + fmt::format("failed to retrieve JSON (server returned code {})", status)); +} + +nlohmann::json getCaseDetails(int courtId, const std::string_view& caseNumber) +{ + ssl_stream stream = connect(serverDomain); + + int status; + std::string result; + std::tie(status, result) = + get(stream, serverDomain, + fmt::format("/cases/api/detail/?id={}&court_site_id={}", caseNumber, courtId)); + if (status == 200) + { + auto uuid = nlohmann::json::parse(result).at("id").get(); + + for (int i = 0; i < 10; i++) + { + auto results = getResults(stream, uuid); + bool finished = results.at("finished").get(); + if (finished) + return results.at("result"); + else + std::this_thread::sleep_for(std::chrono::seconds(1)); + } + throw std::runtime_error("failed to get results in time"); + } + else + throw std::runtime_error( + fmt::format("failed to retrieve JSON (server returned code {})", status)); +} diff --git a/CourtApi.h b/CourtApi.h new file mode 100644 index 0000000..91e5861 --- /dev/null +++ b/CourtApi.h @@ -0,0 +1,12 @@ +#ifndef COURT_MONITOR_COURT_API_H +#define COURT_MONITOR_COURT_API_H + +#include + +#include + +nlohmann::json findCases(const std::string_view& name); + +nlohmann::json getCaseDetails(int courtId, const std::string_view& caseNumber); + +#endif // COURT_MONITOR_COURT_API_H diff --git a/Storage.cpp b/Storage.cpp new file mode 100644 index 0000000..d7e3960 --- /dev/null +++ b/Storage.cpp @@ -0,0 +1,84 @@ +#include "Storage.h" + +#include +#include + +#include + +using json = nlohmann::json; + +std::uint32_t parseTime(const std::string& str) +{ + std::istringstream ss(str); + std::tm tm = {}; + ss >> std::get_time(&tm, "%H:%M:%S"); + if (ss.fail()) + throw std::invalid_argument("invalid time string"); + return tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec; +} + +std::string timeToString(std::uint32_t time) +{ + auto hours = time / 3600; + auto minutes = (time % 3600) / 60; + auto seconds = (time % 3600) % 60; + return fmt::format("{:0>2}:{:0>2}:{:0>2}", hours, minutes, seconds); +} + +void loadStorage(LocalStorage& storage) +{ + std::ifstream ifs("storage.json"); + if (ifs.is_open()) + { + auto data = json::parse(ifs); + storage.token = data.at("token").get(); + storage.checkTime = parseTime(data.at("check_time").get()); + + for (const auto& subscription : data.at("subscriptions")) + { + Subscription s; + s.userId = subscription.at("user_id"); + for (const auto& counter : subscription.at("counters")) + { + Counter c; + c.courtId = counter.at("court").get(); + c.caseNumber = counter.at("case").get(); + c.value = counter.at("value").get(); + s.counters.push_back(std::move(c)); + } + storage.subscriptions.push_back(std::move(s)); + } + } + else + throw std::runtime_error("failed to load storage"); +} + +void saveStorage(const LocalStorage& storage) +{ + json data; + data["token"] = storage.token; + data["check_time"] = timeToString(storage.checkTime); + + data["subscriptions"] = json::array(); + for (const auto& subscription : storage.subscriptions) + { + json jsonSubscription; + jsonSubscription["user_id"] = subscription.userId; + jsonSubscription["counters"] = json::array(); + for (const auto& counter : subscription.counters) + { + json jsonCounter; + jsonCounter["court"] = counter.courtId; + jsonCounter["case"] = counter.caseNumber; + jsonCounter["value"] = counter.value; + jsonSubscription["counters"].push_back(std::move(jsonCounter)); + } + data["subscriptions"].push_back(std::move(jsonSubscription)); + } + + std::ofstream ofs("storage.json"); + if (ofs.is_open()) + ofs << std::setw(2) << data; + else + throw std::runtime_error("failed to save storage"); +} diff --git a/Storage.h b/Storage.h new file mode 100644 index 0000000..e7bb6da --- /dev/null +++ b/Storage.h @@ -0,0 +1,31 @@ +#ifndef COURT_MONITOR_STORAGE_H +#define COURT_MONITOR_STORAGE_H + +#include +#include +#include + +struct Counter +{ + int courtId = 0; + std::string caseNumber; + std::size_t value = 0; +}; + +struct Subscription +{ + int userId = 0; + std::vector counters; +}; + +struct LocalStorage +{ + std::string token; + std::vector subscriptions; + std::uint32_t checkTime; // секунды с 00:00 +}; + +void loadStorage(LocalStorage& storage); +void saveStorage(const LocalStorage& storage); + +#endif // COURT_MONITOR_STORAGE_H diff --git a/external/CMakeLists.txt b/external/CMakeLists.txt new file mode 100644 index 0000000..125b6c3 --- /dev/null +++ b/external/CMakeLists.txt @@ -0,0 +1,8 @@ +add_subdirectory(fmt EXCLUDE_FROM_ALL) + +add_subdirectory(nlohmann_json EXCLUDE_FROM_ALL) + +add_subdirectory(banana EXCLUDE_FROM_ALL) + +set(BUILD_TESTING OFF CACHE INTERNAL "") +add_subdirectory(certify EXCLUDE_FROM_ALL) diff --git a/external/banana b/external/banana new file mode 160000 index 0000000..075ac78 --- /dev/null +++ b/external/banana @@ -0,0 +1 @@ +Subproject commit 075ac78bba0950341c9c477ca38a523730857fee diff --git a/external/certify b/external/certify new file mode 160000 index 0000000..97f5eeb --- /dev/null +++ b/external/certify @@ -0,0 +1 @@ +Subproject commit 97f5eebfd99a5d6e99d07e4820240994e4e59787 diff --git a/external/fmt b/external/fmt new file mode 160000 index 0000000..a337011 --- /dev/null +++ b/external/fmt @@ -0,0 +1 @@ +Subproject commit a33701196adfad74917046096bf5a2aa0ab0bb50 diff --git a/external/nlohmann_json b/external/nlohmann_json new file mode 160000 index 0000000..bc889af --- /dev/null +++ b/external/nlohmann_json @@ -0,0 +1 @@ +Subproject commit bc889afb4c5bf1c0d8ee29ef35eaaf4c8bef8a5d diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..9ba33db --- /dev/null +++ b/main.cpp @@ -0,0 +1,200 @@ +#include "Asio.h" +#include "CourtApi.h" +#include "Storage.h" + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +struct CaseHistoryItem +{ + std::string date; + std::string time; + std::string status; + std::string publishDate; + std::string publishTime; +}; + +std::vector parseHistory(const nlohmann::json& details) +{ + std::vector items; + const auto& history = details.at("history"); + for (const auto& obj : history) + { + CaseHistoryItem item; + item.date = obj.at("date").get(); + item.time = obj.at("time").get(); + item.status = obj.at("status").get(); + item.publishDate = obj.at("publish_date").get(); + item.publishTime = obj.at("publish_time").get(); + items.push_back(std::move(item)); + } + return items; +} + +void notifyUser(banana::agent::beast_callback& bot, + int userId, + const std::string& caseNumber, + std::string caseUrl, + const CaseHistoryItem& item) +{ + caseUrl = fmt::format("https://mirsud.spb.ru{}", caseUrl); + std::string message = fmt::format( + "Новое событие по делу [№{}]({}):\n" + "{}\n" + "Дата: {} {}\n", + caseNumber, caseUrl, item.status, item.date, item.time); + banana::api::send_message(bot, {.chat_id = userId, .text = message, .parse_mode = "markdown"}, + [](const auto&) {}); +} + +void processAllSubscriptions(LocalStorage& storage, banana::agent::beast_callback& bot) +{ + for (auto& subscription : storage.subscriptions) + { + try + { + fmt::print("* Processing subscriptions for user {}\n", subscription.userId); + for (auto& counter : subscription.counters) + { + fmt::print("** Processing case {}\n", counter.caseNumber); + auto details = getCaseDetails(counter.courtId, counter.caseNumber); + fmt::print("{}\n", details.dump()); + auto url = details["url"].get(); + + auto history = parseHistory(details); + for (std::size_t i = counter.value; i < history.size(); i++) + notifyUser(bot, subscription.userId, counter.caseNumber, url, history[i]); + counter.value = history.size(); + } + } + catch (const std::exception& e) + { + fmt::print(stderr, "{}\n", e.what()); + continue; + } + } +} + +void processUpdate(const banana::api::update_t& update) +{ + if (update.message) + { + if (update.message->text) + { + fmt::print("rx: {}\n", *update.message->text); + } + } +} + +std::chrono::system_clock::time_point getNextCheckTime(std::uint32_t checkTime) +{ + std::time_t now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + std::tm tm = *std::localtime(&now); + std::uint32_t secsOfDay = tm.tm_hour * 3600 + tm.tm_min * 60 + tm.tm_sec; + auto dayBegin = std::chrono::system_clock::from_time_t(now - secsOfDay); + auto t = dayBegin + std::chrono::seconds(checkTime); + return (secsOfDay < checkTime) ? t : (t + std::chrono::days(1)); +} + +auto asioWork = boost::asio::make_work_guard(asioContext); +bool terminate = false; + +void handleSignal(int) +{ + fmt::print("signal!\n"); + terminate = true; + asioWork.reset(); + asioContext.stop(); +} + +int64_t offset = 0; + +void getUpdates(banana::agent::beast_callback& bot); + +void processUpdates(banana::agent::beast_callback bot, + banana::expected> updates) +{ + if (terminate) + { + fmt::print("exit\n"); + return; + } + + if (updates) + { + for (const auto& update : *updates) + { + processUpdate(update); + offset = update.update_id + 1; + } + } + else + fmt::print(stderr, "failed to get updates: {}\n", updates.error()); + + getUpdates(bot); +} + +void getUpdates(banana::agent::beast_callback& bot) +{ + banana::api::get_updates(bot, {.offset = offset, .timeout = 50}, + [bot](auto updates) { processUpdates(bot, std::move(updates)); }); +} + +int main() +{ + std::signal(SIGTERM, handleSignal); + + try + { + // Загрузить данные из локального хранилища + LocalStorage storage; + loadStorage(storage); + fmt::print("Storage loaded\n"); + + // Инициализировать SSL + initSSL(); + + // Создать бота + banana::agent::beast_callback bot(storage.token, asioContext, sslContext); + + // Создать таймер ежедневной проверки + boost::asio::system_timer checkTimer(asioContext); + std::function onTimer = + [&](const boost::system::error_code& error) + { + if (!error) + { + processAllSubscriptions(storage, bot); + checkTimer.expires_at(getNextCheckTime(storage.checkTime)); + checkTimer.async_wait(onTimer); + } + }; + checkTimer.expires_at(getNextCheckTime(storage.checkTime)); + checkTimer.async_wait(onTimer); + + // Запустить асинхронное получение обновлений + getUpdates(bot); + + // Запустить цикл обработки событий + asioContext.run(); + + // Сохранить данные в локальное хранилище + fmt::print("Saving storage\n"); + saveStorage(storage); + return 0; + } + catch (const std::exception& e) + { + fmt::print(stderr, "{}\n", e.what()); + return 1; + } +}