mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-29 07:57:14 +00:00
feat(chat-filter): the server filters phrases in normal chat both ways
The client's word check before sending is a light one; the server is the full filter. In normal (whitelist) chat, block list phrases are now stopped even when each word is allowed, and allowed phrases (from the dashboard or a line with spaces in the allowed words file) let their words through together. The dashboard can allow phrases and its message test explains both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ namespace ModerationTools {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Whether an entry is a phrase (more than one word). Phrases can only be blocked: whitelist chat checks one word at a time.
|
||||
// Whether an entry is a phrase (more than one word)
|
||||
inline bool IsPhrase(const std::string& entry) { return ChatFilterWords::WordCount(entry) > 1; }
|
||||
|
||||
// The words of a plain word list (chatplus_en_us.txt) as the filter reads them: one per line, lower case; sorted, each once
|
||||
@@ -59,7 +59,7 @@ namespace ModerationTools {
|
||||
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
|
||||
std::string phrase; // the blocked phrase this word is part of (blocked_here or block_file), when it was a phrase
|
||||
std::string phrase; // the blocked or allowed phrase this word is part of, when it was a phrase
|
||||
};
|
||||
|
||||
// Where the filter finds its words (callbacks keep this pure; the route reads the files and the database)
|
||||
@@ -70,12 +70,14 @@ namespace ModerationTools {
|
||||
std::function<bool(const std::string&)> blockFile; // blocklist.dcf (by hash)
|
||||
bool blockFileLoaded{};
|
||||
uint32_t maxWords{ 1 }; // the longest blocked phrase, in words (here or in the file)
|
||||
uint32_t maxAllowedWords{ 1 }; // the longest allowed phrase, in words (here or in the file)
|
||||
};
|
||||
|
||||
/**
|
||||
* Each word of a message with what dChatFilter::IsSentenceOkay decides about it for a player below GM level 2 (higher
|
||||
* levels skip the filter). Blocked words and phrases (here always, the block file's in free chat) are stopped; a phrase
|
||||
* stops each of its words. Normal chat (allowList) needs every other word allowed, one at a time; best friends' free
|
||||
* levels skip the filter). Blocked words and phrases are stopped (here always, the block file's phrases always and its
|
||||
* single words in free chat); a phrase stops each of its words. Normal chat (allowList) needs every other word allowed,
|
||||
* on its own or as part of an allowed phrase; best friends' free
|
||||
* chat stops only blocked ones, or every word when there is no blocked words file. Words are split at spaces as the
|
||||
* filter splits them (ChatFilterWords::CheckMessage). Pure; unit tested.
|
||||
*/
|
||||
@@ -93,7 +95,7 @@ namespace ModerationTools {
|
||||
|
||||
const auto blockedHere = [&sources](const std::string& entry) { const auto here = sources.dashboard(entry); return here && !*here; };
|
||||
const auto matches = ChatFilterWords::FindBlocked(tokens, std::max(sources.maxWords, 1u), [&](const std::string& entry) {
|
||||
return blockedHere(entry) || (!allowList && sources.blockFile(entry));
|
||||
return blockedHere(entry) || ((!allowList || ChatFilterWords::IsPhrase(entry)) && sources.blockFile(entry));
|
||||
});
|
||||
for (const auto& match : matches) {
|
||||
const auto reason = blockedHere(match.entry) ? "blocked_here" : "block_file";
|
||||
@@ -104,8 +106,25 @@ namespace ModerationTools {
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed phrases in normal chat: their words pass together (a stopped word breaks a phrase)
|
||||
if (allowList && sources.maxAllowedWords > 1) {
|
||||
auto open = tokens;
|
||||
for (size_t i = 0; i < open.size(); i++) if (verdicts[i].stopped && !open[i].word.empty()) open[i].word = "\x01";
|
||||
const auto allowedHere = [&sources](const std::string& entry) { const auto here = sources.dashboard(entry); return here && *here; };
|
||||
const auto allowed = ChatFilterWords::FindBlocked(open, sources.maxAllowedWords, [&](const std::string& entry) {
|
||||
return ChatFilterWords::IsPhrase(entry) && (sources.allowFile(entry) || allowedHere(entry));
|
||||
});
|
||||
for (const auto& match : allowed) {
|
||||
const auto reason = sources.allowFile(match.entry) ? "allow_file" : "allowed_here";
|
||||
for (size_t i = match.first; i <= match.last; i++) {
|
||||
verdicts[i].reason = reason;
|
||||
verdicts[i].phrase = match.entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& verdict : verdicts) {
|
||||
if (verdict.stopped) continue;
|
||||
if (verdict.stopped || !verdict.phrase.empty()) continue;
|
||||
const auto here = sources.dashboard(verdict.word);
|
||||
if (!allowList) {
|
||||
verdict.reason = "not_in_block_file";
|
||||
|
||||
@@ -305,17 +305,14 @@ namespace {
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::POST, "/api/chat_filter/words", Perm("chat_filter_manage"),
|
||||
"Allow or block a word, or block a phrase (or move it to the other list); running worlds pick it up at once. Phrases can't be allowed: "
|
||||
"whitelist chat checks each word on its own, as the client does. Body: {word, allowed: bool}",
|
||||
"Allow or block a word or phrase (or move it to the other list); running worlds pick it up at once. An allowed phrase lets its words "
|
||||
"through together in normal chat. 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 a word or phrase, up to 64 characters");
|
||||
const bool allowed = body->value("allowed", false);
|
||||
if (allowed && ModerationTools::IsPhrase(*word)) {
|
||||
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Phrases can only be blocked: normal chat checks each word on its own, so allow the words instead");
|
||||
}
|
||||
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");
|
||||
@@ -365,7 +362,11 @@ namespace {
|
||||
sources.blockFile = [&blocked](const std::string& entry) { return blocked.list.Contains(entry); };
|
||||
sources.blockFileLoaded = !blocked.list.Empty();
|
||||
sources.maxWords = blocked.list.maxWords;
|
||||
for (const auto& [entry, allowed] : dashboard) if (!allowed) sources.maxWords = std::max(sources.maxWords, ChatFilterWords::WordCount(entry));
|
||||
for (const auto& [entry, allowed] : dashboard) {
|
||||
auto& longest = allowed ? sources.maxAllowedWords : sources.maxWords;
|
||||
longest = std::max(longest, ChatFilterWords::WordCount(entry));
|
||||
}
|
||||
for (const auto& entry : all) sources.maxAllowedWords = std::max(sources.maxAllowedWords, ChatFilterWords::WordCount(entry));
|
||||
nlohmann::json words = nlohmann::json::array();
|
||||
bool stopped = false;
|
||||
for (const auto& verdict : ModerationTools::ExplainMessage(message, allowList, sources)) {
|
||||
|
||||
Reference in New Issue
Block a user