Skip to content

Commit

Permalink
Merge pull request #229 from bdach/more-chat-filtering
Browse files Browse the repository at this point in the history
Add extended capabilities for chat filters
  • Loading branch information
peppy authored May 1, 2024
2 parents d18c475 + c7c2f8c commit 5487ab1
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 13 deletions.
98 changes: 98 additions & 0 deletions osu.Server.Spectator.Tests/ChatFiltersTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Threading.Tasks;
using Moq;
using osu.Game.Online.Multiplayer;
using osu.Server.Spectator.Database;
using osu.Server.Spectator.Database.Models;
using Xunit;

namespace osu.Server.Spectator.Tests
{
public class ChatFiltersTest
{
private readonly Mock<IDatabaseFactory> factoryMock;
private readonly Mock<IDatabaseAccess> databaseMock;

public ChatFiltersTest()
{
factoryMock = new Mock<IDatabaseFactory>();
databaseMock = new Mock<IDatabaseAccess>();

factoryMock.Setup(factory => factory.GetInstance()).Returns(databaseMock.Object);
}

[Theory]
[InlineData("bad phrase", "good phrase")]
[InlineData("WHAT HAPPENS IF I SAY BAD THING IN CAPS", "WHAT HAPPENS IF I SAY good THING IN CAPS")]
[InlineData("thing is bad", "thing is good")]
[InlineData("look at this badness", "look at this goodness")]
public async Task TestPlainFilterReplacement(string input, string expectedOutput)
{
databaseMock.Setup(db => db.GetAllChatFiltersAsync()).ReturnsAsync([
new chat_filter { match = "bad", replacement = "good" },
new chat_filter { match = "fullword", replacement = "okay", whitespace_delimited = true },
new chat_filter { match = "absolutely forbidden", replacement = "", block = true }
]);

var filters = new ChatFilters(factoryMock.Object);

Assert.Equal(expectedOutput, await filters.FilterAsync(input));
}

[Theory]
[InlineData("fullword at the start", "okay at the start")]
[InlineData("FULLWORD IN CAPS!!", "okay IN CAPS!!")]
[InlineData("at the end is fullword", "at the end is okay")]
[InlineData("middle is where the fullword is", "middle is where the okay is")]
[InlineData("anotherfullword is not replaced", "anotherfullword is not replaced")]
[InlineData("fullword fullword2", "okay great")]
[InlineData("fullwordfullword2", "fullwordfullword2")]
[InlineData("i do a delimiter/inside", "i do a nice try")]
public async Task TestWhitespaceDelimitedFilterReplacement(string input, string expectedOutput)
{
databaseMock.Setup(db => db.GetAllChatFiltersAsync()).ReturnsAsync([
new chat_filter { match = "bad", replacement = "good" },
new chat_filter { match = "fullword", replacement = "okay", whitespace_delimited = true },
new chat_filter { match = "fullword2", replacement = "great", whitespace_delimited = true },
new chat_filter { match = "delimiter/inside", replacement = "nice try", whitespace_delimited = true },
new chat_filter { match = "absolutely forbidden", replacement = "", block = true }
]);

var filters = new ChatFilters(factoryMock.Object);

Assert.Equal(expectedOutput, await filters.FilterAsync(input));
}

[Theory]
[InlineData("absolutely forbidden")]
[InlineData("sPoNGeBoB SaYS aBSolUtElY FoRbIdDeN")]
[InlineData("this is absolutely forbidden full stop!!!")]
public async Task TestBlockingFilter(string input)
{
databaseMock.Setup(db => db.GetAllChatFiltersAsync()).ReturnsAsync([
new chat_filter { match = "bad", replacement = "good" },
new chat_filter { match = "fullword", replacement = "okay", whitespace_delimited = true },
new chat_filter { match = "absolutely forbidden", replacement = "", block = true }
]);

var filters = new ChatFilters(factoryMock.Object);

await Assert.ThrowsAsync<InvalidStateException>(() => filters.FilterAsync(input));
}

[Fact]
public async Task TestLackOfBlockingFilters()
{
databaseMock.Setup(db => db.GetAllChatFiltersAsync()).ReturnsAsync([
new chat_filter { match = "bad", replacement = "good" },
new chat_filter { match = "fullword", replacement = "okay", whitespace_delimited = true },
]);

var filters = new ChatFilters(factoryMock.Object);

await filters.FilterAsync("this should be completely fine"); // should not throw
}
}
}
11 changes: 11 additions & 0 deletions osu.Server.Spectator.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -769,9 +769,19 @@ See the LICENCE file in the repository root for full licence text.&#xD;
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateStaticReadonly/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=StaticReadonly/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=TypeParameters/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=15b5b1f1_002D457c_002D4ca6_002Db278_002D5615aedc07d3/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="READONLY_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=236f7aa5_002D7b06_002D43ca_002Dbf2a_002D9b31bfcff09a/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Private" Description="Constant fields (private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="CONSTANT_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=2c62818f_002D621b_002D4425_002Dadc9_002D78611099bfcb/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Any" Description="Type parameters"&gt;&lt;ElementKinds&gt;&lt;Kind Name="TYPE_PARAMETER" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=4a98fdf6_002D7d98_002D4f5a_002Dafeb_002Dea44ad98c70c/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="FIELD" /&gt;&lt;Kind Name="READONLY_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"&gt;&lt;ExtraRule Prefix="_" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=669e5282_002Dfb4b_002D4e90_002D91e7_002D07d269d04b60/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Constant fields (not private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="CONSTANT_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=76f79b1e_002Dece7_002D4df2_002Da322_002D1bd7fea25eb7/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local functions"&gt;&lt;ElementKinds&gt;&lt;Kind Name="LOCAL_FUNCTION" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=8b8504e3_002Df0be_002D4c14_002D9103_002Dc732f2bddc15/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Any" Description="Enum members"&gt;&lt;ElementKinds&gt;&lt;Kind Name="ENUM_MEMBER" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="False" Prefix="" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=9d1af99b_002Dbefe_002D48a4_002D9eb3_002D661384e29869/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static, Instance" AccessRightKinds="Private" Description="private methods"&gt;&lt;ElementKinds&gt;&lt;Kind Name="ASYNC_METHOD" /&gt;&lt;Kind Name="METHOD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=9ffbe43b_002Dc610_002D411b_002D9839_002D1416a146d9b0/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static, Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public" Description="internal/protected/public methods"&gt;&lt;ElementKinds&gt;&lt;Kind Name="ASYNC_METHOD" /&gt;&lt;Kind Name="METHOD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=a4c2df6c_002Db202_002D48d5_002Db077_002De678cb548c25/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static, Instance" AccessRightKinds="Private" Description="private properties"&gt;&lt;ElementKinds&gt;&lt;Kind Name="PROPERTY" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=a4f433b8_002Dabcd_002D4e55_002Da08f_002D82e78cef0f0c/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local constants"&gt;&lt;ElementKinds&gt;&lt;Kind Name="LOCAL_CONSTANT" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=c873eafb_002Dd57f_002D481d_002D8c93_002D77f6863c2f88/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Static readonly fields (not private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="READONLY_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=f9fce829_002De6f4_002D4cb2_002D80f1_002D5497c44f51df/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=fd562728_002Dc23d_002D417f_002Da19f_002D9d854247fbea/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static, Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public" Description="internal/protected/public properties"&gt;&lt;ElementKinds&gt;&lt;Kind Name="PROPERTY" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/JavaScriptNaming/UserRules/=JS_005FBLOCK_005FSCOPE_005FCONSTANT/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/JavaScriptNaming/UserRules/=JS_005FBLOCK_005FSCOPE_005FFUNCTION/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /&gt;</s:String>
Expand Down Expand Up @@ -837,6 +847,7 @@ See the LICENCE file in the repository root for full licence text.&#xD;
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002ECSharpPlaceAttributeOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateThisQualifierSettings/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EPredefinedNamingRulesToUserRulesUpgrade/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/Environment/UnitTesting/NUnitProvider/SetCurrentDirectoryTo/@EntryValue">TestFolder</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=28A2A5FC43E07C488A4BC7430879479E/@KeyIndexDefined">True</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=28A2A5FC43E07C488A4BC7430879479E/Applicability/=Live/@EntryIndexedValue">True</s:Boolean>
Expand Down
69 changes: 58 additions & 11 deletions osu.Server.Spectator/ChatFilters.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Collections.Immutable;
using System.Text;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using osu.Game.Online.Multiplayer;
using osu.Server.Spectator.Database;
using osu.Server.Spectator.Database.Models;

Expand All @@ -12,7 +15,12 @@ namespace osu.Server.Spectator
public class ChatFilters
{
private readonly IDatabaseFactory factory;
private ImmutableArray<chat_filter>? filters;

private bool filtersInitialised;
private Regex? blockRegex;

private readonly List<(string match, string replacement)> nonWhitespaceDelimitedReplaces = new List<(string, string)>();
private readonly List<(Regex match, string replacement)> whitespaceDelimitedReplaces = new List<(Regex, string)>();

public ChatFilters(IDatabaseFactory factory)
{
Expand All @@ -21,18 +29,57 @@ public ChatFilters(IDatabaseFactory factory)

public async Task<string> FilterAsync(string input)
{
if (filters == null)
if (!filtersInitialised)
await initialiseFilters();

if (blockRegex?.Match(input).Success == true)
throw new InvalidStateException("You can't say that.");

// this is a touch inefficient due to string allocs,
// but there's no way for `StringBuilder` to do case-insensitive replaces on strings
// or any replaces on regexes at all...

foreach (var filter in nonWhitespaceDelimitedReplaces)
input = input.Replace(filter.match, filter.replacement, StringComparison.OrdinalIgnoreCase);

foreach (var filter in whitespaceDelimitedReplaces)
input = filter.match.Replace(input, filter.replacement);

return input;
}

private async Task initialiseFilters()
{
using var db = factory.GetInstance();
var allFilters = await db.GetAllChatFiltersAsync();

var blockingFilters = allFilters.Where(f => f.block).ToArray();
if (blockingFilters.Length > 0)
blockRegex = new Regex(string.Join('|', blockingFilters.Select(singleFilterRegex)), RegexOptions.Compiled | RegexOptions.IgnoreCase);

foreach (var nonBlockingFilter in allFilters.Where(f => !f.block))
{
using var db = factory.GetInstance();
filters = (await db.GetAllChatFiltersAsync()).ToImmutableArray();
if (nonBlockingFilter.whitespace_delimited)
{
whitespaceDelimitedReplaces.Add((
new Regex(singleFilterRegex(nonBlockingFilter), RegexOptions.Compiled | RegexOptions.IgnoreCase),
nonBlockingFilter.replacement));
}
else
{
nonWhitespaceDelimitedReplaces.Add((nonBlockingFilter.match, nonBlockingFilter.replacement));
}
}

var stringBuilder = new StringBuilder(input);

foreach (var filter in filters)
stringBuilder.Replace(filter.match, filter.replacement);
filtersInitialised = true;
}

return stringBuilder.ToString();
private static string singleFilterRegex(chat_filter filter)
{
string term = Regex.Escape(filter.match);
if (filter.whitespace_delimited)
term = $@"\b{term}\b";
return term;
}
}
}
2 changes: 2 additions & 0 deletions osu.Server.Spectator/Database/Models/chat_filter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ public class chat_filter
public long id { get; set; }
public string match { get; set; } = string.Empty;
public string replacement { get; set; } = string.Empty;
public bool block { get; set; }
public bool whitespace_delimited { get; set; }
}
}
2 changes: 0 additions & 2 deletions osu.Server.Spectator/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
using System.IO;
using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting;
using osu.Framework.Logging;
using osu.Framework.Platform;
using Sentry;
using StatsdClient;

namespace osu.Server.Spectator
Expand Down

0 comments on commit 5487ab1

Please sign in to comment.