diff --git a/Chat/Commands/ChatCommandResult.cs b/Chat/Commands/ChatCommandResult.cs index bf12dc5..6dad8a2 100644 --- a/Chat/Commands/ChatCommandResult.cs +++ b/Chat/Commands/ChatCommandResult.cs @@ -9,5 +9,6 @@ namespace TwitchChatTTS.Chat.Commands Syntax = 4, Fail = 5, OtherRoom = 6, + RateLimited = 7 } } \ No newline at end of file diff --git a/Chat/Commands/CommandManager.cs b/Chat/Commands/CommandManager.cs index 96d4fc7..9d4586c 100644 --- a/Chat/Commands/CommandManager.cs +++ b/Chat/Commands/CommandManager.cs @@ -3,6 +3,7 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using Microsoft.Extensions.DependencyInjection; using Serilog; +using TwitchChatTTS.Chat.Commands.Limits; using TwitchChatTTS.Chat.Groups.Permissions; using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Twitch.Socket.Messages; @@ -17,6 +18,7 @@ namespace TwitchChatTTS.Chat.Commands private readonly HermesSocketClient _hermes; //private readonly TwitchWebsocketClient _twitch; private readonly IGroupPermissionManager _permissionManager; + private readonly IUsagePolicy _permissionPolicy; private readonly ILogger _logger; private string CommandStartSign { get; } = "!"; @@ -26,6 +28,7 @@ namespace TwitchChatTTS.Chat.Commands [FromKeyedServices("hermes")] SocketClient hermes, //[FromKeyedServices("twitch")] SocketClient twitch, IGroupPermissionManager permissionManager, + IUsagePolicy limitManager, ILogger logger ) { @@ -33,6 +36,7 @@ namespace TwitchChatTTS.Chat.Commands _hermes = (hermes as HermesSocketClient)!; //_twitch = (twitch as TwitchWebsocketClient)!; _permissionManager = permissionManager; + _permissionPolicy = limitManager; _logger = logger; } @@ -69,9 +73,10 @@ namespace TwitchChatTTS.Chat.Commands // Check if command can be executed by this chatter. var command = selectorResult.Command; long chatterId = long.Parse(message.ChatterUserId); + var path = $"tts.commands.{com}"; if (chatterId != _user.OwnerId) { - bool executable = command.AcceptCustomPermission ? CanExecute(chatterId, groups, $"tts.commands.{com}", selectorResult.Permissions) : false; + bool executable = command.AcceptCustomPermission ? CanExecute(chatterId, groups, path, selectorResult.Permissions) : false; if (!executable) { _logger.Warning($"Denied permission to use command [chatter id: {chatterId}][args: {arg}][command type: {command.GetType().Name}]"); @@ -79,6 +84,12 @@ namespace TwitchChatTTS.Chat.Commands } } + if (!_permissionPolicy.TryUse(chatterId, groups, path)) + { + _logger.Warning($"Chatter reached usage limit on command [command type: {command.GetType().Name}][chatter id: {chatterId}][path: {path}][groups: {string.Join("|", groups)}]"); + return ChatCommandResult.RateLimited; + } + // Check if the arguments are valid. var arguments = _commandSelector.GetNonStaticArguments(args, selectorResult.Path); foreach (var entry in arguments) @@ -88,7 +99,7 @@ namespace TwitchChatTTS.Chat.Commands // Optional parameters were validated while fetching this command. if (!parameter.Optional && !parameter.Validate(argument, message.Message.Fragments)) { - _logger.Warning($"Command failed due to an argument being invalid [argument name: {parameter.Name}][argument value: {argument}][arguments: {arg}][command type: {command.GetType().Name}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]"); + _logger.Warning($"Command failed due to an argument being invalid [argument name: {parameter.Name}][argument value: {argument}][parameter type: {parameter.GetType().Name}][arguments: {arg}][command type: {command.GetType().Name}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]"); return ChatCommandResult.Syntax; } } diff --git a/Chat/Commands/Limits/CommandLimitManager.cs b/Chat/Commands/Limits/CommandLimitManager.cs deleted file mode 100644 index 1e3c5a7..0000000 --- a/Chat/Commands/Limits/CommandLimitManager.cs +++ /dev/null @@ -1,88 +0,0 @@ -namespace TwitchChatTTS.Chat.Commands.Limits -{ - public interface ICommandLimitManager - { - - bool HasReachedLimit(long chatterId, string name, string group); - void RemoveUsageLimit(string name, string group); - void SetUsageLimit(int count, TimeSpan span, string name, string group); - bool TryUse(long chatterId, string name, string group); - } - - public class CommandLimitManager : ICommandLimitManager - { - // group + name -> chatter id -> usage - private readonly IDictionary> _usages; - // group + name -> limit - private readonly IDictionary _limits; - - - public CommandLimitManager() - { - _usages = new Dictionary>(); - _limits = new Dictionary(); - } - - - public bool HasReachedLimit(long chatterId, string name, string group) - { - throw new NotImplementedException(); - } - - public void RemoveUsageLimit(string name, string group) - { - throw new NotImplementedException(); - } - - public void SetUsageLimit(int count, TimeSpan span, string name, string group) - { - throw new NotImplementedException(); - } - - public bool TryUse(long chatterId, string name, string group) - { - var path = $"{group}.{name}"; - if (!_limits.TryGetValue(path, out var limit)) - return true; - - if (!_usages.TryGetValue(path, out var groupUsage)) - { - groupUsage = new Dictionary(); - _usages.Add(path, groupUsage); - } - - if (!groupUsage.TryGetValue(chatterId, out var usage)) - { - usage = new Usage() - { - Usages = new long[limit.Count], - Index = 0 - }; - groupUsage.Add(chatterId, usage); - } - - int first = (usage.Index + 1) % limit.Count; - long timestamp = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMillisecond; - if (timestamp - usage.Usages[first] < limit.Span) - { - return false; - } - - usage.Usages[usage.Index] = timestamp; - usage.Index = first; - return true; - } - - private class Usage - { - public long[] Usages { get; set; } - public int Index { get; set; } - } - - private struct Limit - { - public int Count { get; set; } - public int Span { get; set; } - } - } -} \ No newline at end of file diff --git a/Chat/Commands/Limits/IUsagePolicy.cs b/Chat/Commands/Limits/IUsagePolicy.cs new file mode 100644 index 0000000..03cd7a9 --- /dev/null +++ b/Chat/Commands/Limits/IUsagePolicy.cs @@ -0,0 +1,10 @@ +namespace TwitchChatTTS.Chat.Commands.Limits +{ + public interface IUsagePolicy + { + void Remove(string group, string policy); + void Set(string group, string policy, int count, TimeSpan span); + bool TryUse(K key, string group, string policy); + public bool TryUse(K key, IEnumerable groups, string policy); + } +} \ No newline at end of file diff --git a/Chat/Commands/Limits/UsagePolicy.cs b/Chat/Commands/Limits/UsagePolicy.cs new file mode 100644 index 0000000..5c8a81b --- /dev/null +++ b/Chat/Commands/Limits/UsagePolicy.cs @@ -0,0 +1,216 @@ +using Serilog; + +namespace TwitchChatTTS.Chat.Commands.Limits +{ + public class UsagePolicy : IUsagePolicy where K : notnull + { + private readonly ILogger _logger; + + private readonly UsagePolicyNode _root; + + + public UsagePolicy(ILogger logger) + { + _logger = logger; + _root = new UsagePolicyNode(string.Empty, null, null, logger); + } + + + public void Remove(string group, string policy) + { + ArgumentException.ThrowIfNullOrWhiteSpace(group, nameof(group)); + ArgumentException.ThrowIfNullOrWhiteSpace(policy, nameof(policy)); + + string[] path = (group + '.' + policy).Split('.'); + _root.Remove(path); + } + + public void Set(string group, string policy, int count, TimeSpan span) + { + ArgumentException.ThrowIfNullOrWhiteSpace(group, nameof(group)); + ArgumentException.ThrowIfNullOrWhiteSpace(policy, nameof(policy)); + if (count <= 0) + throw new InvalidOperationException("Count cannot be 0 or lower."); + if (span.TotalMilliseconds == 0) + throw new InvalidOperationException("Time span cannot be 0 milliseconds."); + + string[] path = (group + '.' + policy).Split('.'); + _root.Set(path, count, span); + } + + public bool TryUse(K key, string group, string policy) + { + ArgumentException.ThrowIfNullOrWhiteSpace(group, nameof(group)); + ArgumentException.ThrowIfNullOrWhiteSpace(policy, nameof(policy)); + + string[] path = (group + '.' + policy).Split('.'); + UsagePolicyNode? node = _root.Get(path); + _logger.Debug($"Fetched policy node [is null: {node == null}]"); + if (node == null) + return false; + return node.TryUse(key, DateTime.UtcNow); + } + + public bool TryUse(K key, IEnumerable groups, string policy) + { + ArgumentNullException.ThrowIfNull(groups, nameof(groups)); + ArgumentException.ThrowIfNullOrWhiteSpace(policy, nameof(policy)); + + foreach (string group in groups) + { + if (TryUse(key, group, policy)) + { + _logger.Debug($"Checking policy node [policy: {group}.{policy}][result: True]"); + return true; + } + _logger.Debug($"Checking policy node [policy: {group}.{policy}][result: False]"); + } + return false; + } + + + private class UsagePolicyLimit + { + public int Count { get; set; } + public TimeSpan Span { get; set; } + + + public UsagePolicyLimit(int count, TimeSpan span) + { + Count = count; + Span = span; + } + } + + private class UserUsageData + { + public DateTime[] Uses { get; set; } + public int Index { get; set; } + + public UserUsageData(int size, int index) + { + Uses = new DateTime[size]; + Index = index; + } + } + + private class UsagePolicyNode where T : notnull + { + public string Name { get; set; } + public UsagePolicyLimit? Limit { get; private set; } + private UsagePolicyNode? _parent { get; } + private IDictionary _usages { get; } + private IList> _children { get; } + private ILogger _logger; + private object _lock { get; } + + public UsagePolicyNode(string name, UsagePolicyLimit? data, UsagePolicyNode? parent, ILogger logger) + { + //ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(name)); + Name = name; + Limit = data; + _parent = parent; + _usages = new Dictionary(); + _children = new List>(); + _logger = logger; + _lock = new object(); + } + + + public UsagePolicyNode? Get(IEnumerable path) + { + if (!path.Any()) + return this; + + var nextName = path.First(); + var next = _children.FirstOrDefault(c => c.Name == nextName); + if (next == null) + return this; + return next.Get(path.Skip(1)); + } + + public UsagePolicyNode? Remove(IEnumerable path) + { + if (!path.Any()) + { + if (_parent == null) + throw new InvalidOperationException("Cannot remove root node"); + + _parent._children.Remove(this); + return this; + } + + var nextName = path.First(); + var next = _children.FirstOrDefault(c => c.Name == nextName); + _logger.Debug($"internal remove node [is null: {next == null}][path: {string.Join('.', path)}]"); + if (next == null) + return null; + return next.Remove(path.Skip(1)); + } + + public void Set(IEnumerable path, int count, TimeSpan span) + { + if (!path.Any()) + { + Limit = new UsagePolicyLimit(count, span); + return; + } + + var nextName = path.First(); + var next = _children.FirstOrDefault(c => c.Name == nextName); + _logger.Debug($"internal set node [is null: {next == null}][path: {string.Join('.', path)}]"); + if (next == null) + { + next = new UsagePolicyNode(nextName, null, this, _logger); + _children.Add(next); + } + next.Set(path.Skip(1), count, span); + } + + public bool TryUse(T key, DateTime timestamp) + { + if (_parent == null) + return false; + if (Limit == null || Limit.Count <= 0) + return _parent.TryUse(key, timestamp); + + UserUsageData? usage; + lock (_lock) + { + if (!_usages.TryGetValue(key, out usage)) + { + usage = new UserUsageData(Limit.Count, 1 % Limit.Count); + usage.Uses[0] = timestamp; + _usages.Add(key, usage); + _logger.Debug($"internal use node create"); + return true; + } + + if (usage.Uses.Length != Limit.Count) + { + var sizeDiff = Math.Max(0, usage.Uses.Length - Limit.Count); + var temp = usage.Uses.Skip(sizeDiff); + var tempSize = usage.Uses.Length - sizeDiff; + usage.Uses = temp.Union(new DateTime[Math.Max(0, Limit.Count - tempSize)]).ToArray(); + } + } + + // Attempt on parent node if policy has been abused. + if (timestamp - usage.Uses[usage.Index] < Limit.Span) + { + _logger.Debug($"internal use node spam [span: {(timestamp - usage.Uses[usage.Index]).TotalMilliseconds}][index: {usage.Index}]"); + return _parent.TryUse(key, timestamp); + } + + _logger.Debug($"internal use node normal [span: {(timestamp - usage.Uses[usage.Index]).TotalMilliseconds}][index: {usage.Index}]"); + lock (_lock) + { + usage.Uses[usage.Index] = timestamp; + usage.Index = (usage.Index + 1) % Limit.Count; + } + + return true; + } + } + } +} \ No newline at end of file diff --git a/Chat/Messaging/ChatMessageReader.cs b/Chat/Messaging/ChatMessageReader.cs index 09fd2c5..4e75472 100644 --- a/Chat/Messaging/ChatMessageReader.cs +++ b/Chat/Messaging/ChatMessageReader.cs @@ -12,7 +12,7 @@ using TwitchChatTTS.Twitch.Socket.Messages; namespace TwitchChatTTS.Chat.Messaging { - public class ChatMessageReader + public class ChatMessageReader : IChatMessageReader { private readonly User _user; private readonly TTSPlayer _player; @@ -94,7 +94,6 @@ namespace TwitchChatTTS.Chat.Messaging private IEnumerable HandlePartialMessage(string voice, string message) { var parts = _sfxRegex.Split(message); - if (parts.Length == 1) { return [new TTSMessage() diff --git a/Chat/Messaging/IChatMessageReader.cs b/Chat/Messaging/IChatMessageReader.cs new file mode 100644 index 0000000..88bd5e6 --- /dev/null +++ b/Chat/Messaging/IChatMessageReader.cs @@ -0,0 +1,10 @@ +using TwitchChatTTS.Twitch.Socket; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Chat.Messaging +{ + public interface IChatMessageReader + { + Task Read(TwitchWebsocketClient sender, long broadcasterId, long? chatterId, string? chatterLogin, string? messageId, TwitchReplyInfo? reply, TwitchChatFragment[] fragments, int priority); + } +} \ No newline at end of file diff --git a/Hermes/Socket/Handlers/LoginAckHandler.cs b/Hermes/Socket/Handlers/LoginAckHandler.cs index 2089dcb..3fad6a8 100644 --- a/Hermes/Socket/Handlers/LoginAckHandler.cs +++ b/Hermes/Socket/Handlers/LoginAckHandler.cs @@ -68,6 +68,7 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers await client.FetchEmotes(); await client.FetchRedemptions(); await client.FetchPermissions(); + await client.FetchPolicies(); if (_user.NightbotConnection != null) { diff --git a/Hermes/Socket/Handlers/RequestAckHandler.cs b/Hermes/Socket/Handlers/RequestAckHandler.cs index 77bbaea..d9a12f0 100644 --- a/Hermes/Socket/Handlers/RequestAckHandler.cs +++ b/Hermes/Socket/Handlers/RequestAckHandler.cs @@ -6,8 +6,10 @@ using CommonSocketLibrary.Common; using HermesSocketLibrary.Requests.Callbacks; using HermesSocketLibrary.Requests.Messages; using HermesSocketLibrary.Socket.Data; +using HermesSocketServer.Models; using Microsoft.Extensions.DependencyInjection; using Serilog; +using TwitchChatTTS.Chat.Commands.Limits; using TwitchChatTTS.Chat.Emotes; using TwitchChatTTS.Chat.Groups; using TwitchChatTTS.Chat.Groups.Permissions; @@ -19,6 +21,8 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers { private User _user; private readonly ICallbackManager _callbackManager; + private readonly IChatterGroupManager _groups; + private readonly IUsagePolicy _policies; private readonly TwitchApiClient _twitch; private readonly NightbotApiClient _nightbot; private readonly IServiceProvider _serviceProvider; @@ -32,6 +36,8 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers public RequestAckHandler( ICallbackManager callbackManager, + IChatterGroupManager groups, + IUsagePolicy policies, TwitchApiClient twitch, NightbotApiClient nightbot, IServiceProvider serviceProvider, @@ -41,6 +47,8 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers ) { _callbackManager = callbackManager; + _groups = groups; + _policies = policies; _twitch = twitch; _nightbot = nightbot; _serviceProvider = serviceProvider; @@ -359,6 +367,74 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers else _logger.Warning("Failed to update default TTS voice via request."); } + else if (message.Request.Type == "get_policies") + { + var policies = JsonSerializer.Deserialize>(message.Data!.ToString()!, _options); + if (policies == null || !policies.Any()) + { + _logger.Information($"Policies have been set to default."); + _policies.Set("everyone", "tts", 100, TimeSpan.FromSeconds(15)); + return; + } + + foreach (var policy in policies) + { + var group = _groups.Get(policy.GroupId.ToString()); + if (policy == null) + { + _logger.Debug($"Policy data failed"); + continue; + } + _logger.Debug($"Policy data [policy id: {policy.Id}][path: {policy.Path}][group id: {policy.GroupId}][group name: {group?.Name}]"); + _policies.Set(group?.Name ?? string.Empty, policy.Path, policy.Usage, TimeSpan.FromMilliseconds(policy.Span)); + } + _logger.Information($"Policies have been loaded, a total of {policies.Count()} policies."); + } + else if (message.Request.Type == "update_policy") + { + var policy = JsonSerializer.Deserialize(message.Data!.ToString()!, _options); + var group = _groups.Get(policy.GroupId.ToString()); + if (policy == null || group == null) + { + _logger.Debug($"Policy data failed"); + return; + } + _logger.Debug($"Policy data [policy id: {policy.Id}][path: {policy.Path}][group id: {policy.GroupId}][group name: {group?.Name}]"); + _policies.Set(group?.Name ?? string.Empty, policy.Path, policy.Usage, TimeSpan.FromMilliseconds(policy.Span)); + _logger.Information($"Policy has been updated [policy id: {policy.Id}]"); + } + else if (message.Request.Type == "create_policy") + { + var policy = JsonSerializer.Deserialize(message.Data!.ToString()!, _options); + + if (policy == null) + { + _logger.Debug($"Policy data failed"); + return; + } + var group = _groups.Get(policy.GroupId.ToString()); + if (group == null) + { + _logger.Debug($"Group data failed"); + return; + } + _logger.Debug($"Policy data [policy id: {policy.Id}][path: {policy.Path}][group id: {policy.GroupId}][group name: {group?.Name}]"); + _policies.Set(group?.Name, policy.Path, policy.Usage, TimeSpan.FromMilliseconds(policy.Span)); + _logger.Information($"Policy has been updated [policy id: {policy.Id}]"); + } + else if (message.Request.Type == "update_policies") + { + var policy = JsonSerializer.Deserialize(message.Data!.ToString()!, _options); + var group = _groups.Get(policy.GroupId.ToString()); + if (policy == null) + { + _logger.Debug($"Policy data failed"); + return; + } + _logger.Debug($"Policy data [policy id: {policy.Id}][path: {policy.Path}][group id: {policy.GroupId}][group name: {group?.Name}]"); + _policies.Set(group?.Name ?? string.Empty, policy.Path, policy.Usage, TimeSpan.FromMilliseconds(policy.Span)); + _logger.Information($"Policy has been updated [policy id: {policy.Id}]"); + } else { _logger.Warning($"Found unknown request type when acknowledging [type: {message.Request.Type}]"); diff --git a/Hermes/Socket/HermesSocketClient.cs b/Hermes/Socket/HermesSocketClient.cs index 9064ce9..5b28d6b 100644 --- a/Hermes/Socket/HermesSocketClient.cs +++ b/Hermes/Socket/HermesSocketClient.cs @@ -215,6 +215,15 @@ namespace TwitchChatTTS.Hermes.Socket }); } + public async Task FetchPolicies() + { + await Send(3, new RequestMessage() + { + Type = "get_policies", + Data = null + }); + } + public async Task FetchConnections() { await Send(3, new RequestMessage() diff --git a/Startup.cs b/Startup.cs index a361db3..2f2f646 100644 --- a/Startup.cs +++ b/Startup.cs @@ -80,8 +80,8 @@ s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); -s.AddSingleton(); s.AddSingleton(); +s.AddTransient(); s.AddSingleton(); s.AddSingleton(); @@ -94,7 +94,8 @@ s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); -s.AddSingleton(); +s.AddSingleton(); +s.AddSingleton, UsagePolicy>(); // OBS websocket s.AddKeyedSingleton("obs"); @@ -137,7 +138,7 @@ s.AddKeyedSingleton("twitch"); s.AddKeyedSingleton("twitch"); s.AddKeyedSingleton("twitch"); -s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); diff --git a/Twitch/Socket/Handlers/ChannelAdBreakBeginHandler.cs b/Twitch/Socket/Handlers/ChannelAdBreakBeginHandler.cs new file mode 100644 index 0000000..b1e5a40 --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelAdBreakBeginHandler.cs @@ -0,0 +1,83 @@ +using Serilog; +using TwitchChatTTS.Twitch.Redemptions; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class ChannelAdBreakBeginHandler : ITwitchSocketHandler + { + public string Name => "channel.ad_break.begin"; + + private readonly IRedemptionManager _redemptionManager; + private readonly ILogger _logger; + + public ChannelAdBreakBeginHandler(IRedemptionManager redemptionManager, ILogger logger) + { + _redemptionManager = redemptionManager; + _logger = logger; + } + + public async Task Execute(TwitchWebsocketClient sender, object data) + { + if (data is not ChannelAdBreakMessage message) + return; + + if (message.IsAutomatic) + _logger.Information($"Ad break has begun [duration: {message.DurationSeconds} seconds][automatic: true]"); + else + _logger.Information($"Ad break has begun [duration: {message.DurationSeconds} seconds][requester: {message.RequesterUserLogin}][requester id: {message.RequesterUserId}]"); + + try + { + var actions = _redemptionManager.Get("adbreak_begin"); + if (!actions.Any()) + { + _logger.Debug($"Found {actions.Count} actions for this Twitch ad break"); + + foreach (var action in actions) + try + { + await _redemptionManager.Execute(action, message.RequesterUserLogin, long.Parse(message.RequesterUserId)); + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to execute redeemable action [action: {action.Name}][action type: {action.Type}][redeem: ad break begin]"); + } + } + else + _logger.Debug($"No redeemable actions for ad break begin was found"); + + Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(message.DurationSeconds)); + if (message.IsAutomatic) + _logger.Information($"Ad break has ended [duration: {message.DurationSeconds} seconds][automatic: true]"); + else + _logger.Information($"Ad break has ended [duration: {message.DurationSeconds} seconds][requester: {message.RequesterUserLogin}][requester id: {message.RequesterUserId}]"); + + actions = _redemptionManager.Get("adbreak_end"); + if (!actions.Any()) + { + _logger.Debug($"Found {actions.Count} actions for this Twitch ad break"); + + foreach (var action in actions) + try + { + await _redemptionManager.Execute(action, message.RequesterUserLogin, long.Parse(message.RequesterUserId)); + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to execute redeemable action [action: {action.Name}][action type: {action.Type}][redeem: ad break end]"); + } + } + else + _logger.Debug($"No redeemable actions for ad break end was found"); + }); + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to fetch the redeemable actions for ad break begin"); + } + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ChannelAdBreakHandler.cs b/Twitch/Socket/Handlers/ChannelAdBreakHandler.cs deleted file mode 100644 index a10c576..0000000 --- a/Twitch/Socket/Handlers/ChannelAdBreakHandler.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Serilog; -using TwitchChatTTS.Twitch.Redemptions; -using TwitchChatTTS.Twitch.Socket.Messages; - -namespace TwitchChatTTS.Twitch.Socket.Handlers -{ - public class ChannelAdBreakHandler : ITwitchSocketHandler - { - public string Name => "channel.ad_break.begin"; - - private readonly IRedemptionManager _redemptionManager; - private readonly ILogger _logger; - - public ChannelAdBreakHandler(IRedemptionManager redemptionManager, ILogger logger) - { - _redemptionManager = redemptionManager; - _logger = logger; - } - - public async Task Execute(TwitchWebsocketClient sender, object data) - { - if (data is not ChannelAdBreakMessage message) - return; - - bool isAutomatic = message.IsAutomatic == "true"; - if (isAutomatic) - _logger.Information($"Ad break has begun [duration: {message.DurationSeconds} seconds][automatic: {isAutomatic}]"); - else - _logger.Information($"Ad break has begun [duration: {message.DurationSeconds} seconds][requester: {message.RequesterUserLogin}][requester id: {message.RequesterUserId}]"); - - try - { - var actions = _redemptionManager.Get("adbreak"); - if (!actions.Any()) - { - _logger.Debug($"No redeemable actions for ad break was found"); - return; - } - _logger.Debug($"Found {actions.Count} actions for this Twitch ad break"); - - foreach (var action in actions) - try - { - await _redemptionManager.Execute(action, message.RequesterUserLogin, long.Parse(message.RequesterUserId)); - } - catch (Exception ex) - { - _logger.Error(ex, $"Failed to execute redeemable action [action: {action.Name}][action type: {action.Type}][redeem: ad break]"); - } - } - catch (Exception ex) - { - _logger.Error(ex, $"Failed to fetch the redeemable actions for ad break"); - } - } - } -} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ChannelChatMessageHandler.cs b/Twitch/Socket/Handlers/ChannelChatMessageHandler.cs index 3ee6fad..beb7a0e 100644 --- a/Twitch/Socket/Handlers/ChannelChatMessageHandler.cs +++ b/Twitch/Socket/Handlers/ChannelChatMessageHandler.cs @@ -1,5 +1,6 @@ using Serilog; using TwitchChatTTS.Chat.Commands; +using TwitchChatTTS.Chat.Commands.Limits; using TwitchChatTTS.Chat.Groups; using TwitchChatTTS.Chat.Groups.Permissions; using TwitchChatTTS.Chat.Messaging; @@ -11,18 +12,20 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers { public string Name => "channel.chat.message"; - private readonly ChatMessageReader _reader; + private readonly IChatMessageReader _reader; private readonly User _user; private readonly ICommandManager _commands; private readonly IGroupPermissionManager _permissionManager; + private readonly IUsagePolicy _permissionPolicy; private readonly IChatterGroupManager _chatterGroupManager; private readonly ILogger _logger; public ChannelChatMessageHandler( - ChatMessageReader reader, + IChatMessageReader reader, ICommandManager commands, IGroupPermissionManager permissionManager, + IUsagePolicy permissionPolicy, IChatterGroupManager chatterGroupManager, User user, ILogger logger @@ -32,8 +35,13 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers _user = user; _commands = commands; _permissionManager = permissionManager; + _permissionPolicy = permissionPolicy; + _chatterGroupManager = chatterGroupManager; _logger = logger; + + _permissionPolicy.Set("everyone", "tts", 100, TimeSpan.FromSeconds(15)); + _permissionPolicy.Set("everyone", "tts.chat.messages.read", 3, TimeSpan.FromMilliseconds(15000)); } @@ -44,10 +52,8 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers if (data is not ChannelChatMessage message) return; - var broadcasterId = long.Parse(message.BroadcasterUserId); var chatterId = long.Parse(message.ChatterUserId); var chatterLogin = message.ChatterUserLogin; - var messageId = message.MessageId; var fragments = message.Message.Fragments; var groups = GetGroups(message.Badges, chatterId); var bits = GetTotalBits(fragments); @@ -56,14 +62,22 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers if (commandResult != ChatCommandResult.Unknown) return; - if (!HasPermission(message.ChannelPointsCustomRewardId, chatterId, groups, bits)) + string permission = GetPermissionPath(message.ChannelPointsCustomRewardId, bits); + if (!HasPermission(chatterId, groups, permission)) { - _logger.Debug($"Blocked message by {chatterLogin}: {message}"); + _logger.Debug($"Blocked message [chatter: {chatterLogin}][message: {message}]"); return; } + if (!_permissionPolicy.TryUse(chatterId, groups, permission)) + { + _logger.Debug($"Chatter has been rate limited from TTS [chatter: {chatterLogin}][chatter id: {chatterId}][message: {message}]"); + return; + } + + var broadcasterId = long.Parse(message.BroadcasterUserId); int priority = _chatterGroupManager.GetPriorityFor(groups); - await _reader.Read(sender, broadcasterId, chatterId, chatterLogin, messageId, message.Reply, fragments, priority); + await _reader.Read(sender, broadcasterId, chatterId, chatterLogin, message.MessageId, message.Reply, fragments, priority); } private async Task CheckForChatCommand(string arguments, ChannelChatMessage message, IEnumerable groups) @@ -95,7 +109,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers var customGroups = _chatterGroupManager.GetGroupNamesFor(chatterId); return defaultGroups.Union(badgesGroups).Union(customGroups); } - + private int GetTotalBits(TwitchChatFragment[] fragments) { return fragments.Where(f => f.Type == "cheermote" && f.Cheermote != null) @@ -103,14 +117,18 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers .Sum(); } - private bool HasPermission(string? customRewardId, long chatterId, IEnumerable groups, int bits) + private string GetPermissionPath(string? customRewardId, int bits) { var permissionPath = "tts.chat.messages.read"; if (!string.IsNullOrWhiteSpace(customRewardId)) permissionPath = "tts.chat.redemptions.read"; else if (bits > 0) permissionPath = "tts.chat.bits.read"; + return permissionPath; + } + private bool HasPermission(long chatterId, IEnumerable groups, string permissionPath) + { return chatterId == _user.OwnerId ? true : _permissionManager.CheckIfAllowed(groups, permissionPath) == true; } } diff --git a/Twitch/Socket/Handlers/ChannelResubscriptionHandler.cs b/Twitch/Socket/Handlers/ChannelResubscriptionHandler.cs index f397091..9681c5c 100644 --- a/Twitch/Socket/Handlers/ChannelResubscriptionHandler.cs +++ b/Twitch/Socket/Handlers/ChannelResubscriptionHandler.cs @@ -9,11 +9,11 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers { public string Name => "channel.subscription.message"; - private readonly ChatMessageReader _reader; + private readonly IChatMessageReader _reader; private readonly IRedemptionManager _redemptionManager; private readonly ILogger _logger; - public ChannelResubscriptionHandler(ChatMessageReader reader, IRedemptionManager redemptionManager, ILogger logger) + public ChannelResubscriptionHandler(IChatMessageReader reader, IRedemptionManager redemptionManager, ILogger logger) { _reader = reader; _redemptionManager = redemptionManager;