#include #include "loader/component_loader.hpp" #include "gsc.hpp" #include "scripting.hpp" #include "scheduler.hpp" #include "command.hpp" #include "game/game.hpp" #include "game/scripting/array.hpp" #include #include #include #include #include #include namespace moderation { namespace { using runtime = utils::moderation::runtime; struct transfer { std::string session, response; std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now(); std::atomic done{false}; bool success = false; }; std::shared_ptr current; std::shared_ptr pending; std::string endpoint, credential, configured_server; int configured_generation = 0; std::chrono::steady_clock::time_point next_poll{}, next_log{}; bool fault = false; long long now() { return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); } void failure() { if (std::chrono::steady_clock::now() >= next_log) { printf("[tws_moderation] Runtime unavailable: verify credentials, policy, clock and persistent storage.\n"); next_log = std::chrono::steady_clock::now() + std::chrono::seconds(30); } } void disconnected(unsigned int slot) { if (current) current->disconnect(static_cast(slot)); } std::string execute(const std::string& kind, int slot, const std::string& reason) { auto active = current; if (!active) return "failed"; // This function is called only by the server-thread GSC pump. No queue/yield // separates runtime target validation from synchronous engine dispatch. if (kind == "announce" || kind == "warn" || kind == "message") { const auto text = std::string("j \"") + reason + "\""; game::SV_GameSendServerCommand(slot, 0, text.c_str()); return "confirmed"; } if (slot < 0 || slot >= 64) return "failed"; if (kind == "mute" || kind == "unmute") { command::execute((kind == "mute" ? "muteclient " : "unmuteclient ") + std::to_string(slot), true); return "unknown"; // Engine has no mute-state readback in this adapter. } if (kind != "kick") return "failed"; const auto connection = active->players.contains(slot) ? active->players.at(slot).connection : std::string(); command::execute("clientkick " + std::to_string(slot) + " \"" + reason + "\"", true); return !active->players.contains(slot) || active->players.at(slot).connection != connection ? "confirmed" : "unknown"; } void pump() { if (!current || fault) return; try { if (pending && pending->done.load()) { auto completed = pending; pending.reset(); if (completed->session == current->session) { if (completed->success) { const auto age = std::chrono::duration_cast(std::chrono::steady_clock::now() - completed->started).count(); current->accept(completed->response, now(), age); } else failure(); } } auto active = current; active->enforce(now(), [active](const auto& kind, int slot, const auto& reason) { return current == active ? execute(kind, slot, reason) : std::string("failed"); }); if (current != active) return; if (!pending && std::chrono::steady_clock::now() >= next_poll) { auto request = current->request().dump(); auto target = endpoint; auto token = credential; pending = std::make_shared(); pending->session = current->session; auto work = pending; next_poll = std::chrono::steady_clock::now() + std::chrono::seconds(3); scheduler::thread_pool.push([work, request, target, token] { try { utils::http::timeouts limits; limits.connect_timeout_ms = 3000; limits.timeout_ms = 5000; limits.max_response_bytes = 1024 * 1024; const auto response = utils::http::get_data(target, request, {{"Authorization", "Bearer " + token}, {"Content-Type", "application/json"}}, "POST", limits); if (response && response->code == CURLE_OK && response->status == 200) { work->response = response->buffer; work->success = true; } } catch (...) { work->success = false; } work->done.store(true); }); } } catch (...) { failure(); } } } class component final : public component_interface { public: void on_startup(plugin::plugin* plugin) override { plugin->get_interface()->callbacks()->on_player_connect(disconnected); plugin->get_interface()->callbacks()->on_player_disconnect(disconnected); scripting::on_shutdown([] { // In-flight requests own only native data. Results from the old VM are abandoned. current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false; }); gsc::function::add("moderationversion", [] { return std::string("1.0.0"); }); gsc::function::add("moderationconfigure", [](const std::string& server, const std::string& url, const std::string& token, int generation) { if (current && !fault && endpoint == url && credential == token && configured_server == server && configured_generation == generation) return true; // Credentials cannot leave the explicitly configured HTTPS origin. Curl does not follow redirects. if (url.size() > 2048 || url.rfind("https://", 0) != 0 || url.find_first_of("\r\n?#@") != std::string::npos || url.size() < 8 || url.find("/api/moderation/runtime") != url.size() - 23 || token.rfind("ec_moderation_", 0) != 0 || token.size() < 40 || token.size() > 256 || token.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-") != std::string::npos) return false; try { auto configured = std::make_shared("tws-moderation/t6", server, url, generation, utils::cryptography::sha256::compute(token, true)); current = std::move(configured); pending.reset(); configured_server = server; configured_generation = generation; endpoint = url; credential = token; next_poll = {}; fault = false; return true; } catch (...) { fault = true; failure(); return false; } }); gsc::method::add("moderationobserve", [](const scripting::entity& entity, const std::string& guid, const std::string& name) { if (!current || fault) return false; const auto ref = entity.get_entity_reference(); if (ref.classnum != 0 || ref.entnum >= 64) return false; try { current->observe(ref.entnum, guid, name); return true; } catch (...) { failure(); return false; } }); gsc::function::add("moderationstop", [] { current.reset(); pending.reset(); credential.clear(); endpoint.clear(); fault = false; }); gsc::function::add("moderationpump", pump); } }; } REGISTER_COMPONENT(moderation::component)