mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -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<std::optional<bool>(const std::string&)> dashboard; // true allowed here, false blocked here, nullopt neither
|
||||
std::function<bool(const std::string&)> allowFile; // chatplus_en_us.txt
|
||||
std::function<bool(const std::string&)> characterName; // approved character names count as allowed words
|
||||
std::function<bool(const std::string&)> 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<WordVerdict> ExplainMessage(const std::string& message, bool allowList, const WordSources& sources) {
|
||||
std::vector<WordVerdict> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <ctime>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
#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<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",
|
||||
|
||||
@@ -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<bool> {
|
||||
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<std::string> Reasons(const std::vector<ModerationTools::WordVerdict>& verdicts) {
|
||||
std::vector<std::string> 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<std::string>{ "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<std::string>{ "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<std::string>{ "x no_block_file", "x no_block_file" }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user