Spaces:
Running
Running
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` ⇒ ${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) '); | |
}); | |