Files
DarkflameServer/dDashboardServer/routes/ModerationTools.cpp
Aaron Kimbrell 1f8dd73e0b refactor(dashboard): routes name zones, objects and phrases through GameText
The routes' own zone name helpers and the CDClient's English zone and
leaderboard header copies give way to GameText, so every name comes from
the locale in the viewer's language; the Network page's world labels,
mail, missions, leaderboards, item info (cached per language) and the
settings/vanity help strings too. Reward code 4 and the plaque text name
their zone from the locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:07:47 -05:00

413 lines
24 KiB
C++

#include "ModerationTools.h"
#include "GameText.h"
#include "ChatFilterWords.h"
#include <ctime>
#include <fstream>
#include <map>
#include <set>
#include "RouteUtils.h"
#include "DashboardRoutes.h"
#include "ClientAssets.h"
#include "GameLabels.h"
#include "PlayerActions.h"
#include "master/PlayerAction.h"
#include "Strikes.h"
#include "WSRoutes.h"
#include "Database.h"
#include "Web.h"
#include "GeneralUtils.h"
#include "eHTTPMethod.h"
#include "eAccountLink.h"
#include "ePlayerReportKind.h"
#include "ePlayerReportStatus.h"
#include "magic_enum.hpp"
using namespace RouteUtils;
namespace {
constexpr uint32_t MAX_REPORTS = 200;
// The chat filter's files, as the servers load them: the allowed words from the client's res folder, the blocked
// words (only their hashes) next to the servers
constexpr const char* ALLOW_FILE = "chatplus_en_us.txt";
constexpr const char* BLOCK_FILE = "blocklist.dcf";
constexpr uint32_t FILE_WORDS_PAGE = 200;
// Recent chat searched when checking what a word would change
constexpr uint32_t CHECK_MESSAGES = 1000;
std::string Trimmed(std::string text, size_t max) {
text.erase(0, text.find_first_not_of(" \t\r\n"));
text.erase(text.find_last_not_of(" \t\r\n") + 1);
return text.substr(0, max);
}
template<typename Enum> nlohmann::json EnumLabels() {
nlohmann::json list = nlohmann::json::array();
for (const auto value : magic_enum::enum_values<Enum>()) {
list.push_back({ {"value", static_cast<int>(value)}, {"key", std::string(magic_enum::enum_name(value))}, {"name", GameLabels::Name(value)} });
}
return list;
}
// Names looked up once per request
struct Names {
std::map<LWOOBJID, std::string> characters;
std::map<uint32_t, std::string> accounts;
const std::string& Character(LWOOBJID id) {
if (!characters.contains(id)) {
const auto info = id ? Database::Get()->GetCharacterInfo(id) : std::nullopt;
characters[id] = info ? info->name : "";
}
return characters[id];
}
const std::string& Account(uint32_t id) {
if (!accounts.contains(id)) {
const auto account = id ? Database::Get()->GetAccountById(id) : nlohmann::json();
accounts[id] = account.is_object() ? account.value("name", "") : "";
}
return accounts[id];
}
};
nlohmann::json ReportJson(const IModeration::PlayerReport& r, Names& names) {
const auto kind = magic_enum::enum_cast<ePlayerReportKind>(r.kind);
const auto status = static_cast<ePlayerReportStatus>(r.status);
const auto& zones = GameText::ZoneNames();
const auto zone = std::to_string(r.zoneId);
std::string propertyName;
if (r.propertyId) {
if (const auto property = Database::Get()->GetPropertyInfo(r.propertyId)) propertyName = property->name;
}
return {
{"id", r.id}, {"created_at", r.createdAt}, {"kind", r.kind}, {"kind_name", kind ? GameLabels::Name(*kind) : r.kind},
{"reporter_id", std::to_string(r.reporterId)}, {"reporter_name", names.Character(r.reporterId)}, {"reporter_account_id", r.reporterAccountId},
{"object_id", std::to_string(r.objectId)}, {"object_lot", r.objectLot}, {"object_name", r.objectLot ? ClientAssets::ItemName(r.objectLot) : ""},
{"target_character_id", std::to_string(r.targetCharacterId)}, {"target_name", names.Character(r.targetCharacterId)},
{"target_account_id", r.targetAccountId}, {"target_account_name", names.Account(r.targetAccountId)},
{"property_id", std::to_string(r.propertyId)}, {"property_name", propertyName},
{"zone_id", r.zoneId}, {"zone_name", zones.contains(zone) ? zones[zone] : nlohmann::json("")}, {"instance_id", r.instanceId}, {"clone_id", r.cloneId},
{"body", r.body}, {"status", r.status}, {"status_key", std::string(magic_enum::enum_name(status))}, {"status_name", GameLabels::Name(status)},
{"handled_by", r.handledBy}, {"handled_at", r.handledAt}, {"resolution", r.resolution}
};
}
// Load a report for staff acting on it; replies with the error when it can't be acted on
std::optional<IModeration::PlayerReport> OpenReport(const HTTPContext& context, HTTPReply& reply) {
const auto id = PathId<uint64_t>(context.path, 2);
const auto report = id ? Database::Get()->GetPlayerReport(*id) : std::nullopt;
if (!report) {
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such report");
return std::nullopt;
}
if (report->status != static_cast<uint8_t>(ePlayerReportStatus::OPEN)) {
JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "This report was already handled by " + report->handledBy);
return std::nullopt;
}
return report;
}
std::string ReportSubject(const IModeration::PlayerReport& report) {
return "Report #" + std::to_string(report.id);
}
// Load the words again in every running world; the reply's request id shows how many did
uint32_t ReloadWorlds(uint32_t requester) {
PlayerActionRequest request;
request.action = ePlayerAction::RELOAD_CHAT_FILTER;
return PlayerActions::Request(request, requester, [](const PlayerActionResult& result) {
return PlayerActions::Outcome{ true, result.affected ? "The chat filter was updated in " + std::to_string(result.affected) + " world(s)"
: "No world is running; they'll use the words when they start" };
});
}
void RegisterPlayerReportRoutes() {
Route(eHTTPMethod::GET, "/player_reports", Perm("player_reports_view"), "Reports players sent from the game",
[](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "player_reports.jinja2", "player_reports"); });
Route(eHTTPMethod::GET, "/api/player_reports", Perm("player_reports_view"),
"Player reports, newest first. Query: status (ePlayerReportStatus value; missing: any), account (reported account), limit, offset. "
"Returns {reports, total, kinds, statuses}",
[](HTTPReply& reply, const HTTPContext& context) {
IModeration::PlayerReportQuery query;
query.status = GeneralUtils::TryParse<int16_t>(QueryValue(context.queryString, "status")).value_or(-1);
query.accountId = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "account")).value_or(0);
query.limit = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "limit")).value_or(50), 1, MAX_REPORTS);
query.offset = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "offset")).value_or(0);
Names names;
nlohmann::json reports = nlohmann::json::array();
for (const auto& report : Database::Get()->GetPlayerReports(query)) reports.push_back(ReportJson(report, names));
JsonSuccess(reply, { {"reports", reports}, {"total", Database::Get()->CountPlayerReports(query)},
{"kinds", EnumLabels<ePlayerReportKind>()}, {"statuses", EnumLabels<ePlayerReportStatus>()},
{"canManage", Can(context, "player_reports_manage")} });
});
Route(eHTTPMethod::POST, "/api/player_reports/:id/action", Perm("player_reports_manage"),
"Mark a report acted on. Body: {reason (what was done), strike (true: also a strike on the reported account)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context).value_or(nlohmann::json::object());
const auto report = OpenReport(context, reply);
if (!report) return;
const auto strike = Strikes::Requested(context, body, reply);
if (!strike) return;
if (*strike && report->targetAccountId == 0) {
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The game didn't say whose this is, so there's no account to give a strike to; nothing was done");
}
if (*strike && !AuthorizeAccountAction(context, report->targetAccountId, reply, eAccountAction::MODERATION)) return;
const auto reason = Trimmed(body.value("reason", ""), 500);
Database::Get()->SetPlayerReportStatus(report->id, static_cast<uint8_t>(ePlayerReportStatus::ACTIONED), context.authenticatedUser, reason,
static_cast<int64_t>(std::time(nullptr)));
Audit(context, "action_player_report", ReportSubject(*report) + (reason.empty() ? "" : ": " + reason),
report->targetAccountId ? AuditTarget::Account(report->targetAccountId) : AuditTarget{});
nlohmann::json result{ {"message", ReportSubject(*report) + " marked acted on" + std::string(*strike ? ", with a strike" : "")} };
if (*strike) {
Strikes::Give(context, report->targetAccountId, report->targetCharacterId, eStrikeSource::PLAYER_REPORT, ReportSubject(*report),
reason.empty() ? report->body.substr(0, 200) : reason).Into(result);
}
BroadcastTableChanged("player_reports", std::to_string(report->id));
JsonSuccess(reply, result);
});
Route(eHTTPMethod::POST, "/api/player_reports/:id/dismiss", Perm("player_reports_manage"), "Dismiss a report: nothing to act on. Body: {reason (optional)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context).value_or(nlohmann::json::object());
const auto report = OpenReport(context, reply);
if (!report) return;
const auto reason = Trimmed(body.value("reason", ""), 500);
Database::Get()->SetPlayerReportStatus(report->id, static_cast<uint8_t>(ePlayerReportStatus::DISMISSED), context.authenticatedUser, reason,
static_cast<int64_t>(std::time(nullptr)));
Audit(context, "dismiss_player_report", ReportSubject(*report) + (reason.empty() ? "" : ": " + reason),
report->targetAccountId ? AuditTarget::Account(report->targetAccountId) : AuditTarget{});
BroadcastTableChanged("player_reports", std::to_string(report->id));
JsonSuccess(reply, { {"message", ReportSubject(*report) + " dismissed"} });
});
}
void RegisterLinkedAccountRoutes() {
Route(eHTTPMethod::GET, "/api/accounts/:id/links", Perm("accounts_links"),
"Other accounts sharing a play key, email address or login address with this one: {accounts: [{account_id, name, gm_level, banned, links: [{link, name, shared, last_seen}]}], loginAddresses}. "
"Addresses themselves are never returned",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = PathId<uint32_t>(context.path, 2);
if (!accountId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid account");
// One entry per other account, with every signal it shares, in the order first found
nlohmann::json accounts = nlohmann::json::array();
std::map<uint32_t, size_t> index;
for (const auto& linked : Database::Get()->GetLinkedAccounts(*accountId)) {
if (!index.contains(linked.accountId)) {
index[linked.accountId] = accounts.size();
accounts.push_back({ {"account_id", linked.accountId}, {"name", linked.name}, {"gm_level", linked.gmLevel}, {"banned", linked.banned},
{"links", nlohmann::json::array()} });
}
const auto link = static_cast<eAccountLink>(linked.link);
accounts[index[linked.accountId]]["links"].push_back({ {"link", std::string(magic_enum::enum_name(link))}, {"name", GameLabels::Name(link)},
{"shared", linked.shared}, {"last_seen", linked.lastSeen} });
}
JsonSuccess(reply, { {"accounts", accounts}, {"loginAddresses", Database::Get()->CountLoginAddresses(*accountId)},
{"reports", Database::Get()->CountPlayerReports({ .status = -1, .accountId = *accountId })},
{"openReports", Database::Get()->CountPlayerReports({ .status = static_cast<int16_t>(ePlayerReportStatus::OPEN), .accountId = *accountId })},
{"canSeeReports", Can(context, "player_reports_view")} });
});
}
std::vector<std::string> AllowFileWords() {
const auto text = ClientAssets::ReadResFile(ALLOW_FILE);
return text ? ModerationTools::FileWords(*text) : std::vector<std::string>{};
}
std::optional<std::vector<size_t>> BlockFileHashes() {
std::ifstream in(BLOCK_FILE, std::ios::binary);
if (!in) return std::nullopt;
return ModerationTools::DcfHashes(std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()));
}
// The dashboard's own lists by word: true allowed, false blocked
std::map<std::string, bool> DashboardWords() {
std::map<std::string, bool> words;
for (const auto& word : Database::Get()->GetChatFilterWords()) words[word.word] = word.allowed;
return words;
}
void RegisterChatFilterRoutes() {
Route(eHTTPMethod::GET, "/chat_filter", Perm("chat_filter_manage"), "Words the chat filter allows and stops",
[](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "chat_filter.jinja2", "chat_filter"); });
Route(eHTTPMethod::GET, "/api/chat_filter", Perm("chat_filter_manage"), "Words staff added to the chat filter: {words: [{word, allowed, added_by, added_at}]}",
[](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json words = nlohmann::json::array();
for (const auto& word : Database::Get()->GetChatFilterWords()) {
words.push_back({ {"word", word.word}, {"allowed", word.allowed}, {"added_by", word.addedBy}, {"added_at", word.addedAt} });
}
JsonSuccess(reply, { {"words", words} });
});
Route(eHTTPMethod::GET, "/api/chat_filter/files", Perm("chat_filter_manage"),
"The words of the filter's files: the allowed words of chatplus_en_us.txt (from the client) matching ?search=, 200 from ?start=, "
"each with what the dashboard's lists say about it, and how many hashed words blocklist.dcf has (its words can't be listed)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto all = AllowFileWords();
const auto search = ModerationTools::FilterWord(QueryValue(context.queryString, "search")).value_or("");
const auto start = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "start")).value_or(0);
const auto dashboard = DashboardWords();
nlohmann::json words = nlohmann::json::array();
uint32_t matched = 0;
for (const auto& word : all) {
if (!search.empty() && word.find(search) == std::string::npos) continue;
if (matched++ < start || words.size() >= FILE_WORDS_PAGE) continue;
const auto it = dashboard.find(word);
words.push_back({ {"word", word}, {"dashboard", it == dashboard.end() ? nlohmann::json(nullptr) : nlohmann::json(it->second ? "allowed" : "blocked")} });
}
const auto blocked = BlockFileHashes();
uint32_t imported = 0;
for (const auto& word : all) if (dashboard.contains(word)) imported++;
JsonSuccess(reply, { {"allowFile", ALLOW_FILE}, {"allowFileFound", !all.empty()}, {"allowTotal", all.size()}, {"matched", matched},
{"start", start}, {"pageSize", FILE_WORDS_PAGE}, {"words", words}, {"onDashboard", imported},
{"blockFile", BLOCK_FILE}, {"blockFileFound", blocked.has_value()}, {"blockTotal", blocked ? blocked->size() : 0} });
});
Route(eHTTPMethod::GET, "/api/chat_filter/lookup", Perm("chat_filter_manage"),
"Where a word stands: in the allowed words file, in the blocked words file (by its hash), and on the dashboard's lists. Query: word",
[](HTTPReply& reply, const HTTPContext& context) {
const auto word = ModerationTools::FilterWord(QueryValue(context.queryString, "word"));
if (!word) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type one word (no spaces), up to 64 characters");
const auto all = AllowFileWords();
const auto blocked = BlockFileHashes();
const auto dashboard = DashboardWords();
const auto it = dashboard.find(*word);
JsonSuccess(reply, { {"word", *word}, {"inAllowFile", std::binary_search(all.begin(), all.end(), *word)},
{"inBlockFile", blocked && std::find(blocked->begin(), blocked->end(), ModerationTools::WordHash(*word)) != blocked->end()},
{"dashboard", it == dashboard.end() ? nlohmann::json(nullptr) : nlohmann::json(it->second ? "allowed" : "blocked")} });
});
Route(eHTTPMethod::POST, "/api/chat_filter/import", Perm("chat_filter_manage"),
"Copy the words of chatplus_en_us.txt into the dashboard's allowed list, so they can be edited here; words already on either of "
"the dashboard's lists are left as they are",
[](HTTPReply& reply, const HTTPContext& context) {
const auto all = AllowFileWords();
if (all.empty()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, std::string("Could not read ") + ALLOW_FILE + " from the client (client_location)");
const auto dashboard = DashboardWords();
const auto now = static_cast<int64_t>(std::time(nullptr));
uint32_t added = 0;
DatabaseTransaction transaction(*Database::Get());
for (const auto& word : all) {
// Only words the filter could compare (the file's odd lines with spaces or punctuation stay in the file only)
const auto filterWord = ModerationTools::FilterWord(word);
if (!filterWord || *filterWord != word || dashboard.contains(word)) continue;
Database::Get()->SetChatFilterWord({ word, true, context.authenticatedUser, now });
added++;
}
transaction.Commit();
Audit(context, "chat_filter_import", "Copied " + std::to_string(added) + " words from " + ALLOW_FILE + " into the chat filter's allowed list");
BroadcastTableChanged("chat_filter");
JsonSuccess(reply, { {"message", "Copied " + std::to_string(added) + " words (" + std::to_string(all.size() - added) + " were already here or can't be used)"},
{"added", added}, {"requestId", ReloadWorlds(context.accountId)} });
});
Route(eHTTPMethod::POST, "/api/chat_filter/words", Perm("chat_filter_manage"),
"Allow or block a word (or move it to the other list); running worlds pick it up at once. Body: {word, allowed: bool}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto word = ModerationTools::FilterWord(body->value("word", ""));
if (!word) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type one word (no spaces), up to 64 characters");
const bool allowed = body->value("allowed", false);
Database::Get()->SetChatFilterWord({ *word, allowed, context.authenticatedUser, static_cast<int64_t>(std::time(nullptr)) });
Audit(context, allowed ? "chat_filter_allow" : "chat_filter_block", (allowed ? "Allowed \"" : "Blocked \"") + *word + "\" in chat");
BroadcastTableChanged("chat_filter");
JsonSuccess(reply, { {"message", "\"" + *word + "\" is now " + (allowed ? "allowed" : "blocked")}, {"requestId", ReloadWorlds(context.accountId)} });
});
Route(eHTTPMethod::POST, "/api/chat_filter/words/delete", Perm("chat_filter_manage"),
"Remove a word added here; the filter's files decide about it again. Body: {word}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
const auto word = body ? ModerationTools::FilterWord(body->value("word", "")) : std::nullopt;
if (!word) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid word");
if (!Database::Get()->DeleteChatFilterWord(*word)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "That word isn't on either list");
Audit(context, "chat_filter_remove", "Removed \"" + *word + "\" from the chat filter's lists");
BroadcastTableChanged("chat_filter");
JsonSuccess(reply, { {"message", "\"" + *word + "\" removed"}, {"requestId", ReloadWorlds(context.accountId)} });
});
Route(eHTTPMethod::POST, "/api/chat_filter/reload", Perm("chat_filter_manage"), "Load the words again in every running world",
[](HTTPReply& reply, const HTTPContext& context) {
Audit(context, "chat_filter_reload", "Reloaded the chat filter's words in running worlds");
JsonSuccess(reply, { {"requestId", ReloadWorlds(context.accountId)} });
});
Route(eHTTPMethod::GET, "/api/chat_filter/test", Perm("chat_filter_manage"),
"Whether the filter would stop a message from a player below GM level 2, and why, word by word. Query: message (up to 300 characters), "
"chat (normal, or free: best friends' free chat). Returns {stopped, words: [{text, word, stopped, reason}]}; reason is one of blocked_here, "
"allowed_here, allow_file, character_name, not_allowed, block_file, not_in_block_file, no_block_file",
[](HTTPReply& reply, const HTTPContext& context) {
const auto message = QueryValue(context.queryString, "message");
if (message.empty() || message.size() > 300) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type a message of 1 to 300 characters");
const bool allowList = QueryValue(context.queryString, "chat") != "free";
const auto all = AllowFileWords();
const auto blocked = BlockFileHashes();
const auto dashboard = DashboardWords();
std::set<std::string> names;
for (auto name : Database::Get()->GetApprovedCharacterNames()) {
std::transform(name.begin(), name.end(), name.begin(), ::tolower);
names.insert(std::move(name));
}
ModerationTools::WordSources sources;
sources.dashboard = [&dashboard](const std::string& word) -> std::optional<bool> {
const auto it = dashboard.find(word);
return it == dashboard.end() ? std::nullopt : std::optional<bool>(it->second);
};
sources.allowFile = [&all](const std::string& word) { return std::binary_search(all.begin(), all.end(), word); };
sources.characterName = [&names](const std::string& word) { return names.contains(word); };
sources.blockFile = [&blocked](const std::string& word) {
return blocked && std::find(blocked->begin(), blocked->end(), ModerationTools::WordHash(word)) != blocked->end();
};
sources.blockFileLoaded = blocked && !blocked->empty();
nlohmann::json words = nlohmann::json::array();
bool stopped = false;
for (const auto& verdict : ModerationTools::ExplainMessage(message, allowList, sources)) {
stopped |= verdict.stopped;
words.push_back({ {"text", verdict.text}, {"word", verdict.word}, {"stopped", verdict.stopped}, {"reason", verdict.reason} });
}
JsonSuccess(reply, { {"message", message}, {"chat", allowList ? "normal" : "free"}, {"stopped", stopped}, {"words", words},
{"allowFileFound", !all.empty()}, {"blockFileFound", blocked.has_value()} });
});
Route(eHTTPMethod::GET, "/api/chat_filter/check", Perm("chat_filter_manage"),
"Recent chat a word would change. Query: word, allowed (1: messages the filter stopped that contain it; otherwise messages it let through "
"that blocking it would have stopped). Searches the newest 1000 messages containing the text. Also needs chat_view; private chat only with chat_private",
[](HTTPReply& reply, const HTTPContext& context) {
if (!Can(context, "chat_view")) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Reading chat needs the chat_view permission");
const auto word = ModerationTools::FilterWord(QueryValue(context.queryString, "word"));
if (!word) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type one word (no spaces), up to 64 characters");
const bool allowed = QueryValue(context.queryString, "allowed") == "1";
IChatLog::ChatQuery query;
query.search = *word;
query.includePrivate = Can(context, "chat_private");
query.includeWhispers = Can(context, "chat_dms");
query.blockedOnly = allowed;
query.newestFirst = true;
query.limit = CHECK_MESSAGES;
const auto& zones = GameText::ZoneNames();
nlohmann::json messages = nlohmann::json::array();
uint32_t searched = 0;
for (const auto& m : Database::Get()->GetChatMessages(query)) {
searched++;
// Only messages from players: web messages aren't filtered, and the filter compares whole words
if (m.channel == "web" || m.blocked != allowed || !ModerationTools::HasFilterWord(m.message, *word)) continue;
const auto zone = std::to_string(m.zoneId);
messages.push_back({ {"id", m.id}, {"time", m.time}, {"channel", m.channel}, {"sender_id", std::to_string(m.senderId)},
{"sender_name", m.senderName}, {"account_id", m.accountId}, {"zone_id", m.zoneId},
{"zone_name", zones.contains(zone) ? zones[zone] : nlohmann::json("")}, {"message", m.message}, {"blocked", m.blocked} });
}
JsonSuccess(reply, { {"word", *word}, {"allowed", allowed}, {"messages", messages}, {"searched", searched}, {"limit", CHECK_MESSAGES} });
});
}
}
void RegisterModerationToolRoutes() {
RegisterPlayerReportRoutes();
RegisterLinkedAccountRoutes();
RegisterChatFilterRoutes();
}