using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; namespace Mocha2023.Classes { public enum ProfanitySeverity { None, Mild, Severe, SevereBypass } public sealed record ProfanityMatch( string CanonicalWord, string MatchedText, ProfanitySeverity Severity, int Index, int Length, bool WasBypassed ); public sealed record ProfanityAnalysis( ProfanitySeverity Severity, string SanitizedText, IReadOnlyList Matches ); public static class ProfanityFilter { private const int MaxRepeatedCharacterCount = 16; private const int MaxSeparatorsBetweenLetters = 12; private const int MaxAnalyzedCharacters = 4096; private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(150); private static readonly string[] MildWords = { "fuck", "fucks", "fucked", "fucker", "fuckers", "fucking", "motherfucker", "motherfuckers", "motherfucking", "shit", "shits", "shitty", "shitting", "bullshit", "bitch", "bitches", "bitchy", "ass", "asses", "asshole", "assholes", "dumbass", "jackass", "dick", "dicks", "dickhead", "dickheads", "cock", "cocks", "pussy", "pussies", "whore", "whores", "slut", "sluts", "bastard", "bastards", "twat", "twats", "damn", "dammit", "hell", "crap", "piss", "pissed", "sex", "sexy", "cum", "dildo", "dildos", "dealdough" }; private static readonly string[] SevereWords = { "nigger", "niggers", "nigga", "niggas", "faggot", "faggots", "fag", "fags", "retard", "retards", "retarded", "cunt", "cunts", "rape", "rapes", "raped", "raping", "rapist", "rapists", "cumslut", "cumsluts", "kike", "kikes", "chink", "chinks", "spic", "spics", "wetback", "wetbacks", "tranny", "trannies" }; private static readonly Dictionary CharacterAliases = new() { ['a'] = [ "4", "@", "à", "á", "â", "ã", "ä", "å", "ɑ", "α", "а" ], ['b'] = [ "8", "ß", "þ", "Ь", "в" ], ['c'] = [ "k", "(", "<", "¢", "с" ], ['d'] = [ "đ", "ԁ" ], ['e'] = [ "3", "€", "è", "é", "ê", "ë", "ε", "е" ], ['f'] = [ "ph", "ƒ" ], ['g'] = [ "9", "q", "ɡ", "ģ" ], ['h'] = [ "#", "н" ], ['i'] = [ "1", "!", "|", "l", "ì", "í", "î", "ï", "ι", "і" ], ['j'] = [ "ј" ], ['k'] = [ "c", "|<", "κ", "к" ], ['l'] = [ "1", "!", "|", "i", "ł", "ⅼ" ], ['m'] = [ "rn", "м" ], ['n'] = [ "ñ", "η", "п" ], ['o'] = [ "0", "°", "ò", "ó", "ô", "õ", "ö", "ο", "о" ], ['p'] = [ "ρ", "р" ], ['q'] = [ "9" ], ['r'] = [ "®", "ŕ", "я" ], ['s'] = [ "5", "$", "z", "ś", "ѕ" ], ['t'] = [ "7", "+", "ţ", "т" ], ['u'] = [ "v", "ù", "ú", "û", "ü", "υ", "ц" ], ['v'] = [ "u", "\\/" ], ['w'] = [ "vv", "\\/\\/" ], ['x'] = [ "%", "×", "х" ], ['y'] = [ "¥", "ý", "ÿ", "у" ], ['z'] = [ "2", "ѕ" ] }; private static readonly Dictionary NormalizedConfusableCharacters = BuildNormalizedConfusableCharacters(); private static readonly CompiledRule[] MildRules = BuildRules(MildWords, ProfanitySeverity.Mild); private static readonly CompiledRule[] SevereRules = BuildRules(SevereWords, ProfanitySeverity.Severe); public static ProfanityAnalysis Analyze(string? input) { if (string.IsNullOrEmpty(input)) { return new ProfanityAnalysis( ProfanitySeverity.None, input ?? string.Empty, Array.Empty() ); } string analyzedInput = input.Length > MaxAnalyzedCharacters ? input[..MaxAnalyzedCharacters] : input; var candidates = new List(); AddMatches(analyzedInput, SevereRules, candidates); AddMatches(analyzedInput, MildRules, candidates); AddNormalizedMatches(analyzedInput, SevereRules, candidates); AddNormalizedMatches(analyzedInput, MildRules, candidates); var selectedMatches = RemoveOverlappingMatches(candidates); ProfanitySeverity finalSeverity; if (selectedMatches.Any(x => x.Severity == ProfanitySeverity.SevereBypass)) { finalSeverity = ProfanitySeverity.SevereBypass; } else if (selectedMatches.Any(x => x.Severity == ProfanitySeverity.Severe)) { finalSeverity = ProfanitySeverity.Severe; } else if (selectedMatches.Any(x => x.Severity == ProfanitySeverity.Mild)) { finalSeverity = ProfanitySeverity.Mild; } else { finalSeverity = ProfanitySeverity.None; } string sanitized = ApplyBlur(input, selectedMatches); return new ProfanityAnalysis( finalSeverity, sanitized, selectedMatches ); } public static ProfanitySeverity GetSeverity(string input) { return Analyze(input).Severity; } public static string Blur(string input) { if (string.IsNullOrEmpty(input)) return input; return Analyze(input).SanitizedText; } public static bool IsPure(string? input) { return Analyze(input).Severity == ProfanitySeverity.None; } public static bool ContainsProfanity(string? input) { return Analyze(input).Severity != ProfanitySeverity.None; } public static IReadOnlyList GetMatches(string? input) { return Analyze(input).Matches; } private static void AddMatches( string input, IEnumerable rules, ICollection results) { foreach (CompiledRule rule in rules) { try { MatchCollection matches = rule.FuzzyRegex.Matches(input); foreach (Match match in matches) { if (!match.Success || match.Length == 0) continue; bool wasBypassed = !rule.ExactRegex.IsMatch(match.Value); ProfanitySeverity severity = rule.BaseSeverity; if (severity == ProfanitySeverity.Severe && wasBypassed) { severity = ProfanitySeverity.SevereBypass; } results.Add(new ProfanityMatch( rule.CanonicalWord, match.Value, severity, match.Index, match.Length, wasBypassed )); } } catch (RegexMatchTimeoutException) { } } } private static void AddNormalizedMatches( string input, IEnumerable rules, ICollection results) { NormalizedDetectionText normalized = NormalizeForDetection(input); if (!normalized.Changed || normalized.Text.Length == 0) return; foreach (CompiledRule rule in rules) { try { foreach (Match match in rule.FuzzyRegex.Matches( normalized.Text)) { if (!match.Success || match.Length == 0) continue; int normalizedEnd = match.Index + match.Length - 1; if (match.Index >= normalized.SourceStarts.Count || normalizedEnd >= normalized.SourceEnds.Count) { continue; } int sourceStart = normalized.SourceStarts[match.Index]; int sourceEnd = normalized.SourceEnds[normalizedEnd]; int sourceLength = sourceEnd - sourceStart; if (sourceLength <= 0) continue; bool duplicate = results.Any(existing => existing.CanonicalWord.Equals( rule.CanonicalWord, StringComparison.OrdinalIgnoreCase) && existing.Index == sourceStart && existing.Length == sourceLength); if (duplicate) continue; ProfanitySeverity severity = rule.BaseSeverity == ProfanitySeverity.Severe ? ProfanitySeverity.SevereBypass : rule.BaseSeverity; results.Add(new ProfanityMatch( rule.CanonicalWord, input.Substring(sourceStart, sourceLength), severity, sourceStart, sourceLength, WasBypassed: true)); } } catch (RegexMatchTimeoutException) { } } } private static NormalizedDetectionText NormalizeForDetection( string input) { var text = new StringBuilder(input.Length); var starts = new List(input.Length); var ends = new List(input.Length); bool changed = false; int sourceIndex = 0; foreach (Rune sourceRune in input.EnumerateRunes()) { int sourceEnd = sourceIndex + sourceRune.Utf16SequenceLength; string compatibilityForm = sourceRune .ToString() .Normalize(NormalizationForm.FormKD); int outputStart = text.Length; foreach (Rune normalizedRune in compatibilityForm.EnumerateRunes()) { UnicodeCategory category = Rune.GetUnicodeCategory(normalizedRune); if (category is UnicodeCategory.NonSpacingMark or UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark) { changed = true; continue; } char output; if (normalizedRune.IsAscii) { output = char.ToLowerInvariant( (char)normalizedRune.Value); } else if (normalizedRune.Value <= char.MaxValue && NormalizedConfusableCharacters.TryGetValue( (char)normalizedRune.Value, out char mapped)) { output = mapped; } else if (Rune.IsLetterOrDigit(normalizedRune) && normalizedRune.Value <= char.MaxValue) { output = char.ToLowerInvariant( (char)normalizedRune.Value); } else { output = ' '; } text.Append(output); starts.Add(sourceIndex); ends.Add(sourceEnd); } if (text.Length == outputStart) { text.Append(' '); starts.Add(sourceIndex); ends.Add(sourceEnd); } string produced = text .ToString(outputStart, text.Length - outputStart); if (!produced.Equals( sourceRune.ToString(), StringComparison.Ordinal)) { changed = true; } sourceIndex = sourceEnd; } return new NormalizedDetectionText( text.ToString(), starts, ends, changed); } private static Dictionary BuildNormalizedConfusableCharacters() { var result = new Dictionary(); foreach ((char canonical, string[] aliases) in CharacterAliases) { foreach (string alias in aliases) { if (alias.Length != 1) continue; char candidate = alias[0]; if (candidate <= 127) continue; UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory(candidate); if (category is UnicodeCategory.UppercaseLetter or UnicodeCategory.LowercaseLetter or UnicodeCategory.TitlecaseLetter or UnicodeCategory.ModifierLetter or UnicodeCategory.OtherLetter) { result.TryAdd( char.ToLowerInvariant(candidate), canonical); } } } return result; } private static IReadOnlyList RemoveOverlappingMatches(IEnumerable matches) { var prioritized = matches .OrderByDescending(x => GetSeverityPriority(x.Severity)) .ThenByDescending(x => x.Length) .ThenBy(x => x.Index) .ToList(); var selected = new List(); foreach (ProfanityMatch candidate in prioritized) { bool overlapsExisting = selected.Any(existing => RangesOverlap( candidate.Index, candidate.Length, existing.Index, existing.Length )); if (!overlapsExisting) selected.Add(candidate); } return selected .OrderBy(x => x.Index) .ToList(); } private static int GetSeverityPriority( ProfanitySeverity severity) { return severity switch { ProfanitySeverity.SevereBypass => 3, ProfanitySeverity.Severe => 2, ProfanitySeverity.Mild => 1, _ => 0 }; } private static bool RangesOverlap( int firstIndex, int firstLength, int secondIndex, int secondLength) { int firstEnd = firstIndex + firstLength; int secondEnd = secondIndex + secondLength; return firstIndex < secondEnd && secondIndex < firstEnd; } private static string ApplyBlur( string input, IEnumerable matches) { char[] characters = input.ToCharArray(); foreach (ProfanityMatch match in matches) { int start = Math.Max(0, match.Index); int end = Math.Min( characters.Length, match.Index + match.Length ); for (int i = start; i < end; i++) { if (!char.IsWhiteSpace(characters[i])) characters[i] = '*'; } } return new string(characters); } private static CompiledRule[] BuildRules( IEnumerable words, ProfanitySeverity baseSeverity) { return words .Where(word => !string.IsNullOrWhiteSpace(word)) .Select(word => word.Trim().ToLowerInvariant()) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderByDescending(word => word.Length) .Select(word => new CompiledRule( word, baseSeverity, BuildFuzzyRegex(word), BuildExactRegex(word) )) .ToArray(); } private static Regex BuildFuzzyRegex(string word) { string pattern = BuildFuzzyWordPattern(word); return new Regex( pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, RegexTimeout ); } private static Regex BuildExactRegex(string word) { string escapedWord = Regex.Escape(word); string pattern = $@"(?( StringComparer.OrdinalIgnoreCase) { character.ToString() }; if (character is >= 'a' and <= 'z') { char fullWidthCharacter = (char)('\uFF41' + (character - 'a')); alternatives.Add(fullWidthCharacter.ToString()); } if (CharacterAliases.TryGetValue( character, out string[]? aliases)) { foreach (string alias in aliases) alternatives.Add(alias); } string alternativesPattern = string.Join( "|", alternatives .OrderByDescending(value => value.Length) .Select(Regex.Escape) ); return $"(?:{alternativesPattern})" + $"{{1,{MaxRepeatedCharacterCount}}}"; } private sealed class CompiledRule { public string CanonicalWord { get; } public ProfanitySeverity BaseSeverity { get; } public Regex FuzzyRegex { get; } public Regex ExactRegex { get; } public CompiledRule( string canonicalWord, ProfanitySeverity baseSeverity, Regex fuzzyRegex, Regex exactRegex) { CanonicalWord = canonicalWord; BaseSeverity = baseSeverity; FuzzyRegex = fuzzyRegex; ExactRegex = exactRegex; } } private sealed record NormalizedDetectionText( string Text, IReadOnlyList SourceStarts, IReadOnlyList SourceEnds, bool Changed); } }