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", "ⓐ", "Ⓐ", "α", "͏", "₳", "ä", "Ä", "Ꮧ", "λ", "Δ", "Ḁ", "Ꭺ", "ǟ", "̾", "a", "A", "ᴀ", "ɐ", "🅐", "𝐚", "𝐀", "𝘢", "𝘈", "𝙖", "𝘼", "𝒶", "𝓪", "𝓐", "𝕒", "𝔸", "𝔞", "𝔄", "𝖆", "𝕬", "🄰", "🅰", "𝒜", "𝚊", "𝙰", "ꍏ", "а", "𝓪"], b: ["b", "8", "ᗷ", "B", "ⓑ", "Ⓑ", "в", "฿", "ḅ", "Ḅ", "Ᏸ", "ϐ", "Ɓ", "ḃ", "Ḃ", "ɮ", "b", "B", "ʙ", "🅑", "𝐛", "𝐁", "𝘣", "𝘉", "𝙗", "𝘽", "𝒷", "𝓫", "𝓑", "𝕓", "𝔹", "𝔟", "𝔅", "𝖇", "𝕭", "🄱", "🅱", "𝐵", "Ⴆ", "𝚋", "𝙱", "♭", "b"], c: ["c", "ç", "ᑕ", "C", "ⓒ", "Ⓒ", "¢", "͏", "₵", "ċ", "Ċ", "ፈ", "ς", "ḉ", "Ḉ", "Ꮯ", "ƈ", "̾", "c", "C", "ᴄ", "ɔ", "🅒", "𝐜", "𝐂", "𝘤", "𝘊", "𝙘", "𝘾", "𝒸", "𝓬", "𝓒", "𝕔", "ℂ", "𝔠", "ℭ", "𝖈", "𝕮", "🄲", "🅲", "𝒞", "𝚌", "𝙲", "☾", "с"], d: ["d", "ᗪ", "D", "ⓓ", "Ⓓ", "∂", "Đ", "ď", "Ď", "Ꮄ", "Ḋ", "Ꭰ", "ɖ", "d", "D", "ᴅ", "🅓", "𝐝", "𝐃", "𝘥", "𝘋", "𝙙", "𝘿", "𝒹", "𝓭", "𝓓", "𝕕", "​", "𝔡", "𝖉", "𝕯", "🄳", "🅳", "𝒟", "ԃ", "𝚍", "𝙳", "◗", "ⅾ"], e: ["e", "3", "é", "ê", "E", "ⓔ", "Ⓔ", "є", "͏", "Ɇ", "ệ", "Ệ", "Ꮛ", "ε", "Σ", "ḕ", "Ḕ", "Ꭼ", "ɛ", "̾", "e", "E", "ᴇ", "ǝ", "🅔", "𝐞", "𝐄", "𝘦", "𝘌", "𝙚", "𝙀", "ℯ", "𝓮", "𝓔", "𝕖", "𝔻", "𝔢", "𝔇", "𝖊", "𝕰", "🄴", "🅴", "𝑒", "𝐸", "ҽ", "𝚎", "𝙴", "€", "е", "ё", "𝓮"], f: ["f", "ᖴ", "F", "ⓕ", "Ⓕ", "₣", "ḟ", "Ḟ", "Ꭶ", "ғ", "ʄ", "f", "F", "ɟ", "🅕", "𝐟", "𝐅", "𝘧", "𝘍", "𝙛", "𝙁", "𝒻", "𝓯", "𝓕", "𝕗", "𝔼", "𝔣", "𝔈", "𝖋", "𝕱", "🄵", "🅵", "𝐹", "ϝ", "𝚏", "𝙵", "Ϝ", "f", "Ƒ"], g: ["g", "q", "6", "9", "G", "ⓖ", "Ⓖ", "͏", "₲", "ġ", "Ġ", "Ꮆ", "ϑ", "Ḡ", "ɢ", "̾", "g", "G", "ƃ", "🅖", "𝐠", "𝐆", "𝘨", "𝘎", "𝙜", "𝙂", "ℊ", "𝓰", "𝓖", "𝕘", "𝔽", "𝔤", "𝔉", "𝖌", "𝕲", "🄶", "🅶", "𝑔", "𝒢", "ɠ", "𝚐", "𝙶", "❡", "ց", "𝙶", "𝓰", "Ԍ"], h: [ "h", "ᕼ", "H", "ⓗ", "Ⓗ", "н", "Ⱨ", "ḧ", "Ḧ", "Ꮒ", "ɦ", "h", "H", "ʜ", "ɥ", "🅗", "𝐡", "𝐇", "𝘩", "𝘏", "𝙝", "𝙃", "𝒽", "𝓱", "𝓗", "𝕙", "𝔾", "𝔥", "𝔊", "𝖍", "𝕳", "🄷", "🅷", "𝐻", "ԋ", "𝚑", "𝙷", "♄", "h", ], i: ["i", "!", "l", "1", "í", "I", "ⓘ", "Ⓘ", "ι", "͏", "ł", "ï", "Ï", "Ꭵ", "ḭ", "Ḭ", "ɨ", "̾", "i", "I", "ɪ", "ı", "🅘", "𝐢", "𝐈", "𝘪", "𝘐", "𝙞", "𝙄", "𝒾", "𝓲", "𝓘", "𝕚", "ℍ", "𝔦", "ℌ", "𝖎", "𝕴", "🄸", "🅸", "𝐼", "𝚒", "𝙸", "♗", "і", "¡", "|", "𝓲"], j: ["j", "ᒍ", "J", "ⓙ", "Ⓙ", "נ", "Ꮰ", "ϳ", "ʝ", "j", "J", "ᴊ", "ɾ", "🅙", "𝐣", "𝐉", "𝘫", "𝘑", "𝙟", "𝙅", "𝒿", "𝓳", "𝓙", "𝕛", "​", "𝔧", "𝖏", "𝕵", "🄹", "🅹", "𝒥", "𝚓", "𝙹", "♪", "ј"], k: ["k", "K", "ⓚ", "Ⓚ", "к", "͏", "₭", "ḳ", "Ḳ", "Ꮶ", "κ", "Ƙ", "ӄ", "̾", "k", "K", "ᴋ", "ʞ", "🅚", "𝐤", "𝐊", "𝘬", "𝘒", "𝙠", "𝙆", "𝓀", "𝓴", "𝓚", "𝕜", "𝕀", "𝔨", "ℑ", "𝖐", "𝕶", "🄺", "🅺", "𝒦", "ƙ", "𝚔", "𝙺", "ϰ", "k", "𝓴"], l: ["l", "i", "1", "/", "|", "ᒪ", "L", "ⓛ", "Ⓛ", "ℓ", "Ⱡ", "ŀ", "Ŀ", "Ꮭ", "Ḷ", "Ꮮ", "ʟ", "l", "L", "🅛", "𝐥", "𝐋", "𝘭", "𝘓", "𝙡", "𝙇", "𝓁", "𝓵", "𝓛", "𝕝", "𝕁", "𝔩", "​", "𝖑", "𝕷", "🄻", "🅻", "𝐿", "ʅ", "𝚕", "𝙻", "↳", "ⅼ"], m: [ "m", "ᗰ", "M", "ⓜ", "Ⓜ", "м", "͏", "₥", "ṃ", "Ṃ", "Ꮇ", "ϻ", "Μ", "ṁ", "Ṁ", "ʍ", "̾", "m", "M", "ᴍ", "ɯ", "🅜", "𝐦", "𝐌", "𝘮", "𝘔", "𝙢", "𝙈", "𝓂", "𝓶", "𝓜", "𝕞", "𝕂", "𝔪", "𝔍", "𝖒", "𝕸", "🄼", "🅼", "𝑀", "ɱ", "𝚖", "𝙼", "♔", "ⅿ", ], n: ["n", "ñ", "ᑎ", "N", "ⓝ", "Ⓝ", "и", "₦", "ń", "Ń", "Ꮑ", "π", "∏", "Ṇ", "ռ", "n", "N", "ɴ", "🅝", "𝐧", "𝐍", "𝘯", "𝘕", "𝙣", "𝙉", "𝓃", "𝓷", "𝓝", "𝕟", "𝕃", "𝔫", "𝔎", "𝖓", "𝕹", "🄽", "🅽", "𝒩", "ɳ", "𝚗", "𝙽", "♫", "ո", "η", "𝙽", "ƞ", "𝓷", "Ν"], o: ["o", "0", "ó", "ô", "õ", "ú", "O", "ⓞ", "Ⓞ", "σ", "͏", "Ø", "ö", "Ö", "Ꭷ", "Θ", "ṏ", "Ṏ", "Ꮎ", "օ", "̾", "o", "O", "ᴏ", "🅞", "𝐨", "𝐎", "𝘰", "𝘖", "𝙤", "𝙊", "ℴ", "𝓸", "𝓞", "𝕠", "𝕄", "𝔬", "𝔏", "𝖔", "𝕺", "🄾", "🅾", "𝑜", "𝒪", "𝚘", "𝙾", "⊙", "ο"], p: ["p", "ᑭ", "P", "ⓟ", "Ⓟ", "ρ", "₱", "ṗ", "Ṗ", "Ꭾ", "Ƥ", "Ꮲ", "ք", "p", "P", "ᴘ", "🅟", "𝐩", "𝐏", "𝘱", "𝘗", "𝙥", "𝙋", "𝓅", "𝓹", "𝓟", "𝕡", "ℕ", "𝔭", "𝔐", "𝖕", "𝕻", "🄿", "🅿", "𝒫", "𝚙", "𝙿", "р"], q: [ "q", "ᑫ", "Q", "ⓠ", "Ⓠ", "͏", "Ꭴ", "φ", "Ⴓ", "զ", "̾", "q", "Q", "ϙ", "ǫ", "🅠", "𝐪", "𝐐", "𝘲", "𝘘", "𝙦", "𝙌", "𝓆", "𝓺", "𝓠", "𝕢", "​", "𝔮", "𝔑", "𝖖", "𝕼", "🅀", "🆀", "𝒬", "𝚚", "𝚀", "☭", "ԛ", ], r: ["r", "ᖇ", "R", "ⓡ", "Ⓡ", "я", "Ɽ", "ŕ", "Ŕ", "Ꮢ", "г", "Γ", "ṙ", "Ṙ", "ʀ", "r", "R", "ɹ", "🅡", "𝐫", "𝐑", "𝘳", "𝘙", "𝙧", "𝙍", "𝓇", "𝓻", "𝓡", "𝕣", "𝕆", "𝔯", "𝔒", "𝖗", "𝕽", "🅁", "🆁", "𝑅", "ɾ", "𝚛", "𝚁", "☈", "r", "𝚁", "𝓻"], s: ["s", "5", "ᔕ", "S", "ⓢ", "Ⓢ", "ѕ", "͏", "₴", "ṩ", "Ṩ", "Ꮥ", "Ѕ", "Ṡ", "ֆ", "̾", "s", "S", "ꜱ", "🅢", "𝐬", "𝐒", "𝘴", "𝘚", "𝙨", "𝙎", "𝓈", "𝓼", "𝓢", "𝕤", "ℙ", "𝔰", "𝔓", "𝖘", "𝕾", "🅂", "🆂", "𝒮", "ʂ", "𝚜", "𝚂", "ѕ", "𝓼"], t: ["t", "+", "T", "ⓣ", "Ⓣ", "т", "₮", "ẗ", "Ṯ", "Ꮦ", "τ", "Ƭ", "Ꮖ", "ȶ", "t", "T", "ᴛ", "ʇ", "🅣", "𝐭", "𝐓", "𝘵", "𝘛", "𝙩", "𝙏", "𝓉", "𝓽", "𝓣", "𝕥", "​", "𝔱", "𝔔", "𝖙", "𝕿", "🅃", "🆃", "𝒯", "ƚ", "𝚝", "𝚃", "☂", "t", "𝓽"], u: ["u", "ú", "ü", "ᑌ", "U", "ⓤ", "Ⓤ", "υ", "͏", "Ʉ", "Ü", "Ꮼ", "Ʊ", "ṳ", "Ṳ", "ʊ", "̾", "u", "U", "ᴜ", "🅤", "𝐮", "𝐔", "𝘶", "𝘜", "𝙪", "𝙐", "𝓊", "𝓾", "𝓤", "𝕦", "ℚ", "𝔲", "ℜ", "𝖚", "𝖀", "🅄", "🆄", "𝒰", "𝚞", "𝚄", "☋", "ս"], v: ["v", "ᐯ", "V", "ⓥ", "Ⓥ", "ν", "ṿ", "Ṿ", "Ꮙ", "Ʋ", "Ṽ", "ʋ", "v", "V", "ᴠ", "ʌ", "🅥", "𝐯", "𝐕", "𝘷", "𝘝", "𝙫", "𝙑", "𝓋", "𝓿", "𝓥", "𝕧", "​", "𝔳", "𝖛", "𝖁", "🅅", "🆅", "𝒱", "𝚟", "𝚅", "✓", "ⅴ"], w: ["w", "ᗯ", "W", "ⓦ", "Ⓦ", "ω", "͏", "₩", "ẅ", "Ẅ", "Ꮗ", "ш", "Ш", "ẇ", "Ẇ", "Ꮃ", "ա", "̾", "w", "W", "ᴡ", "ʍ", "🅦", "𝐰", "𝐖", "𝘸", "𝘞", "𝙬", "𝙒", "𝓌", "𝔀", "𝓦", "𝕨", "ℝ", "𝔴", "𝔖", "𝖜", "𝖂", "🅆", "🆆", "𝒲", "ɯ", "𝚠", "𝚆", "ԝ"], x: ["x", "᙭", "X", "ⓧ", "Ⓧ", "χ", "Ӿ", "ẍ", "Ẍ", "ጀ", "ϰ", "Ж", "х", "Ӽ", "x", "X", "🅧", "𝐱", "𝐗", "𝘹", "𝘟", "𝙭", "𝙓", "𝓍", "𝔁", "𝓧", "𝕩", "​", "𝔵", "𝔗", "𝖝", "𝖃", "🅇", "🆇", "𝒳", "𝚡", "𝚇", "⌘", "х"], y: [ "y", "Y", "ⓨ", "Ⓨ", "у", "͏", "Ɏ", "ÿ", "Ÿ", "Ꭹ", "ψ", "Ψ", "ẏ", "Ẏ", "Ꮍ", "ч", "ʏ", "̾", "y", "Y", "ʎ", "🅨", "𝐲", "𝐘", "𝘺", "𝘠", "𝙮", "𝙔", "𝓎", "𝔂", "𝓨", "𝕪", "𝕊", "𝔶", "𝔘", "𝖞", "𝖄", "🅈", "🆈", "𝒴", "ყ", "𝚢", "𝚈", "☿", "у", ], z: ["z", "ᘔ", "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 & { 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: ${user.name} ${user.getAccountStatusString()} (${!manualForceRename ? 'automatically ' : ''}forcerenamed from ${user.trackRename})` ); 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 = `

Filters

`; if (!user.can('addhtml')) this.checkCan('lock'); let content = ``; for (const key in Chat.monitors) { content += `

${Chat.monitors[key].label} [${key}]

`; if (filterWords[key].length) { content += filterWords[key].map(({ regex, word, reason, publicReason, replacement, hits }) => { let entry = Utils.html`${word}`; if (publicReason) entry += Utils.html` (public reason: ${publicReason})`; if (replacement) entry += Utils.html` ⇒ ${replacement}`; return `${entry}${hits}`; }).join(''); } } if (Punishments.namefilterwhitelist.size) { content += `

Whitelisted names

`; for (const [val] of Punishments.namefilterwhitelist) { content += `${val}`; } } if (!content) { buf += `

There are no filtered words.

`; } else { buf += `${content}
`; } buf += `
`; 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 & { 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) '); });