mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +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:
@@ -57,6 +57,9 @@ namespace ChatFilterWords {
|
||||
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
|
||||
constexpr uint64_t Hash(std::string_view entry) {
|
||||
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
|
||||
* dashboard's always, blocklist.dcf's in free chat) are stopped as one span each. In whitelist chat (allowList) every
|
||||
* other piece must be an allowed word, one at a time, as the client checks words. In free chat without a block list
|
||||
* the whole message is stopped.
|
||||
* The pieces of a message the filter stops, as (position, length) in the message. Blocked words and phrases are
|
||||
* stopped as one span each: the dashboard's in all chat, blocklist.dcf's phrases in all chat and its single words in
|
||||
* free chat (whitelist chat already stops a word that isn't allowed). In whitelist chat (allowList) every other piece
|
||||
* 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) {
|
||||
if (message.empty()) return {};
|
||||
if (!allowList && lists.denied.Empty()) return { { 0, static_cast<uint8_t>(message.length()) } };
|
||||
|
||||
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) {
|
||||
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;
|
||||
@@ -176,6 +180,17 @@ namespace ChatFilterWords {
|
||||
}
|
||||
|
||||
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++) {
|
||||
if (covered[i]) continue;
|
||||
const auto hash = Hash(tokens[i].word);
|
||||
@@ -303,8 +318,13 @@ namespace dChatFilterDCF {
|
||||
std::string line(text.substr(start, end - start));
|
||||
std::erase(line, '\r');
|
||||
line = ChatFilterWords::AsciiLower(std::move(line));
|
||||
list.hashes.insert(ChatFilterWords::Hash(line));
|
||||
list.maxWords = std::max(list.maxWords, 1u);
|
||||
// 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.maxWords = std::max(list.maxWords, 1u);
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
return list;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>' +
|
||||
'<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> ' +
|
||||
(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>';
|
||||
}).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 : '';
|
||||
@@ -120,7 +120,6 @@
|
||||
e.preventDefault();
|
||||
var word = addWord.value.trim().replace(/\s+/g, ' ');
|
||||
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 = ''; });
|
||||
});
|
||||
|
||||
@@ -191,7 +190,7 @@
|
||||
function confirmAdd(word, allowed) {
|
||||
var p = openConfirm({
|
||||
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.'),
|
||||
tone: allowed ? 'success' : 'danger',
|
||||
button: allowed ? 'Allow' : 'Block',
|
||||
|
||||
@@ -55,11 +55,11 @@
|
||||
<div class="card-body">
|
||||
<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-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>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
`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
|
||||
punctuation between them, and the whole phrase is marked. Allowed entries are single words only, because normal
|
||||
(whitelist) chat checks each word on its own, as the client does.
|
||||
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. Phrases in `blocklist.dcf` are stopped in normal (whitelist)
|
||||
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
|
||||
|
||||
@@ -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 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
|
||||
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
|
||||
containing it that would have been stopped, or stopped messages containing it; the newest 1000 messages with the
|
||||
text; needs `chat_view`).
|
||||
|
||||
@@ -142,12 +142,42 @@ TEST(ChatFilterCoreTest, DashboardPhrasesInWhitelistChat) {
|
||||
// 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_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 } }));
|
||||
lists.customAllowed.AddEntry("stranger");
|
||||
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) {
|
||||
const auto parsed = dChatFilterDCF::ReadFile(std::string(DLU_SOURCE_DIR) + "/resources/blocklist.dcf");
|
||||
ASSERT_EQ(parsed.status, dChatFilterDCF::eStatus::OK);
|
||||
|
||||
@@ -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(verdicts[1].phrase, "no 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" }));
|
||||
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" }));
|
||||
}
|
||||
|
||||
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" }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user