commit 0de99a97e88b19a27f73756e11001f6c5c79b79e Author: Kirill Kirilenko Date: Thu Oct 27 18:50:15 2022 +0300 Одиночный проход по всем подпискам из конфига. 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; + } +}