mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
chat_dms (GM 8) reads whispers; chat_private is now team and guild chat only. Adds chat_flag and chat_flag_review (GM 3). Both can be granted per account or character with the permission grants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
663 lines
30 KiB
C++
663 lines
30 KiB
C++
#include "ModeratorHelper.h"
|
|
|
|
#include <algorithm>
|
|
#include <condition_variable>
|
|
#include <cstdlib>
|
|
#include <ctime>
|
|
#include <deque>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <thread>
|
|
|
|
#include "AiBudget.h"
|
|
#include "ClaudeClient.h"
|
|
#include "ModeratorPrompt.h"
|
|
#include "RouteUtils.h"
|
|
#include "DashboardRoutes.h"
|
|
#include "ClientAssets.h"
|
|
#include "GameLabels.h"
|
|
#include "PlayerActions.h"
|
|
#include "Strikes.h"
|
|
#include "Database.h"
|
|
#include "Game.h"
|
|
#include "dConfig.h"
|
|
#include "Logger.h"
|
|
#include "GeneralUtils.h"
|
|
#include "eHTTPMethod.h"
|
|
#include "ePlayerReportKind.h"
|
|
#include "ePlayerReportStatus.h"
|
|
#include "magic_enum.hpp"
|
|
|
|
using namespace RouteUtils;
|
|
using ModeratorPrompt::eKind;
|
|
|
|
namespace {
|
|
// How much history goes in a case
|
|
constexpr uint32_t MAX_CHAT_LINES = 40;
|
|
constexpr uint32_t MAX_STRIKES = 20;
|
|
constexpr uint32_t MAX_HISTORY = 20;
|
|
constexpr uint32_t MAX_EARLIER_REPORTS = 10;
|
|
|
|
struct Settings {
|
|
bool enabled{};
|
|
ClaudeClient::Config client;
|
|
AiBudget::Limits limits;
|
|
std::string rules;
|
|
uint32_t chatMinutes{ 10 };
|
|
};
|
|
|
|
uint32_t IntSetting(const std::string& key, uint32_t fallback, uint32_t min, uint32_t max) {
|
|
return std::clamp(GeneralUtils::TryParse<uint32_t>(Game::config->GetValue(key)).value_or(fallback), min, max);
|
|
}
|
|
|
|
// Main thread only (the config isn't read from the worker)
|
|
Settings ReadSettings() {
|
|
Settings s;
|
|
s.enabled = Game::config->GetValue("ai_helper_enabled") == "1";
|
|
s.client.apiKey = Game::config->GetValue("claude_api_key");
|
|
const auto& base = Game::config->GetValue("claude_api_base");
|
|
if (!base.empty()) s.client.base = base;
|
|
const auto& model = Game::config->GetValue("claude_model");
|
|
if (!model.empty()) s.client.model = model;
|
|
s.client.structuredOutput = Game::config->GetValue("claude_structured_output") != "0";
|
|
s.client.timeoutSeconds = IntSetting("ai_helper_timeout", 60, 5, 600);
|
|
s.client.maxTokens = IntSetting("ai_helper_max_tokens", 2000, 256, 16000);
|
|
s.limits.perMinute = IntSetting("ai_helper_per_minute", 5, 1, 60);
|
|
s.limits.perDay = IntSetting("ai_helper_per_day", 200, 1, 100000);
|
|
s.rules = Game::config->GetValue("ai_helper_rules");
|
|
s.chatMinutes = IntSetting("ai_helper_chat_minutes", 10, 1, 1440);
|
|
return s;
|
|
}
|
|
|
|
int64_t Now() { return static_cast<int64_t>(std::time(nullptr)); }
|
|
|
|
// ---- The budget, seeded from the database once so a restart doesn't reset the day ----
|
|
|
|
AiBudget g_Budget;
|
|
bool g_BudgetSeeded = false;
|
|
|
|
AiBudget& Budget() {
|
|
if (!g_BudgetSeeded) {
|
|
const auto now = Now();
|
|
g_Budget.Seed(now, Database::Get()->GetAiUsageSince(AiBudget::Day(now) * 86400).requests);
|
|
g_BudgetSeeded = true;
|
|
}
|
|
return g_Budget;
|
|
}
|
|
|
|
// ---- The worker: one request at a time, so a slow API never ties up the web thread ----
|
|
|
|
struct Job {
|
|
uint32_t requestId{};
|
|
ClaudeClient::Config config;
|
|
ModeratorPrompt::Prompt prompt;
|
|
ModeratorPrompt::eKind kind{};
|
|
// Kept for the main thread when the job is done
|
|
HTTPContext context;
|
|
int64_t itemId{};
|
|
std::string fingerprint;
|
|
nlohmann::json caseJson;
|
|
uint32_t accountId{};
|
|
LWOOBJID characterId{};
|
|
// Filled in by the worker
|
|
ClaudeClient::Result result;
|
|
ModeratorPrompt::Parsed parsed;
|
|
};
|
|
|
|
std::thread g_Worker;
|
|
std::mutex g_Mutex;
|
|
std::condition_variable g_Wake;
|
|
std::deque<Job> g_Jobs;
|
|
std::deque<Job> g_Done;
|
|
bool g_Stopping = false;
|
|
std::map<std::string, uint32_t> g_InFlight; // kind:item -> request id, main thread only
|
|
|
|
void WorkerLoop() {
|
|
while (true) {
|
|
Job job;
|
|
{
|
|
std::unique_lock lock(g_Mutex);
|
|
g_Wake.wait(lock, [] { return g_Stopping || !g_Jobs.empty(); });
|
|
if (g_Stopping) return;
|
|
job = std::move(g_Jobs.front());
|
|
g_Jobs.pop_front();
|
|
}
|
|
try {
|
|
ClaudeClient::Request request{ job.prompt.system, job.prompt.user, job.prompt.schema };
|
|
// Waits between retries end early, and no new try starts, when the dashboard shuts down
|
|
const auto transport = [](const std::string& url, const std::vector<std::string>& headers, const std::string& body, uint32_t timeout) {
|
|
{
|
|
std::lock_guard lock(g_Mutex);
|
|
if (g_Stopping) return ClaudeClient::HttpResponse{ 0, "", "", "the dashboard is shutting down" };
|
|
}
|
|
return ClaudeClient::CurlTransport(url, headers, body, timeout);
|
|
};
|
|
const auto sleep = [](uint32_t ms) {
|
|
std::unique_lock lock(g_Mutex);
|
|
g_Wake.wait_for(lock, std::chrono::milliseconds(ms), [] { return g_Stopping; });
|
|
};
|
|
job.result = ClaudeClient::Send(job.config, request, transport, sleep);
|
|
if (job.result.ok) job.parsed = ModeratorPrompt::Parse(job.result.text, job.kind, job.prompt);
|
|
} catch (const std::exception& ex) {
|
|
job.result.ok = false;
|
|
job.result.error = ClaudeClient::eError::BAD_RESPONSE;
|
|
job.result.message = std::string("The helper failed: ") + ex.what();
|
|
}
|
|
std::lock_guard lock(g_Mutex);
|
|
g_Done.push_back(std::move(job));
|
|
}
|
|
}
|
|
|
|
void Enqueue(Job job) {
|
|
if (!g_Worker.joinable()) {
|
|
g_Stopping = false;
|
|
g_Worker = std::thread(WorkerLoop);
|
|
}
|
|
{
|
|
std::lock_guard lock(g_Mutex);
|
|
g_Jobs.push_back(std::move(job));
|
|
}
|
|
g_Wake.notify_one();
|
|
}
|
|
|
|
// ---- What staff get back ----
|
|
|
|
nlohmann::json BudgetJson(const Settings& settings) {
|
|
auto& budget = Budget();
|
|
const auto now = Now();
|
|
return { {"used_today", budget.UsedToday(now)}, {"per_day", settings.limits.perDay}, {"remaining_today", budget.RemainingToday(settings.limits, now)},
|
|
{"per_minute", settings.limits.perMinute}, {"resets_at", AiBudget::NextDayStart(now)} };
|
|
}
|
|
|
|
nlohmann::json RowJson(const IAiSuggestions::AiSuggestion& row, bool cached, uint32_t accountId, LWOOBJID characterId) {
|
|
nlohmann::json json{
|
|
{"id", row.id}, {"kind", row.kind}, {"item_id", std::to_string(row.itemId)}, {"cached", cached}, {"created_at", row.createdAt},
|
|
{"requested_by", row.requestedBy}, {"model", row.model}, {"input_tokens", row.inputTokens}, {"output_tokens", row.outputTokens},
|
|
{"account_id", accountId}, {"character_id", std::to_string(characterId)},
|
|
{"suggestion", nlohmann::json::parse(row.suggestion.empty() ? "null" : row.suggestion, nullptr, false)},
|
|
{"case", nlohmann::json::parse(row.context.empty() ? "null" : row.context, nullptr, false)}
|
|
};
|
|
return json;
|
|
}
|
|
|
|
// ---- Building a case from what the dashboard has; only what the asking user may see ----
|
|
|
|
struct Built {
|
|
ModeratorPrompt::Case c;
|
|
int64_t itemId{};
|
|
uint32_t accountId{}; // whose account an action would be on (0: unknown)
|
|
LWOOBJID characterId{};
|
|
std::string error;
|
|
eHTTPStatusCode status{ eHTTPStatusCode::BAD_REQUEST };
|
|
};
|
|
|
|
std::string CharacterName(LWOOBJID id) {
|
|
if (!id) return "";
|
|
const auto info = Database::Get()->GetCharacterInfo(id);
|
|
return info ? info->name : "";
|
|
}
|
|
|
|
std::string ZoneName(uint32_t zoneId) {
|
|
const auto& zones = ZoneNames();
|
|
const auto key = std::to_string(zoneId);
|
|
return zones.contains(key) && zones[key].is_string() ? zones[key].get<std::string>() : "Zone " + key;
|
|
}
|
|
|
|
ModeratorPrompt::ChatLine Line(const IChatLog::ChatMessage& m, uint64_t subjectId, LWOOBJID subjectSender) {
|
|
return { m.time, m.channel, m.senderName, m.recipientName, m.message, m.blocked, subjectId ? m.id == subjectId : m.senderId == subjectSender };
|
|
}
|
|
|
|
// Chat by or to these characters from `window` seconds before `center` to as long after, the lines closest to it
|
|
std::vector<IChatLog::ChatMessage> ChatAround(const HTTPContext& context, const std::vector<LWOOBJID>& characters, int64_t center, int64_t window) {
|
|
std::map<uint64_t, IChatLog::ChatMessage> found;
|
|
for (const auto character : characters) {
|
|
if (!character) continue;
|
|
IChatLog::ChatQuery query;
|
|
query.characterId = character;
|
|
query.since = center - window;
|
|
query.includePrivate = Can(context, "chat_private");
|
|
query.includeWhispers = Can(context, "chat_dms");
|
|
query.includeWhispers = Can(context, "chat_dms");
|
|
query.limit = 200;
|
|
for (auto& m : Database::Get()->GetChatMessages(query)) {
|
|
if (m.time <= center + window) found[m.id] = std::move(m);
|
|
}
|
|
}
|
|
std::vector<IChatLog::ChatMessage> lines;
|
|
for (auto& [id, m] : found) lines.push_back(std::move(m));
|
|
if (lines.size() > MAX_CHAT_LINES) {
|
|
std::stable_sort(lines.begin(), lines.end(), [center](const auto& a, const auto& b) { return std::llabs(a.time - center) < std::llabs(b.time - center); });
|
|
lines.resize(MAX_CHAT_LINES);
|
|
}
|
|
std::sort(lines.begin(), lines.end(), [](const auto& a, const auto& b) { return a.time != b.time ? a.time < b.time : a.id < b.id; });
|
|
return lines;
|
|
}
|
|
|
|
void AddAccountHistory(const HTTPContext& context, Built& built, LWOOBJID nameDecisionsFor, uint64_t exceptReport) {
|
|
const auto accountId = built.accountId;
|
|
if (accountId) {
|
|
const auto since = Strikes::CountsSince();
|
|
built.c.activeStrikes = Strikes::Active(accountId);
|
|
for (const auto& strike : Database::Get()->GetStrikes(accountId)) {
|
|
if (built.c.strikes.size() >= MAX_STRIKES) break;
|
|
// Staff names stay out
|
|
built.c.strikes.push_back({ strike.createdAt, strike.source, strike.subject, strike.reason, strike.revokedAt == 0 && strike.createdAt >= since });
|
|
}
|
|
if (Can(context, "accounts_notes")) {
|
|
for (const auto& note : Database::Get()->GetAccountNotes(accountId)) {
|
|
if (built.c.history.size() >= MAX_HISTORY) break;
|
|
built.c.history.push_back({ note.createdAt, note.kind, note.text });
|
|
}
|
|
}
|
|
if (Can(context, "player_reports_view")) {
|
|
uint32_t added = 0;
|
|
for (const auto& report : Database::Get()->GetPlayerReports({ .status = -1, .accountId = accountId, .limit = MAX_EARLIER_REPORTS + 1 })) {
|
|
if (report.id == exceptReport || added >= MAX_EARLIER_REPORTS) continue;
|
|
const auto status = static_cast<ePlayerReportStatus>(report.status);
|
|
built.c.history.push_back({ report.createdAt, "earlier report about them (" + GameLabels::Name(status) + ")",
|
|
ModeratorPrompt::Clip(report.body, 200) + (report.resolution.empty() ? "" : " -> staff: " + report.resolution) });
|
|
added++;
|
|
}
|
|
}
|
|
}
|
|
if (nameDecisionsFor) {
|
|
for (const auto& d : Database::Get()->GetModerationDecisions("name", nameDecisionsFor, 5)) {
|
|
built.c.history.push_back({ d.value("time", int64_t{}), d.value("approved", false) ? "name approved" : "name rejected",
|
|
d.value("subject", "") + (d.value("reason", "").empty() ? "" : " (" + d.value("reason", "") + ")") });
|
|
}
|
|
}
|
|
std::stable_sort(built.c.history.begin(), built.c.history.end(), [](const auto& a, const auto& b) { return a.time > b.time; });
|
|
}
|
|
|
|
Built Fail(eHTTPStatusCode status, std::string error) {
|
|
Built built;
|
|
built.status = status;
|
|
built.error = std::move(error);
|
|
return built;
|
|
}
|
|
|
|
Built BuildReport(const HTTPContext& context, uint64_t id, const Settings& settings) {
|
|
if (!Can(context, "player_reports_view")) return Fail(eHTTPStatusCode::FORBIDDEN, "Reading player reports needs player_reports_view");
|
|
const auto report = Database::Get()->GetPlayerReport(id);
|
|
if (!report) return Fail(eHTTPStatusCode::NOT_FOUND, "No such report");
|
|
Built built;
|
|
built.c.kind = eKind::PLAYER_REPORT;
|
|
built.itemId = static_cast<int64_t>(report->id);
|
|
built.accountId = report->targetAccountId;
|
|
built.characterId = report->targetCharacterId;
|
|
const auto kind = magic_enum::enum_cast<ePlayerReportKind>(report->kind);
|
|
auto& item = built.c.item;
|
|
item["what_was_reported"] = kind ? GameLabels::Name(*kind) : report->kind;
|
|
item["sent"] = ModeratorPrompt::FormatTime(report->createdAt);
|
|
item["reporter"] = CharacterName(report->reporterId);
|
|
item["reported_player"] = report->targetCharacterId ? CharacterName(report->targetCharacterId) : "unknown";
|
|
if (report->zoneId) item["where"] = ZoneName(report->zoneId);
|
|
if (report->objectLot && (!kind || *kind != ePlayerReportKind::PLAYER)) item["model"] = ClientAssets::ItemName(report->objectLot);
|
|
if (report->propertyId) {
|
|
if (const auto property = Database::Get()->GetPropertyInfo(report->propertyId)) item["property"] = property->name;
|
|
}
|
|
item["report_text"] = report->body;
|
|
item["status"] = GameLabels::Name(static_cast<ePlayerReportStatus>(report->status));
|
|
|
|
if (Can(context, "chat_view")) {
|
|
const auto window = static_cast<int64_t>(settings.chatMinutes) * 60;
|
|
for (const auto& m : ChatAround(context, { report->targetCharacterId, report->reporterId }, report->createdAt, window)) {
|
|
auto line = Line(m, 0, report->targetCharacterId);
|
|
built.c.chat.push_back(std::move(line));
|
|
}
|
|
built.c.chatNote = "Chat of the reported player and the reporter from " + std::to_string(settings.chatMinutes) + " minutes before the report to as long after; "
|
|
"being_judged marks the reported player's own lines.";
|
|
} else {
|
|
built.c.chatNote = "No chat included: the staff member asking can't read the chat log.";
|
|
}
|
|
AddAccountHistory(context, built, 0, report->id);
|
|
return built;
|
|
}
|
|
|
|
std::optional<IChatLog::ChatMessage> FindMessage(const HTTPContext& context, uint64_t id) {
|
|
IChatLog::ChatQuery query;
|
|
query.afterId = id - 1;
|
|
query.limit = 1;
|
|
query.includePrivate = Can(context, "chat_private");
|
|
query.includeWhispers = Can(context, "chat_dms");
|
|
const auto found = Database::Get()->GetChatMessages(query);
|
|
if (found.empty() || found.front().id != id) return std::nullopt;
|
|
return found.front();
|
|
}
|
|
|
|
Built BuildChatMessage(const HTTPContext& context, uint64_t id, const Settings& settings) {
|
|
if (!Can(context, "chat_view")) return Fail(eHTTPStatusCode::FORBIDDEN, "Reading chat needs chat_view");
|
|
const auto message = id ? FindMessage(context, id) : std::nullopt;
|
|
if (!message) return Fail(eHTTPStatusCode::NOT_FOUND, "No such message (or it's private chat you can't read)");
|
|
if (!message->senderId || message->channel == "web") return Fail(eHTTPStatusCode::BAD_REQUEST, "That message came from the web or a bot, not a player");
|
|
Built built;
|
|
built.c.kind = eKind::CHAT_MESSAGE;
|
|
built.itemId = static_cast<int64_t>(message->id);
|
|
built.accountId = message->accountId;
|
|
built.characterId = message->senderId;
|
|
auto& item = built.c.item;
|
|
item["sender"] = message->senderName;
|
|
item["channel"] = message->channel;
|
|
if (!message->recipientName.empty()) item["to"] = message->recipientName;
|
|
if (message->zoneId) item["where"] = ZoneName(message->zoneId);
|
|
item["when"] = ModeratorPrompt::FormatTime(message->time);
|
|
item["message"] = message->message;
|
|
item["stopped_by_filter"] = message->blocked;
|
|
const auto window = static_cast<int64_t>(settings.chatMinutes) * 60;
|
|
for (const auto& m : ChatAround(context, { message->senderId }, message->time, window)) built.c.chat.push_back(Line(m, message->id, 0));
|
|
built.c.chatNote = "The sender's chat from " + std::to_string(settings.chatMinutes) + " minutes before the message to as long after.";
|
|
AddAccountHistory(context, built, 0, 0);
|
|
return built;
|
|
}
|
|
|
|
Built BuildPlayerChat(const HTTPContext& context, LWOOBJID characterId) {
|
|
if (!Can(context, "chat_view")) return Fail(eHTTPStatusCode::FORBIDDEN, "Reading chat needs chat_view");
|
|
const auto info = characterId ? Database::Get()->GetCharacterInfo(characterId) : std::nullopt;
|
|
if (!info) return Fail(eHTTPStatusCode::NOT_FOUND, "No such character");
|
|
IChatLog::ChatQuery query;
|
|
query.characterId = characterId;
|
|
query.includePrivate = Can(context, "chat_private");
|
|
query.includeWhispers = Can(context, "chat_dms");
|
|
query.newestFirst = true;
|
|
query.limit = MAX_CHAT_LINES;
|
|
auto messages = Database::Get()->GetChatMessages(query);
|
|
if (messages.empty()) return Fail(eHTTPStatusCode::BAD_REQUEST, info->name + " has no chat in the log");
|
|
std::reverse(messages.begin(), messages.end());
|
|
Built built;
|
|
built.c.kind = eKind::PLAYER_CHAT;
|
|
built.itemId = characterId;
|
|
built.accountId = info->accountId;
|
|
built.characterId = characterId;
|
|
built.c.item["player"] = info->name;
|
|
built.c.item["from"] = ModeratorPrompt::FormatTime(messages.front().time);
|
|
built.c.item["to"] = ModeratorPrompt::FormatTime(messages.back().time);
|
|
for (const auto& m : messages) built.c.chat.push_back(Line(m, 0, characterId));
|
|
built.c.chatNote = "Their latest " + std::to_string(messages.size()) + " chat lines; being_judged marks their own.";
|
|
AddAccountHistory(context, built, 0, 0);
|
|
return built;
|
|
}
|
|
|
|
Built BuildName(const HTTPContext& context, LWOOBJID characterId) {
|
|
if (!Can(context, "moderate_names")) return Fail(eHTTPStatusCode::FORBIDDEN, "Moderating names needs moderate_names");
|
|
const auto info = characterId ? Database::Get()->GetCharacterInfo(characterId) : std::nullopt;
|
|
if (!info) return Fail(eHTTPStatusCode::NOT_FOUND, "No such character");
|
|
if (info->pendingName.empty()) return Fail(eHTTPStatusCode::BAD_REQUEST, info->name + " has no name waiting for approval");
|
|
Built built;
|
|
built.c.kind = eKind::NAME;
|
|
built.itemId = characterId;
|
|
built.accountId = info->accountId;
|
|
built.characterId = characterId;
|
|
built.c.item["requested_name"] = info->pendingName;
|
|
built.c.item["current_name"] = info->name;
|
|
AddAccountHistory(context, built, characterId, 0);
|
|
return built;
|
|
}
|
|
|
|
Built BuildPetName(const HTTPContext& context, LWOOBJID petId) {
|
|
if (!Can(context, "moderate_pet_names")) return Fail(eHTTPStatusCode::FORBIDDEN, "Moderating pet names needs moderate_pet_names");
|
|
const auto pet = petId ? Database::Get()->GetPetNameInfo(petId) : std::nullopt;
|
|
if (!pet || pet->petName.empty()) return Fail(eHTTPStatusCode::NOT_FOUND, "No such pet name");
|
|
Built built;
|
|
built.c.kind = eKind::PET_NAME;
|
|
built.itemId = petId;
|
|
built.characterId = pet->ownerId;
|
|
built.c.item["pet_name"] = pet->petName;
|
|
if (const auto owner = pet->ownerId ? Database::Get()->GetCharacterInfo(pet->ownerId) : std::nullopt) {
|
|
built.accountId = owner->accountId;
|
|
built.c.item["owner"] = owner->name;
|
|
}
|
|
for (const auto& d : Database::Get()->GetModerationDecisions("pet_name", petId, 5)) {
|
|
built.c.history.push_back({ d.value("time", int64_t{}), d.value("approved", false) ? "pet name approved" : "pet name rejected",
|
|
d.value("subject", "") + (d.value("reason", "").empty() ? "" : " (" + d.value("reason", "") + ")") });
|
|
}
|
|
AddAccountHistory(context, built, 0, 0);
|
|
return built;
|
|
}
|
|
|
|
Built BuildEconomyFlag(const HTTPContext& context, uint64_t id) {
|
|
if (!Can(context, "reports_view")) return Fail(eHTTPStatusCode::FORBIDDEN, "Economy reports need reports_view");
|
|
const auto flag = Database::Get()->GetEconomyFlagRow(id);
|
|
if (!flag) return Fail(eHTTPStatusCode::NOT_FOUND, "No such flag");
|
|
Built built;
|
|
built.c.kind = eKind::ECONOMY_FLAG;
|
|
built.itemId = static_cast<int64_t>(flag->id);
|
|
built.characterId = flag->characterId;
|
|
static const std::map<uint8_t, std::string> KINDS{ {1, "unusual coin income in a day"}, {2, "spike in an item being created"}, {3, "an item that exists in more than one place (duplicate)"} };
|
|
auto& item = built.c.item;
|
|
item["check"] = KINDS.contains(flag->kind) ? KINDS.at(flag->kind) : "unknown";
|
|
if (flag->day) item["day"] = ModeratorPrompt::FormatTime(static_cast<int64_t>(flag->day) * 86400).substr(0, 10);
|
|
if (flag->characterId) {
|
|
if (const auto owner = Database::Get()->GetCharacterInfo(flag->characterId)) {
|
|
built.accountId = owner->accountId;
|
|
item["character"] = owner->name;
|
|
}
|
|
}
|
|
if (flag->lot) item["item"] = ClientAssets::ItemName(flag->lot);
|
|
item["value"] = flag->value;
|
|
item["usual_value"] = flag->baseline;
|
|
item["details"] = flag->details;
|
|
if (flag->characterId) {
|
|
for (const auto& earlier : Database::Get()->GetEconomyFlagsFor(flag->characterId, 10)) {
|
|
if (earlier.value("id", int64_t{}) == static_cast<int64_t>(flag->id)) continue;
|
|
static const std::map<int, std::string> STATUS{ {0, "open"}, {1, "dismissed"}, {2, "actioned"} };
|
|
const auto status = earlier.value("status", 0);
|
|
built.c.history.push_back({ earlier.value("created_at", int64_t{}), "earlier economy flag (" + (STATUS.contains(status) ? STATUS.at(status) : "?") + ")",
|
|
earlier.value("details", "") });
|
|
}
|
|
}
|
|
AddAccountHistory(context, built, 0, 0);
|
|
return built;
|
|
}
|
|
|
|
Built BuildCase(const HTTPContext& context, eKind kind, const nlohmann::json& body, const Settings& settings) {
|
|
const auto& raw = body.contains("id") ? body["id"] : nlohmann::json();
|
|
const auto text = raw.is_string() ? raw.get<std::string>() : raw.is_number_integer() ? raw.dump() : "";
|
|
const auto id = GeneralUtils::TryParse<uint64_t>(text);
|
|
if (kind == eKind::PLAYER_CHAT) {
|
|
// A character typed on the chat log: an ID or a name
|
|
const auto character = ResolveCharacter(text);
|
|
if (!character) return Fail(eHTTPStatusCode::NOT_FOUND, "No such character");
|
|
return BuildPlayerChat(context, *character);
|
|
}
|
|
if (!id || *id == 0) return Fail(eHTTPStatusCode::BAD_REQUEST, "Which one? (id)");
|
|
switch (kind) {
|
|
case eKind::PLAYER_REPORT: return BuildReport(context, *id, settings);
|
|
case eKind::CHAT_MESSAGE: return BuildChatMessage(context, *id, settings);
|
|
case eKind::NAME: return BuildName(context, static_cast<LWOOBJID>(*id));
|
|
case eKind::PET_NAME: return BuildPetName(context, static_cast<LWOOBJID>(*id));
|
|
case eKind::ECONOMY_FLAG: return BuildEconomyFlag(context, *id);
|
|
default: return Fail(eHTTPStatusCode::BAD_REQUEST, "Unknown kind");
|
|
}
|
|
}
|
|
|
|
std::string Summary(const ModeratorPrompt::Suggestion& s) {
|
|
std::string text = s.action;
|
|
if (s.days) text += " " + std::to_string(s.days) + " day(s)";
|
|
if (s.strike && s.action != "strike") text += " + strike";
|
|
return text + ", " + s.confidence + " confidence";
|
|
}
|
|
|
|
void Finish(Job& job) {
|
|
const auto key = ModeratorPrompt::KindKey(job.kind);
|
|
g_InFlight.erase(key + ":" + std::to_string(job.itemId));
|
|
|
|
IAiSuggestions::AiSuggestion row;
|
|
row.kind = key;
|
|
row.itemId = job.itemId;
|
|
row.fingerprint = job.fingerprint;
|
|
row.requestedById = job.context.accountId;
|
|
row.requestedBy = job.context.authenticatedUser;
|
|
row.createdAt = Now();
|
|
row.model = job.result.model.empty() ? job.config.model : job.result.model;
|
|
row.inputTokens = job.result.inputTokens;
|
|
row.outputTokens = job.result.outputTokens;
|
|
row.context = ModeratorPrompt::SafeJson(job.caseJson);
|
|
|
|
std::string message;
|
|
bool success = false;
|
|
if (!job.result.ok) {
|
|
row.status = IAiSuggestions::eAiStatus::FAILED;
|
|
row.error = job.result.message;
|
|
message = job.result.message;
|
|
} else if (!job.parsed.suggestion) {
|
|
row.status = IAiSuggestions::eAiStatus::REJECTED;
|
|
row.error = job.parsed.error;
|
|
message = "The suggestion was thrown away: " + job.parsed.error;
|
|
} else {
|
|
row.status = IAiSuggestions::eAiStatus::OK;
|
|
row.suggestion = job.parsed.suggestion->ToJson().dump();
|
|
message = "Suggestion ready";
|
|
success = true;
|
|
}
|
|
try {
|
|
row.id = Database::Get()->InsertAiSuggestion(row);
|
|
} catch (const std::exception& ex) {
|
|
LOG("Could not store an AI suggestion: %s", ex.what());
|
|
}
|
|
|
|
const std::string subject = "AI suggestion for " + ModeratorPrompt::KindLabel(job.kind) + " #" + std::to_string(job.itemId) + " (" + row.model + ", " +
|
|
std::to_string(row.inputTokens) + "+" + std::to_string(row.outputTokens) + " tokens): ";
|
|
Audit(job.context, "ai_suggest", subject + (success ? Summary(*job.parsed.suggestion) : "failed: " + ModeratorPrompt::Clip(message, 200)),
|
|
job.accountId ? AuditTarget::Account(job.accountId) : AuditTarget{});
|
|
if (!job.result.ok) LOG("AI moderator helper request failed: %s", job.result.message.c_str());
|
|
|
|
PlayerActions::Outcome outcome{ success, message };
|
|
if (success) {
|
|
outcome.data = RowJson(row, false, job.accountId, job.characterId);
|
|
outcome.data["budget"] = BudgetJson(ReadSettings());
|
|
}
|
|
PlayerActions::Finish(job.requestId, outcome);
|
|
}
|
|
}
|
|
|
|
namespace ModeratorHelper {
|
|
void RegisterRoutes() {
|
|
Route(eHTTPMethod::GET, "/api/ai/status", Perm("ai_suggest"),
|
|
"Whether the AI moderator helper is on and set up, and today's budget: {enabled, configured, model, budget: {used_today, per_day, remaining_today, per_minute, resets_at}, tokens_today}",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto settings = ReadSettings();
|
|
const auto usage = Database::Get()->GetAiUsageSince(AiBudget::Day(Now()) * 86400);
|
|
std::string error;
|
|
const bool baseOk = ClaudeClient::Endpoint(settings.client.base, error).has_value();
|
|
JsonSuccess(reply, { {"enabled", settings.enabled}, {"configured", !settings.client.apiKey.empty() && baseOk}, {"model", settings.client.model},
|
|
{"budget", BudgetJson(settings)}, {"tokens_today", { {"input", usage.inputTokens}, {"output", usage.outputTokens} }},
|
|
{"problem", !baseOk ? error : settings.client.apiKey.empty() ? "No Claude API key is set (claude_api_key)" : ""} });
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/ai/suggest", Perm("ai_suggest"),
|
|
"Ask the AI moderator helper to draft a suggestion for staff. Body: {kind (player_report, chat_message, player_chat, name, pet_name, economy_flag), "
|
|
"id (the report, message, character (ID or name for player_chat), pet or flag), refresh (true: ask again even if a stored suggestion fits)}. "
|
|
"Also needs the permission to see that item. Returns {cached: true, suggestion...} when a stored one fits, else {requestId} for the result. "
|
|
"Nothing is applied: staff decide",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto body = ParseBody(context);
|
|
if (!body || !body->is_object()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
|
const auto kind = ModeratorPrompt::ParseKind(body->value("kind", ""));
|
|
if (!kind) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Unknown kind");
|
|
|
|
const auto settings = ReadSettings();
|
|
if (!settings.enabled) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The AI moderator helper is off (Settings, Dashboard, AI moderator helper)");
|
|
if (settings.client.apiKey.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "No Claude API key is set (claude_api_key in Settings, Dashboard, AI moderator helper)");
|
|
std::string baseError;
|
|
if (!ClaudeClient::Endpoint(settings.client.base, baseError)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, baseError);
|
|
|
|
auto built = BuildCase(context, *kind, *body, settings);
|
|
if (!built.error.empty()) return JsonError(reply, built.status, built.error);
|
|
|
|
const auto key = ModeratorPrompt::KindKey(*kind);
|
|
const auto fingerprint = ModeratorPrompt::Fingerprint(built.c, settings.rules, settings.client.model);
|
|
const bool refresh = body->value("refresh", false);
|
|
if (!refresh) {
|
|
if (const auto stored = Database::Get()->FindAiSuggestion(key, built.itemId, fingerprint)) {
|
|
auto result = RowJson(*stored, true, built.accountId, built.characterId);
|
|
result["budget"] = BudgetJson(settings);
|
|
return JsonSuccess(reply, result);
|
|
}
|
|
}
|
|
|
|
const auto flightKey = key + ":" + std::to_string(built.itemId);
|
|
if (g_InFlight.contains(flightKey)) return JsonError(reply, eHTTPStatusCode::CONFLICT, "A suggestion for this is already on its way; wait a moment");
|
|
|
|
const auto now = Now();
|
|
switch (Budget().TryAcquire(settings.limits, now)) {
|
|
case AiBudget::eDecision::DAY:
|
|
return JsonError(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, "Today's AI helper budget (" + std::to_string(settings.limits.perDay) +
|
|
" requests) is used up; it resets at midnight UTC (ai_helper_per_day)");
|
|
case AiBudget::eDecision::MINUTE:
|
|
return JsonError(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, "Too many suggestions this minute; try again in " +
|
|
std::to_string(Budget().MinuteWait(settings.limits, now)) + " seconds (ai_helper_per_minute)");
|
|
case AiBudget::eDecision::OK: break;
|
|
}
|
|
|
|
Job job;
|
|
job.kind = *kind;
|
|
job.config = settings.client;
|
|
job.caseJson = ModeratorPrompt::CaseJson(built.c);
|
|
job.prompt = ModeratorPrompt::Build(built.c, settings.rules, ModeratorPrompt::RandomToken(), "CANARY-" + ModeratorPrompt::RandomToken(8));
|
|
job.context = context;
|
|
job.context.body.clear();
|
|
job.itemId = built.itemId;
|
|
job.fingerprint = fingerprint;
|
|
job.accountId = built.accountId;
|
|
job.characterId = built.characterId;
|
|
// Every try may take the whole timeout, plus the waits between them
|
|
const auto timeout = std::chrono::seconds(settings.client.timeoutSeconds * ClaudeClient::MAX_ATTEMPTS + 90);
|
|
job.requestId = PlayerActions::Begin(context.accountId, timeout);
|
|
g_InFlight[flightKey] = job.requestId;
|
|
const auto requestId = job.requestId;
|
|
Enqueue(std::move(job));
|
|
JsonSuccess(reply, { {"requestId", requestId}, {"budget", BudgetJson(settings)} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/ai/suggestions", Perm("ai_suggest"),
|
|
"Earlier suggestions for an item, newest first. Query: kind, id. Returns {suggestions: [{id, created_at, requested_by, model, input_tokens, output_tokens, status, suggestion, error}]}",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto kind = ModeratorPrompt::ParseKind(QueryValue(context.queryString, "kind"));
|
|
const auto id = GeneralUtils::TryParse<int64_t>(QueryValue(context.queryString, "id"));
|
|
if (!kind || !id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "kind and id are needed");
|
|
// The same permission as seeing the item
|
|
static const std::map<eKind, std::string> NEEDS{ {eKind::PLAYER_REPORT, "player_reports_view"}, {eKind::CHAT_MESSAGE, "chat_view"},
|
|
{eKind::PLAYER_CHAT, "chat_view"}, {eKind::NAME, "moderate_names"}, {eKind::PET_NAME, "moderate_pet_names"}, {eKind::ECONOMY_FLAG, "reports_view"} };
|
|
if (!Can(context, NEEDS.at(*kind))) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You can't see that item");
|
|
nlohmann::json rows = nlohmann::json::array();
|
|
for (const auto& row : Database::Get()->GetAiSuggestions(ModeratorPrompt::KindKey(*kind), *id, 10)) {
|
|
rows.push_back({ {"id", row.id}, {"created_at", row.createdAt}, {"requested_by", row.requestedBy}, {"model", row.model},
|
|
{"input_tokens", row.inputTokens}, {"output_tokens", row.outputTokens}, {"status", static_cast<int>(row.status)},
|
|
{"suggestion", nlohmann::json::parse(row.suggestion.empty() ? "null" : row.suggestion, nullptr, false)}, {"error", row.error} });
|
|
}
|
|
JsonSuccess(reply, { {"suggestions", rows} });
|
|
});
|
|
}
|
|
|
|
void Update() {
|
|
std::deque<Job> done;
|
|
{
|
|
std::lock_guard lock(g_Mutex);
|
|
if (g_Done.empty()) return;
|
|
done.swap(g_Done);
|
|
}
|
|
for (auto& job : done) {
|
|
try {
|
|
Finish(job);
|
|
} catch (const std::exception& ex) {
|
|
LOG("AI moderator helper completion failed: %s", ex.what());
|
|
g_InFlight.erase(ModeratorPrompt::KindKey(job.kind) + ":" + std::to_string(job.itemId));
|
|
PlayerActions::Finish(job.requestId, { false, "The helper failed" });
|
|
}
|
|
}
|
|
}
|
|
|
|
void Shutdown() {
|
|
if (!g_Worker.joinable()) return;
|
|
{
|
|
std::lock_guard lock(g_Mutex);
|
|
g_Stopping = true;
|
|
}
|
|
g_Wake.notify_all();
|
|
// A request in progress finishes first (at most its timeout and retries)
|
|
g_Worker.join();
|
|
g_Jobs.clear();
|
|
g_Done.clear();
|
|
}
|
|
}
|