diff --git a/src/as/com/threerings/crowd/chat/client/CurseFilter.as b/src/as/com/threerings/crowd/chat/client/CurseFilter.as new file mode 100644 index 000000000..7b6d9103d --- /dev/null +++ b/src/as/com/threerings/crowd/chat/client/CurseFilter.as @@ -0,0 +1,218 @@ +// +// $Id$ + +package com.threerings.crowd.chat.client { + +import com.threerings.util.Name; +import com.threerings.util.StringUtil; + +/** + * A chat filter that can filter out curse words from user chat. + */ +public /*abstract*/ class CurseFilter implements ChatFilter +{ + /** Filtration constants. */ + public static const DROP :int = 0; + public static const COMIC :int = 1; + public static const VERNACULAR :int = 2; + public static const UNFILTERED :int = 3; + + /** + * Creates a curse filter. The curse words should be a string in the + * following format: + * + *
+     * *penis*=John_Thomas shit*=barnacle muff=britches
+     * 
+ * + * The key/value pairs are separated by spaces, * matches word characters + * and the value after the = is the string into which to convert the text + * when converting to the vernacular. Underscores in the target string will + * be turned into spaces. + * + *

And stopWords should be in the following format: + * + *

+     * *faggot* rape rapes raped raping
+     * 
+ * + * Words are separated by spaces and * matches any other word characters. + */ + public function CurseFilter (curseWords :String, stopWords :String) + { + configureCurseWords(curseWords); + configureStopWords(stopWords); + } + + /** + * The client will need to provide a way to look up our current chat filter + * mode. + */ + public function getFilterMode () :int + { + throw new Error("abstract"); + } + + // from interface ChatFilter + public function filter ( + msg :String, otherUser :Name, outgoing :Boolean) :String + { + // first, check against the drop-always list + if (_stopExp.test(msg)) { + return null; + } + + // then see what kind of curse filtering the user has configured + var level :int = getFilterMode(); + if (level == UNFILTERED) { + return msg; + } + + var numStops :int = _curseExps.length; + for (var ii :int = 0; ii < numStops; ii++) { + var regexp :RegExp = (_curseExps[ii] as RegExp); + regexp.lastIndex = 0; // reset the matcher + + var result :Object; + var replacement :String; + while (null != (result = regexp.exec(msg))) { + switch (level) { + case DROP: + return null; + + case COMIC: default: + replacement = String(_replacements[ii]).replace( + /\ /g, comicChars(int(_comicLength[ii]))); + break; + + case VERNACULAR: + var vern :String = String(_vernacular[ii]); + var firstChar :String = String(result[2]).charAt(0); + if (StringUtil.isUpperCase(String(result[2]))) { + vern = vern.charAt(0).toUpperCase() + vern.substring(1); + } + replacement = String(_replacements[ii]).replace( + /\ /g, vern); + break; + } + replacement = replacement.replace("$1", String(result[1])); + replacement = replacement.replace("$3", String(result[3])); + + // do the replacement. Note the jimmying of lastIndex: + // abjectscript does not make this smooth! + var matchLength :int = String(result[0]).length; + var matchIndex :int = int(result.index); + msg = msg.substring(0, matchIndex) + replacement + + msg.substring(matchIndex + matchLength); + regexp.lastIndex += (replacement.length - matchLength); + } + } + + return msg; + } + + /** + * Configure the curse word portion of our filtering. + */ + protected function configureCurseWords (curseWords :String) :void + { + var tokens :Array = curseWords.split(" "); + for each (var token :String in tokens) { + token = StringUtil.trim(token); + if (token == "") { + continue; + } + var bits :Array = token.split("="); + if (bits.length != 2) { + Log.getLog(this).warning("Something looks wrong with " + + "your cursewords (" + token + "), skipping."); + continue; + } + + var curse :String = String(bits[0]); + var s :String = ""; + var p :String = ""; + if (curse.charAt(0) == "*") { + curse = curse.substring(1); + p += "([a-zA-Z]*)"; + s += "$1"; + } else { + p += "()"; + } + s += " "; + p += " "; + if (curse.charAt(curse.length - 1) == "*") { + curse = curse.substring(0, curse.length - 1); + p += "([a-zA-Z]*)"; + s += "$3"; + } + + var regexp :RegExp = new RegExp( + "\\b" + p.replace(" ", "(" + curse + ")") + "\\b", "gi"); + _curseExps.push(regexp); + _replacements.push(s); + + var slang :String = StringUtil.trim(String(bits[1])); + slang = slang.replace(/_/g, " "); + _vernacular.push(slang); + _comicLength.push(curse.length); + } + } + + /** + * Configure the words that will stop. + */ + protected function configureStopWords (stopWords :String) :void + { + var tokens :Array = stopWords.split(" "); + var pattern :String = ""; + for each (var token :String in tokens) { + token = StringUtil.trim(token); + if (token == "") { + continue; + } + if ("" == pattern) { + pattern += ".*("; + } else { + pattern += "|"; + } + pattern += "\\b" + + token.replace(/\*/g, "[A-Za-z]*") + "\\b"; + } + pattern += ").*"; + + _stopExp = new RegExp(pattern, "i"); + } + + /** + * Return a comicy replacement of the specified length; + */ + protected function comicChars (length :int) :String + { + var str :String = ""; + while (length-- > 0) { + str += COMIC_CHARS.charAt(int(Math.random() * COMIC_CHARS.length)); + } + return str; + } + + /** A regexp that will always cause a message to be dropped if it + * matches. */ + protected var _stopExp :RegExp; + + /** Regexps for each curseword. */ + protected var _curseExps :Array = []; + + /** Length of comic-y replacements for each curseword. */ + protected var _comicLength :Array = []; + + /** Replacements. */ + protected var _replacements :Array = []; + + /** Replacements for each curseword "in the vernacular". */ + protected var _vernacular :Array = []; + + /** Comic replacement characters. */ + protected const COMIC_CHARS :String = "!@#%&*"; +} +} diff --git a/src/as/com/threerings/util/StringUtil.as b/src/as/com/threerings/util/StringUtil.as index 923337150..95ad53e0a 100644 --- a/src/as/com/threerings/util/StringUtil.as +++ b/src/as/com/threerings/util/StringUtil.as @@ -45,6 +45,26 @@ public class StringUtil return (str.lastIndexOf(substr, 0) == 0); } + /** + * Return true iff the first character is a lower-case character. + */ + public static function isLowerCase (str :String) :Boolean + { + var firstChar :String = str.charAt(0); + return (firstChar.toUpperCase() != firstChar) && + (firstChar.toLowerCase() == firstChar); + } + + /** + * Return true iff the first character is an upper-case character. + */ + public static function isUpperCase (str :String) :Boolean + { + var firstChar :String = str.charAt(0); + return (firstChar.toUpperCase() == firstChar) && + (firstChar.toLowerCase() != firstChar); + } + /** * Append 0 or more copies of the padChar String to the input String * until it is at least the specified length.