Jofthomas's picture
Jofthomas HF staff
Upload 4781 files
5c2ed06 verified
import { FS, Utils } from '../../lib';
import type { FilterWord } from '../chat';
const LEGACY_MONITOR_FILE = 'config/chat-plugins/chat-monitor.tsv';
const MONITOR_FILE = 'config/chat-plugins/chat-filter.json';
const WRITE_THROTTLE_TIME = 5 * 60 * 1000;
// Substitution dictionary adapted from https://github.com/ThreeLetters/NoSwearingPlease/blob/master/index.js
// Licensed under MIT.
const EVASION_DETECTION_SUBSTITUTIONS: { [k: string]: string[] } = {
a: ["a", "4", "@", "รก", "รข", "รฃ", "ร ", "แ—ฉ", "A", "โ“", "โ’ถ", "ฮฑ", "อ", "โ‚ณ", "รค", "ร„", "แ—", "ฮป", "ฮ”", "แธ€", "แŽช", "วŸ", "ฬพ", "๏ฝ", "๏ผก", "แด€", "ษ", "๐Ÿ…", "๐š", "๐€", "๐˜ข", "๐˜ˆ", "๐™–", "๐˜ผ", "๐’ถ", "๐“ช", "๐“", "๐•’", "๐”ธ", "๐”ž", "๐”„", "๐–†", "๐•ฌ", "๐Ÿ„ฐ", "๐Ÿ…ฐ", "๐’œ", "๐šŠ", "๐™ฐ", "๊", "ะฐ", "๐“ช"],
b: ["b", "8", "แ—ท", "B", "โ“‘", "โ’ท", "ะฒ", "เธฟ", "แธ…", "แธ„", "แฐ", "ฯ", "ฦ", "แธƒ", "แธ‚", "ษฎ", "๏ฝ‚", "๏ผข", "ส™", "๐Ÿ…‘", "๐›", "๐", "๐˜ฃ", "๐˜‰", "๐™—", "๐˜ฝ", "๐’ท", "๐“ซ", "๐“‘", "๐•“", "๐”น", "๐”Ÿ", "๐”…", "๐–‡", "๐•ญ", "๐Ÿ„ฑ", "๐Ÿ…ฑ", "๐ต", "แ‚ฆ", "๐š‹", "๐™ฑ", "โ™ญ", "b"],
c: ["c", "รง", "แ‘•", "C", "โ“’", "โ’ธ", "ยข", "อ", "โ‚ต", "ฤ‹", "ฤŠ", "แˆ", "ฯ‚", "แธ‰", "แธˆ", "แŸ", "ฦˆ", "ฬพ", "๏ฝƒ", "๏ผฃ", "แด„", "ษ”", "๐Ÿ…’", "๐œ", "๐‚", "๐˜ค", "๐˜Š", "๐™˜", "๐˜พ", "๐’ธ", "๐“ฌ", "๐“’", "๐•”", "โ„‚", "๐” ", "โ„ญ", "๐–ˆ", "๐•ฎ", "๐Ÿ„ฒ", "๐Ÿ…ฒ", "๐’ž", "๐šŒ", "๐™ฒ", "โ˜พ", "ั"],
d: ["d", "แ—ช", "D", "โ““", "โ’น", "โˆ‚", "ฤ", "ฤ", "ฤŽ", "แŽด", "แธŠ", "แŽ ", "ษ–", "๏ฝ„", "๏ผค", "แด…", "๐Ÿ…“", "๐", "๐ƒ", "๐˜ฅ", "๐˜‹", "๐™™", "๐˜ฟ", "๐’น", "๐“ญ", "๐““", "๐••", "โ€‹", "๐”ก", "๐–‰", "๐•ฏ", "๐Ÿ„ณ", "๐Ÿ…ณ", "๐’Ÿ", "ิƒ", "๐š", "๐™ณ", "โ——", "โ…พ"],
e: ["e", "3", "รฉ", "รช", "E", "โ“”", "โ’บ", "ั”", "อ", "ษ†", "แป‡", "แป†", "แ‹", "ฮต", "ฮฃ", "แธ•", "แธ”", "แŽฌ", "ษ›", "ฬพ", "๏ฝ…", "๏ผฅ", "แด‡", "ว", "๐Ÿ…”", "๐ž", "๐„", "๐˜ฆ", "๐˜Œ", "๐™š", "๐™€", "โ„ฏ", "๐“ฎ", "๐“”", "๐•–", "๐”ป", "๐”ข", "๐”‡", "๐–Š", "๐•ฐ", "๐Ÿ„ด", "๐Ÿ…ด", "๐‘’", "๐ธ", "าฝ", "๐šŽ", "๐™ด", "โ‚ฌ", "ะต", "ั‘", "๐“ฎ"],
f: ["f", "แ–ด", "F", "โ“•", "โ’ป", "โ‚ฃ", "แธŸ", "แธž", "แŽฆ", "า“", "ส„", "๏ฝ†", "๏ผฆ", "ษŸ", "๐Ÿ…•", "๐Ÿ", "๐…", "๐˜ง", "๐˜", "๐™›", "๐™", "๐’ป", "๐“ฏ", "๐“•", "๐•—", "๐”ผ", "๐”ฃ", "๐”ˆ", "๐–‹", "๐•ฑ", "๐Ÿ„ต", "๐Ÿ…ต", "๐น", "ฯ", "๐š", "๐™ต", "ฯœ", "f", "ฦ‘"],
g: ["g", "q", "6", "9", "G", "โ“–", "โ’ผ", "อ", "โ‚ฒ", "ฤก", "ฤ ", "แŽถ", "ฯ‘", "แธ ", "ษข", "ฬพ", "๏ฝ‡", "๏ผง", "ฦƒ", "๐Ÿ…–", "๐ ", "๐†", "๐˜จ", "๐˜Ž", "๐™œ", "๐™‚", "โ„Š", "๐“ฐ", "๐“–", "๐•˜", "๐”ฝ", "๐”ค", "๐”‰", "๐–Œ", "๐•ฒ", "๐Ÿ„ถ", "๐Ÿ…ถ", "๐‘”", "๐’ข", "ษ ", "๐š", "๐™ถ", "โก", "ึ", "๐™ถ", "๐“ฐ", "ิŒ"],
h: [
"h", "แ•ผ", "H", "โ“—", "โ’ฝ", "ะฝ", "โฑง", "แธง", "แธฆ", "แ‚", "ษฆ", "๏ฝˆ", "๏ผจ", "สœ", "ษฅ", "๐Ÿ…—", "๐ก", "๐‡", "๐˜ฉ", "๐˜", "๐™", "๐™ƒ", "๐’ฝ", "๐“ฑ", "๐“—", "๐•™", "๐”พ", "๐”ฅ", "๐”Š", "๐–", "๐•ณ", "๐Ÿ„ท", "๐Ÿ…ท", "๐ป", "ิ‹", "๐š‘", "๐™ท", "โ™„", "h",
],
i: ["i", "!", "l", "1", "รญ", "I", "โ“˜", "โ’พ", "ฮน", "อ", "ล‚", "รฏ", "ร", "แŽฅ", "แธญ", "แธฌ", "ษจ", "ฬพ", "๏ฝ‰", "๏ผฉ", "ษช", "ฤฑ", "๐Ÿ…˜", "๐ข", "๐ˆ", "๐˜ช", "๐˜", "๐™ž", "๐™„", "๐’พ", "๐“ฒ", "๐“˜", "๐•š", "โ„", "๐”ฆ", "โ„Œ", "๐–Ž", "๐•ด", "๐Ÿ„ธ", "๐Ÿ…ธ", "๐ผ", "๐š’", "๐™ธ", "โ™—", "ั–", "ยก", "|", "๐“ฒ"],
j: ["j", "แ’", "J", "โ“™", "โ’ฟ", "ื ", "แ ", "ฯณ", "ส", "๏ฝŠ", "๏ผช", "แดŠ", "ษพ", "๐Ÿ…™", "๐ฃ", "๐‰", "๐˜ซ", "๐˜‘", "๐™Ÿ", "๐™…", "๐’ฟ", "๐“ณ", "๐“™", "๐•›", "โ€‹", "๐”ง", "๐–", "๐•ต", "๐Ÿ„น", "๐Ÿ…น", "๐’ฅ", "๐š“", "๐™น", "โ™ช", "ั˜"],
k: ["k", "K", "โ“š", "โ“€", "ะบ", "อ", "โ‚ญ", "แธณ", "แธฒ", "แฆ", "ฮบ", "ฦ˜", "ำ„", "ฬพ", "๏ฝ‹", "๏ผซ", "แด‹", "สž", "๐Ÿ…š", "๐ค", "๐Š", "๐˜ฌ", "๐˜’", "๐™ ", "๐™†", "๐“€", "๐“ด", "๐“š", "๐•œ", "๐•€", "๐”จ", "โ„‘", "๐–", "๐•ถ", "๐Ÿ„บ", "๐Ÿ…บ", "๐’ฆ", "ฦ™", "๐š”", "๐™บ", "ฯฐ", "k", "๐“ด"],
l: ["l", "i", "1", "/", "|", "แ’ช", "L", "โ“›", "โ“", "โ„“", "โฑ ", "ล€", "ฤฟ", "แ", "แธถ", "แž", "สŸ", "๏ฝŒ", "๏ผฌ", "๐Ÿ…›", "๐ฅ", "๐‹", "๐˜ญ", "๐˜“", "๐™ก", "๐™‡", "๐“", "๐“ต", "๐“›", "๐•", "๐•", "๐”ฉ", "โ€‹", "๐–‘", "๐•ท", "๐Ÿ„ป", "๐Ÿ…ป", "๐ฟ", "ส…", "๐š•", "๐™ป", "โ†ณ", "โ…ผ"],
m: [
"m", "แ—ฐ", "M", "โ“œ", "โ“‚", "ะผ", "อ", "โ‚ฅ", "แนƒ", "แน‚", "แŽท", "ฯป", "ฮœ", "แน", "แน€", "ส", "ฬพ", "๏ฝ", "๏ผญ", "แด", "ษฏ", "๐Ÿ…œ", "๐ฆ", "๐Œ", "๐˜ฎ", "๐˜”", "๐™ข", "๐™ˆ", "๐“‚", "๐“ถ", "๐“œ", "๐•ž", "๐•‚", "๐”ช", "๐”", "๐–’", "๐•ธ", "๐Ÿ„ผ", "๐Ÿ…ผ", "๐‘€", "ษฑ", "๐š–", "๐™ผ", "โ™”", "โ…ฟ",
],
n: ["n", "รฑ", "แ‘Ž", "N", "โ“", "โ“ƒ", "ะธ", "โ‚ฆ", "ล„", "ลƒ", "แ", "ฯ€", "โˆ", "แน†", "ีผ", "๏ฝŽ", "๏ผฎ", "ษด", "๐Ÿ…", "๐ง", "๐", "๐˜ฏ", "๐˜•", "๐™ฃ", "๐™‰", "๐“ƒ", "๐“ท", "๐“", "๐•Ÿ", "๐•ƒ", "๐”ซ", "๐”Ž", "๐–“", "๐•น", "๐Ÿ„ฝ", "๐Ÿ…ฝ", "๐’ฉ", "ษณ", "๐š—", "๐™ฝ", "โ™ซ", "ีธ", "ฮท", "๐™ฝ", "ฦž", "๐“ท", "ฮ"],
o: ["o", "0", "รณ", "รด", "รต", "รบ", "O", "โ“ž", "โ“„", "ฯƒ", "อ", "ร˜", "รถ", "ร–", "แŽง", "ฮ˜", "แน", "แนŽ", "แŽพ", "ึ…", "ฬพ", "๏ฝ", "๏ผฏ", "แด", "๐Ÿ…ž", "๐จ", "๐Ž", "๐˜ฐ", "๐˜–", "๐™ค", "๐™Š", "โ„ด", "๐“ธ", "๐“ž", "๐• ", "๐•„", "๐”ฌ", "๐”", "๐–”", "๐•บ", "๐Ÿ„พ", "๐Ÿ…พ", "๐‘œ", "๐’ช", "๐š˜", "๐™พ", "โŠ™", "ฮฟ"],
p: ["p", "แ‘ญ", "P", "โ“Ÿ", "โ“…", "ฯ", "โ‚ฑ", "แน—", "แน–", "แŽฎ", "ฦค", "แข", "ึ„", "๏ฝ", "๏ผฐ", "แด˜", "๐Ÿ…Ÿ", "๐ฉ", "๐", "๐˜ฑ", "๐˜—", "๐™ฅ", "๐™‹", "๐“…", "๐“น", "๐“Ÿ", "๐•ก", "โ„•", "๐”ญ", "๐”", "๐–•", "๐•ป", "๐Ÿ„ฟ", "๐Ÿ…ฟ", "๐’ซ", "๐š™", "๐™ฟ", "ั€"],
q: [
"q", "แ‘ซ", "Q", "โ“ ", "โ“†", "อ", "แŽค", "ฯ†", "แ‚ณ", "ีฆ", "ฬพ", "๏ฝ‘", "๏ผฑ", "ฯ™", "วซ", "๐Ÿ… ", "๐ช", "๐", "๐˜ฒ", "๐˜˜", "๐™ฆ", "๐™Œ", "๐“†", "๐“บ", "๐“ ", "๐•ข", "โ€‹", "๐”ฎ", "๐”‘", "๐––", "๐•ผ", "๐Ÿ…€", "๐Ÿ†€", "๐’ฌ", "๐šš", "๐š€", "โ˜ญ", "ิ›",
],
r: ["r", "แ–‡", "R", "โ“ก", "โ“‡", "ั", "โฑค", "ล•", "ล”", "แ’", "ะณ", "ฮ“", "แน™", "แน˜", "ส€", "๏ฝ’", "๏ผฒ", "ษน", "๐Ÿ…ก", "๐ซ", "๐‘", "๐˜ณ", "๐˜™", "๐™ง", "๐™", "๐“‡", "๐“ป", "๐“ก", "๐•ฃ", "๐•†", "๐”ฏ", "๐”’", "๐–—", "๐•ฝ", "๐Ÿ…", "๐Ÿ†", "๐‘…", "ษพ", "๐š›", "๐š", "โ˜ˆ", "r", "๐š", "๐“ป"],
s: ["s", "5", "แ”•", "S", "โ“ข", "โ“ˆ", "ั•", "อ", "โ‚ด", "แนฉ", "แนจ", "แ•", "ะ…", "แน ", "ึ†", "ฬพ", "๏ฝ“", "๏ผณ", "๊œฑ", "๐Ÿ…ข", "๐ฌ", "๐’", "๐˜ด", "๐˜š", "๐™จ", "๐™Ž", "๐“ˆ", "๐“ผ", "๐“ข", "๐•ค", "โ„™", "๐”ฐ", "๐”“", "๐–˜", "๐•พ", "๐Ÿ…‚", "๐Ÿ†‚", "๐’ฎ", "ส‚", "๐šœ", "๐š‚", "ั•", "๐“ผ"],
t: ["t", "+", "T", "โ“ฃ", "โ“‰", "ั‚", "โ‚ฎ", "แบ—", "แนฎ", "แ–", "ฯ„", "ฦฌ", "แ†", "ศถ", "๏ฝ”", "๏ผด", "แด›", "ส‡", "๐Ÿ…ฃ", "๐ญ", "๐“", "๐˜ต", "๐˜›", "๐™ฉ", "๐™", "๐“‰", "๐“ฝ", "๐“ฃ", "๐•ฅ", "โ€‹", "๐”ฑ", "๐””", "๐–™", "๐•ฟ", "๐Ÿ…ƒ", "๐Ÿ†ƒ", "๐’ฏ", "ฦš", "๐š", "๐šƒ", "โ˜‚", "t", "๐“ฝ"],
u: ["u", "รบ", "รผ", "แ‘Œ", "U", "โ“ค", "โ“Š", "ฯ…", "อ", "ษ„", "รœ", "แฌ", "ฦฑ", "แนณ", "แนฒ", "สŠ", "ฬพ", "๏ฝ•", "๏ผต", "แดœ", "๐Ÿ…ค", "๐ฎ", "๐”", "๐˜ถ", "๐˜œ", "๐™ช", "๐™", "๐“Š", "๐“พ", "๐“ค", "๐•ฆ", "โ„š", "๐”ฒ", "โ„œ", "๐–š", "๐–€", "๐Ÿ…„", "๐Ÿ†„", "๐’ฐ", "๐šž", "๐š„", "โ˜‹", "ีฝ"],
v: ["v", "แฏ", "V", "โ“ฅ", "โ“‹", "ฮฝ", "แนฟ", "แนพ", "แ‰", "ฦฒ", "แนผ", "ส‹", "๏ฝ–", "๏ผถ", "แด ", "สŒ", "๐Ÿ…ฅ", "๐ฏ", "๐•", "๐˜ท", "๐˜", "๐™ซ", "๐™‘", "๐“‹", "๐“ฟ", "๐“ฅ", "๐•ง", "โ€‹", "๐”ณ", "๐–›", "๐–", "๐Ÿ……", "๐Ÿ†…", "๐’ฑ", "๐šŸ", "๐š…", "โœ“", "โ…ด"],
w: ["w", "แ—ฏ", "W", "โ“ฆ", "โ“Œ", "ฯ‰", "อ", "โ‚ฉ", "แบ…", "แบ„", "แ‡", "ัˆ", "ะจ", "แบ‡", "แบ†", "แŽณ", "ีก", "ฬพ", "๏ฝ—", "๏ผท", "แดก", "ส", "๐Ÿ…ฆ", "๐ฐ", "๐–", "๐˜ธ", "๐˜ž", "๐™ฌ", "๐™’", "๐“Œ", "๐”€", "๐“ฆ", "๐•จ", "โ„", "๐”ด", "๐”–", "๐–œ", "๐–‚", "๐Ÿ…†", "๐Ÿ††", "๐’ฒ", "ษฏ", "๐š ", "๐š†", "ิ"],
x: ["x", "แ™ญ", "X", "โ“ง", "โ“", "ฯ‡", "ำพ", "แบ", "แบŒ", "แŒ€", "ฯฐ", "ะ–", "ั…", "ำผ", "๏ฝ˜", "๏ผธ", "๐Ÿ…ง", "๐ฑ", "๐—", "๐˜น", "๐˜Ÿ", "๐™ญ", "๐™“", "๐“", "๐”", "๐“ง", "๐•ฉ", "โ€‹", "๐”ต", "๐”—", "๐–", "๐–ƒ", "๐Ÿ…‡", "๐Ÿ†‡", "๐’ณ", "๐šก", "๐š‡", "โŒ˜", "ั…"],
y: [
"y", "Y", "โ“จ", "โ“Ž", "ัƒ", "อ", "ษŽ", "รฟ", "ลธ", "แŽฉ", "ฯˆ", "ฮจ", "แบ", "แบŽ", "แŽฝ", "ั‡", "ส", "ฬพ", "๏ฝ™", "๏ผน", "สŽ", "๐Ÿ…จ", "๐ฒ", "๐˜", "๐˜บ", "๐˜ ", "๐™ฎ", "๐™”", "๐“Ž", "๐”‚", "๐“จ", "๐•ช", "๐•Š", "๐”ถ", "๐”˜", "๐–ž", "๐–„", "๐Ÿ…ˆ", "๐Ÿ†ˆ", "๐’ด", "แƒง", "๐šข", "๐šˆ", "โ˜ฟ", "ัƒ",
],
z: ["z", "แ˜”", "Z", "โ“ฉ", "โ“", "โฑซ", "แบ“", "แบ’", "แš", "แƒ", "ส", "๏ฝš", "๏ผบ", "แดข", "๐Ÿ…ฉ", "๐ณ", "๐™", "๐˜ป", "๐˜ก", "๐™ฏ", "๐™•", "๐“", "๐”ƒ", "๐“ฉ", "๐•ซ", "๐•‹", "๐”ท", "๐”™", "๐–Ÿ", "๐–…", "๐Ÿ…‰", "๐Ÿ†‰", "๐’ต", "ศฅ", "๐šฃ", "๐š‰", "โ˜ก", "z", "๐”ƒ"],
};
const filterWords: { [k: string]: Chat.FilterWord[] } = Chat.filterWords;
export const Filters = new class {
readonly EVASION_DETECTION_SUBSTITUTIONS = EVASION_DETECTION_SUBSTITUTIONS;
readonly EVASION_DETECTION_SUB_STRINGS: { [k: string]: string } = {};
constructor() {
for (const letter in EVASION_DETECTION_SUBSTITUTIONS) {
this.EVASION_DETECTION_SUB_STRINGS[letter] = `[${EVASION_DETECTION_SUBSTITUTIONS[letter].join('')}]`;
}
this.load();
}
constructEvasionRegex(str: string) {
const buf = "\\b" +
[...str].map(letter => (this.EVASION_DETECTION_SUB_STRINGS[letter] || letter) + '+').join('\\.?') +
"\\b";
return new RegExp(buf, 'iu');
}
generateRegex(word: string, isEvasion = false, isShortener = false, isReplacement = false) {
try {
if (isEvasion) {
return this.constructEvasionRegex(word);
} else {
return new RegExp((isShortener ? `\\b${word}` : word), (isReplacement ? 'igu' : 'iu'));
}
} catch (e: any) {
throw new Chat.ErrorMessage(
e.message.startsWith('Invalid regular expression: ') ? e.message : `Invalid regular expression: /${word}/: ${e.message}`
);
}
}
stripWordBoundaries(regex: RegExp) {
return new RegExp(regex.toString().replace('/\\b', '').replace('\\b/iu', ''), 'iu');
}
save(force = false) {
FS(MONITOR_FILE).writeUpdate(() => {
const buf: { [k: string]: FilterWord[] } = {};
for (const key in Chat.monitors) {
buf[key] = [];
for (const filterWord of filterWords[key]) {
const word = { ...filterWord };
delete (word as any).regex; // no reason to save this. does not stringify.
buf[key].push(word);
}
}
return JSON.stringify(buf);
}, { throttle: force ? 0 : WRITE_THROTTLE_TIME });
}
add(filterWord: Partial<Chat.FilterWord> & { list: string, word: string }) {
if (!filterWord.hits) filterWord.hits = 0;
const punishment = Chat.monitors[filterWord.list].punishment;
if (!filterWord.regex) {
filterWord.regex = this.generateRegex(
filterWord.word,
punishment === 'EVASION',
punishment === 'SHORTENER',
!!filterWord.replacement,
);
}
if (filterWords[filterWord.list].some(val => String(val.regex) === String(filterWord.regex))) {
throw new Chat.ErrorMessage(`${filterWord.word} is already added to the ${filterWord.list} list.`);
}
filterWords[filterWord.list].push(filterWord as Chat.FilterWord);
this.save(true);
}
load() {
const legacy = FS(LEGACY_MONITOR_FILE);
if (legacy.existsSync()) {
return process.nextTick(() => {
this.loadLegacy();
legacy.renameSync(LEGACY_MONITOR_FILE + '.backup');
Monitor.notice(`Legacy chatfilter data loaded and renamed to a .backup file.`);
});
}
const data = JSON.parse(FS(MONITOR_FILE).readIfExistsSync() || "{}");
for (const k in data) {
filterWords[k] = [];
// previously, this checked to be sure the monitor existed in Chat.monitors and that there was
// a proper `[LOCATION, PUNISHMENT]` pair. Now, we do not do that, as a frequent issue with the TSV was that
// plugins with monitors would not be loaded into Chat before the filter words started loading.
// as such, they would crash, and usually it would lead to the words being overwritten and lost altogether
// Therefore, instead of throwing if it isn't found, we just add it to the list anyway.
// either a) the monitor will be loaded later, and all will be well
// or b) the monitor doesn't exist anymore,
// in which case it can either be deleted manually or the data will be fine if the monitor is re-added later
for (const entry of data[k]) {
if (k === 'evasion') {
entry.regex = this.constructEvasionRegex(entry.word);
} else {
entry.regex = new RegExp(
k === 'shorteners' ? `\\b${entry.word}` : entry.word,
entry.replacement ? 'igu' : 'iu'
);
}
filterWords[k].push(entry);
}
}
}
loadLegacy() {
let data;
try {
data = FS(LEGACY_MONITOR_FILE).readSync();
} catch (e: any) {
if (e.code !== 'ENOENT') throw e;
}
if (!data) return;
const lines = data.split('\n');
loop: for (const line of lines) {
if (!line || line === '\r') continue;
const [location, word, punishment, reason, times, ...rest] = line.split('\t').map(param => param.trim());
if (location === 'Location') continue;
if (!(location && word && punishment)) continue;
for (const key in Chat.monitors) {
if (Chat.monitors[key].location === location && Chat.monitors[key].punishment === punishment) {
const replacement = rest[0];
const publicReason = rest[1];
let regex: RegExp;
if (punishment === 'EVASION') {
regex = Filters.constructEvasionRegex(word);
} else {
regex = new RegExp(punishment === 'SHORTENER' ? `\\b${word}` : word, replacement ? 'igu' : 'iu');
}
const filterWord: FilterWord = { regex, word, hits: parseInt(times) || 0 };
// "undefined" is the result of an issue with filter storage.
// As far as I'm aware, nothing is actually filtered with "undefined" as the reason.
if (reason && reason !== "undefined") filterWord.reason = reason;
if (publicReason) filterWord.publicReason = publicReason;
if (replacement) filterWord.replacement = replacement;
filterWords[key].push(filterWord);
continue loop;
}
}
// this is not thrown because we DO NOT WANT SECRET FILTERS TO BE LEAKED, but we want this to be known
// (this sends the filter line info only in the email, but still reports the crash to Dev)
Monitor.crashlog(new Error("Couldn't find [location, punishment] pair for a filter word"), "The main process", {
location, word, punishment, reason, times, rest,
});
}
}
};
// Register the chat monitors used
Chat.registerMonitor('autolock', {
location: 'EVERYWHERE',
punishment: 'AUTOLOCK',
label: 'Autolock',
monitor(line, room, user, message, lcMessage, isStaff) {
const { regex, word, reason, publicReason } = line;
const match = regex.exec(lcMessage);
if (match) {
if (isStaff) return `${message} __[would be locked: ${word}${reason ? ` (${reason})` : ''}]__`;
message = message.replace(/(https?):\/\//g, '$1__:__//');
message = message.replace(/\./g, '__.__');
if (room) {
void Punishments.autolock(
user, room, 'ChatMonitor', `Filtered phrase: ${word}`,
`<<${room.roomid}>> ${user.name}: ||\`\`${message}\`\`${reason ? ` __(${reason})__` : ''}||`, true
);
} else {
this.errorReply(`Please do not say '${match[0]}'${publicReason ? ` ${publicReason}` : ``}.`);
}
return false;
}
},
});
Chat.registerMonitor('publicwarn', {
location: 'PUBLIC',
punishment: 'WARN',
label: 'Filtered in public',
monitor(line, room, user, message, lcMessage, isStaff) {
const { regex, word, reason, publicReason } = line;
const match = regex.exec(lcMessage);
if (match) {
if (isStaff) return `${message} __[would be filtered in public: ${word}${reason ? ` (${reason})` : ''}]__`;
this.errorReply(`Please do not say '${match[0]}'${publicReason ? ` ${publicReason}` : ``}.`);
return false;
}
},
});
Chat.registerMonitor('warn', {
location: 'EVERYWHERE',
punishment: 'WARN',
label: 'Filtered',
monitor(line, room, user, message, lcMessage, isStaff) {
const { regex, word, reason, publicReason } = line;
const match = regex.exec(lcMessage);
if (match) {
if (isStaff) return `${message} __[would be filtered: ${word}${reason ? ` (${reason})` : ''}]__`;
this.errorReply(`Please do not say '${match[0]}'${publicReason ? ` ${publicReason}` : ``}.`);
return false;
}
},
});
Chat.registerMonitor('evasion', {
location: 'EVERYWHERE',
punishment: 'EVASION',
label: 'Filter Evasion Detection',
monitor(line, room, user, message, lcMessage, isStaff) {
const { regex, word, reason, publicReason } = line;
// Many codepoints used in filter evasion detection can be decomposed
// into multiple codepoints that are canonically equivalent to the
// original. Perform a canonical composition on the message to detect
// when people attempt to evade by abusing this behaviour of Unicode.
let normalizedMessage = lcMessage.normalize('NFKC');
// Normalize spaces and other common evasion characters to a period
normalizedMessage = normalizedMessage.replace(/[\s-_,.]+/g, '.');
const match = regex.exec(normalizedMessage);
if (match) {
// Don't lock someone iff the word itself is used, and whitespace wasn't used to evade the filter,
// in which case message (which doesn't have whitespace stripped) should also match the regex.
if (match[0] === word && regex.test(message)) {
if (isStaff) return `${message} __[would be filtered: ${word}${reason ? ` (${reason})` : ''}]__`;
this.errorReply(`Do not say '${word}'.`);
return false;
}
if (isStaff) return `${message} __[would be locked for filter evading: ${match[0]} (${word})]__`;
message = message.replace(/(https?):\/\//g, '$1__:__//');
if (room) {
void Punishments.autolock(
user, room, 'FilterEvasionMonitor', `Evading filter: ${message} (${match[0]} => ${word})`,
`<<${room.roomid}>> ${user.name}: ||\`\`${message}\`\` __(${match[0]} => ${word})__||`, true
);
} else {
this.errorReply(`Please do not say '${word}'${publicReason ? ` ${publicReason}` : ``}.`);
}
return false;
}
},
});
Chat.registerMonitor('wordfilter', {
location: 'EVERYWHERE',
punishment: 'FILTERTO',
label: 'Filtered to a different phrase',
condition: 'notStaff',
monitor(line, room, user, message, lcMessage, isStaff) {
const { regex, replacement } = line;
let match = regex.exec(message);
while (match) {
let filtered = replacement || '';
if (match[0] === match[0].toUpperCase()) filtered = filtered.toUpperCase();
if (match[0].startsWith(match[0].charAt(0).toUpperCase())) {
filtered = `${filtered ? filtered.charAt(0).toUpperCase() : ''}${filtered.slice(1)}`;
}
message = message.replace(match[0], filtered);
match = regex.exec(message);
}
return message;
},
});
Chat.registerMonitor('namefilter', {
location: 'NAMES',
punishment: 'WARN',
label: 'Filtered in names',
});
Chat.registerMonitor('battlefilter', {
location: 'BATTLES',
punishment: 'MUTE',
label: 'Filtered in battles',
monitor(line, room, user, message, lcMessage, isStaff) {
const { regex, word, reason, publicReason } = line;
const match = regex.exec(lcMessage);
if (match) {
if (isStaff) return `${message} __[would be filtered: ${word}${reason ? ` (${reason})` : ''}]__`;
message = message.replace(/(https?):\/\//g, '$1__:__//');
message = message.replace(/\./g, '__.__');
if (room) {
room.mute(user);
this.errorReply(
`You have been muted for using a banned phrase. Please do not say '${match[0]}'${publicReason ? ` ${publicReason}` : ``}.`
);
const text = `[BattleMonitor] <<${room.roomid}>> MUTED: ${user.name}: ${message}${reason ? ` __(${reason})__` : ''}`;
const adminlog = Rooms.get('adminlog');
if (adminlog) {
adminlog.add(`|c|~|${text}`).update();
} else {
Monitor.log(text);
}
void (room as GameRoom).uploadReplay(user, this.connection, 'forpunishment');
}
return false;
}
},
});
Chat.registerMonitor('shorteners', {
location: 'EVERYWHERE',
punishment: 'SHORTENER',
label: 'URL Shorteners',
condition: 'notTrusted',
monitor(line, room, user, message, lcMessage, isStaff) {
const { regex, word, publicReason } = line;
if (regex.test(lcMessage)) {
if (isStaff) return `${message} __[shortener: ${word}]__`;
this.errorReply(`Please do not use URL shorteners such as '${word}'${publicReason ? ` ${publicReason}` : ``}.`);
return false;
}
},
});
/*
* Columns Location and Punishment use keywords. Possible values:
*
* Location: EVERYWHERE, PUBLIC, NAMES, BATTLES
* Punishment: AUTOLOCK, WARN, FILTERTO, SHORTENER, MUTE, EVASION
*/
/* The sucrase transformation of optional chaining is too expensive to be used in a hot function like this. */
/* eslint-disable @typescript-eslint/prefer-optional-chain */
export const chatfilter: Chat.ChatFilter = function (message, user, room) {
let lcMessage = message
.replace(/\u039d/g, 'N').toLowerCase()
// eslint-disable-next-line no-misleading-character-class
.replace(/[\u200b\u007F\u00AD\uDB40\uDC00\uDC21]/g, '')
.replace(/\u03bf/g, 'o')
.replace(/\u043e/g, 'o')
.replace(/\u0430/g, 'a')
.replace(/\u0435/g, 'e')
.replace(/\u039d/g, 'e');
lcMessage = lcMessage.replace(/__|\*\*|``|\[\[|\]\]/g, '');
const isStaffRoom = room && (
(room.persist && room.roomid.endsWith('staff')
) || room.roomid.startsWith('help-'));
const isStaff = isStaffRoom || user.isStaff || !!(this.pmTarget && this.pmTarget.isStaff);
for (const list in Chat.monitors) {
const { location, condition, monitor } = Chat.monitors[list];
if (!monitor) continue;
// Ignore challenge games, which are unrated and not part of roomtours.
if (location === 'BATTLES' && !(room && room.battle && room.battle.challengeType !== 'challenge')) continue;
if (location === 'PUBLIC' && room && room.settings.isPrivate === true) continue;
switch (condition) {
case 'notTrusted':
if (user.trusted && !isStaffRoom) continue;
break;
case 'notStaff':
if (isStaffRoom) continue;
break;
}
for (const line of Chat.filterWords[list]) {
const ret = monitor.call(this, line, room, user, message, lcMessage, isStaff);
if (ret !== undefined && ret !== message) {
line.hits++;
Filters.save();
}
if (typeof ret === 'string') {
message = ret;
} else if (ret === false) {
return false;
}
}
}
return message;
};
/* eslint-enable @typescript-eslint/prefer-optional-chain */
export const namefilter: Chat.NameFilter = (name, user) => {
const id = toID(name);
if (Punishments.namefilterwhitelist.has(id)) return name;
if (Monitor.forceRenames.has(id)) {
if (typeof Monitor.forceRenames.get(id) === 'number') {
// we check this for hotpatching reasons, since on the initial chat patch this will still be a Utils.Multiset
// we're gonna assume no one has seen it since that covers people who _haven't_ actually, and those who have
// likely will not be attempting to log into it
Monitor.forceRenames.set(id, false);
}
// false means the user has not seen it yet
if (!Monitor.forceRenames.get(id)) {
user.trackRename = id;
Monitor.forceRenames.set(id, true);
}
// Don't allow reuse of forcerenamed names
return '';
}
if (id === toID(user.trackRename)) return '';
let lcName = name
.replace(/\u039d/g, 'N').toLowerCase()
.replace(/[\u200b\u007F\u00AD]/g, '')
.replace(/\u03bf/g, 'o')
.replace(/\u043e/g, 'o')
.replace(/\u0430/g, 'a')
.replace(/\u0435/g, 'e')
.replace(/\u039d/g, 'e');
// Remove false positives.
lcName = lcName.replace('herapist', '').replace('grape', '').replace('scrape', '');
for (const list in filterWords) {
if (!Chat.monitors[list] || Chat.monitors[list].location === 'BATTLES') continue;
const punishment = Chat.monitors[list].punishment;
for (const line of filterWords[list]) {
const regex = (punishment === 'EVASION' ? Filters.stripWordBoundaries(line.regex) : line.regex);
if (regex.test(lcName)) {
if (Chat.monitors[list].punishment === 'AUTOLOCK') {
void Punishments.autolock(
user, 'staff', `NameMonitor`, `inappropriate name: ${name}`,
`using an inappropriate name: ||${name} (from ${user.name})||`, false, name
);
}
line.hits++;
Filters.save();
return '';
}
}
}
return name;
};
export const loginfilter: Chat.LoginFilter = user => {
if (user.namelocked) return;
if (user.trackRename) {
const manualForceRename = Monitor.forceRenames.has(toID(user.trackRename));
Rooms.global.notifyRooms(
['staff'],
Utils.html`|html|[NameMonitor] Username used: <span class="username">${user.name}</span> ${user.getAccountStatusString()} (${!manualForceRename ? 'automatically ' : ''}forcerenamed from <span class="username">${user.trackRename}</span>)`
);
user.trackRename = '';
}
const offlineWarn = Punishments.offlineWarns.get(user.id);
if (typeof offlineWarn !== 'undefined') {
user.send(`|c|~|/warn You were warned while offline${offlineWarn.length ? `: ${offlineWarn}` : '.'}`);
Punishments.offlineWarns.delete(user.id);
}
};
export const nicknamefilter: Chat.NicknameFilter = (name, user) => {
let lcName = name
.replace(/\u039d/g, 'N').toLowerCase()
.replace(/[\u200b\u007F\u00AD]/g, '')
.replace(/\u03bf/g, 'o')
.replace(/\u043e/g, 'o')
.replace(/\u0430/g, 'a')
.replace(/\u0435/g, 'e')
.replace(/\u039d/g, 'e');
// Remove false positives.
lcName = lcName.replace('herapist', '').replace('grape', '').replace('scrape', '');
for (const list in filterWords) {
if (!Chat.monitors[list]) continue;
if (Chat.monitors[list].location === 'BATTLES') continue;
for (const line of filterWords[list]) {
let { regex, word } = line;
if (Chat.monitors[list].punishment === 'EVASION') {
// Evasion banwords by default require whitespace on either side.
// If we didn't remove it here, it would be quite easy to evade the filter
// and use slurs in Pokรฉmon nicknames.
regex = Filters.stripWordBoundaries(regex);
}
const match = regex.exec(lcName);
if (match) {
if (Chat.monitors[list].punishment === 'AUTOLOCK') {
void Punishments.autolock(
user, 'staff', `NameMonitor`, `inappropriate Pokรฉmon nickname: ${name}`,
`${user.name} - using an inappropriate Pokรฉmon nickname: ||${name}||`, true
);
} else if (Chat.monitors[list].punishment === 'EVASION' && match[0] !== lcName) {
// Don't autolock unless it's an evasion regex and they're evading
void Punishments.autolock(
user, 'staff', 'FilterEvasionMonitor', `Evading filter in Pokรฉmon nickname (${name} => ${word})`,
`${user.name}: Pokรฉmon nicknamed ||\`\`${name} => ${word}\`\`||`, true
);
}
line.hits++;
Filters.save();
return '';
}
}
}
return name;
};
export const statusfilter: Chat.StatusFilter = (status, user) => {
let lcStatus = status
.replace(/\u039d/g, 'N').toLowerCase()
.replace(/[\u200b\u007F\u00AD]/g, '')
.replace(/\u03bf/g, 'o')
.replace(/\u043e/g, 'o')
.replace(/\u0430/g, 'a')
.replace(/\u0435/g, 'e')
.replace(/\u039d/g, 'e');
// Remove false positives.
lcStatus = lcStatus.replace('herapist', '').replace('grape', '').replace('scrape', '');
// Check for blatant staff impersonation attempts. Ideally this could be completely generated from Config.grouplist
// for better support for side servers, but not all ranks are staff ranks or should necessarily be filted.
const impersonationRegex = /\b(?:global|room|upper|senior)?\s*(?:staff|admin|administrator|leader|owner|founder|mod|moderator|driver|voice|operator|sysop|creator)\b/gi;
if (!user.can('lock') && impersonationRegex.test(lcStatus)) return '';
for (const list in filterWords) {
if (!Chat.monitors[list]) continue;
const punishment = Chat.monitors[list].punishment;
for (const line of filterWords[list]) {
const regex = (punishment === 'EVASION' ? Filters.stripWordBoundaries(line.regex) : line.regex);
if (regex.test(lcStatus)) {
if (punishment === 'AUTOLOCK') {
// I'm only locking for true autolock phrases, not evasion of slurs
// because someone might understandably expect a popular slur to be
// already registered and therefore try to make the name different from the original slur.
void Punishments.autolock(
user, 'staff', `NameMonitor`, `inappropriate status message: ${status}`,
`${user.name} - using an inappropriate status: ||${status}||`, true
);
}
line.hits++;
Filters.save();
return '';
}
}
}
return status;
};
export const pages: Chat.PageTable = {
filters(query, user, connection) {
if (!user.named) return Rooms.RETRY_AFTER_LOGIN;
this.title = 'Filters';
let buf = `<div class="pad ladder"><h2>Filters</h2>`;
if (!user.can('addhtml')) this.checkCan('lock');
let content = ``;
for (const key in Chat.monitors) {
content += `<tr><th colspan="2"><h3>${Chat.monitors[key].label} <span style="font-size:8pt;">[${key}]</span></h3></tr></th>`;
if (filterWords[key].length) {
content += filterWords[key].map(({ regex, word, reason, publicReason, replacement, hits }) => {
let entry = Utils.html`<abbr title="${reason}"><code>${word}</code></abbr>`;
if (publicReason) entry += Utils.html` <small>(public reason: ${publicReason})</small>`;
if (replacement) entry += Utils.html` &rArr; ${replacement}`;
return `<tr><td>${entry}</td><td>${hits}</td></tr>`;
}).join('');
}
}
if (Punishments.namefilterwhitelist.size) {
content += `<tr><th colspan="2"><h3>Whitelisted names</h3></tr></th>`;
for (const [val] of Punishments.namefilterwhitelist) {
content += `<tr><td>${val}</td></tr>`;
}
}
if (!content) {
buf += `<p>There are no filtered words.</p>`;
} else {
buf += `<table>${content}</table>`;
}
buf += `</div>`;
return buf;
},
};
export const commands: Chat.ChatCommands = {
filters: 'filter',
filter: {
add(target, room, user) {
this.checkCan('rangeban');
let separator = ',';
if (target.includes('\n')) {
separator = '\n';
} else if (target.includes('/')) {
separator = '/';
}
let [list, ...rest] = target.split(separator);
list = toID(list);
if (!list || !rest.length) {
return this.errorReply(`Syntax: /filter add list ${separator} word ${separator} reason [${separator} optional public reason]`);
}
if (!(list in filterWords)) {
return this.errorReply(`Invalid list: ${list}. Possible options: ${Object.keys(filterWords).join(', ')}`);
}
const filterWord = { list, word: '' } as Partial<FilterWord> & { list: string, word: string };
rest = rest.map(part => part.trim());
if (Chat.monitors[list].punishment === 'FILTERTO') {
[filterWord.word, filterWord.replacement, filterWord.reason, filterWord.publicReason] = rest;
if (!filterWord.replacement) {
return this.errorReply(
`Syntax for word filters: /filter add ${list} ${separator} regex ${separator} reason [${separator} optional public reason]`
);
}
} else {
[filterWord.word, filterWord.reason, filterWord.publicReason] = rest;
}
filterWord.word = filterWord.word.trim();
if (!filterWord.word) {
return this.errorReply(`Invalid word: '${filterWord.word}'.`);
}
Filters.add(filterWord);
const reason = filterWord.reason ? ` (${filterWord.reason})` : '';
if (Chat.monitors[list].punishment === 'FILTERTO') {
this.globalModlog(`ADDFILTER`, null, `'${String(filterWord.regex)} => ${filterWord.replacement}' to ${list} list${reason}`);
} else {
this.globalModlog(`ADDFILTER`, null, `'${filterWord.word}' to ${list} list${reason}`);
}
const output = `'${filterWord.word}' was added to the ${list} list.`;
Rooms.get('upperstaff')?.add(output).update();
if (room?.roomid !== 'upperstaff') this.sendReply(output);
},
remove(target, room, user) {
this.checkCan('rangeban');
let [list, ...words] = target.split(target.includes('\n') ? '\n' : ',').map(param => param.trim());
list = toID(list);
if (!list || !words.length) return this.errorReply("Syntax: /filter remove list, words");
if (!(list in filterWords)) {
return this.errorReply(`Invalid list: ${list}. Possible options: ${Object.keys(filterWords).join(', ')}`);
}
const notFound = words.filter(val => !filterWords[list].filter(entry => entry.word === val).length);
if (notFound.length) {
return this.errorReply(`${notFound.join(', ')} ${Chat.plural(notFound, "are", "is")} not on the ${list} list.`);
}
filterWords[list] = filterWords[list].filter(entry => !words.includes(entry.word));
this.globalModlog(`REMOVEFILTER`, null, `'${words.join(', ')}' from ${list} list`);
Filters.save(true);
const output = `'${words.join(', ')}' ${Chat.plural(words, "were", "was")} removed from the ${list} list.`;
Rooms.get('upperstaff')?.add(output).update();
if (room?.roomid !== 'upperstaff') this.sendReply(output);
},
'': 'view',
list: 'view',
view(target, room, user) {
this.parse(`/join view-filters`);
},
help(target, room, user) {
this.parse(`/help filter`);
},
test(target, room, user) {
this.checkCan('lock');
if (room && ['staff', 'upperstaff'].includes(room.roomid)) {
this.runBroadcast(true, `!filter test ${target}`);
}
const lcMessage = Chat.stripFormatting(target
.replace(/\u039d/g, 'N')
.toLowerCase()
// eslint-disable-next-line no-misleading-character-class
.replace(/[\u200b\u007F\u00AD\uDB40\uDC00\uDC21]/g, '')
.replace(/\u03bf/g, 'o')
.replace(/\u043e/g, 'o')
.replace(/\u0430/g, 'a')
.replace(/\u0435/g, 'e')
.replace(/\u039d/g, 'e'));
const buf = [];
for (const monitorName in Chat.monitors) {
const monitor = Chat.monitors[monitorName];
for (const line of Chat.filterWords[monitorName]) {
let ret;
if (monitor.monitor) {
ret = monitor.monitor.call(this, line, room, user, target, lcMessage, true);
} else {
ret = line.regex.exec(target)?.[0];
}
if (typeof ret === 'string') {
buf.push(`${monitorName}: ${ret}`);
break;
} else if (ret === false) {
buf.push(`${monitorName}: "${target}" would be blocked from being sent.`);
break;
}
}
}
if (buf.length) {
return this.sendReplyBox(Chat.formatText(buf.join('\n'), false, true));
} else {
throw new Chat.ErrorMessage(
`"${target}" doesn't trigger any filters. Check spelling?`
);
}
},
testhelp: [
`/filter test [test string] - Tests whether or not the provided test string would trigger any of the chat monitors.`,
`Requires: % @ ~`,
],
},
filterhelp: [
`/filter add list, word, reason[, optional public reason] - Adds a word to the given filter list. Requires: ~`,
`/filter remove list, words - Removes words from the given filter list. Requires: ~`,
`/filter view - Opens the list of filtered words. Requires: % @ ~`,
`/filter test [test string] - Tests whether or not the provided test string would trigger any of the chat monitors. Requires: % @ ~`,
`You may use / instead of , in /filter add if you want to specify a reason that includes commas.`,
],
allowname(target, room, user) {
this.checkCan('forcerename');
target = toID(target);
if (!target) return this.errorReply(`Syntax: /allowname username`);
if (Punishments.namefilterwhitelist.has(target)) {
return this.errorReply(`${target} is already allowed as a username.`);
}
const msg = `${target} was allowed as a username by ${user.name}.`;
const toNotify: RoomID[] = ['staff', 'upperstaff'];
Rooms.global.notifyRooms(toNotify, `|c|${user.getIdentity()}|/log ${msg}`);
if (!room || !toNotify.includes(room.roomid)) {
this.sendReply(msg);
}
this.globalModlog(`ALLOWNAME`, target);
Monitor.forceRenames.delete(target as ID);
},
};
process.nextTick(() => {
Chat.multiLinePattern.register('/filter (add|remove) ');
});