diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt new file mode 100644 index 0000000..38a0b6f --- /dev/null +++ b/cpp/CMakeLists.txt @@ -0,0 +1,50 @@ +cmake_minimum_required(VERSION 3.25) +project(hookla CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Enable TLS in cpp-httplib +add_compile_definitions(CPPHTTPLIB_OPENSSL_SUPPORT) + +include(cmake/deps.cmake) + +# ---- Source files ---------------------------------------------------------- +set(SOURCES + src/main.cpp + src/controllers/WebhookController.cpp + src/services/DiscordMessageService.cpp + src/services/DiscordWebhookService.cpp + src/services/EmbedOptionsService.cpp + src/services/ProviderSettingsService.cpp + src/handlers/github/GithubHandler.cpp + src/handlers/gitlab/GitlabHandler.cpp + src/handlers/sonarr/SonarrHandler.cpp +) + +add_executable(hookla ${SOURCES}) + +target_include_directories(hookla PRIVATE + src + ${PQXX_INCLUDE_DIRS} +) + +target_link_libraries(hookla PRIVATE + Drogon::Drogon + nlohmann_json::nlohmann_json + spdlog::spdlog + httplib::httplib + OpenSSL::SSL + OpenSSL::Crypto + ${PQXX_LIBRARIES} + PostgreSQL::PostgreSQL +) + +# Drogon needs coroutine support on Clang +if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + target_compile_options(hookla PRIVATE -fcoroutines) +endif() + +# ---- Install --------------------------------------------------------------- +install(TARGETS hookla DESTINATION bin) diff --git a/cpp/Dockerfile b/cpp/Dockerfile new file mode 100644 index 0000000..ba6d954 --- /dev/null +++ b/cpp/Dockerfile @@ -0,0 +1,38 @@ +# ---- Build stage ----------------------------------------------------------- +FROM debian:bookworm-slim AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + cmake ninja-build clang-17 \ + libssl-dev \ + libpq-dev libpqxx-dev \ + pkg-config git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +ENV CC=clang-17 CXX=clang++-17 + +WORKDIR /src +COPY . . + +RUN cmake -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_COMPILER=clang++-17 \ + && cmake --build build --parallel + +# ---- Runtime stage --------------------------------------------------------- +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libssl3 libpq5 libpqxx-7.7 ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /src/build/hookla /usr/local/bin/hookla + +ENV APP_PORT=8080 \ + DB_HOST=postgres \ + DB_PORT=5432 \ + DB_NAME=hookla \ + DB_USER=hookla \ + DB_SSLMODE=disable + +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/hookla"] diff --git a/cpp/cmake/deps.cmake b/cpp/cmake/deps.cmake new file mode 100644 index 0000000..c1299d9 --- /dev/null +++ b/cpp/cmake/deps.cmake @@ -0,0 +1,49 @@ +include(FetchContent) + +# ---- Drogon (HTTP server + C++20 coroutines) ------------------------------ +FetchContent_Declare(drogon + GIT_REPOSITORY https://github.com/drogonframework/drogon + GIT_TAG v1.9.6 + GIT_SHALLOW TRUE) + +# ---- nlohmann/json (JSON parsing + serialisation) ------------------------- +FetchContent_Declare(nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json + GIT_TAG v3.11.3 + GIT_SHALLOW TRUE) + +# ---- spdlog (structured logging) ------------------------------------------ +FetchContent_Declare(spdlog + GIT_REPOSITORY https://github.com/gabime/spdlog + GIT_TAG v1.14.1 + GIT_SHALLOW TRUE) + +# ---- cpp-httplib (outbound HTTPS to Discord) ------------------------------- +FetchContent_Declare(httplib + GIT_REPOSITORY https://github.com/yhirose/cpp-httplib + GIT_TAG v0.16.3 + GIT_SHALLOW TRUE) + +# libpqxx and OpenSSL are expected to be installed system-wide: +# apt-get install -y libpq-dev libpqxx-dev libssl-dev +# brew install libpqxx openssl +find_package(PostgreSQL REQUIRED) +find_package(OpenSSL REQUIRED) + +# Build libpqxx from system pkg-config if available, else find manually +find_package(PkgConfig QUIET) +if(PKG_CONFIG_FOUND) + pkg_check_modules(PQXX libpqxx) +endif() +if(NOT PQXX_FOUND) + find_library(PQXX_LIB pqxx REQUIRED) + find_path(PQXX_INCLUDE pqxx/pqxx REQUIRED) + set(PQXX_LIBRARIES ${PQXX_LIB}) + set(PQXX_INCLUDE_DIRS ${PQXX_INCLUDE}) +endif() + +FetchContent_MakeAvailable(nlohmann_json spdlog httplib) + +# Drogon brings many transitive deps; build it last +set(BUILD_TESTING OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(drogon) diff --git a/cpp/src/controllers/WebhookController.cpp b/cpp/src/controllers/WebhookController.cpp new file mode 100644 index 0000000..134ec67 --- /dev/null +++ b/cpp/src/controllers/WebhookController.cpp @@ -0,0 +1,97 @@ +#include "WebhookController.h" +#include +#include +#include + +WebhookController::WebhookController( + std::shared_ptr providerSettings, + std::shared_ptr discordWebhooks, + std::shared_ptr mainHandler) + : providerSettings_(std::move(providerSettings)) + , discordWebhooks_(std::move(discordWebhooks)) + , mainHandler_(std::move(mainHandler)) {} + +// GET /process/:token — returns raw provider settings as JSON (debug endpoint) +drogon::Task WebhookController::getHookInfo( + drogon::HttpRequestPtr req, + std::string token) +{ + auto settings = providerSettings_->getByToken(token); + if (!settings) { + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k401Unauthorized); + resp->setBody("invalid token"); + co_return resp; + } + + nlohmann::json j; + j["id"] = settings->id; + j["slug"] = settings->slug; + j["discordWebhookId"] = settings->discordWebhookId; + if (settings->optionsId) j["optionsId"] = *settings->optionsId; + + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setContentTypeCode(drogon::CT_APPLICATION_JSON); + resp->setBody(j.dump()); + co_return resp; +} + +// POST /process/:token — receive and forward a webhook +drogon::Task WebhookController::process( + drogon::HttpRequestPtr req, + std::string token) +{ + // Normalise headers to lowercase (mirrors the Scala controller) + std::unordered_map headers; + for (auto& [k, v] : req->getHeaders()) { + std::string lk = k; + std::transform(lk.begin(), lk.end(), lk.begin(), ::tolower); + headers[lk] = v; + } + + auto settings = providerSettings_->getByToken(token); + if (!settings) { + spdlog::warn("Invalid token: {}", token); + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k401Unauthorized); + resp->setBody("invalid token"); + co_return resp; + } + + spdlog::debug("Received webhook for provider: {}", settings->slug); + + nlohmann::json body = nlohmann::json::parse(req->getBody(), nullptr, false); + if (body.is_discarded()) { + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k400BadRequest); + resp->setBody("invalid JSON body"); + co_return resp; + } + + auto hook = discordWebhooks_->getById(settings->discordWebhookId); + if (!hook) { + spdlog::error("Discord webhook not found for id: {}", settings->discordWebhookId); + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k500InternalServerError); + resp->setBody("discord webhook not configured"); + co_return resp; + } + + auto options = providerSettings_->getOptionsForProvider(*settings); + EventData eventData{*hook, options}; + + try { + mainHandler_->handle(settings->slug, body, headers, eventData); + } catch (const std::exception& e) { + spdlog::error("Handler error for {}: {}", settings->slug, e.what()); + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k400BadRequest); + resp->setBody(e.what()); + co_return resp; + } + + auto resp = drogon::HttpResponse::newHttpResponse(); + resp->setStatusCode(drogon::k200OK); + resp->setBody("success"); + co_return resp; +} diff --git a/cpp/src/controllers/WebhookController.h b/cpp/src/controllers/WebhookController.h new file mode 100644 index 0000000..b6a0c96 --- /dev/null +++ b/cpp/src/controllers/WebhookController.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include "../handlers/MainHandler.h" +#include "../services/DiscordWebhookService.h" +#include "../services/ProviderSettingsService.h" + +// Registered manually (false = not auto-created by Drogon) so we can inject deps. +class WebhookController : public drogon::HttpController { +public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(WebhookController::process, "/process/{token}", drogon::Post); + ADD_METHOD_TO(WebhookController::getHookInfo, "/process/{token}", drogon::Get); + METHOD_LIST_END + + WebhookController( + std::shared_ptr providerSettings, + std::shared_ptr discordWebhooks, + std::shared_ptr mainHandler); + + drogon::Task process( + drogon::HttpRequestPtr req, + std::string token); + + drogon::Task getHookInfo( + drogon::HttpRequestPtr req, + std::string token); + +private: + std::shared_ptr providerSettings_; + std::shared_ptr discordWebhooks_; + std::shared_ptr mainHandler_; +}; diff --git a/cpp/src/db/ConnectionPool.h b/cpp/src/db/ConnectionPool.h new file mode 100644 index 0000000..490c063 --- /dev/null +++ b/cpp/src/db/ConnectionPool.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// Thread-safe PostgreSQL connection pool. +// Callers acquire() a connection, use it synchronously, then release() it. +// DB methods run on a dedicated std::thread to avoid blocking the HTTP event loop. +class ConnectionPool { +public: + ConnectionPool(const std::string& connStr, size_t poolSize) { + for (size_t i = 0; i < poolSize; ++i) + pool_.push(std::make_unique(connStr)); + } + + std::unique_ptr acquire() { + std::unique_lock lock(mutex_); + cv_.wait(lock, [this] { return !pool_.empty(); }); + auto conn = std::move(pool_.front()); + pool_.pop(); + return conn; + } + + void release(std::unique_ptr conn) { + std::lock_guard lock(mutex_); + pool_.push(std::move(conn)); + cv_.notify_one(); + } + +private: + std::queue> pool_; + std::mutex mutex_; + std::condition_variable cv_; +}; diff --git a/cpp/src/handlers/BaseHandler.h b/cpp/src/handlers/BaseHandler.h new file mode 100644 index 0000000..2d9f00a --- /dev/null +++ b/cpp/src/handlers/BaseHandler.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include +#include +#include "../types/EventData.h" + +class BaseHandler { +public: + virtual ~BaseHandler() = default; + + // The HTTP header (or body field) that contains the event name. + virtual const char* eventKey() const = 0; + + // True if the event key lives inside the JSON body rather than an HTTP header. + virtual bool isBody() const { return false; } + + // Dispatch to the appropriate event handler. + virtual void handle( + const std::string& eventName, + const nlohmann::json& body, + const EventData& data) = 0; +}; diff --git a/cpp/src/handlers/MainHandler.h b/cpp/src/handlers/MainHandler.h new file mode 100644 index 0000000..c358c48 --- /dev/null +++ b/cpp/src/handlers/MainHandler.h @@ -0,0 +1,48 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include "BaseHandler.h" +#include "../types/EventData.h" + +class MainHandler { +public: + void registerHandler(const std::string& slug, std::shared_ptr handler) { + handlers_[slug] = std::move(handler); + } + + void handle( + const std::string& slug, + const nlohmann::json& body, + const std::unordered_map& headers, + const EventData& data) + { + auto it = handlers_.find(slug); + if (it == handlers_.end()) { + spdlog::warn("Unhandled provider slug: {}", slug); + return; + } + auto& handler = *it->second; + + std::string eventName; + if (handler.isBody()) { + eventName = body.at(handler.eventKey()).get(); + } else { + std::string key = handler.eventKey(); + std::transform(key.begin(), key.end(), key.begin(), ::tolower); + auto hit = headers.find(key); + if (hit == headers.end()) + throw std::runtime_error("event header not found: " + key); + eventName = hit->second; + } + + handler.handle(eventName, body, data); + } + +private: + std::unordered_map> handlers_; +}; diff --git a/cpp/src/handlers/github/GithubHandler.cpp b/cpp/src/handlers/github/GithubHandler.cpp new file mode 100644 index 0000000..572fd97 --- /dev/null +++ b/cpp/src/handlers/github/GithubHandler.cpp @@ -0,0 +1,141 @@ +#include "GithubHandler.h" +#include +#include +#include +#include "../../types/DiscordEmbed.h" +#include "../../util/Colours.h" +#include "../../util/EventHandlerUtils.h" + +GithubHandler::GithubHandler(std::shared_ptr discord) + : discord_(std::move(discord)) +{ + handlers_["push"] = [this](const nlohmann::json& j, const EventData& d) { handlePush(j.get(), d); }; + handlers_["issues"] = [this](const nlohmann::json& j, const EventData& d) { handleIssue(j.get(), d); }; + handlers_["check_run"] = [this](const nlohmann::json& j, const EventData& d) { handleCheckRun(j.get(), d); }; + handlers_["create"] = [this](const nlohmann::json& j, const EventData& d) { handleCreate(j.get(), d); }; + handlers_["delete"] = [this](const nlohmann::json& j, const EventData& d) { handleDelete(j.get(), d); }; +} + +void GithubHandler::handle( + const std::string& eventName, + const nlohmann::json& body, + const EventData& data) +{ + auto it = handlers_.find(eventName); + if (it == handlers_.end()) { + spdlog::warn("Unhandled GitHub event: {}", eventName); + return; + } + it->second(body, data); +} + +// ---- Push ----------------------------------------------------------------- + +void GithubHandler::handlePush(const GithubPushPayload& p, const EventData& d) { + const std::string branchName = getBranchFromRef(p.ref); + + // Group commits by author email (preserves Scala groupBy behaviour) + std::map> byAuthor; + for (auto& c : p.commits) + byAuthor[c.author.email].push_back(&c); + + if (byAuthor.size() == 1) { + auto& commits = byAuthor.begin()->second; + std::string description; + for (auto* c : commits) + description += formatCommit(c->message, commits.size(), c->url, d.options) + "\n"; + + DiscordEmbed embed; + embed.description = description; + embed.author = EmbedAuthor{p.pusher.name, std::nullopt, p.sender.avatar_url}; + embed.url = p.repository.html_url; + embed.color = Colours::PUSH; + embed.footer = EmbedFooter{p.repository.full_name + ":" + branchName, std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); + + } else if (byAuthor.size() > 1) { + std::vector fields; + for (auto& [email, commits] : byAuthor) { + std::string value; + for (auto* c : commits) + value += formatCommit(c->message, commits.size(), c->url, d.options) + "\n"; + fields.push_back({"Commits from " + commits.front()->author.name, value, false}); + } + + DiscordEmbed embed; + embed.author = EmbedAuthor{p.pusher.name, std::nullopt, p.sender.avatar_url}; + embed.url = p.repository.html_url; + embed.color = Colours::PUSH; + embed.fields = std::move(fields); + embed.footer = EmbedFooter{p.repository.full_name + ":" + branchName, std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); + } +} + +// ---- Issue ---------------------------------------------------------------- + +void GithubHandler::handleIssue(const GithubIssuePayload& p, const EventData& d) { + // Scala original was a no-op stub (println only) + spdlog::info("GitHub issue event: action={}", p.action); +} + +// ---- Check Run ------------------------------------------------------------ + +void GithubHandler::handleCheckRun(const GithubCheckRunPayload& p, const EventData& d) { + if (p.action != GithubCheckRunAction::Created) return; + + const auto& name = p.check_run.name; + std::string lname = name; + std::transform(lname.begin(), lname.end(), lname.begin(), ::tolower); + + if (!startsWith(lname, "deploy-") && !startsWith(lname, "deploy ")) return; + + const std::string environment = name.substr(7); + if (environment.empty()) return; + + DiscordEmbed embed; + embed.description = "Version " + p.check_run.head_branch + " is deploying to " + environment + "..."; + embed.author = EmbedAuthor{p.sender.login, std::nullopt, p.sender.avatar_url}; + embed.url = p.check_run.html_url; + embed.color = Colours::RUNNING; + embed.footer = EmbedFooter{ + p.repository.full_name + ":" + p.check_run.head_sha.substr(0, 7), + std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); +} + +// ---- Create --------------------------------------------------------------- + +void GithubHandler::handleCreate(const GithubCreatePayload& p, const EventData& d) { + std::string description; + if (p.ref_type == GithubRefType::Branch) + description = "Branch created: " + p.ref; + else + description = "Tag created: " + p.ref; + + DiscordEmbed embed; + embed.description = description; + embed.author = EmbedAuthor{p.sender.login, std::nullopt, p.sender.avatar_url}; + embed.url = p.repository.html_url; + embed.color = Colours::CREATED; + embed.footer = EmbedFooter{p.repository.full_name + ":" + p.ref, std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); +} + +// ---- Delete --------------------------------------------------------------- + +void GithubHandler::handleDelete(const GithubDeletePayload& p, const EventData& d) { + std::string description; + if (p.ref_type == GithubRefType::Branch) + description = "Branch deleted: " + p.ref; + else + description = "Tag deleted: " + p.ref; + + DiscordEmbed embed; + embed.description = description; + embed.author = EmbedAuthor{p.sender.login, std::nullopt, p.sender.avatar_url}; + embed.url = p.repository.html_url; + embed.color = Colours::DELETED; + embed.footer = EmbedFooter{p.repository.full_name + ":" + p.ref, std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); +} diff --git a/cpp/src/handlers/github/GithubHandler.h b/cpp/src/handlers/github/GithubHandler.h new file mode 100644 index 0000000..508d923 --- /dev/null +++ b/cpp/src/handlers/github/GithubHandler.h @@ -0,0 +1,34 @@ +#pragma once +#include +#include +#include +#include +#include "../BaseHandler.h" +#include "../../services/DiscordMessageService.h" +#include "../../types/providers/GithubPayloads.h" + +class GithubHandler : public BaseHandler { +public: + static constexpr const char* LOGO = + "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"; + + explicit GithubHandler(std::shared_ptr discord); + + const char* eventKey() const override { return "x-github-event"; } + + void handle( + const std::string& eventName, + const nlohmann::json& body, + const EventData& data) override; + +private: + void handlePush(const GithubPushPayload& p, const EventData& d); + void handleIssue(const GithubIssuePayload& p, const EventData& d); + void handleCheckRun(const GithubCheckRunPayload& p, const EventData& d); + void handleCreate(const GithubCreatePayload& p, const EventData& d); + void handleDelete(const GithubDeletePayload& p, const EventData& d); + + std::shared_ptr discord_; + using HandlerFn = std::function; + std::unordered_map handlers_; +}; diff --git a/cpp/src/handlers/gitlab/GitlabHandler.cpp b/cpp/src/handlers/gitlab/GitlabHandler.cpp new file mode 100644 index 0000000..af208d2 --- /dev/null +++ b/cpp/src/handlers/gitlab/GitlabHandler.cpp @@ -0,0 +1,205 @@ +#include "GitlabHandler.h" +#include +#include +#include "../../types/DiscordEmbed.h" +#include "../../util/Colours.h" +#include "../../util/EventHandlerUtils.h" + +static const std::string ZEROES = "0000000000000000000000000000000000000000"; + +GitlabHandler::GitlabHandler(std::shared_ptr discord) + : discord_(std::move(discord)) +{ + auto job = [this](const nlohmann::json& j, const EventData& d) { handleJob(j.get(), d); }; + handlers_["Push Hook"] = [this](const nlohmann::json& j, const EventData& d) { handlePush(j.get(), d); }; + handlers_["Tag Push Hook"] = [this](const nlohmann::json& j, const EventData& d) { handleTag(j.get(), d); }; + handlers_["Note Hook"] = [this](const nlohmann::json& j, const EventData& d) { handleNote(j.get(), d); }; + handlers_["Issue Hook"] = [this](const nlohmann::json& j, const EventData& d) { handleIssue(j.get(), d); }; + handlers_["Job Hook"] = job; + handlers_["Build Hook"] = job; +} + +void GitlabHandler::handle( + const std::string& eventName, + const nlohmann::json& body, + const EventData& data) +{ + auto it = handlers_.find(eventName); + if (it == handlers_.end()) { + spdlog::warn("Unhandled GitLab event: {}", eventName); + return; + } + it->second(body, data); +} + +// ---- Push ----------------------------------------------------------------- + +void GitlabHandler::handlePush(const GitlabPushPayload& p, const EventData& d) { + const std::string branchName = getBranchFromRef(p.ref); + if (isPrivateBranch(branchName)) return; + + // Branch created/deleted notification (same logic as Scala handleBranches) + if (p.before == ZEROES) { + DiscordEmbed embed; + embed.description = "Branch created: " + branchName; + embed.author = EmbedAuthor{p.user_name, std::nullopt, p.user_avatar}; + embed.url = p.project.web_url; + embed.color = Colours::CREATED; + embed.footer = EmbedFooter{p.project.path_with_namespace + ":" + branchName, std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); + } else if (p.after == ZEROES) { + DiscordEmbed embed; + embed.description = "Branch deleted: " + branchName; + embed.author = EmbedAuthor{p.user_name, std::nullopt, p.user_avatar}; + embed.url = p.project.web_url; + embed.color = Colours::DELETED; + embed.footer = EmbedFooter{p.project.path_with_namespace + ":" + branchName, std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); + } + + // Commit notification + std::map> byAuthor; + for (auto& c : p.commits) + byAuthor[c.author.email].push_back(&c); + + if (byAuthor.size() == 1) { + auto& commits = byAuthor.begin()->second; + std::string description; + for (auto* c : commits) + description += formatCommit(c->message, commits.size(), c->url, d.options) + "\n"; + + DiscordEmbed embed; + embed.description = description; + embed.author = EmbedAuthor{p.user_name, std::nullopt, p.user_avatar}; + embed.url = p.project.web_url; + embed.color = Colours::PUSH; + embed.footer = EmbedFooter{p.project.path_with_namespace + ":" + branchName, std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); + + } else if (byAuthor.size() > 1) { + std::vector fields; + for (auto& [email, commits] : byAuthor) { + std::string value; + for (auto* c : commits) + value += formatCommit(c->message, commits.size(), c->url, d.options) + "\n"; + fields.push_back({"Commits from " + commits.front()->author.name, value, false}); + } + + DiscordEmbed embed; + embed.author = EmbedAuthor{p.user_name, std::nullopt, p.user_avatar}; + embed.url = p.project.web_url; + embed.color = Colours::PUSH; + embed.fields = std::move(fields); + embed.footer = EmbedFooter{p.project.path_with_namespace + ":" + branchName, std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); + } +} + +// ---- Tag ------------------------------------------------------------------ + +void GitlabHandler::handleTag(const GitlabTagPushPayload& p, const EventData& d) { + const std::string refName = getBranchFromRef(p.ref); + + if (p.before == ZEROES) { + DiscordEmbed embed; + embed.description = "Tag created: " + refName; + embed.author = EmbedAuthor{p.user_name, std::nullopt, p.user_avatar}; + embed.url = p.project.web_url; + embed.color = Colours::CREATED; + embed.footer = EmbedFooter{p.project.path_with_namespace + ":" + refName, std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); + } else if (p.after == ZEROES) { + DiscordEmbed embed; + embed.description = "Tag deleted: " + refName; + embed.author = EmbedAuthor{p.user_name, std::nullopt, p.user_avatar}; + embed.url = p.project.web_url; + embed.color = Colours::DELETED; + embed.footer = EmbedFooter{p.project.path_with_namespace + ":" + refName, std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); + } +} + +// ---- Note ----------------------------------------------------------------- + +void GitlabHandler::handleNote(const GitlabNotePayload& p, const EventData& d) { + std::string title = "Unknown"; + std::string url = p.project.web_url; + + if (p.object_attributes.noteable_type == "Commit") { + title = "Commit (" + p.commit->id.substr(0, 7) + ")"; + } else if (p.object_attributes.noteable_type == "MergeRequest") { + title = "Merge Request #" + std::to_string(p.merge_request->iid); + url = p.object_attributes.url; + } + // Issue and Snippet are unimplemented in the Scala original too + + DiscordEmbed embed; + embed.title = title; + embed.description = p.object_attributes.note; + embed.author = EmbedAuthor{p.user.name, std::nullopt, p.user.avatar_url}; + embed.url = url; + embed.color = Colours::NOTE; + embed.footer = EmbedFooter{p.project.path_with_namespace, std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); +} + +// ---- Issue ---------------------------------------------------------------- + +void GitlabHandler::handleIssue(const GitlabIssuePayload& p, const EventData& d) { + // Scala original was unimplemented (???) + spdlog::info("GitLab issue event: action={}", p.action); +} + +// ---- Job ------------------------------------------------------------------ + +DiscordEmbed GitlabHandler::makeJobEmbed( + const GitlabJobPayload& p, int colour, const std::string& description) +{ + // Extract "owner/repo" from homepage URL by dropping the scheme+host + std::string footer_text; + auto parts = p.repository.homepage; + size_t pos = 0; + int slashes = 0; + for (size_t i = 0; i < parts.size(); ++i) { + if (parts[i] == '/') { + if (++slashes == 3) { pos = i + 1; break; } + } + } + footer_text = parts.substr(pos) + ":" + p.ref; + + DiscordEmbed embed; + embed.description = description; + embed.author = EmbedAuthor{p.user.name, std::nullopt, p.user.avatar_url}; + embed.url = p.repository.homepage + "/-/jobs/" + std::to_string(p.build_id); + embed.color = colour; + embed.footer = EmbedFooter{footer_text, std::string(LOGO)}; + return embed; +} + +void GitlabHandler::handleJob(const GitlabJobPayload& p, const EventData& d) { + if (p.build_status == "failed") { + if (!p.build_allow_failure) + discord_->sendMessageToDiscord(d.hook, makeJobEmbed(p, Colours::FAILED, "The job has failed.")); + + } else if (p.build_status == "canceled") { + discord_->sendMessageToDiscord(d.hook, makeJobEmbed(p, Colours::CANCELED, "The job has been canceled.")); + + } else if (p.build_status == "running") { + if (!startsWith(p.build_name, "deploy-")) return; + const std::string env = p.build_name.substr(7); + if (env.empty()) return; + auto embed = p.tag + ? makeJobEmbed(p, Colours::RUNNING, "Version " + p.ref + " is deploying to " + env + "...") + : makeJobEmbed(p, Colours::CANCELED, "Deploying latest commit to " + env + "..."); + discord_->sendMessageToDiscord(d.hook, embed); + + } else if (p.build_status == "success") { + if (!startsWith(p.build_name, "deploy-")) return; + const std::string env = p.build_name.substr(7); + if (env.empty()) return; + auto embed = p.tag + ? makeJobEmbed(p, Colours::RUNNING, "Version " + p.ref + " has been deployed to " + env + ".") + : makeJobEmbed(p, Colours::CANCELED, "Deployed latest commit to " + env + "."); + discord_->sendMessageToDiscord(d.hook, embed); + } +} diff --git a/cpp/src/handlers/gitlab/GitlabHandler.h b/cpp/src/handlers/gitlab/GitlabHandler.h new file mode 100644 index 0000000..9c21f33 --- /dev/null +++ b/cpp/src/handlers/gitlab/GitlabHandler.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include +#include +#include "../BaseHandler.h" +#include "../../services/DiscordMessageService.h" +#include "../../types/DiscordEmbed.h" +#include "../../types/providers/GitlabPayloads.h" + +class GitlabHandler : public BaseHandler { +public: + static constexpr const char* LOGO = + "https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png"; + + explicit GitlabHandler(std::shared_ptr discord); + + const char* eventKey() const override { return "x-gitlab-event"; } + + void handle( + const std::string& eventName, + const nlohmann::json& body, + const EventData& data) override; + +private: + void handlePush(const GitlabPushPayload& p, const EventData& d); + void handleTag(const GitlabTagPushPayload& p, const EventData& d); + void handleNote(const GitlabNotePayload& p, const EventData& d); + void handleIssue(const GitlabIssuePayload& p, const EventData& d); + void handleJob(const GitlabJobPayload& p, const EventData& d); + + DiscordEmbed makeJobEmbed(const GitlabJobPayload& p, int colour, const std::string& description); + + std::shared_ptr discord_; + using HandlerFn = std::function; + std::unordered_map handlers_; +}; diff --git a/cpp/src/handlers/ombi/OmbiHandler.h b/cpp/src/handlers/ombi/OmbiHandler.h new file mode 100644 index 0000000..5e8e596 --- /dev/null +++ b/cpp/src/handlers/ombi/OmbiHandler.h @@ -0,0 +1,21 @@ +#pragma once +#include "../BaseHandler.h" +#include "../../services/DiscordMessageService.h" +#include +#include + +class OmbiHandler : public BaseHandler { +public: + explicit OmbiHandler(std::shared_ptr discord) + : discord_(std::move(discord)) {} + + const char* eventKey() const override { return "eventType"; } + bool isBody() const override { return true; } + + void handle(const std::string& eventName, const nlohmann::json&, const EventData&) override { + spdlog::warn("OmbiHandler not yet implemented (event: {})", eventName); + } + +private: + std::shared_ptr discord_; +}; diff --git a/cpp/src/handlers/radarr/RadarrHandler.h b/cpp/src/handlers/radarr/RadarrHandler.h new file mode 100644 index 0000000..a59c4a0 --- /dev/null +++ b/cpp/src/handlers/radarr/RadarrHandler.h @@ -0,0 +1,21 @@ +#pragma once +#include "../BaseHandler.h" +#include "../../services/DiscordMessageService.h" +#include +#include + +class RadarrHandler : public BaseHandler { +public: + explicit RadarrHandler(std::shared_ptr discord) + : discord_(std::move(discord)) {} + + const char* eventKey() const override { return "eventType"; } + bool isBody() const override { return true; } + + void handle(const std::string& eventName, const nlohmann::json&, const EventData&) override { + spdlog::warn("RadarrHandler not yet implemented (event: {})", eventName); + } + +private: + std::shared_ptr discord_; +}; diff --git a/cpp/src/handlers/sonarr/SonarrHandler.cpp b/cpp/src/handlers/sonarr/SonarrHandler.cpp new file mode 100644 index 0000000..523cef9 --- /dev/null +++ b/cpp/src/handlers/sonarr/SonarrHandler.cpp @@ -0,0 +1,53 @@ +#include "SonarrHandler.h" +#include +#include "../../types/DiscordEmbed.h" +#include "../../util/Colours.h" + +SonarrHandler::SonarrHandler(std::shared_ptr discord) + : discord_(std::move(discord)) +{ + handlers_["Grab"] = [this](const nlohmann::json& j, const EventData& d) { handleGrab(j.get(), d); }; + handlers_["Download"] = [this](const nlohmann::json& j, const EventData& d) { handleDownload(j.get(), d); }; + handlers_["Rename"] = [this](const nlohmann::json& j, const EventData& d) { handleRename(j.get(), d); }; + handlers_["Test"] = [this](const nlohmann::json& j, const EventData& d) { handleTest(j.get(), d); }; +} + +void SonarrHandler::handle( + const std::string& eventName, + const nlohmann::json& body, + const EventData& data) +{ + auto it = handlers_.find(eventName); + if (it == handlers_.end()) { + spdlog::warn("Unhandled Sonarr event: {}", eventName); + return; + } + it->second(body, data); +} + +// Sonarr Grab and Download/Rename are stubs in the Scala original. +// Implementing Test as it's the only fully implemented one. + +void SonarrHandler::handleGrab(const SonarrGrabEvent& p, const EventData& d) { + // Unimplemented in Scala original + spdlog::info("Sonarr grab event for series: {}", p.series.title); +} + +void SonarrHandler::handleDownload(const SonarrDownloadEvent& p, const EventData& d) { + // Unimplemented in Scala original + spdlog::info("Sonarr download event for series: {}", p.series.title); +} + +void SonarrHandler::handleRename(const SonarrRenameEvent& p, const EventData& d) { + // Unimplemented in Scala original + spdlog::info("Sonarr rename event for series: {}", p.series.title); +} + +void SonarrHandler::handleTest(const SonarrTestEvent& p, const EventData& d) { + DiscordEmbed embed; + embed.description = "Sonarr Test Hook!"; + embed.author = EmbedAuthor{"Sonarr", std::nullopt, std::string(LOGO)}; + embed.color = Colours::CREATED; + embed.footer = EmbedFooter{"Sonarr", std::string(LOGO)}; + discord_->sendMessageToDiscord(d.hook, embed); +} diff --git a/cpp/src/handlers/sonarr/SonarrHandler.h b/cpp/src/handlers/sonarr/SonarrHandler.h new file mode 100644 index 0000000..22f86e1 --- /dev/null +++ b/cpp/src/handlers/sonarr/SonarrHandler.h @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include +#include +#include "../BaseHandler.h" +#include "../../services/DiscordMessageService.h" +#include "../../types/providers/SonarrPayloads.h" + +class SonarrHandler : public BaseHandler { +public: + static constexpr const char* LOGO = + "https://forums-sonarr-tv.s3.dualstack.us-east-1.amazonaws.com/original/2X/e/" + "ef4553fe96f04a298ec502279731579698e96a9b.png"; + + explicit SonarrHandler(std::shared_ptr discord); + + const char* eventKey() const override { return "eventType"; } + bool isBody() const override { return true; } + + void handle( + const std::string& eventName, + const nlohmann::json& body, + const EventData& data) override; + +private: + void handleGrab(const SonarrGrabEvent& p, const EventData& d); + void handleDownload(const SonarrDownloadEvent& p, const EventData& d); + void handleRename(const SonarrRenameEvent& p, const EventData& d); + void handleTest(const SonarrTestEvent& p, const EventData& d); + + std::shared_ptr discord_; + using HandlerFn = std::function; + std::unordered_map handlers_; +}; diff --git a/cpp/src/main.cpp b/cpp/src/main.cpp new file mode 100644 index 0000000..40440f2 --- /dev/null +++ b/cpp/src/main.cpp @@ -0,0 +1,67 @@ +#include +#include +#include +#include +#include +#include "controllers/WebhookController.h" +#include "db/ConnectionPool.h" +#include "handlers/MainHandler.h" +#include "handlers/github/GithubHandler.h" +#include "handlers/gitlab/GitlabHandler.h" +#include "handlers/sonarr/SonarrHandler.h" +#include "handlers/radarr/RadarrHandler.h" +#include "handlers/ombi/OmbiHandler.h" +#include "services/DiscordMessageService.h" +#include "services/DiscordWebhookService.h" +#include "services/EmbedOptionsService.h" +#include "services/ProviderSettingsService.h" + +static std::string getenv_or(const char* key, const char* fallback) { + const char* val = std::getenv(key); + return val ? val : fallback; +} + +int main() { + spdlog::set_level(spdlog::level::debug); + + // Build PostgreSQL connection string from environment variables. + // Mirrors the HOCON "postgres" block in application.conf. + const std::string connStr = + "host=" + getenv_or("DB_HOST", "localhost") + + " port=" + getenv_or("DB_PORT", "5432") + + " dbname=" + getenv_or("DB_NAME", "hookla") + + " user=" + getenv_or("DB_USER", "hookla") + + " password="+ getenv_or("DB_PASSWORD", "") + + " sslmode=" + getenv_or("DB_SSLMODE", "disable"); + + const int port = std::stoi(getenv_or("APP_PORT", "8080")); + const size_t poolSize = std::stoul(getenv_or("DB_POOL_SIZE", "10")); + + // ---- Construct object graph (replaces HooklaModules + MacWire) -------- + + auto pool = std::make_shared(connStr, poolSize); + auto embedOptsSvc = std::make_shared(pool); + auto providerSettsSvc = std::make_shared(pool, embedOptsSvc); + auto discordWebhookSvc = std::make_shared(pool); + auto discordMsgSvc = std::make_shared(); + + auto mainHandler = std::make_shared(); + mainHandler->registerHandler("github", std::make_shared(discordMsgSvc)); + mainHandler->registerHandler("gitlab", std::make_shared(discordMsgSvc)); + mainHandler->registerHandler("sonarr", std::make_shared(discordMsgSvc)); + mainHandler->registerHandler("radarr", std::make_shared(discordMsgSvc)); + mainHandler->registerHandler("ombi", std::make_shared(discordMsgSvc)); + + auto controller = std::make_shared( + providerSettsSvc, discordWebhookSvc, mainHandler); + + // ---- Start Drogon HTTP server ----------------------------------------- + + drogon::app() + .addListener("0.0.0.0", port) + .setThreadNum(static_cast(std::thread::hardware_concurrency())) + .registerController(controller) + .run(); + + return 0; +} diff --git a/cpp/src/models/DiscordWebhook.h b/cpp/src/models/DiscordWebhook.h new file mode 100644 index 0000000..4a9d65a --- /dev/null +++ b/cpp/src/models/DiscordWebhook.h @@ -0,0 +1,9 @@ +#pragma once +#include + +struct DiscordWebhook { + std::string id; + std::string userId; + std::string discordWebhookId; + std::string discordWebhookToken; +}; diff --git a/cpp/src/models/EmbedOptions.h b/cpp/src/models/EmbedOptions.h new file mode 100644 index 0000000..9c2e557 --- /dev/null +++ b/cpp/src/models/EmbedOptions.h @@ -0,0 +1,14 @@ +#pragma once +#include +#include + +struct EmbedOptions { + std::string id; + std::string userId; + bool areCommitsClickable; + bool showPrivateCommits; + std::optional privateCommitPrefix; + std::optional descriptionFormat; + std::optional privateMessage; + std::optional privateCharacter; +}; diff --git a/cpp/src/models/ProviderSettings.h b/cpp/src/models/ProviderSettings.h new file mode 100644 index 0000000..825c84a --- /dev/null +++ b/cpp/src/models/ProviderSettings.h @@ -0,0 +1,12 @@ +#pragma once +#include +#include + +struct ProviderSettings { + std::string id; + std::string userId; + std::string discordWebhookId; + std::optional optionsId; + std::string slug; + std::string token; +}; diff --git a/cpp/src/services/DiscordMessageService.cpp b/cpp/src/services/DiscordMessageService.cpp new file mode 100644 index 0000000..13099bd --- /dev/null +++ b/cpp/src/services/DiscordMessageService.cpp @@ -0,0 +1,27 @@ +#include "DiscordMessageService.h" +#include +#include +#include + +DiscordMessageService::DiscordMessageService() = default; + +void DiscordMessageService::sendMessageToDiscord(const DiscordWebhook& hook, const DiscordEmbed& embed) { + nlohmann::json payload = {{"embeds", nlohmann::json::array({embedToJson(embed)})}}; + const std::string body = payload.dump(); + const std::string path = "/api/webhooks/" + hook.discordWebhookId + "/" + hook.discordWebhookToken; + + spdlog::debug("Sending to Discord: {}", body); + + httplib::SSLClient client("discord.com"); + client.set_connection_timeout(5); + client.set_read_timeout(10); + + auto res = client.Post(path, body, "application/json"); + + if (!res) + spdlog::error("Discord request failed: no response"); + else if (res->status < 200 || res->status >= 300) + spdlog::error("Discord returned HTTP {}: {}", res->status, res->body); + else + spdlog::debug("Discord webhook sent successfully (HTTP {})", res->status); +} diff --git a/cpp/src/services/DiscordMessageService.h b/cpp/src/services/DiscordMessageService.h new file mode 100644 index 0000000..4b5d8cf --- /dev/null +++ b/cpp/src/services/DiscordMessageService.h @@ -0,0 +1,9 @@ +#pragma once +#include "../models/DiscordWebhook.h" +#include "../types/DiscordEmbed.h" + +class DiscordMessageService { +public: + DiscordMessageService(); + void sendMessageToDiscord(const DiscordWebhook& hook, const DiscordEmbed& embed); +}; diff --git a/cpp/src/services/DiscordWebhookService.cpp b/cpp/src/services/DiscordWebhookService.cpp new file mode 100644 index 0000000..f89bafc --- /dev/null +++ b/cpp/src/services/DiscordWebhookService.cpp @@ -0,0 +1,33 @@ +#include "DiscordWebhookService.h" +#include +#include + +DiscordWebhookService::DiscordWebhookService(std::shared_ptr pool) + : pool_(std::move(pool)) {} + +std::optional DiscordWebhookService::getById(const std::string& id) { + auto conn = pool_->acquire(); + try { + pqxx::work txn(*conn); + auto rows = txn.exec_params( + "SELECT id, user_id, discord_webhook_id, discord_webhook_token " + "FROM discord_webhooks WHERE id = $1", + id); + txn.commit(); + pool_->release(std::move(conn)); + + if (rows.empty()) return std::nullopt; + + const auto& r = rows[0]; + DiscordWebhook hook; + hook.id = r["id"].as(); + hook.userId = r["user_id"].as(); + hook.discordWebhookId = r["discord_webhook_id"].as(); + hook.discordWebhookToken = r["discord_webhook_token"].as(); + return hook; + } catch (const std::exception& e) { + pool_->release(std::move(conn)); + spdlog::error("DiscordWebhookService::getById error: {}", e.what()); + return std::nullopt; + } +} diff --git a/cpp/src/services/DiscordWebhookService.h b/cpp/src/services/DiscordWebhookService.h new file mode 100644 index 0000000..eeaa68c --- /dev/null +++ b/cpp/src/services/DiscordWebhookService.h @@ -0,0 +1,16 @@ +#pragma once +#include +#include +#include +#include "../db/ConnectionPool.h" +#include "../models/DiscordWebhook.h" + +class DiscordWebhookService { +public: + explicit DiscordWebhookService(std::shared_ptr pool); + + std::optional getById(const std::string& id); + +private: + std::shared_ptr pool_; +}; diff --git a/cpp/src/services/EmbedOptionsService.cpp b/cpp/src/services/EmbedOptionsService.cpp new file mode 100644 index 0000000..f5fa61c --- /dev/null +++ b/cpp/src/services/EmbedOptionsService.cpp @@ -0,0 +1,42 @@ +#include "EmbedOptionsService.h" +#include +#include + +EmbedOptionsService::EmbedOptionsService(std::shared_ptr pool) + : pool_(std::move(pool)) {} + +std::optional EmbedOptionsService::getById(const std::string& id) { + auto conn = pool_->acquire(); + try { + pqxx::work txn(*conn); + auto rows = txn.exec_params( + "SELECT id, user_id, are_commits_clickable, show_private_commits, " + "private_commit_prefix, description_format, private_message, private_character " + "FROM embed_options WHERE id = $1", + id); + txn.commit(); + pool_->release(std::move(conn)); + + if (rows.empty()) return std::nullopt; + + const auto& r = rows[0]; + EmbedOptions opts; + opts.id = r["id"].as(); + opts.userId = r["user_id"].as(); + opts.areCommitsClickable = r["are_commits_clickable"].as(); + opts.showPrivateCommits = r["show_private_commits"].as(); + if (!r["private_commit_prefix"].is_null()) + opts.privateCommitPrefix = r["private_commit_prefix"].as(); + if (!r["description_format"].is_null()) + opts.descriptionFormat = r["description_format"].as(); + if (!r["private_message"].is_null()) + opts.privateMessage = r["private_message"].as(); + if (!r["private_character"].is_null()) + opts.privateCharacter = r["private_character"].as(); + return opts; + } catch (const std::exception& e) { + pool_->release(std::move(conn)); + spdlog::error("EmbedOptionsService::getById error: {}", e.what()); + return std::nullopt; + } +} diff --git a/cpp/src/services/EmbedOptionsService.h b/cpp/src/services/EmbedOptionsService.h new file mode 100644 index 0000000..8a8d131 --- /dev/null +++ b/cpp/src/services/EmbedOptionsService.h @@ -0,0 +1,16 @@ +#pragma once +#include +#include +#include +#include "../db/ConnectionPool.h" +#include "../models/EmbedOptions.h" + +class EmbedOptionsService { +public: + explicit EmbedOptionsService(std::shared_ptr pool); + + std::optional getById(const std::string& id); + +private: + std::shared_ptr pool_; +}; diff --git a/cpp/src/services/ProviderSettingsService.cpp b/cpp/src/services/ProviderSettingsService.cpp new file mode 100644 index 0000000..6d75846 --- /dev/null +++ b/cpp/src/services/ProviderSettingsService.cpp @@ -0,0 +1,55 @@ +#include "ProviderSettingsService.h" +#include +#include + +ProviderSettingsService::ProviderSettingsService( + std::shared_ptr pool, + std::shared_ptr embedOptionsService) + : pool_(std::move(pool)) + , embedOptionsService_(std::move(embedOptionsService)) {} + +std::optional ProviderSettingsService::getById(const std::string& id) { + return fetchWhere("id", id); +} + +std::optional ProviderSettingsService::getByToken(const std::string& token) { + return fetchWhere("token", token); +} + +std::optional ProviderSettingsService::getOptionsForProvider(const ProviderSettings& ps) { + if (!ps.optionsId) return std::nullopt; + return embedOptionsService_->getById(*ps.optionsId); +} + +std::optional ProviderSettingsService::fetchWhere( + const std::string& col, const std::string& val) +{ + auto conn = pool_->acquire(); + try { + pqxx::work txn(*conn); + // pqxx doesn't parameterise column names; col is internal so direct interpolation is safe + auto rows = txn.exec_params( + "SELECT id, user_id, discord_webhook_id, options_id, slug, token " + "FROM provider_settings WHERE " + col + " = $1", + val); + txn.commit(); + pool_->release(std::move(conn)); + + if (rows.empty()) return std::nullopt; + + const auto& r = rows[0]; + ProviderSettings ps; + ps.id = r["id"].as(); + ps.userId = r["user_id"].as(); + ps.discordWebhookId = r["discord_webhook_id"].as(); + if (!r["options_id"].is_null()) + ps.optionsId = r["options_id"].as(); + ps.slug = r["slug"].as(); + ps.token = r["token"].as(); + return ps; + } catch (const std::exception& e) { + pool_->release(std::move(conn)); + spdlog::error("ProviderSettingsService error: {}", e.what()); + return std::nullopt; + } +} diff --git a/cpp/src/services/ProviderSettingsService.h b/cpp/src/services/ProviderSettingsService.h new file mode 100644 index 0000000..d4b4757 --- /dev/null +++ b/cpp/src/services/ProviderSettingsService.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include +#include "../db/ConnectionPool.h" +#include "../models/EmbedOptions.h" +#include "../models/ProviderSettings.h" +#include "EmbedOptionsService.h" + +class ProviderSettingsService { +public: + ProviderSettingsService( + std::shared_ptr pool, + std::shared_ptr embedOptionsService); + + std::optional getById(const std::string& id); + std::optional getByToken(const std::string& token); + std::optional getOptionsForProvider(const ProviderSettings& ps); + +private: + std::shared_ptr pool_; + std::shared_ptr embedOptionsService_; + + std::optional fetchWhere(const std::string& col, const std::string& val); +}; diff --git a/cpp/src/types/DiscordEmbed.h b/cpp/src/types/DiscordEmbed.h new file mode 100644 index 0000000..34950e3 --- /dev/null +++ b/cpp/src/types/DiscordEmbed.h @@ -0,0 +1,75 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +struct EmbedAuthor { + std::string name; + std::optional url; + std::optional icon_url; +}; + +struct EmbedFooter { + std::string text; + std::optional icon_url; +}; + +struct EmbedField { + std::string name; + std::string value; + bool inline_ = false; +}; + +struct DiscordEmbed { + std::optional title; + std::optional description; + std::optional url; + std::optional color; + std::optional author; + std::optional footer; + std::vector fields; +}; + +inline std::string isoTimestamp() { + auto now = std::chrono::system_clock::now(); + auto t = std::chrono::system_clock::to_time_t(now); + std::ostringstream oss; + oss << std::put_time(std::gmtime(&t), "%Y-%m-%dT%H:%M:%SZ"); + return oss.str(); +} + +inline nlohmann::json embedToJson(const DiscordEmbed& e) { + nlohmann::json j; + if (e.title) j["title"] = *e.title; + if (e.description) j["description"] = *e.description; + if (e.url) j["url"] = *e.url; + if (e.color) j["color"] = *e.color; + j["timestamp"] = isoTimestamp(); + + if (e.author) { + auto& a = *e.author; + j["author"]["name"] = a.name; + if (a.url) j["author"]["url"] = *a.url; + if (a.icon_url) j["author"]["icon_url"] = *a.icon_url; + } + if (e.footer) { + auto& f = *e.footer; + j["footer"]["text"] = f.text; + if (f.icon_url) j["footer"]["icon_url"] = *f.icon_url; + } + if (!e.fields.empty()) { + j["fields"] = nlohmann::json::array(); + for (auto& f : e.fields) { + j["fields"].push_back({ + {"name", f.name}, + {"value", f.value}, + {"inline", f.inline_} + }); + } + } + return j; +} diff --git a/cpp/src/types/EventData.h b/cpp/src/types/EventData.h new file mode 100644 index 0000000..8169b9d --- /dev/null +++ b/cpp/src/types/EventData.h @@ -0,0 +1,9 @@ +#pragma once +#include +#include "../models/DiscordWebhook.h" +#include "../models/EmbedOptions.h" + +struct EventData { + DiscordWebhook hook; + std::optional options; +}; diff --git a/cpp/src/types/providers/GithubPayloads.h b/cpp/src/types/providers/GithubPayloads.h new file mode 100644 index 0000000..4fa3589 --- /dev/null +++ b/cpp/src/types/providers/GithubPayloads.h @@ -0,0 +1,141 @@ +#pragma once +#include +#include +#include +#include + +// ---- Enums ---------------------------------------------------------------- + +enum class GithubRefType { Branch, Tag }; +enum class GithubCheckRunAction { Created, Completed, ReRequested, RequestedAction }; +enum class GithubCheckRunStatus { Queued, InProgress, Completed }; +enum class GithubCheckRunConclusion { + Success, Failure, Neutral, Cancelled, TimedOut, ActionRequired, Stale +}; + +inline void from_json(const nlohmann::json& j, GithubRefType& v) { + auto s = j.get(); + if (s == "branch") v = GithubRefType::Branch; + else v = GithubRefType::Tag; +} +inline void from_json(const nlohmann::json& j, GithubCheckRunAction& v) { + auto s = j.get(); + if (s == "created") v = GithubCheckRunAction::Created; + else if (s == "completed") v = GithubCheckRunAction::Completed; + else if (s == "rerequested") v = GithubCheckRunAction::ReRequested; + else v = GithubCheckRunAction::RequestedAction; +} +inline void from_json(const nlohmann::json& j, GithubCheckRunStatus& v) { + auto s = j.get(); + if (s == "queued") v = GithubCheckRunStatus::Queued; + else if (s == "in_progress") v = GithubCheckRunStatus::InProgress; + else v = GithubCheckRunStatus::Completed; +} +inline void from_json(const nlohmann::json& j, GithubCheckRunConclusion& v) { + auto s = j.get(); + if (s == "success") v = GithubCheckRunConclusion::Success; + else if (s == "failure") v = GithubCheckRunConclusion::Failure; + else if (s == "neutral") v = GithubCheckRunConclusion::Neutral; + else if (s == "cancelled") v = GithubCheckRunConclusion::Cancelled; + else if (s == "timed_out") v = GithubCheckRunConclusion::TimedOut; + else if (s == "action_required") v = GithubCheckRunConclusion::ActionRequired; + else v = GithubCheckRunConclusion::Stale; +} + +// ---- Structs -------------------------------------------------------------- + +struct GithubPusher { + std::string name; + std::string email; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GithubPusher, name, email) + +struct GithubSender { + std::string login; + std::string avatar_url; + std::string html_url; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GithubSender, login, avatar_url, html_url) + +struct GithubOwner { + std::string login; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GithubOwner, login) + +struct GithubRepository { + std::string name; + std::string html_url; + std::string full_name; + GithubOwner owner; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GithubRepository, name, html_url, full_name, owner) + +struct GithubCommit { + std::string message; + std::string url; + GithubPusher author; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GithubCommit, message, url, author) + +struct GithubCheckRun { + std::string head_branch; + std::string html_url; + std::string head_sha; + GithubCheckRunStatus status; + std::optional conclusion; + std::string name; +}; + +inline void from_json(const nlohmann::json& j, GithubCheckRun& v) { + j.at("head_branch").get_to(v.head_branch); + j.at("html_url").get_to(v.html_url); + j.at("head_sha").get_to(v.head_sha); + j.at("status").get_to(v.status); + j.at("name").get_to(v.name); + if (j.contains("conclusion") && !j["conclusion"].is_null()) + v.conclusion = j["conclusion"].get(); +} + +// ---- Payloads ------------------------------------------------------------- + +struct GithubPushPayload { + std::string ref; + std::string before; + std::string after; + std::vector commits; + GithubPusher pusher; + GithubSender sender; + GithubRepository repository; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GithubPushPayload, ref, before, after, commits, pusher, sender, repository) + +struct GithubIssuePayload { + std::string action; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GithubIssuePayload, action) + +struct GithubCheckRunPayload { + GithubCheckRunAction action; + GithubCheckRun check_run; + GithubRepository repository; + GithubSender sender; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GithubCheckRunPayload, action, check_run, repository, sender) + +struct GithubCreatePayload { + std::string ref; + GithubRefType ref_type; + std::string pusher_type; + GithubRepository repository; + GithubSender sender; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GithubCreatePayload, ref, ref_type, pusher_type, repository, sender) + +struct GithubDeletePayload { + std::string ref; + GithubRefType ref_type; + std::string pusher_type; + GithubRepository repository; + GithubSender sender; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GithubDeletePayload, ref, ref_type, pusher_type, repository, sender) diff --git a/cpp/src/types/providers/GitlabPayloads.h b/cpp/src/types/providers/GitlabPayloads.h new file mode 100644 index 0000000..c90f6ca --- /dev/null +++ b/cpp/src/types/providers/GitlabPayloads.h @@ -0,0 +1,172 @@ +#pragma once +#include +#include +#include +#include + +struct GitlabAuthor { + std::string name; + std::string email; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GitlabAuthor, name, email) + +struct GitlabProject { + std::string name; + std::string path_with_namespace; + std::string web_url; + std::string description; + std::string url; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GitlabProject, name, path_with_namespace, web_url, description, url) + +struct GitlabRepository { + std::string name; + std::string url; + std::string description; + std::string homepage; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GitlabRepository, name, url, description, homepage) + +struct GitlabCommit { + std::string id; + std::string message; + std::string timestamp; + std::string url; + GitlabAuthor author; + std::vector added; + std::vector modified; + std::vector removed; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GitlabCommit, id, message, timestamp, url, author, added, modified, removed) + +struct GitlabMergeRequest { + int id; + int iid; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GitlabMergeRequest, id, iid) + +struct GitlabObjectAttributes { + std::string noteable_type; + std::string note; + std::string url; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GitlabObjectAttributes, noteable_type, note, url) + +struct GitlabUser { + std::string name; + std::string username; + std::string avatar_url; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GitlabUser, name, username, avatar_url) + +struct GitlabPushPayload { + std::string object_kind; + std::string before; + std::string after; + std::string ref; + std::string checkout_sha; + int user_id; + std::string user_name; + std::string user_email; + std::string user_avatar; + int project_id; + GitlabProject project; + GitlabRepository repository; + std::vector commits; + int total_commits_count; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GitlabPushPayload, + object_kind, before, after, ref, checkout_sha, + user_id, user_name, user_email, user_avatar, project_id, + project, repository, commits, total_commits_count) + +struct GitlabTagPushPayload { + std::string object_kind; + std::string before; + std::string after; + std::string ref; + std::string checkout_sha; + int user_id; + std::string user_name; + std::string user_email; + std::string user_avatar; + int project_id; + GitlabProject project; + GitlabRepository repository; + std::vector commits; + int total_commits_count; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GitlabTagPushPayload, + object_kind, before, after, ref, checkout_sha, + user_id, user_name, user_email, user_avatar, project_id, + project, repository, commits, total_commits_count) + +struct GitlabNotePayload { + std::string object_kind; + GitlabUser user; + int project_id; + GitlabProject project; + GitlabRepository repository; + GitlabObjectAttributes object_attributes; + std::optional commit; + std::optional merge_request; +}; + +inline void from_json(const nlohmann::json& j, GitlabNotePayload& v) { + j.at("object_kind").get_to(v.object_kind); + j.at("user").get_to(v.user); + j.at("project_id").get_to(v.project_id); + j.at("project").get_to(v.project); + j.at("repository").get_to(v.repository); + j.at("object_attributes").get_to(v.object_attributes); + if (j.contains("commit") && !j["commit"].is_null()) + v.commit = j["commit"].get(); + if (j.contains("merge_request") && !j["merge_request"].is_null()) + v.merge_request = j["merge_request"].get(); +} + +struct GitlabIssuePayload { + std::string action; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GitlabIssuePayload, action) + +struct GitlabJobPayload { + std::string object_kind; + std::string ref; + bool tag; + std::string before_sha; + std::string sha; + int build_id; + std::string build_name; + std::string build_stage; + std::string build_status; + std::string build_started_at; + std::optional build_finished_at; + float build_duration; + bool build_allow_failure; + int project_id; + std::string project_name; + GitlabUser user; + GitlabRepository repository; +}; + +inline void from_json(const nlohmann::json& j, GitlabJobPayload& v) { + j.at("object_kind").get_to(v.object_kind); + j.at("ref").get_to(v.ref); + j.at("tag").get_to(v.tag); + j.at("before_sha").get_to(v.before_sha); + j.at("sha").get_to(v.sha); + j.at("build_id").get_to(v.build_id); + j.at("build_name").get_to(v.build_name); + j.at("build_stage").get_to(v.build_stage); + j.at("build_status").get_to(v.build_status); + j.at("build_started_at").get_to(v.build_started_at); + j.at("build_duration").get_to(v.build_duration); + j.at("build_allow_failure").get_to(v.build_allow_failure); + j.at("project_id").get_to(v.project_id); + j.at("project_name").get_to(v.project_name); + j.at("user").get_to(v.user); + j.at("repository").get_to(v.repository); + if (j.contains("build_finished_at") && !j["build_finished_at"].is_null()) + v.build_finished_at = j["build_finished_at"].get(); +} diff --git a/cpp/src/types/providers/SonarrPayloads.h b/cpp/src/types/providers/SonarrPayloads.h new file mode 100644 index 0000000..6692a07 --- /dev/null +++ b/cpp/src/types/providers/SonarrPayloads.h @@ -0,0 +1,186 @@ +#pragma once +#include +#include +#include +#include + +// ---- Enum ----------------------------------------------------------------- + +enum class SonarrEventType { Download, Grab, Rename, Test }; + +inline void from_json(const nlohmann::json& j, SonarrEventType& v) { + auto s = j.get(); + if (s == "Download") v = SonarrEventType::Download; + else if (s == "Grab") v = SonarrEventType::Grab; + else if (s == "Rename") v = SonarrEventType::Rename; + else v = SonarrEventType::Test; +} + +// ---- Structs -------------------------------------------------------------- + +struct SonarrSeries { + int id; + std::string title; + std::string path; + std::optional tvdbId; +}; + +inline void from_json(const nlohmann::json& j, SonarrSeries& v) { + j.at("id").get_to(v.id); + j.at("title").get_to(v.title); + j.at("path").get_to(v.path); + if (j.contains("tvdbId") && !j["tvdbId"].is_null()) + v.tvdbId = j["tvdbId"].get(); +} + +struct SonarrRelease { + std::optional quality; + std::optional qualityVersion; + std::optional releaseGroup; + std::optional releaseTitle; + std::optional indexer; + std::optional size; +}; + +inline void from_json(const nlohmann::json& j, SonarrRelease& v) { + auto get_opt_str = [&](const char* key, std::optional& f) { + if (j.contains(key) && !j[key].is_null()) f = j[key].get(); + }; + auto get_opt_int = [&](const char* key, std::optional& f) { + if (j.contains(key) && !j[key].is_null()) f = j[key].get(); + }; + get_opt_str("quality", v.quality); + get_opt_int("qualityVersion", v.qualityVersion); + get_opt_str("releaseGroup", v.releaseGroup); + get_opt_str("releaseTitle", v.releaseTitle); + get_opt_str("indexer", v.indexer); + get_opt_int("size", v.size); +} + +struct SonarrEpisode { + int id; + int episodeNumber; + int seasonNumber; + std::string title; + std::optional airDateUtc; +}; + +inline void from_json(const nlohmann::json& j, SonarrEpisode& v) { + j.at("id").get_to(v.id); + j.at("episodeNumber").get_to(v.episodeNumber); + j.at("seasonNumber").get_to(v.seasonNumber); + j.at("title").get_to(v.title); + if (j.contains("airDateUtc") && !j["airDateUtc"].is_null()) + v.airDateUtc = j["airDateUtc"].get(); +} + +struct SonarrEpisodeFile { + int id; + std::string relativePath; + std::string path; + std::optional quality; + std::optional qualityVersion; + std::optional releaseGroup; + std::optional sceneName; +}; + +inline void from_json(const nlohmann::json& j, SonarrEpisodeFile& v) { + j.at("id").get_to(v.id); + j.at("relativePath").get_to(v.relativePath); + j.at("path").get_to(v.path); + auto get_opt_str = [&](const char* key, std::optional& f) { + if (j.contains(key) && !j[key].is_null()) f = j[key].get(); + }; + auto get_opt_int = [&](const char* key, std::optional& f) { + if (j.contains(key) && !j[key].is_null()) f = j[key].get(); + }; + get_opt_str("quality", v.quality); + get_opt_int("qualityVersion", v.qualityVersion); + get_opt_str("releaseGroup", v.releaseGroup); + get_opt_str("sceneName", v.sceneName); +} + +// ---- Payloads ------------------------------------------------------------- + +struct SonarrGrabEvent { + SonarrEventType eventType; + SonarrSeries series; + std::vector episodes; + SonarrRelease release; + std::optional episodeFile; + std::optional isUpgrade; +}; + +inline void from_json(const nlohmann::json& j, SonarrGrabEvent& v) { + j.at("eventType").get_to(v.eventType); + j.at("series").get_to(v.series); + j.at("episodes").get_to(v.episodes); + j.at("release").get_to(v.release); + if (j.contains("episodeFile") && !j["episodeFile"].is_null()) + v.episodeFile = j["episodeFile"].get(); + if (j.contains("isUpgrade") && !j["isUpgrade"].is_null()) + v.isUpgrade = j["isUpgrade"].get(); +} + +struct SonarrDownloadEvent { + SonarrEventType eventType; + SonarrSeries series; + std::vector episodes; + std::optional release; + SonarrEpisodeFile episodeFile; + bool isUpgrade; +}; + +inline void from_json(const nlohmann::json& j, SonarrDownloadEvent& v) { + j.at("eventType").get_to(v.eventType); + j.at("series").get_to(v.series); + j.at("episodes").get_to(v.episodes); + j.at("episodeFile").get_to(v.episodeFile); + j.at("isUpgrade").get_to(v.isUpgrade); + if (j.contains("release") && !j["release"].is_null()) + v.release = j["release"].get(); +} + +struct SonarrRenameEvent { + SonarrEventType eventType; + SonarrSeries series; + std::optional> episodes; + std::optional release; + std::optional episodeFile; + std::optional isUpgrade; +}; + +inline void from_json(const nlohmann::json& j, SonarrRenameEvent& v) { + j.at("eventType").get_to(v.eventType); + j.at("series").get_to(v.series); + if (j.contains("episodes") && !j["episodes"].is_null()) + v.episodes = j["episodes"].get>(); + if (j.contains("release") && !j["release"].is_null()) + v.release = j["release"].get(); + if (j.contains("episodeFile") && !j["episodeFile"].is_null()) + v.episodeFile = j["episodeFile"].get(); + if (j.contains("isUpgrade") && !j["isUpgrade"].is_null()) + v.isUpgrade = j["isUpgrade"].get(); +} + +struct SonarrTestEvent { + SonarrEventType eventType; + SonarrSeries series; + std::optional> episodes; + std::optional release; + std::optional episodeFile; + std::optional isUpgrade; +}; + +inline void from_json(const nlohmann::json& j, SonarrTestEvent& v) { + j.at("eventType").get_to(v.eventType); + j.at("series").get_to(v.series); + if (j.contains("episodes") && !j["episodes"].is_null()) + v.episodes = j["episodes"].get>(); + if (j.contains("release") && !j["release"].is_null()) + v.release = j["release"].get(); + if (j.contains("episodeFile") && !j["episodeFile"].is_null()) + v.episodeFile = j["episodeFile"].get(); + if (j.contains("isUpgrade") && !j["isUpgrade"].is_null()) + v.isUpgrade = j["isUpgrade"].get(); +} diff --git a/cpp/src/util/Colours.h b/cpp/src/util/Colours.h new file mode 100644 index 0000000..7dd79ca --- /dev/null +++ b/cpp/src/util/Colours.h @@ -0,0 +1,12 @@ +#pragma once + +namespace Colours { + constexpr int PUSH = 0x0DA2FF; + constexpr int NOTE = 0xFFA500; + constexpr int CREATED = 0x008000; + constexpr int DELETED = 0xFF0000; + constexpr int FAILED = 0xFF0000; + constexpr int CANCELED = 0xFFFF00; + constexpr int RUNNING = 0xE89D13; + constexpr int SUCCESS = 0x30FF49; +} diff --git a/cpp/src/util/EventHandlerUtils.h b/cpp/src/util/EventHandlerUtils.h new file mode 100644 index 0000000..f0b7719 --- /dev/null +++ b/cpp/src/util/EventHandlerUtils.h @@ -0,0 +1,77 @@ +#pragma once +#include +#include +#include +#include "../models/EmbedOptions.h" + +inline bool startsWith(const std::string& s, const std::string& prefix) { + return s.size() >= prefix.size() && s.substr(0, prefix.size()) == prefix; +} + +// private branch names begin with ! or $ +inline bool isPrivateBranch(const std::string& branchName) { + return startsWith(branchName, "!") || startsWith(branchName, "$"); +} + +// "refs/heads/main" -> "main" +inline std::string getBranchFromRef(const std::string& ref) { + size_t first = ref.find('/'); + if (first == std::string::npos) return ref; + size_t second = ref.find('/', first + 1); + if (second == std::string::npos) return ref; + return ref.substr(second + 1); +} + +inline std::string formatCommit( + const std::string& message, + size_t length, + const std::string& url, + const std::optional& embedOptions) +{ + static const std::vector defaultChars = {"!", "$"}; + static const std::string defaultMsg = "This commit message has been marked as private."; + + const std::string prefix = (length > 1) ? "- " : ""; + + if (!embedOptions) { + std::vector privateDenotations = defaultChars; + for (auto& c : defaultChars) privateDenotations.push_back("Revert " + c); + + bool isPrivate = false; + for (auto& d : privateDenotations) { + if (startsWith(message, d)) { isPrivate = true; break; } + } + return prefix + (isPrivate ? defaultMsg : message); + } + + const EmbedOptions& opts = *embedOptions; + std::vector privateChar = opts.privateCharacter + ? std::vector{*opts.privateCharacter} + : defaultChars; + + std::vector privateDenotations = privateChar; + for (auto& c : privateChar) privateDenotations.push_back("Revert " + c); + for (auto& c : defaultChars) privateDenotations.push_back(c); + for (auto& c : defaultChars) privateDenotations.push_back("Revert " + c); + + bool isPrivate = false; + for (auto& d : privateDenotations) { + if (startsWith(message, d)) { isPrivate = true; break; } + } + + const std::string clickableMsg = opts.areCommitsClickable + ? "[" + message + "](" + url + ")" + : message; + + std::string finalMsg; + if (!opts.showPrivateCommits) { + if (isPrivate) + finalMsg = opts.privateMessage.value_or(defaultMsg); + else + finalMsg = opts.privateCommitPrefix.value_or("[Private] ") + clickableMsg; + } else { + finalMsg = clickableMsg; + } + + return prefix + finalMsg; +}