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;
}
// 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;

View File

@@ -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";

View 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)) {

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>' +
'<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',

View File

@@ -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>

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
`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`).

View File

@@ -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);

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(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" }));
}