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

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