From 29edb182983ef607d96c3fdf27ff5db38ff60c1c Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 00:58:06 -0500 Subject: [PATCH] feat(dashboard): test a message against the chat filter GET /api/chat_filter/test?message=&chat=normal|free says whether the filter would stop a message from a player below GM 2, word by word and why: blocked or allowed on the staff lists, in chatplus_en_us.txt, an approved character name, not allowed, in blocklist.dcf, or no blocklist.dcf (free chat then stops everything). It follows dChatFilter::IsSentenceOkay: words split at spaces, normalized the same way. ModerationTools::ExplainMessage, unit tested. Check: dWebTests ChatFilterWordsTest.Explain*; the API with a word from each list, in normal and free chat. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/routes/ChatFilterWords.h | 55 +++++++++++++++++++++ dDashboardServer/routes/ModerationTools.cpp | 38 ++++++++++++++ tests/dWebTests/ModerationToolsTests.cpp | 40 +++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/dDashboardServer/routes/ChatFilterWords.h b/dDashboardServer/routes/ChatFilterWords.h index 6052408cd..29b07cea8 100644 --- a/dDashboardServer/routes/ChatFilterWords.h +++ b/dDashboardServer/routes/ChatFilterWords.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -64,4 +65,58 @@ namespace ModerationTools { const auto words = dChatFilter::Words(message); return std::find(words.begin(), words.end(), word) != words.end(); } + + // What the filter decides about one word of a message, and why + struct WordVerdict { + std::string text; // as typed + std::string word; // as the filter compares it + bool stopped{}; + std::string reason; // blocked_here, allowed_here, allow_file, character_name, not_allowed, block_file, not_in_block_file, no_block_file + }; + + // Where the filter finds its words (callbacks keep this pure; the route reads the files and the database) + struct WordSources { + std::function(const std::string&)> dashboard; // true allowed here, false blocked here, nullopt neither + std::function allowFile; // chatplus_en_us.txt + std::function characterName; // approved character names count as allowed words + std::function blockFile; // blocklist.dcf (by hash) + bool blockFileLoaded{}; + }; + + /** + * Each word of a message with what dChatFilter::IsSentenceOkay decides about it for a player below GM level 2 (higher + * levels skip the filter). Normal chat (allowList) needs every word allowed; best friends' free chat (!allowList) stops + * only blocked words, or every word when there is no blocked words file. Words are split at spaces as the filter + * splits them. Pure; unit tested. + */ + inline std::vector ExplainMessage(const std::string& message, bool allowList, const WordSources& sources) { + std::vector verdicts; + std::stringstream stream(message); + std::string segment; + while (std::getline(stream, segment, ' ')) { + WordVerdict verdict{ segment, dChatFilter::NormalizeWord(segment) }; + const auto here = sources.dashboard(verdict.word); + if (!allowList && !sources.blockFileLoaded) { + verdict.stopped = true; + verdict.reason = "no_block_file"; + } else if (here && !*here) { + verdict.stopped = true; + verdict.reason = "blocked_here"; + } else if (!allowList) { + verdict.stopped = sources.blockFile(verdict.word); + verdict.reason = verdict.stopped ? "block_file" : "not_in_block_file"; + } else if (sources.allowFile(verdict.word)) { + verdict.reason = "allow_file"; + } else if (here) { + verdict.reason = "allowed_here"; + } else if (sources.characterName(verdict.word)) { + verdict.reason = "character_name"; + } else { + verdict.stopped = true; + verdict.reason = "not_allowed"; + } + verdicts.push_back(std::move(verdict)); + } + return verdicts; + } } diff --git a/dDashboardServer/routes/ModerationTools.cpp b/dDashboardServer/routes/ModerationTools.cpp index dabe253a9..09ce2496a 100644 --- a/dDashboardServer/routes/ModerationTools.cpp +++ b/dDashboardServer/routes/ModerationTools.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "RouteUtils.h" #include "DashboardRoutes.h" @@ -334,6 +335,43 @@ namespace { 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 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 { + const auto it = dashboard.find(word); + return it == dashboard.end() ? std::nullopt : std::optional(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", diff --git a/tests/dWebTests/ModerationToolsTests.cpp b/tests/dWebTests/ModerationToolsTests.cpp index 2948f91b3..960f832c1 100644 --- a/tests/dWebTests/ModerationToolsTests.cpp +++ b/tests/dWebTests/ModerationToolsTests.cpp @@ -81,3 +81,43 @@ TEST(ChatFilterWordsTest, DcfHashes) { ASSERT_FALSE(ModerationTools::DcfHashes(bytes.substr(0, bytes.size() - sizeof(size_t) * 2)).has_value()); ASSERT_FALSE(ModerationTools::DcfHashes("DCFB").has_value()); } + +namespace { + ModerationTools::WordSources Sources(bool blockFileLoaded = true) { + ModerationTools::WordSources sources; + sources.dashboard = [](const std::string& w) -> std::optional { + if (w == "darn") return false; + if (w == "brickbuild") return true; + return std::nullopt; + }; + sources.allowFile = [](const std::string& w) { return w == "hello" || w == "darn"; }; + sources.characterName = [](const std::string& w) { return w == "bob"; }; + sources.blockFile = [](const std::string& w) { return w == "rude"; }; + sources.blockFileLoaded = blockFileLoaded; + return sources; + } + + std::vector Reasons(const std::vector& verdicts) { + std::vector reasons; + for (const auto& v : verdicts) reasons.push_back((v.stopped ? "x " : "ok ") + v.reason); + return reasons; + } +} + +TEST(ChatFilterWordsTest, ExplainNormalChat) { + const auto verdicts = ModerationTools::ExplainMessage("Hello, Bob! brickbuild darn zzz", true, Sources()); + ASSERT_EQ(verdicts.size(), 5u); + EXPECT_EQ(verdicts[0].text, "Hello,"); + EXPECT_EQ(verdicts[0].word, "hello"); + // A blocked word is stopped even though the file allows it + EXPECT_EQ(Reasons(verdicts), (std::vector{ "ok allow_file", "ok character_name", "ok allowed_here", "x blocked_here", "x not_allowed" })); + EXPECT_TRUE(ModerationTools::ExplainMessage("", true, Sources()).empty()); +} + +TEST(ChatFilterWordsTest, ExplainFreeChat) { + EXPECT_EQ(Reasons(ModerationTools::ExplainMessage("rude zzz darn", false, Sources())), + (std::vector{ "x block_file", "ok not_in_block_file", "x blocked_here" })); + // Without blocklist.dcf free chat stops every word + EXPECT_EQ(Reasons(ModerationTools::ExplainMessage("zzz darn", false, Sources(false))), + (std::vector{ "x no_block_file", "x no_block_file" })); +}