diff --git a/dChatFilter/ChatFilterCore.h b/dChatFilter/ChatFilterCore.h index b6acbb609..c0d0e9f2c 100644 --- a/dChatFilter/ChatFilterCore.h +++ b/dChatFilter/ChatFilterCore.h @@ -57,6 +57,9 @@ namespace ChatFilterWords { return entry.empty() ? 0 : static_cast(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> CheckMessage(std::string_view message, bool allowList, const Lists& lists) { if (message.empty()) return {}; if (!allowList && lists.denied.Empty()) return { { 0, static_cast(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> 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 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; diff --git a/dDashboardServer/routes/ChatFilterWords.h b/dDashboardServer/routes/ChatFilterWords.h index 00dcb4cb4..2ececd59f 100644 --- a/dDashboardServer/routes/ChatFilterWords.h +++ b/dDashboardServer/routes/ChatFilterWords.h @@ -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 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"; diff --git a/dDashboardServer/routes/ModerationTools.cpp b/dDashboardServer/routes/ModerationTools.cpp index 31c63e9de..02a823e48 100644 --- a/dDashboardServer/routes/ModerationTools.cpp +++ b/dDashboardServer/routes/ModerationTools.cpp @@ -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(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)) { diff --git a/dDashboardServer/static/js/chat-filter.js b/dDashboardServer/static/js/chat-filter.js index 5852fb287..a7eac9a26 100644 --- a/dDashboardServer/static/js/chat-filter.js +++ b/dDashboardServer/static/js/chat-filter.js @@ -90,7 +90,7 @@ return '' + esc(w.word) + phraseBadge(w.word) + '' + (w.allowed ? fmt.badge('Allowed', 'success') : fmt.badge('Blocked', 'danger')) + '' + '' + esc(w.added_by) + '' + esc(fmt.unix(w.added_at)) + '' + ' ' + - (isPhrase(w.word) && !w.allowed ? '' : ' ') + + ' ' + ''; }).join('') || '' + (words.length ? 'No words match.' : 'No words added yet.') + ''; 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', diff --git a/dDashboardServer/templates/chat_filter.jinja2 b/dDashboardServer/templates/chat_filter.jinja2 index 7caae932d..27c790802 100644 --- a/dDashboardServer/templates/chat_filter.jinja2 +++ b/dDashboardServer/templates/chat_filter.jinja2 @@ -55,11 +55,11 @@
Blocked
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.
-
Allowed
Usable in normal chat, like the words in chatplus_en_us.txt. Single words only: normal chat checks each word on its own, as the client does.
+
Allowed
Usable in normal chat, like the words in chatplus_en_us.txt. 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.
-
+
diff --git a/docs/Dashboard.md b/docs/Dashboard.md index cc3f42889..bb8be959e 100644 --- a/docs/Dashboard.md +++ b/docs/Dashboard.md @@ -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`). diff --git a/tests/dCommonTests/ChatFilterCoreTests.cpp b/tests/dCommonTests/ChatFilterCoreTests.cpp index b4c7f71b1..ff124a81e 100644 --- a/tests/dCommonTests/ChatFilterCoreTests.cpp +++ b/tests/dCommonTests/ChatFilterCoreTests.cpp @@ -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); diff --git a/tests/dWebTests/ModerationToolsTests.cpp b/tests/dWebTests/ModerationToolsTests.cpp index ccc2b980c..c7acd6a28 100644 --- a/tests/dWebTests/ModerationToolsTests.cpp +++ b/tests/dWebTests/ModerationToolsTests.cpp @@ -123,7 +123,25 @@ TEST(ChatFilterWordsTest, ExplainPhrases) { EXPECT_EQ(Reasons(verdicts), (std::vector{ "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{ "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{ "x block_file", "x block_file" })); EXPECT_EQ(Reasons(ModerationTools::ExplainMessage("rude very", false, sources)), (std::vector{ "ok not_in_block_file", "ok not_in_block_file" })); } + +TEST(ChatFilterWordsTest, ExplainAllowedPhrases) { + auto sources = Sources(); + sources.dashboard = [](const std::string& w) -> std::optional { + 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{ "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{ "x not_allowed" })); + // Free chat doesn't use allowed phrases + EXPECT_EQ(Reasons(ModerationTools::ExplainMessage("nexus tower", false, sources)), (std::vector{ "ok not_in_block_file", "ok not_in_block_file" })); +}