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
This commit is contained in:
Pooble
2025-11-22 21:56:53 -05:00
committed by GitHub
parent 6ea9477f4c
commit c73dc100b0
12 changed files with 442 additions and 21 deletions
+6 -2
View File
@@ -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)
+8 -1
View File
@@ -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') {
+118 -13
View File
@@ -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);
}
@@ -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;
};
@@ -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' && <SettingsGeneral />}
{activeTab === 'chatPage' && <ChatPageSettings />}
{activeTab === 'textHighlight' && <TextHighlightSettings />}
{activeTab === 'textBlacklist' && <TextBlacklistSettings />}
{activeTab === 'statPanel' && <SettingsStatPanel />}
</Stack.Item>
</Stack>
@@ -387,3 +398,133 @@ const TextHighlightSetting = (props) => {
</Stack.Item>
);
};
const TextBlacklistSettings = (props) => {
const blacklistSettings = useSelector(selectBlacklistSettings) || [];
const dispatch = useDispatch();
return (
<Section fill scrollable height="230px">
<Section>
<Stack vertical>
{blacklistSettings.map((id, i) => (
<TextBlacklistSetting key={i} id={id} mb={i + 1 === blacklistSettings.length ? 0 : '10px'} />
))}
{blacklistSettings.length < MAX_HIGHLIGHT_SETTINGS && (
<Stack.Item>
<Button
color="transparent"
icon="plus"
onClick={() => {
dispatch(addBlacklistSetting());
}}
>
Add Blacklist Setting
</Button>
</Stack.Item>
)}
</Stack>
</Section>
<Stack.Divider />
<Box>
<Button icon="check" onClick={() => dispatch(rebuildChat())}>
Apply now
</Button>
<Box inline fontSize="0.9em" ml={1} color="label">
Can freeze the chat for a while.
</Box>
</Box>
</Section>
);
};
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 (
<Stack.Item {...rest}>
<Stack mb={1} color="label" align="baseline">
<Stack.Item grow>
<Button
color="transparent"
icon="times"
onClick={() =>
dispatch(
removeBlacklistSetting({
id: id,
})
)
}
>
Delete
</Button>
</Stack.Item>
<Stack.Item>
<Button.Checkbox
checked={censor}
tooltipPosition="bottom-start"
tooltip="If this option is selected, the phrase will be censored instead of removing the whole message."
onClick={() =>
dispatch(
updateBlacklistSetting({
id: id,
censor: !censor,
})
)
}
>
Censor
</Button.Checkbox>
</Stack.Item>
<Stack.Item>
<Button.Checkbox
checked={matchWord}
tooltipPosition="bottom-start"
tooltip="If this option is selected, only exact matches (no extra letters before or after) will trigger. Not compatible with punctuation. Overriden if regex is used."
onClick={() =>
dispatch(
updateBlacklistSetting({
id: id,
matchWord: !matchWord,
})
)
}
>
Exact
</Button.Checkbox>
</Stack.Item>
<Stack.Item>
<Button.Checkbox
tooltip="If this option is selected, the blacklist will be case-sensitive."
checked={matchCase}
onClick={() =>
dispatch(
updateBlacklistSetting({
id: id,
matchCase: !matchCase,
})
)
}
>
Case
</Button.Checkbox>
</Stack.Item>
</Stack>
<TextArea
fluid
height="3em"
value={blacklistText}
placeholder="Put terms to blacklist here. Separate terms with commas or vertical bars, i.e. (term1 | term2) or (term1, term2). Regex syntax is /[regex]/"
onChange={(value) =>
dispatch(
updateBlacklistSetting({
id: id,
blacklistText: value,
})
)
}
/>
</Stack.Item>
);
};
+6 -1
View File
@@ -6,7 +6,7 @@
import { createAction } from 'common/redux';
import { createHighlightSetting } from './model';
import { createBlacklistSetting, createHighlightSetting } from './model';
export const updateSettings = createAction('settings/update');
export const loadSettings = createAction('settings/load');
@@ -18,3 +18,8 @@ export const addHighlightSetting = createAction('settings/addHighlightSetting',
}));
export const removeHighlightSetting = createAction('settings/removeHighlightSetting');
export const updateHighlightSetting = createAction('settings/updateHighlightSetting');
export const addBlacklistSetting = createAction('settings/addBlacklistSetting', () => ({
payload: createBlacklistSetting(),
}));
export const removeBlacklistSetting = createAction('settings/removeBlacklistSetting');
export const updateBlacklistSetting = createAction('settings/updateBlacklistSetting');
@@ -13,6 +13,10 @@ export const SETTINGS_TABS = [
id: 'textHighlight',
name: 'Text Highlights',
},
{
id: 'textBlacklist',
name: 'Text Blacklist',
},
{
id: 'chatPage',
name: 'Chat Tabs',
@@ -8,9 +8,12 @@ import { storage } from 'common/storage';
import { setClientTheme } from '../themes';
import {
addBlacklistSetting,
addHighlightSetting,
loadSettings,
removeBlacklistSetting,
removeHighlightSetting,
updateBlacklistSetting,
updateHighlightSetting,
updateSettings,
} from './actions';
@@ -77,7 +80,10 @@ export const settingsMiddleware = (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
) {
// Set client theme
const theme = payload?.theme;
@@ -18,3 +18,18 @@ export const createDefaultHighlightSetting = (obj?: Record<string, any>) =>
id: 'default',
...obj,
});
export const createBlacklistSetting = (obj?: Record<string, any>) => ({
id: createUuid(),
blacklistText: '',
censor: false,
matchWord: false,
matchCase: false,
...obj,
});
export const createDefaultBlacklistSetting = (obj?: Record<string, any>) =>
createBlacklistSetting({
id: 'default',
...obj,
});
+65 -1
View File
@@ -7,19 +7,23 @@
import { storage } from 'common/storage';
import {
addBlacklistSetting,
addHighlightSetting,
changeSettingsTab,
loadSettings,
openChatSettings,
removeBlacklistSetting,
removeHighlightSetting,
toggleSettings,
updateBlacklistSetting,
updateHighlightSetting,
updateSettings,
} from './actions';
import { FONTS, MAX_HIGHLIGHT_SETTINGS, SETTINGS_TABS } from './constants';
import { createDefaultHighlightSetting } from './model';
import { createDefaultBlacklistSetting, createDefaultHighlightSetting } from './model';
const defaultHighlightSetting = createDefaultHighlightSetting();
const defaultBlacklistSetting = createDefaultBlacklistSetting();
const initialState = {
version: 1,
@@ -36,6 +40,10 @@ const initialState = {
highlightSettingById: {
[defaultHighlightSetting.id]: defaultHighlightSetting,
},
blacklistSettings: [defaultBlacklistSetting.id],
blacklistSettingById: {
[defaultBlacklistSetting.id]: defaultBlacklistSetting,
},
view: {
visible: false,
activeTab: SETTINGS_TABS[0].id,
@@ -80,6 +88,16 @@ export const settingsReducer = (state = initialState, action) => {
nextState.highlightSettings = [defaultHighlightSetting.id, ...nextState.highlightSettings];
nextState.highlightSettingById[defaultHighlightSetting.id] = defaultHighlightSetting;
}
// Lazy init blacklist
if (!nextState.blacklistSettings) {
nextState.blacklistSettings = [defaultBlacklistSetting.id];
nextState.blacklistSettingById[defaultBlacklistSetting.id] = defaultBlacklistSetting;
}
// Just doing the same as above
else if (!nextState.blacklistSettingById[defaultBlacklistSetting.id]) {
nextState.blacklistSettings = [defaultBlacklistSetting.id, ...nextState.blacklistSettings];
nextState.blacklistSettingById[defaultBlacklistSetting.id] = defaultBlacklistSetting;
}
// Update the highlight settings for default highlight
// settings compatibility
const highlightSetting = nextState.highlightSettingById[defaultHighlightSetting.id];
@@ -181,5 +199,51 @@ export const settingsReducer = (state = initialState, action) => {
return nextState;
}
if (type === addBlacklistSetting.type) {
const blacklistSetting = payload;
if (state.blacklistSettings.length >= MAX_HIGHLIGHT_SETTINGS) {
return state;
}
return {
...state,
blacklistSettings: [...state.blacklistSettings, blacklistSetting.id],
blacklistSettingById: {
...state.blacklistSettingById,
[blacklistSetting.id]: blacklistSetting,
},
};
}
if (type === removeBlacklistSetting.type) {
const { id } = payload;
const nextState = {
...state,
blacklistSettings: [...state.blacklistSettings],
blacklistSettingById: {
...state.blacklistSettingById,
},
};
delete nextState.blacklistSettingById[id];
nextState.blacklistSettings = nextState.blacklistSettings.filter((sid) => sid !== id);
return nextState;
}
if (type === updateBlacklistSetting.type) {
const { id, ...settings } = payload;
const nextState = {
...state,
blacklistSettings: [...state.blacklistSettings],
blacklistSettingById: {
...state.blacklistSettingById,
},
};
if (nextState.blacklistSettingById[id]) {
nextState.blacklistSettingById[id] = {
...nextState.blacklistSettingById[id],
...settings,
};
}
return nextState;
}
return state;
};
@@ -8,3 +8,5 @@ export const selectSettings = (state) => state.settings;
export const selectActiveTab = (state) => state.settings.view.activeTab;
export const selectHighlightSettings = (state) => state.settings.highlightSettings;
export const selectHighlightSettingById = (state) => state.settings.highlightSettingById;
export const selectBlacklistSettings = (state) => state.settings.blacklistSettings;
export const selectBlacklistSettingById = (state) => state.settings.blacklistSettingById;
File diff suppressed because one or more lines are too long