From c73dc100b0868310e6ea2f059b577689ae090bae Mon Sep 17 00:00:00 2001 From: Pooble <90473506+poobsie@users.noreply.github.com> Date: Sat, 22 Nov 2025 21:56:53 -0500 Subject: [PATCH] Add ability to blacklist phrases in chat (#30902) * add ability to blacklist phrases in chat * support exact * support exact * i think this fixes the tgui thing * prettier linter eeeeeeeeeeee --- code/modules/tgui/tgui_panel/to_chat.dm | 8 +- tgui/packages/tgui-panel/chat/middleware.js | 9 +- tgui/packages/tgui-panel/chat/renderer.js | 131 ++++++++++++++-- .../tgui-panel/chat/replaceInTextNode.js | 68 +++++++++ .../tgui-panel/settings/SettingsPanel.tsx | 143 +++++++++++++++++- tgui/packages/tgui-panel/settings/actions.js | 7 +- .../packages/tgui-panel/settings/constants.js | 4 + .../tgui-panel/settings/middleware.js | 8 +- tgui/packages/tgui-panel/settings/model.ts | 15 ++ tgui/packages/tgui-panel/settings/reducer.js | 66 +++++++- .../packages/tgui-panel/settings/selectors.js | 2 + tgui/public/tgui-panel.bundle.js | 2 +- 12 files changed, 442 insertions(+), 21 deletions(-) diff --git a/code/modules/tgui/tgui_panel/to_chat.dm b/code/modules/tgui/tgui_panel/to_chat.dm index b2e6f4a737a..8464f02a906 100644 --- a/code/modules/tgui/tgui_panel/to_chat.dm +++ b/code/modules/tgui/tgui_panel/to_chat.dm @@ -7,7 +7,7 @@ * Circumvents the message queue and sends the message to the recipient (target) as soon as possible. * trailing_newline, confidential, and handle_whitespace currently have no effect, please fix this in the future or remove the arguments to lower cache! */ -/proc/to_chat_immediate(target, html, type, text, avoid_highlighting = FALSE, handle_whitespace = TRUE, trailing_newline = TRUE, confidential = FALSE, ticket_id = -1) +/proc/to_chat_immediate(target, html, type, text, avoid_highlighting = FALSE, avoid_blacklisting = FALSE, handle_whitespace = TRUE, trailing_newline = TRUE, confidential = FALSE, ticket_id = -1) // Useful where the integer 0 is the entire message. Use case is enabling to_chat(target, some_boolean) while preventing to_chat(target, "") html = "[html]" text = "[text]" @@ -29,6 +29,8 @@ message["html"] = html if(avoid_highlighting) message["avoidHighlighting"] = avoid_highlighting + if(avoid_blacklisting) + message["avoidBlacklisting"] = avoid_blacklisting if(ticket_id != -1) message["ticket_id"] = ticket_id @@ -53,7 +55,7 @@ * * `trailing_newline`, `confidential`, and `handle_whitespace` currently have no effect, please fix this in the future or remove the arguments to lower cache! */ -/proc/to_chat(target, html, type, text, avoid_highlighting, handle_whitespace = TRUE, trailing_newline = TRUE, confidential = FALSE, ticket_id = -1) +/proc/to_chat(target, html, type, text, avoid_highlighting, avoid_blacklisting, handle_whitespace = TRUE, trailing_newline = TRUE, confidential = FALSE, ticket_id = -1) if(Master.current_runlevel == RUNLEVEL_INIT || !SSchat?.initialized) to_chat_immediate(target, html, type, text) return @@ -79,6 +81,8 @@ message["html"] = html if(avoid_highlighting) message["avoidHighlighting"] = avoid_highlighting + if(avoid_blacklisting) + message["avoidBlacklisting"] = avoid_blacklisting if(ticket_id != -1) message["ticket_id"] = ticket_id SSchat.queue(target, message) diff --git a/tgui/packages/tgui-panel/chat/middleware.js b/tgui/packages/tgui-panel/chat/middleware.js index 11df1522ae7..17f600710f7 100644 --- a/tgui/packages/tgui-panel/chat/middleware.js +++ b/tgui/packages/tgui-panel/chat/middleware.js @@ -8,9 +8,12 @@ import { storage } from 'common/storage'; import DOMPurify from 'dompurify'; import { + addBlacklistSetting, addHighlightSetting, loadSettings, + removeBlacklistSetting, removeHighlightSetting, + updateBlacklistSetting, updateHighlightSetting, updateSettings, } from '../settings/actions'; @@ -180,11 +183,15 @@ export const chatMiddleware = (store) => { type === loadSettings.type || type === addHighlightSetting.type || type === removeHighlightSetting.type || - type === updateHighlightSetting.type + type === updateHighlightSetting.type || + type === addBlacklistSetting.type || + type === removeBlacklistSetting.type || + type === updateBlacklistSetting.type ) { next(action); const settings = selectSettings(store.getState()); chatRenderer.setHighlight(settings.highlightSettings, settings.highlightSettingById); + chatRenderer.setBlacklist(settings.blacklistSettings, settings.blacklistSettingById); return; } if (type === 'roundrestart') { diff --git a/tgui/packages/tgui-panel/chat/renderer.js b/tgui/packages/tgui-panel/chat/renderer.js index 9ca65ad592e..655e5c1161d 100644 --- a/tgui/packages/tgui-panel/chat/renderer.js +++ b/tgui/packages/tgui-panel/chat/renderer.js @@ -16,12 +16,14 @@ import { IMAGE_RETRY_MESSAGE_AGE, MAX_VISIBLE_MESSAGES, MESSAGE_PRUNE_INTERVAL, + MESSAGE_TYPE_ADMINPM, MESSAGE_TYPE_INTERNAL, + MESSAGE_TYPE_MENTORPM, MESSAGE_TYPE_UNKNOWN, MESSAGE_TYPES, } from './constants'; import { canPageAcceptType, createMessage, isSameMessage } from './model'; -import { highlightNode, linkifyNode } from './replaceInTextNode'; +import { highlightNode, linkifyNode, processBlacklistNode } from './replaceInTextNode'; const logger = createLogger('chatRenderer'); @@ -264,6 +266,89 @@ class ChatRenderer { }); } + setBlacklist(blacklistSettings, blacklistSettingById) { + this.blacklistParsers = null; + if (!blacklistSettings) { + return; + } + blacklistSettings.map((id) => { + const setting = blacklistSettingById[id]; + const text = setting.blacklistText; + const censor = setting.censor; + const matchWord = setting.matchWord; + const matchCase = setting.matchCase; + const allowedRegex = /^[a-zа-яё0-9_\-$/^[\s\]\\]+$/gi; + const regexEscapeCharacters = /[!#$%^&*)(+=.<>{}[\]:;'"|~`_\-\\/]/g; + const lines = String(text) + .split(/[,|]/) + .map((str) => str.trim()) + .filter( + (str) => + // Must be longer than one character + str && + str.length > 1 && + // Must be alphanumeric (with some punctuation) + allowedRegex.test(str) && + // Reset last + ((allowedRegex.lastIndex = 0) || true) + ); + let blacklistWords; + let blacklistRegex; + // Nothing to blacklist + if (lines.length === 0) { + return; + } + let regexExpressions = []; + // Organize each blacklist entry into regex expressions and words + for (let line of lines) { + // Regex expression syntax is /[exp]/ + if (line.charAt(0) === '/' && line.charAt(line.length - 1) === '/') { + const expr = line.substring(1, line.length - 1); + // Check if this is more than one character + if (/^(\[.*\]|\\.|.)$/.test(expr)) { + continue; + } + regexExpressions.push(expr); + } else { + // Lazy init + if (!blacklistWords) { + blacklistWords = []; + } + // We're not going to let regex characters fuck up our RegEx operation. + line = line.replace(regexEscapeCharacters, '\\$&'); + + blacklistWords.push(line); + } + } + const regexStr = regexExpressions.join('|'); + const flags = 'g' + (matchCase ? '' : 'i'); + try { + // setting regex overrides blacklistWords + if (regexStr) { + blacklistRegex = new RegExp('(' + regexStr + ')', flags); + } else { + const pattern = `${matchWord ? '\\b' : ''}(${blacklistWords.join('|')})${matchWord ? '\\b' : ''}`; + blacklistRegex = new RegExp(pattern, flags); + } + } catch { + // We just reset it if it's invalid. + blacklistRegex = null; + } + // Lazy init + if (!this.blacklistParsers) { + this.blacklistParsers = []; + } + this.blacklistParsers.push({ + blacklistWords, + blacklistRegex, + censor, + }); + }); + + // Rebuild chat to apply new blacklist settings to existing messages + this.rebuildChat(); + } + scrollToBottom() { // scrollHeight is always bigger than scrollTop and is // automatically clamped to the valid range. @@ -382,18 +467,38 @@ class ChatRenderer { } }); } - // Linkify text - const linkifyNodes = node.querySelectorAll('.linkify'); - for (let i = 0; i < linkifyNodes.length; ++i) { - linkifyNode(linkifyNodes[i]); - } - // Assign an image error handler - if (now < message.createdAt + IMAGE_RETRY_MESSAGE_AGE) { - const imgNodes = node.querySelectorAll('img'); - for (let i = 0; i < imgNodes.length; i++) { - const imgNode = imgNodes[i]; - imgNode.addEventListener('error', handleImageError); + } + + // Process blacklist patterns + let isBlacklisted = false; + // No blacklisting for Admin PMs and Mentor PMs + if ( + !message.avoidBlacklisting && + message.type !== MESSAGE_TYPE_ADMINPM && + message.type !== MESSAGE_TYPE_MENTORPM && + this.blacklistParsers + ) { + this.blacklistParsers.forEach((parser) => { + const foundMatch = processBlacklistNode(node, parser.blacklistRegex, parser.blacklistWords, parser.censor); + + if (!parser.censor && foundMatch) { + // Mark for removal + isBlacklisted = true; } + }); + } + + // Linkify text + const linkifyNodes = node.querySelectorAll('.linkify'); + for (let i = 0; i < linkifyNodes.length; ++i) { + linkifyNode(linkifyNodes[i]); + } + // Assign an image error handler + if (now < message.createdAt + IMAGE_RETRY_MESSAGE_AGE) { + const imgNodes = node.querySelectorAll('img'); + for (let i = 0; i < imgNodes.length; i++) { + const imgNode = imgNodes[i]; + imgNode.addEventListener('error', handleImageError); } } // Store the node in the message @@ -410,7 +515,7 @@ class ChatRenderer { countByType[message.type] += 1; // TODO: Detect duplicates this.messages.push(message); - if (canPageAcceptType(this.page, message.type)) { + if (canPageAcceptType(this.page, message.type) && !isBlacklisted) { fragment.appendChild(node); this.visibleMessages.push(message); } diff --git a/tgui/packages/tgui-panel/chat/replaceInTextNode.js b/tgui/packages/tgui-panel/chat/replaceInTextNode.js index 272d4ca0c5b..3ea05bce52f 100644 --- a/tgui/packages/tgui-panel/chat/replaceInTextNode.js +++ b/tgui/packages/tgui-panel/chat/replaceInTextNode.js @@ -197,3 +197,71 @@ const linkifyTextNode = replaceInTextNode(URL_REGEX, null, (text) => { node.textContent = text; return node; }); + +// Blacklist +// -------------------------------------------------------- + +/** + * Censored text node replaces non-space characters with asterisks + */ +const createCensorNode = (text) => { + const node = document.createTextNode(text.replace(/[^\s]/g, '*')); + return node; +}; + +/** + * Check to see if blacklisted content is contained within a node + */ +const checkBlacklistMatch = (node, regex, words) => { + const childNodes = node.childNodes; + for (let i = 0; i < childNodes.length; i++) { + const childNode = childNodes[i]; + // Is a text node + if (childNode.nodeType === 3) { + const text = childNode.textContent; + // Use regex if available + if (regex && regex.test(text)) { + return true; + } + } else { + // Recursively check child nodes + if (checkBlacklistMatch(childNode, regex, words)) { + return true; + } + } + } + return false; +}; + +/** + * Censor or detect blacklist content in a node + * + * @param {Node} node Node which you want to process + * @param {RegExp} regex Regular expression to blacklist + * @param {string[]} words Array of words to blacklist + * @param {boolean} censor If true, censor the content with asterisks; if false, just detect matches + * @returns {boolean} True if blacklisted content was found + */ +export const processBlacklistNode = (node, regex, words, censor = false) => { + if (!censor) { + return checkBlacklistMatch(node, regex, words); + } + + // Use replacement + let matchCount = 0; + const childNodes = node.childNodes; + for (let i = 0; i < childNodes.length; i++) { + const childNode = childNodes[i]; + // Is a text node + if (childNode.nodeType === 3) { + matchCount += replaceInTextNode(regex, words, createCensorNode)(childNode); + } else { + // Recursively process child nodes + if (processBlacklistNode(childNode, regex, words, censor)) { + matchCount++; + } + } + } + + return matchCount > 0; +}; diff --git a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx index 2d58e4a3f61..4803868b702 100644 --- a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx +++ b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx @@ -27,14 +27,24 @@ import { ChatPageSettings } from '../chat'; import { clearChat, rebuildChat, saveChatToDisk } from '../chat/actions'; import { THEMES } from '../themes'; import { + addBlacklistSetting, addHighlightSetting, changeSettingsTab, + removeBlacklistSetting, removeHighlightSetting, + updateBlacklistSetting, updateHighlightSetting, updateSettings, } from './actions'; import { FONTS, MAX_HIGHLIGHT_SETTINGS, SETTINGS_TABS } from './constants'; -import { selectActiveTab, selectHighlightSettingById, selectHighlightSettings, selectSettings } from './selectors'; +import { + selectActiveTab, + selectBlacklistSettingById, + selectBlacklistSettings, + selectHighlightSettingById, + selectHighlightSettings, + selectSettings, +} from './selectors'; import { SettingsStatPanel } from './SettingsStatPanel'; export const SettingsPanel = (props) => { @@ -67,6 +77,7 @@ export const SettingsPanel = (props) => { {activeTab === 'general' && } {activeTab === 'chatPage' && } {activeTab === 'textHighlight' && } + {activeTab === 'textBlacklist' && } {activeTab === 'statPanel' && } @@ -387,3 +398,133 @@ const TextHighlightSetting = (props) => { ); }; + +const TextBlacklistSettings = (props) => { + const blacklistSettings = useSelector(selectBlacklistSettings) || []; + const dispatch = useDispatch(); + return ( +
+
+ + {blacklistSettings.map((id, i) => ( + + ))} + {blacklistSettings.length < MAX_HIGHLIGHT_SETTINGS && ( + + + + )} + +
+ + + + + Can freeze the chat for a while. + + +
+ ); +}; + +const TextBlacklistSetting = (props) => { + const { id, ...rest } = props; + const blacklistSettingById = useSelector(selectBlacklistSettingById) || {}; + const dispatch = useDispatch(); + const { blacklistText = '', censor = false, matchWord = false, matchCase = false } = blacklistSettingById[id] || {}; + return ( + + + + + + + + dispatch( + updateBlacklistSetting({ + id: id, + censor: !censor, + }) + ) + } + > + Censor + + + + + dispatch( + updateBlacklistSetting({ + id: id, + matchWord: !matchWord, + }) + ) + } + > + Exact + + + + + dispatch( + updateBlacklistSetting({ + id: id, + matchCase: !matchCase, + }) + ) + } + > + Case + + + +