diff --git a/TPP.Core/Commands/CommandProcessor.cs b/TPP.Core/Commands/CommandProcessor.cs index 644bdea5..9275c38f 100644 --- a/TPP.Core/Commands/CommandProcessor.cs +++ b/TPP.Core/Commands/CommandProcessor.cs @@ -5,7 +5,10 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using NodaTime; using TPP.ArgsParsing; +using TPP.Core.Utils; +using TPP.Model; using TPP.Persistence; namespace TPP.Core.Commands @@ -32,16 +35,45 @@ public class CommandProcessor : ICommandProcessor private readonly ILogger _logger; private readonly ICommandLogger _commandLogger; private readonly ArgsParser _argsParser; + private readonly IClock _clock; + private readonly Dictionary _commands = new(); + private readonly float _maxLoadFactor; + private readonly Duration _maxLoadFactorTimeframe; + private readonly float _additionalLoadFactorAtHighThreshold; + private Dictionary> _loadsPerUser = new(); + + /// + /// Create a new command processor instance + /// + /// logger + /// command logger + /// args parser instance + /// clock + /// maximum load factor before commands are silently dropped + /// timeframe for which the load factor is computed + /// + /// additional load to add to the load factor when a user is at their maximum load capacity. + /// It is linearly interpolated from 0 when there are no messages within the timeframe, + /// up to the supplied number multiplier when at the maximum amount of messages within the timeframe. + /// This is to have the load factor be more effective against continuous spam than sporadic bursts. public CommandProcessor( ILogger logger, ICommandLogger commandLogger, - ArgsParser argsParser) + ArgsParser argsParser, + IClock clock, + float maxLoadFactor = 200f, + Duration? maxLoadFactorTimeframe = null, + float additionalLoadFactorAtHighThreshold = 3f) { _logger = logger; _commandLogger = commandLogger; _argsParser = argsParser; + _clock = clock; + _maxLoadFactor = maxLoadFactor; + _maxLoadFactorTimeframe = maxLoadFactorTimeframe ?? Duration.FromMinutes(10); + _additionalLoadFactorAtHighThreshold = additionalLoadFactorAtHighThreshold; } public void InstallCommand(Command command) @@ -77,8 +109,35 @@ public void UninstallCommand(params string[] commandOrAlias) public Command? FindCommand(string commandName) => _commands.TryGetValue(commandName.ToLower(), out Command command) ? command : null; + private float CheckAndUpdateLoadFactorForUser(User user) + { + _loadsPerUser = _loadsPerUser + .Where(kvp => kvp.Value.Count > 0) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + if (!_loadsPerUser.TryGetValue(user, out TtlQueue? loads)) + { + loads = new TtlQueue(_maxLoadFactorTimeframe, _clock); + _loadsPerUser[user] = loads; + } + float sum = loads.Sum(); + float ratioFilled = Math.Min(1, sum / _maxLoadFactor); + float toAdd = 1 + ratioFilled * _additionalLoadFactorAtHighThreshold; + loads.Enqueue(toAdd); + return sum + toAdd; + } + public async Task Process(string commandName, IImmutableList args, Message message) { + float loadFactor = CheckAndUpdateLoadFactorForUser(message.User); + _logger.LogDebug("new load factor is {LoadFactor}", loadFactor); + if (loadFactor > _maxLoadFactor) + { + _logger.LogDebug( + "command '{Command}' from user {User} ignored because load factor is {LoadFactor} " + + "for timeframe {Duration}, which is above the maximum of {MaxLoadFactor}", + commandName, message.User, loadFactor, _maxLoadFactorTimeframe, _maxLoadFactor); + return new CommandResult(); + } if (!_commands.TryGetValue(commandName.ToLower(), out Command command)) { _logger.LogDebug("unknown command '{Command}'", commandName); diff --git a/TPP.Core/Setups.cs b/TPP.Core/Setups.cs index 62e4be7a..33532590 100644 --- a/TPP.Core/Setups.cs +++ b/TPP.Core/Setups.cs @@ -70,7 +70,7 @@ public static ICommandProcessor SetUpCommandProcessor( { ICommandProcessor commandProcessor = new CommandProcessor( loggerFactory.CreateLogger(), - databases.CommandLogger, argsParser); + databases.CommandLogger, argsParser, SystemClock.Instance); IEnumerable commands = new[] { diff --git a/tests/TPP.Core.Tests/Commands/CommandProcessorTest.cs b/tests/TPP.Core.Tests/Commands/CommandProcessorTest.cs index eb0c9fcb..b48a88b6 100644 --- a/tests/TPP.Core.Tests/Commands/CommandProcessorTest.cs +++ b/tests/TPP.Core.Tests/Commands/CommandProcessorTest.cs @@ -20,19 +20,20 @@ public class CommandProcessorTest private readonly ILogger _nullLogger = new NullLogger(); private readonly Mock _commandLoggerMock = new(); private readonly ImmutableList _noArgs = ImmutableList.Empty; - private readonly User _mockUser = new User( + private static User MockUser() => new( id: Guid.NewGuid().ToString(), name: "MockUser", twitchDisplayName: "☺MockUser", simpleName: "mockuser", color: null, firstActiveAt: Instant.FromUnixTimeSeconds(0), lastActiveAt: Instant.FromUnixTimeSeconds(0), lastMessageAt: null, pokeyen: 0, tokens: 0); + private readonly User _mockUser = MockUser(); - private Message MockMessage(string text = "") - => new Message(_mockUser, text, MessageSource.Chat, string.Empty); + private Message MockMessage(string text = "") => new(_mockUser, text, MessageSource.Chat, string.Empty); [Test] public async Task TestUnknownCommand() { - var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser()); + var commandProcessor = new CommandProcessor( + _nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of()); CommandResult? result = await commandProcessor.Process("unknown", _noArgs, MockMessage()); @@ -44,7 +45,8 @@ public async Task TestUnknownCommand() public async Task TestLogSlowCommand() { var loggerMock = new Mock>(); - var commandProcessor = new CommandProcessor(loggerMock.Object, _commandLoggerMock.Object, new ArgsParser()); + var commandProcessor = new CommandProcessor( + loggerMock.Object, _commandLoggerMock.Object, new ArgsParser(), Mock.Of()); commandProcessor.InstallCommand(new Command("slow", async _ => { await Task.Delay(TimeSpan.FromMilliseconds(1050)); @@ -62,7 +64,8 @@ public async Task TestLogSlowCommand() public async Task TestCommandThrowsError() { var loggerMock = new Mock>(); - var commandProcessor = new CommandProcessor(loggerMock.Object, _commandLoggerMock.Object, new ArgsParser()); + var commandProcessor = new CommandProcessor( + loggerMock.Object, _commandLoggerMock.Object, new ArgsParser(), Mock.Of()); commandProcessor.InstallCommand(new Command("broken", _ => throw new InvalidOperationException("this command is busted!"))); @@ -78,7 +81,8 @@ public async Task TestCommandThrowsError() [Test] public async Task TestCaseInsensitive() { - var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser()); + var commandProcessor = new CommandProcessor( + _nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of()); commandProcessor.InstallCommand(new Command("MiXeD", CommandUtils.StaticResponse("Hi!"))); foreach (string command in ImmutableList.Create("MiXeD", "mixed", "MIXED")) @@ -91,7 +95,8 @@ public async Task TestCaseInsensitive() [Test] public async Task TestAliases() { - var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser()); + var commandProcessor = new CommandProcessor( + _nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of()); commandProcessor.InstallCommand(new Command( "main", CommandUtils.StaticResponse("Hi!")) { Aliases = new[] { "alias1", "alias2" } }); @@ -105,7 +110,8 @@ public async Task TestAliases() [Test] public void InstallConflictName() { - var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser()); + var commandProcessor = new CommandProcessor( + _nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of()); commandProcessor.InstallCommand(new Command("a", CommandUtils.StaticResponse("Hi!"))); ArgumentException ex = Assert.Throws(() => commandProcessor @@ -116,7 +122,8 @@ public void InstallConflictName() [Test] public void InstallConflictAlias() { - var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser()); + var commandProcessor = new CommandProcessor( + _nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of()); commandProcessor.InstallCommand(new Command( "a", CommandUtils.StaticResponse("Hi!")) { Aliases = new[] { "x" } }); @@ -128,7 +135,8 @@ public void InstallConflictAlias() [Test] public void InstallConflictNameVsAlias() { - var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser()); + var commandProcessor = new CommandProcessor( + _nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of()); commandProcessor.InstallCommand(new Command( "a", CommandUtils.StaticResponse("Hi!")) { Aliases = new[] { "b" } }); @@ -140,7 +148,8 @@ public void InstallConflictNameVsAlias() [Test] public async Task TestPermissions() { - var commandProcessor = new CommandProcessor(_nullLogger, _commandLoggerMock.Object, new ArgsParser()); + var commandProcessor = new CommandProcessor( + _nullLogger, _commandLoggerMock.Object, new ArgsParser(), Mock.Of()); commandProcessor.InstallCommand( new Command("opsonly", CommandUtils.StaticResponse("you are an operator")).WithCondition( canExecute: ctx => IsOperator(ctx.Message.User), @@ -161,5 +170,49 @@ bool IsOperator(User user) => "opsonly", _noArgs, new Message(op, "", MessageSource.Chat, "")); Assert.That(opResult?.Response, Is.EqualTo("you are an operator")); } + + [Test] + public async Task MaxCommandsPerUser() + { + Mock clockMock = new(); + var commandProcessor = new CommandProcessor( + _nullLogger, _commandLoggerMock.Object, new ArgsParser(), clockMock.Object, + maxLoadFactor: 6, maxLoadFactorTimeframe: Duration.FromSeconds(10), + additionalLoadFactorAtHighThreshold: 6); + + commandProcessor.InstallCommand(new Command("foo", + _ => Task.FromResult(new CommandResult {Response = "yes!"}))); + + clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(0)); + CommandResult? resultOk1 = await commandProcessor.Process( + "foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, "")); + + // has +1 additional load factor because the load factor is already at 1/6, which * 6 additional load is 1 + // result is a total load of 3 + clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(5)); + CommandResult? resultOk2 = await commandProcessor.Process( + "foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, "")); + + // at 50% load already. this gets rejected and adds an additional +3 load (50% of additional 6 load) + // result is a total load of 7 + clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(10)); + CommandResult? resultNo = await commandProcessor.Process( + "foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, "")); + + // make sure this is per-user + CommandResult? resultOkOtherUser = await commandProcessor.Process( + "foo", ImmutableList.Create(""), new Message(MockUser(), "", MessageSource.Chat, "")); + + // letting everything so far expire lets the user use commands again + clockMock.Setup(clock => clock.GetCurrentInstant()).Returns(Instant.FromUnixTimeSeconds(21)); + CommandResult? resultOk3 = await commandProcessor.Process( + "foo", ImmutableList.Create(""), new Message(_mockUser, "", MessageSource.Chat, "")); + + Assert.That(resultOk1?.Response, Is.EqualTo("yes!")); + Assert.That(resultOk2?.Response, Is.EqualTo("yes!")); + Assert.That(resultNo?.Response, Is.Null); + Assert.That(resultOkOtherUser?.Response, Is.EqualTo("yes!")); + Assert.That(resultOk3?.Response, Is.EqualTo("yes!")); + } } }