diff --git a/code/__DEFINES/chat_filter.dm b/code/__DEFINES/chat_filter.dm new file mode 100644 index 00000000000..6b39b253937 --- /dev/null +++ b/code/__DEFINES/chat_filter.dm @@ -0,0 +1,9 @@ +/// The index of the word that was filtered in a is_*_filtered proc +#define CHAT_FILTER_INDEX_WORD 1 + +/// The index of the reason why a word was filtered in a is_*_filtered proc +#define CHAT_FILTER_INDEX_REASON 2 + +/// Given a chat filter result, will send a to_chat to the user telling them about why their message was blocked +#define REPORT_CHAT_FILTER_TO_USER(user, filter_result) \ + to_chat(user, span_warning("The word [html_encode(filter_result[CHAT_FILTER_INDEX_WORD])] is prohibited: [html_encode(filter_result[CHAT_FILTER_INDEX_REASON])]")) diff --git a/code/__DEFINES/rust_g.dm b/code/__DEFINES/rust_g.dm index 0da05e7c1db..c2fb07f2510 100644 --- a/code/__DEFINES/rust_g.dm +++ b/code/__DEFINES/rust_g.dm @@ -102,7 +102,9 @@ #define rustg_sql_disconnect_pool(handle) call(RUST_G, "sql_disconnect_pool")(handle) #define rustg_sql_check_query(job_id) call(RUST_G, "sql_check_query")("[job_id]") -#define rustg_url_encode(text) call(RUST_G, "url_encode")(text) +#define rustg_read_toml_file(path) json_decode(call(RUST_G, "toml_file_to_json")(path) || "null") + +#define rustg_url_encode(text) call(RUST_G, "url_encode")("[text]") #define rustg_url_decode(text) call(RUST_G, "url_decode")(text) #ifdef RUSTG_OVERRIDE_BUILTINS diff --git a/code/__DEFINES/say.dm b/code/__DEFINES/say.dm index 67c10e5d9b4..98b70f8eb6a 100644 --- a/code/__DEFINES/say.dm +++ b/code/__DEFINES/say.dm @@ -92,9 +92,6 @@ #define MAX_BROADCAST_LEN 512 #define MAX_CHARTER_LEN 80 -// Is something in the IC chat filter? This is config dependent. -#define CHAT_FILTER_CHECK(T) (config.ic_filter_regex && findtext(T, config.ic_filter_regex)) - // Audio/Visual Flags. Used to determine what sense are required to notice a message. #define MSG_VISUAL (1<<0) #define MSG_AUDIBLE (1<<1) diff --git a/code/__HELPERS/chat_filter.dm b/code/__HELPERS/chat_filter.dm new file mode 100644 index 00000000000..8b5c706b71a --- /dev/null +++ b/code/__HELPERS/chat_filter.dm @@ -0,0 +1,38 @@ +// [2] is the group index of the blocked term when it is not using word bounds. +// This is sanity checked by unit tests. +#define GET_MATCHED_GROUP(regex) (regex.group[2] || regex.match) + +/// Given a text, will return what word is on the IC filter, with the reason. +/// Returns null if the message is OK. +/proc/is_ic_filtered(message) + if (config.ic_filter_regex?.Find(message)) + var/matched_group = GET_MATCHED_GROUP(config.ic_filter_regex) + return list( + matched_group, + config.ic_filter_reasons[matched_group] || config.ic_outside_pda_filter_reasons[matched_group] || config.shared_filter_reasons[matched_group], + ) + + return null + +/// Given a text, will return what word is on the IC filter, ignoring words allowed on the PDA, with the reason. +/// Returns null if the message is OK. +/proc/is_ic_filtered_for_pdas(message) + if (config.ic_outside_pda_filter_regex?.Find(message)) + var/matched_group = GET_MATCHED_GROUP(config.ic_outside_pda_filter_regex) + return list( + matched_group, + config.ic_filter_reasons[matched_group] || config.shared_filter_reasons[matched_group], + ) + + return null + +/// Given a text, will return what word is on the OOC filter, with the reason. +/// Returns null if the message is OK. +/proc/is_ooc_filtered(message) + if (config.ooc_filter_regex?.Find(message)) + var/matched_group = GET_MATCHED_GROUP(config.ooc_filter_regex) + return list(matched_group, config.shared_filter_reasons[matched_group]) + + return null + +#undef GET_MATCHED_GROUP diff --git a/code/__HELPERS/text.dm b/code/__HELPERS/text.dm index 18a92cda7ce..028734640fe 100644 --- a/code/__HELPERS/text.dm +++ b/code/__HELPERS/text.dm @@ -23,7 +23,7 @@ ///returns nothing with an alert instead of the message if it contains something in the ic filter, and sanitizes normally if the name is fine. It returns nothing so it backs out of the input the same way as if you had entered nothing. /proc/sanitize_name(t,allow_numbers=FALSE) - if(CHAT_FILTER_CHECK(t)) + if(is_ic_filtered(t)) tgui_alert(usr, "You cannot set a name that contains a word prohibited in IC chat!") return "" var/r = reject_bad_name(t,allow_numbers=allow_numbers,strict=TRUE) @@ -222,7 +222,7 @@ return //(not case sensitive) // Protects against names containing IC chat prohibited words. - if(CHAT_FILTER_CHECK(t_out)) + if(is_ic_filtered(t_out)) return return t_out diff --git a/code/controllers/configuration/configuration.dm b/code/controllers/configuration/configuration.dm index 6c6a5ae716c..685dd900b96 100644 --- a/code/controllers/configuration/configuration.dm +++ b/code/controllers/configuration/configuration.dm @@ -24,8 +24,24 @@ /// If the configuration is loaded var/loaded = FALSE + /// A regex that matches words blocked IC var/static/regex/ic_filter_regex + /// A regex that matches words blocked OOC + var/static/regex/ooc_filter_regex + + /// A regex that matches words blocked IC, but not in PDAs + var/static/regex/ic_outside_pda_filter_regex + + /// An assoc list of blocked IC words to their reasons + var/static/list/ic_filter_reasons + + /// An assoc list of words that are blocked IC, but not in PDAs, to their reasons + var/static/list/ic_outside_pda_filter_reasons + + /// An assoc list of words that are blocked both IC and OOC to their reasons + var/static/list/shared_filter_reasons + /datum/controller/configuration/proc/admin_reload() if(IsAdminAdvancedProcCall()) return @@ -335,17 +351,84 @@ Example config: log_config("Unknown command in map vote config: '[command]'") /datum/controller/configuration/proc/LoadChatFilter() - var/list/in_character_filter = list() - if(!fexists("[directory]/in_character_filter.txt")) + if(!fexists("[directory]/word_filter.toml")) + load_legacy_chat_filter() return + + log_config("Loading config file word_filter.toml...") + + var/list/word_filter = rustg_read_toml_file("[directory]/word_filter.toml") + if (!islist(word_filter)) + var/message = "The word filter configuration did not output a list, contact someone with configuration access to make sure it's setup properly." + log_config(message) + DelayedMessageAdmins(message) + return + + ic_filter_reasons = try_extract_from_word_filter(word_filter, "ic") + ic_outside_pda_filter_reasons = try_extract_from_word_filter(word_filter, "ic_outside_pda") + shared_filter_reasons = try_extract_from_word_filter(word_filter, "shared") + + update_chat_filter_regexes() + +/datum/controller/configuration/proc/load_legacy_chat_filter() + if (!fexists("[directory]/in_character_filter.txt")) + return + log_config("Loading config file in_character_filter.txt...") - for(var/line in world.file2list("[directory]/in_character_filter.txt")) - if(!line) + + ic_filter_reasons = list() + ic_outside_pda_filter_reasons = list() + shared_filter_reasons = list() + + for (var/line in world.file2list("[directory]/in_character_filter.txt")) + if (!line) continue - if(findtextEx(line,"#",1,2)) + if (findtextEx(line, "#", 1, 2)) continue - in_character_filter += REGEX_QUOTE(line) - ic_filter_regex = in_character_filter.len ? regex("\\b([jointext(in_character_filter, "|")])\\b", "i") : null + // The older filter didn't apply to PDA + ic_outside_pda_filter_reasons[line] = "No reason available" + + update_chat_filter_regexes() + +/// Will update the internal regexes of the chat filter based on the filter reasons +/datum/controller/configuration/proc/update_chat_filter_regexes() + ic_filter_regex = compile_filter_regex(ic_filter_reasons + ic_outside_pda_filter_reasons + shared_filter_reasons) + ic_outside_pda_filter_regex = compile_filter_regex(ic_filter_reasons + shared_filter_reasons) + ooc_filter_regex = compile_filter_regex(shared_filter_reasons) + +/datum/controller/configuration/proc/try_extract_from_word_filter(list/word_filter, key) + var/list/banned_words = word_filter[key] + + if (isnull(banned_words)) + return list() + else if (!islist(banned_words)) + var/message = "The word filter configuration's '[key]' key was invalid, contact someone with configuration access to make sure it's setup properly." + log_config(message) + DelayedMessageAdmins(message) + return list() + + return banned_words + +/datum/controller/configuration/proc/compile_filter_regex(list/banned_words) + if (isnull(banned_words) || banned_words.len == 0) + return null + + var/static/regex/should_join_on_word_bounds = regex(@"^\w+$") + + // Stuff like emoticons needs another split, since there's no way to get ":)" on a word bound. + // Furthermore, normal words need to be on word bounds, so "(adminhelp)" gets filtered. + var/list/to_join_on_whitespace_splits = list() + var/list/to_join_on_word_bounds = list() + + for (var/banned_word in banned_words) + if (findtext(banned_word, should_join_on_word_bounds)) + to_join_on_word_bounds += REGEX_QUOTE(banned_word) + else + to_join_on_whitespace_splits += REGEX_QUOTE(banned_word) + + var/whitespace_split = @"(?:(?:^|\s+)(" + jointext(to_join_on_whitespace_splits, "|") + @")(?:$|\s+))" + var/word_bounds = @"(\b(" + jointext(to_join_on_word_bounds, "|") + "))" + return regex("([whitespace_split]|[word_bounds])", "i") //Message admins when you can. /datum/controller/configuration/proc/DelayedMessageAdmins(text) diff --git a/code/game/atoms.dm b/code/game/atoms.dm index b3671afdf99..8ef855efb21 100644 --- a/code/game/atoms.dm +++ b/code/game/atoms.dm @@ -1321,7 +1321,7 @@ if(href_list[VV_HK_AUTO_RENAME] && check_rights(R_VAREDIT)) var/newname = input(usr, "What do you want to rename this to?", "Automatic Rename") as null|text // Check the new name against the chat filter. If it triggers the IC chat filter, give an option to confirm. - if(newname && !(CHAT_FILTER_CHECK(newname) && tgui_alert(usr, "Your selected name contains words restricted by IC chat filters. Confirm this new name?", "IC Chat Filter Conflict", list("Confirm", "Cancel")) != "Confirm")) + if(newname && !(is_ic_filtered(newname) && tgui_alert(usr, "Your selected name contains words restricted by IC chat filters. Confirm this new name?", "IC Chat Filter Conflict", list("Confirm", "Cancel")) != "Confirm")) vv_auto_rename(newname) if(href_list[VV_HK_EDIT_FILTERS] && check_rights(R_VAREDIT)) diff --git a/code/game/objects/items/AI_modules.dm b/code/game/objects/items/AI_modules.dm index 022b01492e0..52ee9417c6e 100644 --- a/code/game/objects/items/AI_modules.dm +++ b/code/game/objects/items/AI_modules.dm @@ -258,7 +258,7 @@ AI MODULES var/targName = stripped_input(user, "Please enter a new law for the AI.", "Freeform Law Entry", laws[1], CONFIG_GET(number/max_law_len)) if(!targName) return - if(CHAT_FILTER_CHECK(targName)) + if(is_ic_filtered(targName)) to_chat(user, span_warning("Error: Law contains invalid text.")) // AI LAW 2 SAY U W U WITHOUT THE SPACES return laws[1] = targName @@ -465,7 +465,7 @@ AI MODULES var/targName = stripped_input(user, "Please enter a new core law for the AI.", "Freeform Law Entry", laws[1], CONFIG_GET(number/max_law_len)) if(!targName) return - if(CHAT_FILTER_CHECK(targName)) + if(is_ic_filtered(targName)) to_chat(user, span_warning("Error: Law contains invalid text.")) return laws[1] = targName @@ -490,7 +490,7 @@ AI MODULES var/targName = stripped_input(user, "Please enter a new law for the AI.", "Freeform Law Entry", laws[1], CONFIG_GET(number/max_law_len)) if(!targName) return - if(CHAT_FILTER_CHECK(targName)) // not even the syndicate can uwu + if(is_ic_filtered(targName)) // not even the syndicate can uwu to_chat(user, span_warning("Error: Law contains invalid text.")) return laws[1] = targName diff --git a/code/game/objects/items/devices/PDA/PDA.dm b/code/game/objects/items/devices/PDA/PDA.dm index 4e49fff2b2a..bf5a7744b7e 100644 --- a/code/game/objects/items/devices/PDA/PDA.dm +++ b/code/game/objects/items/devices/PDA/PDA.dm @@ -763,6 +763,12 @@ GLOBAL_LIST_EMPTY(PDAs) return if((last_text && world.time < last_text + 10) || (everyone && last_everyone && world.time < last_everyone + PDA_SPAM_DELAY)) return + + var/list/filter_result = is_ic_filtered_for_pdas(message) + if (filter_result) + REPORT_CHAT_FILTER_TO_USER(user, filter_result) + return + if(prob(1)) message += "\nSent from my PDA" // Send the signal diff --git a/code/modules/admin/view_variables/topic.dm b/code/modules/admin/view_variables/topic.dm index 2a323c4e8e3..5aa82accde5 100644 --- a/code/modules/admin/view_variables/topic.dm +++ b/code/modules/admin/view_variables/topic.dm @@ -32,7 +32,7 @@ // If the new name is something that would be restricted by IC chat filters, // give the admin a warning but allow them to do it anyway if they want. - if(CHAT_FILTER_CHECK(new_name) && tgui_alert(usr, "Your selected name contains words restricted by IC chat filters. Confirm this new name?", "IC Chat Filter Conflict", list("Confirm", "Cancel")) == "Cancel") + if(is_ic_filtered(new_name) && tgui_alert(usr, "Your selected name contains words restricted by IC chat filters. Confirm this new name?", "IC Chat Filter Conflict", list("Confirm", "Cancel")) == "Cancel") return if( !new_name || !M ) diff --git a/code/modules/antagonists/cult/cult_comms.dm b/code/modules/antagonists/cult/cult_comms.dm index 2065fbd95c4..7f4394d0fa6 100644 --- a/code/modules/antagonists/cult/cult_comms.dm +++ b/code/modules/antagonists/cult/cult_comms.dm @@ -20,8 +20,10 @@ var/input = stripped_input(usr, "Please choose a message to tell to the other acolytes.", "Voice of Blood", "") if(!input || !IsAvailable()) return - if(CHAT_FILTER_CHECK(input)) - to_chat(usr, span_warning("You cannot send a message that contains a word prohibited in IC chat!")) + + var/list/filter_result = is_ic_filtered(input) + if(filter_result) + REPORT_CHAT_FILTER_TO_USER(usr, filter_result) return cultist_commune(usr, input) diff --git a/code/modules/client/verbs/ooc.dm b/code/modules/client/verbs/ooc.dm index 837e8ed7325..612274369cc 100644 --- a/code/modules/client/verbs/ooc.dm +++ b/code/modules/client/verbs/ooc.dm @@ -31,6 +31,11 @@ GLOBAL_VAR_INIT(normal_ooc_colour, "#002eb8") msg = copytext_char(sanitize(msg), 1, MAX_MESSAGE_LEN) var/raw_msg = msg + var/list/filter_result = is_ooc_filtered(msg) + if (filter_result) + REPORT_CHAT_FILTER_TO_USER(usr, filter_result) + return + if(!msg) return diff --git a/code/modules/mob/living/living_say.dm b/code/modules/mob/living/living_say.dm index 4e2aa7c0b5b..e2e2cb023f0 100644 --- a/code/modules/mob/living/living_say.dm +++ b/code/modules/mob/living/living_say.dm @@ -97,19 +97,21 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( return new_msg /mob/living/say(message, bubble_type,list/spans = list(), sanitize = TRUE, datum/language/language = null, ignore_spam = FALSE, forced = null) - var/ic_blocked = FALSE - if(client && !forced && CHAT_FILTER_CHECK(message)) + var/list/filter_result + if(client && !forced) //The filter doesn't act on the sanitized message, but the raw message. - ic_blocked = TRUE + filter_result = is_ic_filtered(message) if(sanitize) message = trim(copytext_char(sanitize(message), 1, MAX_MESSAGE_LEN)) if(!message || message == "") return - if(ic_blocked) + if(filter_result) //The filter warning message shows the sanitized message though. - to_chat(src, span_warning("That message contained a word prohibited in IC chat! Consider reviewing the server rules.\n\"[message]\"")) + to_chat(src, span_warning("That message contained a word prohibited in IC chat! Consider reviewing the server rules.")) + to_chat(src, span_warning("\"[message]\"")) + REPORT_CHAT_FILTER_TO_USER(src, filter_result) SSblackbox.record_feedback("tally", "ic_blocked_words", 1, lowertext(config.ic_filter_regex.match)) return var/list/message_mods = list() diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm index f9ffdb18c84..0afb6860410 100644 --- a/code/modules/unit_tests/_unit_tests.dm +++ b/code/modules/unit_tests/_unit_tests.dm @@ -50,6 +50,7 @@ #include "breath.dm" #include "card_mismatch.dm" #include "chain_pull_through_space.dm" +#include "chat_filter.dm" #include "combat.dm" #include "component_tests.dm" #include "connect_loc.dm" diff --git a/code/modules/unit_tests/chat_filter.dm b/code/modules/unit_tests/chat_filter.dm new file mode 100644 index 00000000000..b8fbaa16a31 --- /dev/null +++ b/code/modules/unit_tests/chat_filter.dm @@ -0,0 +1,81 @@ +#define BLOCKED_IC "This message is not allowed IC, please use a different weird test phrase." +#define BLOCKED_IC_OUTSIDE_PDA "Kirby dancing is strictly prohibited on this server." +#define BLOCKED_SHARED "This message is not allowed anywhere, please use a different weird test phrase." + +/// Tests the sanity of the chat filter, ensuring it properly blocks words and gives the reason +/datum/unit_test/chat_filter_sanity + +/datum/unit_test/chat_filter_sanity/Run() + // Update the chat filters to only have test phrases, just in case the toml is different + config.shared_filter_reasons = list("blockedinshared" = BLOCKED_SHARED) + config.ic_filter_reasons = list("blockedinic" = BLOCKED_IC) + config.ic_outside_pda_filter_reasons = list("<(0_0<)" = BLOCKED_IC_OUTSIDE_PDA) + config.update_chat_filter_regexes() + + test_filter( + "this message is blockedinic, AND has a comma (which needs word bounds)", + "blockedinic", + BLOCKED_IC, + BLOCKED_IC, + null, + ) + + test_filter( + "<(0_0<) <(0_0)> (>0_0)> KIRBY DANCE!!!", + "<(0_0<)", + BLOCKED_IC_OUTSIDE_PDA, + null, + null, + ) + + test_filter( + "This message is blockedinshared, meaning it's banned EVERYWHERE", + "blockedinshared", + BLOCKED_SHARED, + BLOCKED_SHARED, + BLOCKED_SHARED, + ) + +/datum/unit_test/chat_filter_sanity/proc/test_filter( + message, + blocked_word, + ic_filter_result, + pda_filter_result, + ooc_filter_result, +) + var/ic_filter = is_ic_filtered(message) + var/pda_filter = is_ic_filtered_for_pdas(message) + var/ooc_filter = is_ooc_filtered(message) + + test_filter_result("IC", message, ic_filter, ic_filter_result, blocked_word) + test_filter_result("PDA", message, pda_filter, pda_filter_result, blocked_word) + test_filter_result("OOC", message, ooc_filter, ooc_filter_result, blocked_word) + +/datum/unit_test/chat_filter_sanity/proc/test_filter_result( + filter_type, + message, + outcome, + expected_reason, + expected_blocked_word, +) + if (isnull(outcome) && isnull(expected_reason)) + return + + if (isnull(outcome)) + Fail("[message] was not blocked on the [filter_type] filter when it was expected to") + return + + if (isnull(expected_reason)) + Fail("[message] was blocked on the [filter_type] filter when it wasn't expected to: [json_encode(outcome)]") + return + + if (outcome[CHAT_FILTER_INDEX_WORD] != expected_blocked_word) + Fail("[message] was blocked on the [filter_type] filter, but for a different word: \"[outcome[CHAT_FILTER_INDEX_WORD]]\" (instead of [expected_blocked_word])") + return + + if (outcome[CHAT_FILTER_INDEX_REASON] != expected_reason) + Fail("[message] was blocked on the [filter_type] filter, but for a different reason: \"[outcome[CHAT_FILTER_INDEX_REASON]]\" (instead of [expected_reason])") + +#undef BLOCKED_IC +#undef BLOCKED_IC_OUTSIDE_PDA +#undef BLOCKED_SHARED diff --git a/config/in_character_filter.txt b/config/in_character_filter.txt deleted file mode 100644 index 46df3fd60c5..00000000000 --- a/config/in_character_filter.txt +++ /dev/null @@ -1,7 +0,0 @@ -############################################################################################### -# Words that will block in character chat messages from sending. # -# Case is not important. Commented-out examples are listed below, just remove the "#". # -############################################################################################### -#lol -#omg -#wtf \ No newline at end of file diff --git a/config/word_filter.toml b/config/word_filter.toml new file mode 100644 index 00000000000..11484b46a33 --- /dev/null +++ b/config/word_filter.toml @@ -0,0 +1,15 @@ +# This configuration file forms the in-game chat filter. +# Values are stored as "word" = "reason for being banned". + +# Anything in here will be blocked from IC interactions, dead chat, and OOC +[shared] +"blockedinshared" = "This message is not allowed anywhere, please use a different weird test phrase." + +# Anything in here is blocked just from IC interactions, such as chat or names, but is allowed in dead chat and OOC +[ic] +"blockedinic" = "This message is not allowed IC, please use a different weird test phrase." + +# Anything in here is blocked from all IC interactions *except* PDAs. +# On /tg/, netspeak is OK in PDAs. +[ic_outside_pda] +"<(0_0<)" = "Kirby dancing is strictly prohibited on this server." diff --git a/dependencies.sh b/dependencies.sh index 82d8b0cdb0f..d6386a54b44 100644 --- a/dependencies.sh +++ b/dependencies.sh @@ -8,7 +8,7 @@ export BYOND_MAJOR=514 export BYOND_MINOR=1557 #SKYRAT EDIT CHANGE - Compiler failure due to insufficent memory when running previous builds. #rust_g git tag -export RUST_G_VERSION=0.4.10 +export RUST_G_VERSION=0.5.0 #node version export NODE_VERSION=12 diff --git a/rust_g.dll b/rust_g.dll index 26f6942861d..be19f57946d 100644 Binary files a/rust_g.dll and b/rust_g.dll differ diff --git a/tgstation.dme b/tgstation.dme index c60ea495687..d155a200b6c 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -43,6 +43,7 @@ #include "code\__DEFINES\callbacks.dm" #include "code\__DEFINES\cargo.dm" #include "code\__DEFINES\chat.dm" +#include "code\__DEFINES\chat_filter.dm" #include "code\__DEFINES\cinematics.dm" #include "code\__DEFINES\cleaning.dm" #include "code\__DEFINES\colors.dm" @@ -215,6 +216,7 @@ #include "code\__HELPERS\atmospherics.dm" #include "code\__HELPERS\bitflag_lists.dm" #include "code\__HELPERS\chat.dm" +#include "code\__HELPERS\chat_filter.dm" #include "code\__HELPERS\cmp.dm" #include "code\__HELPERS\colors.dm" #include "code\__HELPERS\config.dm"