From 1761f1eaf676308ab7859c7aba9e90063768314f Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 7 Aug 2024 22:01:04 +0000 Subject: [PATCH] Fixed raid spam prevention. Gave a proper error message when connecting to Twitch websockets without linking Twitch account to Twitch. --- Helpers/WebClientWrap.cs | 12 +- Seven/Socket/SevenSocketClient.cs | 2 +- Startup.cs | 3 +- TTS.cs | 14 +- Twitch/Socket/Handlers/ChannelRaidHandler.cs | 8 +- Twitch/Socket/Handlers/NotificationHandler.cs | 3 +- .../Socket/Handlers/SessionWelcomeHandler.cs | 50 ++++++- Twitch/Socket/TwitchConnectionManager.cs | 124 +++++++++--------- Twitch/Socket/TwitchWebsocketClient.cs | 2 +- Twitch/TwitchApiClient.cs | 28 +++- 10 files changed, 162 insertions(+), 84 deletions(-) diff --git a/Helpers/WebClientWrap.cs b/Helpers/WebClientWrap.cs index 0d3363c..de10449 100644 --- a/Helpers/WebClientWrap.cs +++ b/Helpers/WebClientWrap.cs @@ -6,13 +6,13 @@ namespace TwitchChatTTS.Helpers public class WebClientWrap { private readonly HttpClient _client; - private readonly JsonSerializerOptions _options; + public JsonSerializerOptions Options { get; } public WebClientWrap(JsonSerializerOptions options) { _client = new HttpClient(); - _options = options; + Options = options; } @@ -26,7 +26,7 @@ namespace TwitchChatTTS.Helpers public async Task GetJson(string uri, JsonSerializerOptions? options = null) { var response = await _client.GetAsync(uri); - return JsonSerializer.Deserialize(await response.Content.ReadAsStreamAsync(), options ?? _options); + return JsonSerializer.Deserialize(await response.Content.ReadAsStreamAsync(), options ?? Options); } public async Task Get(string uri) @@ -36,17 +36,17 @@ namespace TwitchChatTTS.Helpers public async Task Post(string uri, T data) { - return await _client.PostAsJsonAsync(uri, data, _options); + return await _client.PostAsJsonAsync(uri, data, Options); } public async Task Post(string uri) { - return await _client.PostAsJsonAsync(uri, new object(), _options); + return await _client.PostAsJsonAsync(uri, new object(), Options); } public async Task Delete(string uri) { - return await _client.DeleteFromJsonAsync(uri, _options); + return await _client.DeleteFromJsonAsync(uri, Options); } public async Task Delete(string uri) diff --git a/Seven/Socket/SevenSocketClient.cs b/Seven/Socket/SevenSocketClient.cs index ef84342..53834bb 100644 --- a/Seven/Socket/SevenSocketClient.cs +++ b/Seven/Socket/SevenSocketClient.cs @@ -105,7 +105,7 @@ namespace TwitchChatTTS.Seven.Socket if (!Connected) { - await Task.Delay(30000); + await Task.Delay(TimeSpan.FromSeconds(30)); await Connect(); } } diff --git a/Startup.cs b/Startup.cs index 8b2da3a..0b03e09 100644 --- a/Startup.cs +++ b/Startup.cs @@ -127,11 +127,12 @@ s.AddKeyedSingleton("twitch"); s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); -s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); diff --git a/TTS.cs b/TTS.cs index 47ab8b9..74ba7a3 100644 --- a/TTS.cs +++ b/TTS.cs @@ -43,7 +43,6 @@ namespace TwitchChatTTS User user, HermesApiClient hermesApiClient, SevenApiClient sevenApiClient, - TwitchApiClient twitchApiClient, [FromKeyedServices("hermes")] SocketClient hermes, [FromKeyedServices("obs")] SocketClient obs, [FromKeyedServices("7tv")] SocketClient seven, @@ -60,7 +59,6 @@ namespace TwitchChatTTS _user = user; _hermesApiClient = hermesApiClient; _sevenApiClient = sevenApiClient; - _twitchApiClient = twitchApiClient; _hermes = (hermes as HermesSocketClient)!; _obs = (obs as OBSSocketClient)!; _seven = (seven as SevenSocketClient)!; @@ -127,7 +125,17 @@ namespace TwitchChatTTS await Task.Delay(TimeSpan.FromSeconds(30)); return; } - await _twitch.Connect(); + + try + { + await _twitch.Connect(); + } + catch (Exception e) + { + _logger.Error(e, "Failed to connect to Twitch websocket server."); + await Task.Delay(TimeSpan.FromSeconds(30)); + return; + } var emoteSet = await _sevenApiClient.FetchChannelEmoteSet(_user.TwitchUserId.ToString()); if (emoteSet != null) diff --git a/Twitch/Socket/Handlers/ChannelRaidHandler.cs b/Twitch/Socket/Handlers/ChannelRaidHandler.cs index 40cde62..3d5085f 100644 --- a/Twitch/Socket/Handlers/ChannelRaidHandler.cs +++ b/Twitch/Socket/Handlers/ChannelRaidHandler.cs @@ -25,7 +25,8 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers if (data is not ChannelRaidMessage message) return; - var chatters = await _api.GetChatters(message.ToBroadcasterUserId, message.ToBroadcasterUserLogin); + _logger.Information($"A raid has started. Starting raid spam prevention. [from: {message.FromBroadcasterUserLogin}][from id: {message.FromBroadcasterUserId}]."); + var chatters = await _api.GetChatters(_user.TwitchUserId.ToString(), _user.TwitchUserId.ToString()); if (chatters?.Data == null) { _logger.Error("Could not fetch the list of chatters in chat."); @@ -43,6 +44,11 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers } } + Task.Run(EndOfRaidSpamProtection); + } + + private async Task EndOfRaidSpamProtection() + { await Task.Delay(TimeSpan.FromSeconds(30)); lock (_lock) diff --git a/Twitch/Socket/Handlers/NotificationHandler.cs b/Twitch/Socket/Handlers/NotificationHandler.cs index 6da1e49..6b8bac2 100644 --- a/Twitch/Socket/Handlers/NotificationHandler.cs +++ b/Twitch/Socket/Handlers/NotificationHandler.cs @@ -33,10 +33,11 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers _messageTypes.Add("channel.adbreak.begin", typeof(ChannelAdBreakMessage)); _messageTypes.Add("channel.ban", typeof(ChannelBanMessage)); _messageTypes.Add("channel.chat.message", typeof(ChannelChatMessage)); - _messageTypes.Add("channel.chat.clear_user_messages", typeof(ChannelChatClearUserMessage)); _messageTypes.Add("channel.chat.clear", typeof(ChannelChatClearMessage)); + _messageTypes.Add("channel.chat.clear_user_messages", typeof(ChannelChatClearUserMessage)); _messageTypes.Add("channel.chat.message_delete", typeof(ChannelChatDeleteMessage)); _messageTypes.Add("channel.channel_points_custom_reward_redemption.add", typeof(ChannelCustomRedemptionMessage)); + _messageTypes.Add("channel.raid", typeof(ChannelRaidMessage)); _messageTypes.Add("channel.follow", typeof(ChannelFollowMessage)); _messageTypes.Add("channel.subscribe", typeof(ChannelSubscriptionMessage)); _messageTypes.Add("channel.subscription.message", typeof(ChannelResubscriptionMessage)); diff --git a/Twitch/Socket/Handlers/SessionWelcomeHandler.cs b/Twitch/Socket/Handlers/SessionWelcomeHandler.cs index e92e4c1..4882fec 100644 --- a/Twitch/Socket/Handlers/SessionWelcomeHandler.cs +++ b/Twitch/Socket/Handlers/SessionWelcomeHandler.cs @@ -31,9 +31,17 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers return; } - await _hermes.AuthorizeTwitch(); - var token = await _hermes.FetchTwitchBotToken(); - _api.Initialize(token); + try + { + await _hermes.AuthorizeTwitch(); + var token = await _hermes.FetchTwitchBotToken(); + _api.Initialize(token); + } + catch (Exception) + { + _logger.Error("Ensure you have your Twitch account linked on TTS. Restart application once you do."); + return; + } string broadcasterId = _user.TwitchUserId.ToString(); string[] subscriptionsv1 = [ @@ -46,8 +54,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers "channel.subscription.message", "channel.ad_break.begin", "channel.ban", - "channel.channel_points_custom_reward_redemption.add", - "channel.raid" + "channel.channel_points_custom_reward_redemption.add" ]; string[] subscriptionsv2 = [ "channel.follow", @@ -77,6 +84,8 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers await Subscribe(sender, subscription, message.Session.Id, broadcasterId, "1"); foreach (var subscription in subscriptionsv2) await Subscribe(sender, subscription, message.Session.Id, broadcasterId, "2"); + + await Subscribe(sender, "channel.raid", broadcasterId, async () => await _api.CreateChannelRaidEventSubscription("1", message.Session.Id, to: broadcasterId)); sender.Identify(message.Session.Id); } @@ -111,5 +120,36 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers _logger.Error(ex, $"Failed to create an event subscription [subscription type: {subscriptionName}][reason: exception]"); } } + + private async Task Subscribe(TwitchWebsocketClient sender, string subscriptionName, string broadcasterId, Func?>> subscribe) + { + try + { + var response = await subscribe(); + if (response == null) + { + return; + } + if (response.Data == null) + { + _logger.Error($"Failed to create an event subscription [subscription type: {subscriptionName}][reason: data is null]"); + return; + } + if (!response.Data.Any()) + { + _logger.Error($"Failed to create an event subscription [subscription type: {subscriptionName}][reason: data is empty]"); + return; + } + + foreach (var d in response.Data) + sender.AddSubscription(broadcasterId, d.Type, d.Id); + + _logger.Information($"Sucessfully added subscription to Twitch websockets [subscription type: {subscriptionName}]"); + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to create an event subscription [subscription type: {subscriptionName}][reason: exception]"); + } + } } } \ No newline at end of file diff --git a/Twitch/Socket/TwitchConnectionManager.cs b/Twitch/Socket/TwitchConnectionManager.cs index 11ef886..8cf68dd 100644 --- a/Twitch/Socket/TwitchConnectionManager.cs +++ b/Twitch/Socket/TwitchConnectionManager.cs @@ -64,69 +64,73 @@ namespace TwitchChatTTS.Twitch.Socket client.Initialize(); _backup = client; - client.OnIdentified += async (s, e) => - { - bool clientDisconnect = false; - lock (_lock) - { - if (_identified == null || _identified.ReceivedReconnecting) - { - if (_backup != null && _backup.UID == client.UID) - { - _logger.Information($"Twitch client has been identified [client: {client.UID}][main: {_identified?.UID}][backup: {_backup.UID}]"); - _identified = _backup; - _backup = null; - } - else - _logger.Warning($"Twitch client identified from unknown sources [client: {client.UID}][main: {_identified?.UID}][backup: {_backup?.UID}]"); - } - else if (_identified.UID == client.UID) - { - _logger.Warning($"Twitch client has been re-identified [client: {client.UID}][main: {_identified.UID}][backup: {_backup?.UID}]"); - } - else if (_backup == null || _backup.UID != client.UID) - { - _logger.Warning($"Twitch client has been identified, but isn't main or backup [client: {client.UID}][main: {_identified.UID}][backup: {_backup?.UID}]"); - clientDisconnect = true; - } - } - - if (clientDisconnect) - await client.DisconnectAsync(new SocketDisconnectionEventArgs("Closed", "No need for a tertiary client.")); - }; - client.OnDisconnected += async (s, e) => - { - bool reconnecting = false; - lock (_lock) - { - if (_identified?.UID == client.UID) - { - _logger.Warning($"Identified Twitch client has disconnected [client: {client.UID}][main: {_identified.UID}][backup: {_backup?.UID}]"); - _identified = null; - reconnecting = true; - } - else if (_backup?.UID == client.UID) - { - _logger.Warning($"Backup Twitch client has disconnected [client: {client.UID}][main: {_identified?.UID}][backup: {_backup.UID}]"); - _backup = null; - } - else if (client.ReceivedReconnecting) - { - _logger.Debug($"Twitch client disconnected due to reconnection [client: {client.UID}][main: {_identified?.UID}][backup: {_backup?.UID}]"); - } - else - _logger.Error($"Twitch client disconnected from unknown source [client: {client.UID}][main: {_identified?.UID}][backup: {_backup?.UID}]"); - } - - if (reconnecting) - { - var client = GetWorkingClient(); - await client.Connect(); - } - }; + client.OnIdentified += async (_, _) => await OnIdentified(client); + client.OnDisconnected += async (_, _) => await OnDisconnection(client); _logger.Debug("Created a Twitch websocket client."); return client; } + + private async Task OnDisconnection(TwitchWebsocketClient client) + { + bool reconnecting = false; + lock (_lock) + { + if (_identified?.UID == client.UID) + { + _logger.Warning($"Identified Twitch client has disconnected [client: {client.UID}][main: {_identified.UID}][backup: {_backup?.UID}]"); + _identified = null; + reconnecting = true; + } + else if (_backup?.UID == client.UID) + { + _logger.Warning($"Backup Twitch client has disconnected [client: {client.UID}][main: {_identified?.UID}][backup: {_backup.UID}]"); + _backup = null; + } + else if (client.ReceivedReconnecting) + { + _logger.Debug($"Twitch client disconnected due to reconnection [client: {client.UID}][main: {_identified?.UID}][backup: {_backup?.UID}]"); + } + else + _logger.Error($"Twitch client disconnected from unknown source [client: {client.UID}][main: {_identified?.UID}][backup: {_backup?.UID}]"); + } + + if (reconnecting) + { + var newClient = GetWorkingClient(); + await newClient.Connect(); + } + } + + private async Task OnIdentified(TwitchWebsocketClient client) + { + bool clientDisconnect = false; + lock (_lock) + { + if (_identified == null || _identified.ReceivedReconnecting) + { + if (_backup != null && _backup.UID == client.UID) + { + _logger.Information($"Twitch client has been identified [client: {client.UID}][main: {_identified?.UID}][backup: {_backup.UID}]"); + _identified = _backup; + _backup = null; + } + else + _logger.Warning($"Twitch client identified from unknown sources [client: {client.UID}][main: {_identified?.UID}][backup: {_backup?.UID}]"); + } + else if (_identified.UID == client.UID) + { + _logger.Warning($"Twitch client has been re-identified [client: {client.UID}][main: {_identified.UID}][backup: {_backup?.UID}]"); + } + else if (_backup == null || _backup.UID != client.UID) + { + _logger.Warning($"Twitch client has been identified, but isn't main or backup [client: {client.UID}][main: {_identified.UID}][backup: {_backup?.UID}]"); + clientDisconnect = true; + } + } + + if (clientDisconnect) + await client.DisconnectAsync(new SocketDisconnectionEventArgs("Closed", "No need for a tertiary client.")); + } } } \ No newline at end of file diff --git a/Twitch/Socket/TwitchWebsocketClient.cs b/Twitch/Socket/TwitchWebsocketClient.cs index f641b2a..8e6f90a 100644 --- a/Twitch/Socket/TwitchWebsocketClient.cs +++ b/Twitch/Socket/TwitchWebsocketClient.cs @@ -176,7 +176,7 @@ namespace TwitchChatTTS.Twitch.Socket _logger.Warning("Twitch websocket message payload is null."); return; } - await handler.Execute(this, data); + await Task.Run(async () => await handler.Execute(this, data)); } public async Task Send(string type, T data) diff --git a/Twitch/TwitchApiClient.cs b/Twitch/TwitchApiClient.cs index 881008a..35db407 100644 --- a/Twitch/TwitchApiClient.cs +++ b/Twitch/TwitchApiClient.cs @@ -28,9 +28,8 @@ public class TwitchApiClient }); } - public async Task?> CreateEventSubscription(string type, string version, string sessionId, string userId, string? broadcasterId = null) + public async Task?> CreateEventSubscription(string type, string version, string sessionId, IDictionary conditions) { - var conditions = new Dictionary() { { "user_id", userId }, { "broadcaster_user_id", broadcasterId ?? userId }, { "moderator_user_id", broadcasterId ?? userId } }; var subscriptionData = new EventSubscriptionMessage(type, version, sessionId, conditions); var base_url = _configuration.Environment == "PROD" || string.IsNullOrWhiteSpace(_configuration.Twitch?.ApiUrl) ? "https://api.twitch.tv/helix" : _configuration.Twitch.ApiUrl; @@ -38,12 +37,31 @@ public class TwitchApiClient if (response.StatusCode == HttpStatusCode.Accepted) { _logger.Debug($"Twitch API call [type: create event subscription][subscription type: {type}][response: {await response.Content.ReadAsStringAsync()}]"); - return await response.Content.ReadFromJsonAsync(typeof(EventResponse)) as EventResponse; + return await response.Content.ReadFromJsonAsync(typeof(EventResponse), _web.Options) as EventResponse; } _logger.Error($"Twitch API call failed [type: create event subscription][subscription type: {type}][response: {await response.Content.ReadAsStringAsync()}]"); return null; } + public async Task?> CreateEventSubscription(string type, string version, string sessionId, string userId, string? broadcasterId = null) + { + var conditions = new Dictionary() { { "user_id", userId }, { "broadcaster_user_id", broadcasterId ?? userId }, { "moderator_user_id", broadcasterId ?? userId } }; + return await CreateEventSubscription(type, version, sessionId, conditions); + } + + public async Task?> CreateChannelRaidEventSubscription(string version, string sessionId, string? from = null, string? to = null) + { + var conditions = new Dictionary(); + if (from == null && to == null) + throw new InvalidOperationException("Either or both from and to values must be non-null."); + if (from != null) + conditions.Add("from_broadcaster_user_id", from); + if (to != null) + conditions.Add("to_broadcaster_user_id", to); + + return await CreateEventSubscription("channel.raid", version, sessionId, conditions); + } + public async Task DeleteEventSubscription(string subscriptionId) { var base_url = _configuration.Environment == "PROD" || string.IsNullOrWhiteSpace(_configuration.Twitch?.ApiUrl) @@ -55,10 +73,10 @@ public class TwitchApiClient { moderatorId ??= broadcasterId; var response = await _web.Get($"https://api.twitch.tv/helix/chat/chatters?broadcaster_id={broadcasterId}&moderator_id={moderatorId}"); - if (response.StatusCode == HttpStatusCode.Accepted) + if (response.StatusCode == HttpStatusCode.OK) { _logger.Debug($"Twitch API call [type: get chatters][response: {await response.Content.ReadAsStringAsync()}]"); - return await response.Content.ReadFromJsonAsync(typeof(EventResponse)) as EventResponse; + return await response.Content.ReadFromJsonAsync(typeof(EventResponse), _web.Options) as EventResponse; } _logger.Error($"Twitch API call failed [type: get chatters][response: {await response.Content.ReadAsStringAsync()}]"); return null;