Spaces:
Paused
Paused
| 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) '); | |
| }); | |