Add more chat filtering.
CAPS LOCK FILTER if a line is long enough and contains mostly caps. Shadowban filter blocks messages with configurable substrings present in them. Does not permanently shadowban the user, yet, just drops the message. Add a test that was missed in a previous commit.
This commit is contained in:
parent
4f9edbb97a
commit
65f74ef816
|
@ -136,6 +136,7 @@ cah.$.ErrorCode.TOO_MANY_GAMES = "tmg";
|
||||||
cah.$.ErrorCode.INVALID_ID_CODE = "iid";
|
cah.$.ErrorCode.INVALID_ID_CODE = "iid";
|
||||||
cah.$.ErrorCode.CANNOT_JOIN_ANOTHER_GAME = "cjag";
|
cah.$.ErrorCode.CANNOT_JOIN_ANOTHER_GAME = "cjag";
|
||||||
cah.$.ErrorCode.NO_MSG_SPECIFIED = "nms";
|
cah.$.ErrorCode.NO_MSG_SPECIFIED = "nms";
|
||||||
|
cah.$.ErrorCode.CAPSLOCK = "CL";
|
||||||
cah.$.ErrorCode.ALREADY_STARTED = "as";
|
cah.$.ErrorCode.ALREADY_STARTED = "as";
|
||||||
cah.$.ErrorCode.NOT_ADMIN = "na";
|
cah.$.ErrorCode.NOT_ADMIN = "na";
|
||||||
cah.$.ErrorCode.INVALID_GAME = "ig";
|
cah.$.ErrorCode.INVALID_GAME = "ig";
|
||||||
|
@ -199,6 +200,7 @@ cah.$.ErrorCode_msg['B&'] = "Banned.";
|
||||||
cah.$.ErrorCode_msg['mtl'] = "Messages cannot be longer than 200 characters.";
|
cah.$.ErrorCode_msg['mtl'] = "Messages cannot be longer than 200 characters.";
|
||||||
cah.$.ErrorCode_msg['in'] = "Nickname must contain only upper and lower case letters, numbers, or underscores, must be 3 to 30 characters long, and must not start with a number.";
|
cah.$.ErrorCode_msg['in'] = "Nickname must contain only upper and lower case letters, numbers, or underscores, must be 3 to 30 characters long, and must not start with a number.";
|
||||||
cah.$.ErrorCode_msg['serr'] = "An error occured on the server.";
|
cah.$.ErrorCode_msg['serr'] = "An error occured on the server.";
|
||||||
|
cah.$.ErrorCode_msg['CL'] = "Try turning caps lock off.";
|
||||||
cah.$.ErrorCode_msg['dnhc'] = "You don't have that card.";
|
cah.$.ErrorCode_msg['dnhc'] = "You don't have that card.";
|
||||||
cah.$.ErrorCode_msg['as'] = "The game has already started.";
|
cah.$.ErrorCode_msg['as'] = "The game has already started.";
|
||||||
cah.$.ErrorCode_msg['nns'] = "No nickname specified.";
|
cah.$.ErrorCode_msg['nns'] = "No nickname specified.";
|
||||||
|
|
|
@ -11,6 +11,9 @@ pyx.id_code_salt=
|
||||||
# comma-separated listed of IP addresses (v4 or v6) from which users are considered admins.
|
# comma-separated listed of IP addresses (v4 or v6) from which users are considered admins.
|
||||||
pyx.admin_addrs=127.0.0.1,::1
|
pyx.admin_addrs=127.0.0.1,::1
|
||||||
|
|
||||||
|
# comma-separated list of strings that will cause a chat message to get silently dropped without
|
||||||
|
# notifying the user. does not currently permanently shadowban the user, but maybe it should...
|
||||||
|
pyx.shadowban_strings=
|
||||||
# Settings for global chat protection. Some of these do not apply to game chats.
|
# Settings for global chat protection. Some of these do not apply to game chats.
|
||||||
# Ratio of 'basic' characters to length of message. Basic characters are defined by
|
# Ratio of 'basic' characters to length of message. Basic characters are defined by
|
||||||
# Character.isJavaIdentifierPart, which stipulates:
|
# Character.isJavaIdentifierPart, which stipulates:
|
||||||
|
@ -25,6 +28,9 @@ pyx.admin_addrs=127.0.0.1,::1
|
||||||
pyx.global.basic_ratio=.5
|
pyx.global.basic_ratio=.5
|
||||||
# A message must have at least this many characters for that ratio to apply.
|
# A message must have at least this many characters for that ratio to apply.
|
||||||
pyx.global.basic_min_len=10
|
pyx.global.basic_min_len=10
|
||||||
|
# Message longer than min_len characters cannot have more than ratio of CAPS CHARACTERS
|
||||||
|
pyx.global.capslock_min_len=50
|
||||||
|
pyx.global.capslock_ratio=.5
|
||||||
# messages longer than min_len characters require at least min_count spaces between words
|
# messages longer than min_len characters require at least min_count spaces between words
|
||||||
pyx.global.spaces_min_len=50
|
pyx.global.spaces_min_len=50
|
||||||
pyx.global.spaces_min_count=4
|
pyx.global.spaces_min_count=4
|
||||||
|
|
|
@ -6,12 +6,15 @@ pyx.server.broadcast_connects_and_disconnects=${pyx.broadcast_connects_and_disco
|
||||||
pyx.server.global_chat_enabled=${pyx.global_chat_enabled}
|
pyx.server.global_chat_enabled=${pyx.global_chat_enabled}
|
||||||
pyx.server.id_code_salt=${pyx.id_code_salt}
|
pyx.server.id_code_salt=${pyx.id_code_salt}
|
||||||
pyx.server.admin_addrs=${pyx.admin_addrs}
|
pyx.server.admin_addrs=${pyx.admin_addrs}
|
||||||
|
pyx.chat.shadowban_strings=${pyx.shadowban_strings}
|
||||||
pyx.chat.global.flood_count=${pyx.global.flood_count}
|
pyx.chat.global.flood_count=${pyx.global.flood_count}
|
||||||
pyx.chat.global.flood_time=${pyx.global.flood_time}
|
pyx.chat.global.flood_time=${pyx.global.flood_time}
|
||||||
pyx.chat.global.basic_ratio=${pyx.global.basic_ratio}
|
pyx.chat.global.basic_ratio=${pyx.global.basic_ratio}
|
||||||
pyx.chat.global.basic_min_len=${pyx.global.basic_min_len}
|
pyx.chat.global.basic_min_len=${pyx.global.basic_min_len}
|
||||||
pyx.chat.global.spaces_min_len=${pyx.global.spaces_min_len}
|
pyx.chat.global.spaces_min_len=${pyx.global.spaces_min_len}
|
||||||
pyx.chat.global.spaces_min_count=${pyx.global.spaces_min_count}
|
pyx.chat.global.spaces_min_count=${pyx.global.spaces_min_count}
|
||||||
|
pyx.chat.global.capslock_min_len=${pyx.global.capslock_min_len}
|
||||||
|
pyx.chat.global.capslock_ratio=${pyx.global.capslock_ratio}
|
||||||
pyx.chat.game.flood_count=${pyx.game.flood_count}
|
pyx.chat.game.flood_count=${pyx.game.flood_count}
|
||||||
pyx.chat.game.flood_time=${pyx.game.flood_time}
|
pyx.chat.game.flood_time=${pyx.game.flood_time}
|
||||||
pyx.build=${buildNumber}
|
pyx.build=${buildNumber}
|
||||||
|
|
|
@ -351,6 +351,7 @@ public class Constants {
|
||||||
@DuplicationAllowed
|
@DuplicationAllowed
|
||||||
BANNED(DisconnectReason.BANNED, "Banned."),
|
BANNED(DisconnectReason.BANNED, "Banned."),
|
||||||
CANNOT_JOIN_ANOTHER_GAME("cjag", "You cannot join another game."),
|
CANNOT_JOIN_ANOTHER_GAME("cjag", "You cannot join another game."),
|
||||||
|
CAPSLOCK("CL", "Try turning caps lock off."),
|
||||||
CARDCAST_CANNOT_FIND("ccf", "Cannot find Cardcast deck with given ID. If you just added this"
|
CARDCAST_CANNOT_FIND("ccf", "Cannot find Cardcast deck with given ID. If you just added this"
|
||||||
+ " deck to Cardcast, wait a few minutes and try again."),
|
+ " deck to Cardcast, wait a few minutes and try again."),
|
||||||
CARDCAST_INVALID_ID("cii", "Invalid Cardcast ID. Must be exactly 5 characters."),
|
CARDCAST_INVALID_ID("cii", "Invalid Cardcast ID. Must be exactly 5 characters."),
|
||||||
|
|
|
@ -93,13 +93,18 @@ public class ChatHandler extends Handler {
|
||||||
|
|
||||||
final ChatFilter.Result filterResult = chatFilter.filterGlobal(user, message);
|
final ChatFilter.Result filterResult = chatFilter.filterGlobal(user, message);
|
||||||
switch (filterResult) {
|
switch (filterResult) {
|
||||||
case OK:
|
case CAPSLOCK:
|
||||||
// nothing to do
|
return error(ErrorCode.CAPSLOCK);
|
||||||
break;
|
case DROP_MESSAGE:
|
||||||
|
// Don't tell the user we dropped it, and don't send it to everyone else...
|
||||||
|
return data;
|
||||||
case NO_MESSAGE:
|
case NO_MESSAGE:
|
||||||
return error(ErrorCode.NO_MSG_SPECIFIED);
|
return error(ErrorCode.NO_MSG_SPECIFIED);
|
||||||
case NOT_ENOUGH_SPACES:
|
case NOT_ENOUGH_SPACES:
|
||||||
return error(ErrorCode.NOT_ENOUGH_SPACES);
|
return error(ErrorCode.NOT_ENOUGH_SPACES);
|
||||||
|
case OK:
|
||||||
|
// nothing to do
|
||||||
|
break;
|
||||||
case REPEAT:
|
case REPEAT:
|
||||||
return error(ErrorCode.REPEAT_MESSAGE);
|
return error(ErrorCode.REPEAT_MESSAGE);
|
||||||
case TOO_FAST:
|
case TOO_FAST:
|
||||||
|
|
|
@ -78,13 +78,18 @@ public class GameChatHandler extends GameWithPlayerHandler {
|
||||||
|
|
||||||
final ChatFilter.Result filterResult = chatFilter.filterGame(user, message);
|
final ChatFilter.Result filterResult = chatFilter.filterGame(user, message);
|
||||||
switch (filterResult) {
|
switch (filterResult) {
|
||||||
case OK:
|
case CAPSLOCK:
|
||||||
// nothing to do
|
return error(ErrorCode.CAPSLOCK);
|
||||||
break;
|
case DROP_MESSAGE:
|
||||||
|
// Don't tell the user we dropped it, and don't send it to everyone else...
|
||||||
|
return data;
|
||||||
case NO_MESSAGE:
|
case NO_MESSAGE:
|
||||||
return error(ErrorCode.NO_MSG_SPECIFIED);
|
return error(ErrorCode.NO_MSG_SPECIFIED);
|
||||||
case NOT_ENOUGH_SPACES:
|
case NOT_ENOUGH_SPACES:
|
||||||
return error(ErrorCode.NOT_ENOUGH_SPACES);
|
return error(ErrorCode.NOT_ENOUGH_SPACES);
|
||||||
|
case OK:
|
||||||
|
// nothing to do
|
||||||
|
break;
|
||||||
case REPEAT:
|
case REPEAT:
|
||||||
return error(ErrorCode.REPEAT_MESSAGE);
|
return error(ErrorCode.REPEAT_MESSAGE);
|
||||||
case TOO_FAST:
|
case TOO_FAST:
|
||||||
|
|
|
@ -28,6 +28,7 @@ import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.TreeMap;
|
import java.util.TreeMap;
|
||||||
import java.util.WeakHashMap;
|
import java.util.WeakHashMap;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
@ -35,6 +36,7 @@ import java.util.regex.Pattern;
|
||||||
|
|
||||||
import org.apache.log4j.Logger;
|
import org.apache.log4j.Logger;
|
||||||
|
|
||||||
|
import com.google.common.collect.ImmutableSet;
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
import com.google.inject.Provider;
|
import com.google.inject.Provider;
|
||||||
import com.google.inject.Singleton;
|
import com.google.inject.Singleton;
|
||||||
|
@ -56,6 +58,9 @@ public class ChatFilter {
|
||||||
private static final double DEFAULT_BASIC_CHARACTER_RATIO = .5;
|
private static final double DEFAULT_BASIC_CHARACTER_RATIO = .5;
|
||||||
private static final int DEFAULT_SPACES_MIN_MSG_LENGTH = 75;
|
private static final int DEFAULT_SPACES_MIN_MSG_LENGTH = 75;
|
||||||
private static final int DEFAULT_SPACES_REQUIRED = 3;
|
private static final int DEFAULT_SPACES_REQUIRED = 3;
|
||||||
|
private static final int DEFAULT_CAPSLOCK_MIN_MSG_LENGTH = 50;
|
||||||
|
private static final double DEFAULT_CAPSLOCK_RATIO = .5;
|
||||||
|
private static final String DEFAULT_SHADOWBAN_CHARACTERS = "";
|
||||||
|
|
||||||
public static final Pattern SIMPLE_MESSAGE_PATTERN = Pattern
|
public static final Pattern SIMPLE_MESSAGE_PATTERN = Pattern
|
||||||
.compile("^[a-zA-Z0-9 _\\-=+*()\\[\\]\\\\/|,.!:'\"`~#]+$");
|
.compile("^[a-zA-Z0-9 _\\-=+*()\\[\\]\\\\/|,.!:'\"`~#]+$");
|
||||||
|
@ -64,7 +69,7 @@ public class ChatFilter {
|
||||||
private final Map<User, FilterData> filterData = Collections.synchronizedMap(new WeakHashMap<>());
|
private final Map<User, FilterData> filterData = Collections.synchronizedMap(new WeakHashMap<>());
|
||||||
|
|
||||||
public enum Result {
|
public enum Result {
|
||||||
OK, NO_MESSAGE, NOT_ENOUGH_SPACES, REPEAT, TOO_FAST, TOO_LONG, TOO_MANY_SPECIALS
|
CAPSLOCK, DROP_MESSAGE, NO_MESSAGE, NOT_ENOUGH_SPACES, OK, REPEAT, TOO_FAST, TOO_LONG, TOO_MANY_SPECIALS
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum Scope {
|
private enum Scope {
|
||||||
|
@ -89,7 +94,8 @@ public class ChatFilter {
|
||||||
// do some more in-depth analysis. we don't want too many emoji or non-latin characters
|
// do some more in-depth analysis. we don't want too many emoji or non-latin characters
|
||||||
final long basic = message.codePoints().filter(c -> Character.isJavaIdentifierPart(c))
|
final long basic = message.codePoints().filter(c -> Character.isJavaIdentifierPart(c))
|
||||||
.count();
|
.count();
|
||||||
if (((double) basic) / total < getBasicCharacterRatio(Scope.global)) {
|
if (((double) basic) / total < getDoubleParameter(Scope.global, "basic_ratio",
|
||||||
|
DEFAULT_BASIC_CHARACTER_RATIO)) {
|
||||||
return Result.TOO_MANY_SPECIALS;
|
return Result.TOO_MANY_SPECIALS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -100,6 +106,13 @@ public class ChatFilter {
|
||||||
return Result.NOT_ENOUGH_SPACES;
|
return Result.NOT_ENOUGH_SPACES;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final long caps = message.codePoints().filter(c -> Character.isUpperCase(c)).count();
|
||||||
|
if (total >= getIntParameter(Scope.global, "capslock_min_len", DEFAULT_CAPSLOCK_MIN_MSG_LENGTH)
|
||||||
|
&& ((double) caps) / total > getDoubleParameter(Scope.global, "capslock_ratio",
|
||||||
|
DEFAULT_CAPSLOCK_RATIO)) {
|
||||||
|
return Result.CAPSLOCK;
|
||||||
|
}
|
||||||
|
|
||||||
getMessageTimes(user, Scope.global).add(System.currentTimeMillis());
|
getMessageTimes(user, Scope.global).add(System.currentTimeMillis());
|
||||||
return Result.OK;
|
return Result.OK;
|
||||||
}
|
}
|
||||||
|
@ -110,8 +123,6 @@ public class ChatFilter {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO?
|
|
||||||
|
|
||||||
getMessageTimes(user, Scope.game).add(System.currentTimeMillis());
|
getMessageTimes(user, Scope.game).add(System.currentTimeMillis());
|
||||||
return Result.OK;
|
return Result.OK;
|
||||||
}
|
}
|
||||||
|
@ -141,12 +152,22 @@ public class ChatFilter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO keep track of how much someone does this and perma-shadowban them...
|
||||||
|
for (final String banned : getShadowbanCharacters()) {
|
||||||
|
if (message.contains(banned)) {
|
||||||
|
LOG.info(String.format(
|
||||||
|
"Dropping message '%s' from user %s (%s); contains banned string %s.", message,
|
||||||
|
user.getNickname(), user.getHostname(), banned));
|
||||||
|
return Result.DROP_MESSAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Result.OK;
|
return Result.OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int getIntParameter(final Scope scope, final String name, final int defaultValue) {
|
private int getIntParameter(final Scope scope, final String name, final int defaultValue) {
|
||||||
try {
|
try {
|
||||||
return Integer.parseInt(propsProvider.get().getProperty(
|
return Integer.parseInt(getPropValue(
|
||||||
String.format("pyx.chat.%s.%s", scope, name), String.valueOf(defaultValue)));
|
String.format("pyx.chat.%s.%s", scope, name), String.valueOf(defaultValue)));
|
||||||
} catch (final NumberFormatException e) {
|
} catch (final NumberFormatException e) {
|
||||||
LOG.warn(String.format("Unable to parse pyx.chat.%s.%s as a number,"
|
LOG.warn(String.format("Unable to parse pyx.chat.%s.%s as a number,"
|
||||||
|
@ -155,6 +176,27 @@ public class ChatFilter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private double getDoubleParameter(final Scope scope, final String name,
|
||||||
|
final double defaultValue) {
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(
|
||||||
|
getPropValue(String.format("pyx.chat.%s.%s", scope, name), String.valueOf(defaultValue)));
|
||||||
|
} catch (final NumberFormatException e) {
|
||||||
|
LOG.warn(String.format("Unable to parse pyx.chat.%s.%s as a number,"
|
||||||
|
+ " using default of %d", scope, name, defaultValue), e);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> getShadowbanCharacters() {
|
||||||
|
return ImmutableSet.copyOf(getPropValue("pyx.chat.shadowban_strings",
|
||||||
|
DEFAULT_SHADOWBAN_CHARACTERS).split(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getPropValue(final String name, final String defaultValue) {
|
||||||
|
return propsProvider.get().getProperty(name, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
private int getFloodCount(final Scope scope) {
|
private int getFloodCount(final Scope scope) {
|
||||||
return getIntParameter(scope, "flood_count", DEFAULT_CHAT_FLOOD_MESSAGE_COUNT);
|
return getIntParameter(scope, "flood_count", DEFAULT_CHAT_FLOOD_MESSAGE_COUNT);
|
||||||
}
|
}
|
||||||
|
@ -164,18 +206,6 @@ public class ChatFilter {
|
||||||
.toMillis(getIntParameter(scope, "flood_time", DEFAULT_CHAT_FLOOD_TIME_SECONDS));
|
.toMillis(getIntParameter(scope, "flood_time", DEFAULT_CHAT_FLOOD_TIME_SECONDS));
|
||||||
}
|
}
|
||||||
|
|
||||||
private double getBasicCharacterRatio(final Scope scope) {
|
|
||||||
try {
|
|
||||||
return Double.parseDouble(propsProvider.get().getProperty(
|
|
||||||
String.format("pyx.chat.%s.basic_ratio", scope),
|
|
||||||
String.valueOf(DEFAULT_BASIC_CHARACTER_RATIO)));
|
|
||||||
} catch (final NumberFormatException e) {
|
|
||||||
LOG.warn(String.format("Unable to parse pyx.chat.%s.basic_ratio as a number,"
|
|
||||||
+ " using default of %d", scope, DEFAULT_BASIC_CHARACTER_RATIO), e);
|
|
||||||
return DEFAULT_BASIC_CHARACTER_RATIO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private FilterData getFilterData(final User user) {
|
private FilterData getFilterData(final User user) {
|
||||||
FilterData data;
|
FilterData data;
|
||||||
synchronized (filterData) {
|
synchronized (filterData) {
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2018, Andy Janata
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without modification, are permitted
|
||||||
|
* provided that the following conditions are met:
|
||||||
|
*
|
||||||
|
* * Redistributions of source code must retain the above copyright notice, this list of conditions
|
||||||
|
* and the following disclaimer.
|
||||||
|
* * Redistributions in binary form must reproduce the above copyright notice, this list of
|
||||||
|
* conditions and the following disclaimer in the documentation and/or other materials provided
|
||||||
|
* with the distribution.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
|
||||||
|
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
|
||||||
|
* WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package net.socialgamer.cah.util;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class ChatFilterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSimpleMessagePattern() {
|
||||||
|
assertTrue(ChatFilter.SIMPLE_MESSAGE_PATTERN.matcher("Hello world.").matches());
|
||||||
|
assertTrue(ChatFilter.SIMPLE_MESSAGE_PATTERN.matcher(":) :( =\\ ._.").matches());
|
||||||
|
assertTrue(ChatFilter.SIMPLE_MESSAGE_PATTERN.matcher("*neat* (cool) [awesome]").matches());
|
||||||
|
assertFalse(ChatFilter.SIMPLE_MESSAGE_PATTERN.matcher(":$").matches());
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue