Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add extended capabilities for chat filters #229

Merged
merged 4 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 <[email protected]>. 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
}
}
}
68 changes: 57 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 <[email protected]>. 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,11 @@ namespace osu.Server.Spectator
public class ChatFilters
{
private readonly IDatabaseFactory factory;
private ImmutableArray<chat_filter>? filters;

private bool filtersInitialised;
private Regex? blockRegex;
private List<(string match, string replacement)> nonWhitespaceDelimitedReplaces = new List<(string, string)>();
private List<(Regex match, string replacement)> whitespaceDelimitedReplaces = new List<(Regex, string)>();

public ChatFilters(IDatabaseFactory factory)
{
Expand All @@ -21,18 +28,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(string.Empty);
peppy marked this conversation as resolved.
Show resolved Hide resolved

// 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; }
}
}