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:
Aaron Kimbrell
2026-09-30 08:27:14 -05:00
parent 0f8c762122
commit 1657ca006d
8 changed files with 120 additions and 31 deletions

View File

@@ -57,6 +57,9 @@ namespace ChatFilterWords {
return entry.empty() ? 0 : static_cast<uint32_t>(std::count(entry.begin(), entry.end(), ' ')) + 1; return entry.empty() ? 0 : static_cast<uint32_t>(std::count(entry.begin(), entry.end(), ' ')) + 1;
} }
// Whether an entry is several words (entries are normalized words joined by single spaces)
inline bool IsPhrase(std::string_view entry) { return entry.find(' ') != std::string_view::npos; }
// 64-bit FNV-1a: offset basis 0xcbf29ce484222325, prime 0x100000001b3, one byte at a time // 64-bit FNV-1a: offset basis 0xcbf29ce484222325, prime 0x100000001b3, one byte at a time
constexpr uint64_t Hash(std::string_view entry) { constexpr uint64_t Hash(std::string_view entry) {
uint64_t hash = 0xcbf29ce484222325ULL; uint64_t hash = 0xcbf29ce484222325ULL;
@@ -151,19 +154,20 @@ namespace ChatFilterWords {
}; };
/** /**
* The pieces of a message the filter stops, as (position, length) in the message. Blocked words and phrases (the * The pieces of a message the filter stops, as (position, length) in the message. Blocked words and phrases are
* dashboard's always, blocklist.dcf's in free chat) are stopped as one span each. In whitelist chat (allowList) every * stopped as one span each: the dashboard's in all chat, blocklist.dcf's phrases in all chat and its single words in
* other piece must be an allowed word, one at a time, as the client checks words. In free chat without a block list * free chat (whitelist chat already stops a word that isn't allowed). In whitelist chat (allowList) every other piece
* the whole message is stopped. * must be allowed, as a word or as part of an allowed phrase; the client only checks single words before sending,
* the server is the full check. In free chat without a block list the whole message is stopped.
*/ */
inline std::set<std::pair<uint8_t, uint8_t>> CheckMessage(std::string_view message, bool allowList, const Lists& lists) { inline std::set<std::pair<uint8_t, uint8_t>> CheckMessage(std::string_view message, bool allowList, const Lists& lists) {
if (message.empty()) return {}; if (message.empty()) return {};
if (!allowList && lists.denied.Empty()) return { { 0, static_cast<uint8_t>(message.length()) } }; if (!allowList && lists.denied.Empty()) return { { 0, static_cast<uint8_t>(message.length()) } };
const auto tokens = Tokenize(message); const auto tokens = Tokenize(message);
const uint32_t maxWords = std::max(lists.customBlocked.maxWords, allowList ? 0u : lists.denied.maxWords); const uint32_t maxWords = std::max(lists.customBlocked.maxWords, lists.denied.maxWords);
const auto matches = FindBlocked(tokens, maxWords, [&](const std::string& entry) { const auto matches = FindBlocked(tokens, maxWords, [&](const std::string& entry) {
return lists.customBlocked.Contains(entry) || (!allowList && lists.denied.Contains(entry)); return lists.customBlocked.Contains(entry) || ((!allowList || IsPhrase(entry)) && lists.denied.Contains(entry));
}); });
std::set<std::pair<uint8_t, uint8_t>> bad; std::set<std::pair<uint8_t, uint8_t>> bad;
@@ -176,6 +180,17 @@ namespace ChatFilterWords {
} }
if (allowList) { if (allowList) {
// Allowed phrases let their words through together, even where one alone isn't allowed
const uint32_t allowedWords = std::max(lists.approved.maxWords, lists.customAllowed.maxWords);
if (allowedWords > 1) {
std::vector<Token> open = tokens;
// A stopped word breaks a phrase (a word no entry can hold, so it isn't skipped like an empty piece)
for (size_t i = 0; i < open.size(); i++) if (covered[i] && !open[i].word.empty()) open[i].word = "\x01";
const auto allowed = FindBlocked(open, allowedWords, [&](const std::string& entry) {
return IsPhrase(entry) && (lists.approved.Contains(entry) || lists.customAllowed.Contains(entry));
});
for (const auto& match : allowed) for (size_t i = match.first; i <= match.last; i++) covered[i] = true;
}
for (size_t i = 0; i < tokens.size(); i++) { for (size_t i = 0; i < tokens.size(); i++) {
if (covered[i]) continue; if (covered[i]) continue;
const auto hash = Hash(tokens[i].word); const auto hash = Hash(tokens[i].word);
@@ -303,8 +318,13 @@ namespace dChatFilterDCF {
std::string line(text.substr(start, end - start)); std::string line(text.substr(start, end - start));
std::erase(line, '\r'); std::erase(line, '\r');
line = ChatFilterWords::AsciiLower(std::move(line)); line = ChatFilterWords::AsciiLower(std::move(line));
// A line with spaces is an allowed phrase, compared as the filter reads messages; words stay as written
if (line.find(' ') != std::string::npos) {
list.AddEntry(ChatFilterWords::NormalizeEntry(line));
} else {
list.hashes.insert(ChatFilterWords::Hash(line)); list.hashes.insert(ChatFilterWords::Hash(line));
list.maxWords = std::max(list.maxWords, 1u); list.maxWords = std::max(list.maxWords, 1u);
}
start = end + 1; start = end + 1;
} }
return list; return list;

View File

@@ -25,7 +25,7 @@ namespace ModerationTools {
return entry; 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; } 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 // 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 std::string word; // as the filter compares it
bool stopped{}; 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 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) // 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) std::function<bool(const std::string&)> blockFile; // blocklist.dcf (by hash)
bool blockFileLoaded{}; bool blockFileLoaded{};
uint32_t maxWords{ 1 }; // the longest blocked phrase, in words (here or in the file) 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 * 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 * levels skip the filter). Blocked words and phrases are stopped (here always, the block file's phrases always and its
* stops each of its words. Normal chat (allowList) needs every other word allowed, one at a time; best friends' free * 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 * 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. * 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 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) { 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) { for (const auto& match : matches) {
const auto reason = blockedHere(match.entry) ? "blocked_here" : "block_file"; 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) { for (auto& verdict : verdicts) {
if (verdict.stopped) continue; if (verdict.stopped || !verdict.phrase.empty()) continue;
const auto here = sources.dashboard(verdict.word); const auto here = sources.dashboard(verdict.word);
if (!allowList) { if (!allowList) {
verdict.reason = "not_in_block_file"; verdict.reason = "not_in_block_file";

View File

@@ -305,17 +305,14 @@ namespace {
}); });
Route(eHTTPMethod::POST, "/api/chat_filter/words", Perm("chat_filter_manage"), 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: " "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 "
"whitelist chat checks each word on its own, as the client does. Body: {word, allowed: bool}", "through together in normal chat. Body: {word, allowed: bool}",
[](HTTPReply& reply, const HTTPContext& context) { [](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context); const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto word = ModerationTools::FilterWord(body->value("word", "")); 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"); if (!word) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type a word or phrase, up to 64 characters");
const bool allowed = body->value("allowed", false); 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)) }); 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"); Audit(context, allowed ? "chat_filter_allow" : "chat_filter_block", (allowed ? "Allowed \"" : "Blocked \"") + *word + "\" in chat");
BroadcastTableChanged("chat_filter"); BroadcastTableChanged("chat_filter");
@@ -365,7 +362,11 @@ namespace {
sources.blockFile = [&blocked](const std::string& entry) { return blocked.list.Contains(entry); }; sources.blockFile = [&blocked](const std::string& entry) { return blocked.list.Contains(entry); };
sources.blockFileLoaded = !blocked.list.Empty(); sources.blockFileLoaded = !blocked.list.Empty();
sources.maxWords = blocked.list.maxWords; 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(); nlohmann::json words = nlohmann::json::array();
bool stopped = false; bool stopped = false;
for (const auto& verdict : ModerationTools::ExplainMessage(message, allowList, sources)) { for (const auto& verdict : ModerationTools::ExplainMessage(message, allowList, sources)) {

View File

@@ -90,7 +90,7 @@
return '<tr><td>' + esc(w.word) + phraseBadge(w.word) + '</td><td>' + (w.allowed ? fmt.badge('Allowed', 'success') : fmt.badge('Blocked', 'danger')) + '</td>' + return '<tr><td>' + esc(w.word) + phraseBadge(w.word) + '</td><td>' + (w.allowed ? fmt.badge('Allowed', 'success') : fmt.badge('Blocked', 'danger')) + '</td>' +
'<td class="small">' + esc(w.added_by) + '</td><td class="small text-nowrap">' + esc(fmt.unix(w.added_at)) + '</td>' + '<td class="small">' + esc(w.added_by) + '</td><td class="small text-nowrap">' + esc(fmt.unix(w.added_at)) + '</td>' +
'<td class="text-end text-nowrap"><button type="button" class="btn btn-sm btn-outline-secondary" data-test="' + esc(w.word) + '">Test</button> ' + '<td class="text-end text-nowrap"><button type="button" class="btn btn-sm btn-outline-secondary" data-test="' + esc(w.word) + '">Test</button> ' +
(isPhrase(w.word) && !w.allowed ? '' : '<button type="button" class="btn btn-sm btn-outline-' + (w.allowed ? 'danger' : 'success') + '" data-add="' + other + '" data-word="' + esc(w.word) + '">' + (w.allowed ? 'Block…' : 'Allow…') + '</button> ') + '<button type="button" class="btn btn-sm btn-outline-' + (w.allowed ? 'danger' : 'success') + '" data-add="' + other + '" data-word="' + esc(w.word) + '">' + (w.allowed ? 'Block…' : 'Allow…') + '</button> ' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-remove="' + esc(w.word) + '">Remove…</button></td></tr>'; '<button type="button" class="btn btn-sm btn-outline-secondary" data-remove="' + esc(w.word) + '">Remove…</button></td></tr>';
}).join('') || '<tr><td colspan="5" class="text-body-secondary">' + (words.length ? 'No words match.' : 'No words added yet.') + '</td></tr>'; }).join('') || '<tr><td colspan="5" class="text-body-secondary">' + (words.length ? 'No words match.' : 'No words added yet.') + '</td></tr>';
document.getElementById('listRange').textContent = rows.length ? (listStart + 1) + '–' + (listStart + shown.length) + ' of ' + rows.length : ''; document.getElementById('listRange').textContent = rows.length ? (listStart + 1) + '–' + (listStart + shown.length) + ' of ' + rows.length : '';
@@ -120,7 +120,6 @@
e.preventDefault(); e.preventDefault();
var word = addWord.value.trim().replace(/\s+/g, ' '); var word = addWord.value.trim().replace(/\s+/g, ' ');
if (!word) return; if (!word) return;
if (addList === 'allowed' && isPhrase(word)) { toast('Phrases can only be blocked: normal chat checks each word on its own, so allow the words instead', 'warning'); return; }
confirmAdd(word, addList === 'allowed').then(function (done) { if (done) addWord.value = ''; }); confirmAdd(word, addList === 'allowed').then(function (done) { if (done) addWord.value = ''; });
}); });
@@ -191,7 +190,7 @@
function confirmAdd(word, allowed) { function confirmAdd(word, allowed) {
var p = openConfirm({ var p = openConfirm({
title: (allowed ? 'Allow "' : 'Block "') + word + '"?', title: (allowed ? 'Allow "' : 'Block "') + word + '"?',
text: allowed ? 'Players may use it in normal chat. Running worlds apply it at once.' text: allowed ? (isPhrase(word) ? 'Players may use these words together in normal chat, even where one alone isn\'t allowed. Running worlds apply it at once.' : 'Players may use it in normal chat. Running worlds apply it at once.')
: (isPhrase(word) ? 'The phrase is stopped in all chat when its words come in a row. Running worlds apply it at once.' : 'It is stopped in all chat, even where a word file allows it. Running worlds apply it at once.'), : (isPhrase(word) ? 'The phrase is stopped in all chat when its words come in a row. Running worlds apply it at once.' : 'It is stopped in all chat, even where a word file allows it. Running worlds apply it at once.'),
tone: allowed ? 'success' : 'danger', tone: allowed ? 'success' : 'danger',
button: allowed ? 'Allow' : 'Block', button: allowed ? 'Allow' : 'Block',

View File

@@ -55,11 +55,11 @@
<div class="card-body"> <div class="card-body">
<dl class="small cf-explain mb-3 row"> <dl class="small cf-explain mb-3 row">
<dt class="col-sm-2 text-danger">Blocked</dt><dd class="col-sm-10">Stopped in all chat, even if a word file allows it. A phrase (several words) is stopped when its words come in a row, whatever the spaces and punctuation between them.</dd> <dt class="col-sm-2 text-danger">Blocked</dt><dd class="col-sm-10">Stopped in all chat, even if a word file allows it. A phrase (several words) is stopped when its words come in a row, whatever the spaces and punctuation between them.</dd>
<dt class="col-sm-2 text-success">Allowed</dt><dd class="col-sm-10">Usable in normal chat, like the words in <code>chatplus_en_us.txt</code>. Single words only: normal chat checks each word on its own, as the client does.</dd> <dt class="col-sm-2 text-success">Allowed</dt><dd class="col-sm-10">Usable in normal chat, like the words in <code>chatplus_en_us.txt</code>. An allowed phrase lets its words through together, even where one alone isn't allowed. Block lists win: a phrase from the block list is stopped in normal chat too.</dd>
</dl> </dl>
<form class="row g-2 align-items-end mb-3" id="addForm"> <form class="row g-2 align-items-end mb-3" id="addForm">
<div class="col-md-6"><label class="form-label small mb-1" for="addWord">Add a word or phrase</label> <div class="col-md-6"><label class="form-label small mb-1" for="addWord">Add a word or phrase</label>
<input class="form-control form-control-sm" id="addWord" maxlength="64" required autocomplete="off" placeholder="A word, or a phrase to block"></div> <input class="form-control form-control-sm" id="addWord" maxlength="64" required autocomplete="off" placeholder="A word or phrase"></div>
<div class="col-md-6 d-flex gap-2"> <div class="col-md-6 d-flex gap-2">
<button type="submit" class="btn btn-sm btn-danger" data-list="blocked">Block…</button> <button type="submit" class="btn btn-sm btn-danger" data-list="blocked">Block…</button>
<button type="submit" class="btn btn-sm btn-success" data-list="allowed">Allow…</button> <button type="submit" class="btn btn-sm btn-success" data-list="allowed">Allow…</button>

View File

@@ -1218,9 +1218,11 @@ character names also count as allowed. Words are compared lower case, without `!
running worlds and in the chat server's web chat; servers that start later read them. Changes are audited and go to the running worlds and in the chat server's web chat; servers that start later read them. Changes are audited and go to the
`moderation` webhook event. `moderation` webhook event.
Blocked entries can be phrases: a phrase is stopped when its words come in a row in a message, whatever the spaces and Entries can be phrases. A blocked phrase is stopped when its words come in a row in a message, whatever the spaces and
punctuation between them, and the whole phrase is marked. Allowed entries are single words only, because normal punctuation between them, and the whole phrase is marked. Phrases in `blocklist.dcf` are stopped in normal (whitelist)
(whitelist) chat checks each word on its own, as the client does. chat too; its single words only matter in free chat, since normal chat already needs every word allowed. An allowed
phrase (in `chatplus_en_us.txt` or on the dashboard) lets its words through together in normal chat, even where one
alone isn't allowed. The client only checks single words before it sends a message; the server is the full check.
#### Block list file #### Block list file
@@ -1248,7 +1250,7 @@ The page has three sections:
word by word (in the file, allowed or blocked here, a character name, not allowed, in the blocked words file). Each word by word (in the file, allowed or blocked here, a character name, not allowed, in the blocked words file). Each
word has a Block, Allow or Remove button. word has a Block, Allow or Remove button.
- **Staff lists**: **Blocked** words and phrases are stopped in all chat, even where a file allows them (phrases are - **Staff lists**: **Blocked** words and phrases are stopped in all chat, even where a file allows them (phrases are
marked **Phrase**); **Allowed** words are usable in normal chat. Search, filter by list, 50 per page. Block, Allow (or move to the other list) and Remove each open a marked **Phrase**); **Allowed** words and phrases are usable in normal chat. Search, filter by list, 50 per page. Block, Allow (or move to the other list) and Remove each open a
confirmation that shows where the word stands now and, for Block and Allow, the recent chat it changes (players' chat confirmation that shows where the word stands now and, for Block and Allow, the recent chat it changes (players' chat
containing it that would have been stopped, or stopped messages containing it; the newest 1000 messages with the containing it that would have been stopped, or stopped messages containing it; the newest 1000 messages with the
text; needs `chat_view`). text; needs `chat_view`).

View File

@@ -142,12 +142,42 @@ TEST(ChatFilterCoreTest, DashboardPhrasesInWhitelistChat) {
// Blocked on the dashboard: stopped in whitelist chat too, even though each word is allowed // Blocked on the dashboard: stopped in whitelist chat too, even though each word is allowed
EXPECT_EQ(CheckMessage("hello bad phrase", true, lists), (Spans{ { 6, 10 } })); EXPECT_EQ(CheckMessage("hello bad phrase", true, lists), (Spans{ { 6, 10 } }));
EXPECT_TRUE(CheckMessage("hello bad there phrase", true, lists).empty()); EXPECT_TRUE(CheckMessage("hello bad there phrase", true, lists).empty());
// Whitelist chat checks one word at a time, as the client does // A word that isn't allowed is stopped in whitelist chat
EXPECT_EQ(CheckMessage("hello stranger", true, lists), (Spans{ { 6, 8 } })); EXPECT_EQ(CheckMessage("hello stranger", true, lists), (Spans{ { 6, 8 } }));
lists.customAllowed.AddEntry("stranger"); lists.customAllowed.AddEntry("stranger");
EXPECT_TRUE(CheckMessage("hello stranger", true, lists).empty()); EXPECT_TRUE(CheckMessage("hello stranger", true, lists).empty());
} }
TEST(ChatFilterCoreTest, BlockListPhrasesInWhitelistChat) {
// Each word is allowed, the phrase is on the block list: the server stops it in whitelist chat too
const auto lists = FreeChatLists("bad phrase\nfriend\n");
EXPECT_EQ(CheckMessage("hello bad phrase", true, lists), (Spans{ { 6, 10 } }));
// A single blocked word only counts in free chat; whitelist chat goes by the allowed words
EXPECT_TRUE(CheckMessage("hello friend", true, lists).empty());
EXPECT_EQ(CheckMessage("hello friend", false, lists), (Spans{ { 6, 6 } }));
}
TEST(ChatFilterCoreTest, AllowedPhrasesInWhitelistChat) {
auto lists = FreeChatLists("");
lists.customAllowed.AddEntry(NormalizeEntry("Nexus Tower"));
// Neither word is allowed alone, together they are
EXPECT_TRUE(CheckMessage("hello nexus tower", true, lists).empty());
EXPECT_TRUE(CheckMessage("Nexus Tower!", true, lists).empty());
EXPECT_EQ(CheckMessage("hello tower", true, lists), (Spans{ { 6, 5 } }));
EXPECT_EQ(CheckMessage("nexus hello tower", true, lists), (Spans{ { 0, 5 }, { 12, 5 } }));
// From the allowed words file as well
Lists fromFile;
fromFile.approved = dChatFilterDCF::AllowListFromText("hello\nnexus tower\n");
EXPECT_TRUE(CheckMessage("hello nexus tower", true, fromFile).empty());
// A blocked word breaks the phrase
lists.customBlocked.AddEntry("tower");
EXPECT_EQ(CheckMessage("nexus tower", true, lists), (Spans{ { 0, 5 }, { 6, 5 } }));
// Free chat ignores the allowed lists
auto freeChat = FreeChatLists("tower\n");
freeChat.customAllowed.AddEntry(NormalizeEntry("Nexus Tower"));
EXPECT_EQ(CheckMessage("nexus tower", false, freeChat), (Spans{ { 6, 5 } }));
}
TEST(ChatFilterCoreTest, ShippedBlockListIsPortable) { TEST(ChatFilterCoreTest, ShippedBlockListIsPortable) {
const auto parsed = dChatFilterDCF::ReadFile(std::string(DLU_SOURCE_DIR) + "/resources/blocklist.dcf"); const auto parsed = dChatFilterDCF::ReadFile(std::string(DLU_SOURCE_DIR) + "/resources/blocklist.dcf");
ASSERT_EQ(parsed.status, dChatFilterDCF::eStatus::OK); ASSERT_EQ(parsed.status, dChatFilterDCF::eStatus::OK);

View File

@@ -123,7 +123,25 @@ TEST(ChatFilterWordsTest, ExplainPhrases) {
EXPECT_EQ(Reasons(verdicts), (std::vector<std::string>{ "ok allow_file", "x blocked_here", "x blocked_here", "x blocked_here" })); EXPECT_EQ(Reasons(verdicts), (std::vector<std::string>{ "ok allow_file", "x blocked_here", "x blocked_here", "x blocked_here" }));
EXPECT_EQ(verdicts[1].phrase, "no way"); EXPECT_EQ(verdicts[1].phrase, "no way");
EXPECT_EQ(verdicts[3].text, "way!"); EXPECT_EQ(verdicts[3].text, "way!");
// The block file's phrases only in free chat // The block file's phrases in free chat and in normal chat, even when each word is allowed
EXPECT_EQ(Reasons(ModerationTools::ExplainMessage("very rude", false, sources)), (std::vector<std::string>{ "x block_file", "x block_file" })); EXPECT_EQ(Reasons(ModerationTools::ExplainMessage("very rude", false, sources)), (std::vector<std::string>{ "x block_file", "x block_file" }));
sources.allowFile = [](const std::string& w) { return w == "very" || w == "rude"; };
EXPECT_EQ(Reasons(ModerationTools::ExplainMessage("very rude", true, sources)), (std::vector<std::string>{ "x block_file", "x block_file" }));
EXPECT_EQ(Reasons(ModerationTools::ExplainMessage("rude very", false, sources)), (std::vector<std::string>{ "ok not_in_block_file", "ok not_in_block_file" })); EXPECT_EQ(Reasons(ModerationTools::ExplainMessage("rude very", false, sources)), (std::vector<std::string>{ "ok not_in_block_file", "ok not_in_block_file" }));
} }
TEST(ChatFilterWordsTest, ExplainAllowedPhrases) {
auto sources = Sources();
sources.dashboard = [](const std::string& w) -> std::optional<bool> {
if (w == "nexus tower") return true;
return std::nullopt;
};
sources.maxAllowedWords = 2;
// Neither word is allowed alone; together they are
auto verdicts = ModerationTools::ExplainMessage("hello Nexus Tower", true, sources);
EXPECT_EQ(Reasons(verdicts), (std::vector<std::string>{ "ok allow_file", "ok allowed_here", "ok allowed_here", "ok allowed_here" }));
EXPECT_EQ(verdicts[1].phrase, "nexus tower");
EXPECT_EQ(Reasons(ModerationTools::ExplainMessage("tower", true, sources)), (std::vector<std::string>{ "x not_allowed" }));
// Free chat doesn't use allowed phrases
EXPECT_EQ(Reasons(ModerationTools::ExplainMessage("nexus tower", false, sources)), (std::vector<std::string>{ "ok not_in_block_file", "ok not_in_block_file" }));
}