From 8014c12bc576011503e48da1d2cb7c84851c97a5 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 6 Aug 2024 19:29:29 +0000 Subject: [PATCH] Fixed 7tv & Twitch reconnection. Added adbreak, follow, subscription handlers for Twitch. Added multi-chat support. Added support to unsubscribe from Twitch event subs. --- Chat/ChatMessageHandler.cs | 310 ------------------ Chat/Commands/ChatCommand.cs | 2 +- Chat/Commands/ChatCommandResult.cs | 3 +- Chat/Commands/CommandBuilder.cs | 12 +- Chat/Commands/CommandFactory.cs | 41 +++ Chat/Commands/CommandManager.cs | 43 ++- Chat/Commands/ICommandFactory.cs | 9 + Chat/Commands/ICommandManager.cs | 9 + Chat/Commands/OBSCommand.cs | 7 +- Chat/Commands/RefreshCommand.cs | 30 +- Chat/Commands/SkipCommand.cs | 5 +- Chat/Commands/TTSCommand.cs | 119 ++++++- Chat/Commands/VersionCommand.cs | 5 +- Chat/Commands/VoiceCommand.cs | 19 +- Chat/Groups/ChatterGroupManager.cs | 43 ++- .../Permissions/GroupPermissionManager.cs | 22 ++ .../Permissions/IGroupPermissionManager.cs | 2 + Chat/Speech/TTSPlayer.cs | 19 +- Helpers/WebClientWrap.cs | 10 + Hermes/Account.cs | 2 + Hermes/CustomDataManager.cs | 11 +- Hermes/HermesApiClient.cs | 16 +- Hermes/Socket/Handlers/RequestAckHandler.cs | 15 +- Hermes/Socket/HermesSocketClient.cs | 21 +- OBS/Socket/OBSSocketClient.cs | 5 +- Seven/Socket/Handlers/DispatchHandler.cs | 6 +- Seven/Socket/SevenSocketClient.cs | 5 +- Startup.cs | 31 +- TTS.cs | 83 ++--- Twitch/Redemptions/IRedemptionManager.cs | 11 + Twitch/Redemptions/RedemptionManager.cs | 2 +- .../Socket/Handlers/ChannelAdBreakHandler.cs | 57 ++++ Twitch/Socket/Handlers/ChannelBanHandler.cs | 2 +- .../Handlers/ChannelChatClearHandler.cs | 2 +- .../Handlers/ChannelChatClearUserHandler.cs | 10 +- .../ChannelChatDeleteMessageHandler.cs | 2 +- .../Handlers/ChannelChatMessageHandler.cs | 16 +- .../ChannelCustomRedemptionHandler.cs | 6 +- .../Socket/Handlers/ChannelFollowHandler.cs | 52 +++ .../Handlers/ChannelResubscriptionHandler.cs | 52 +++ .../ChannelSubscriptionGiftHandler.cs | 52 +++ .../Handlers/ChannelSubscriptionHandler.cs | 45 ++- .../Socket/Handlers/ITwitchSocketHandler.cs | 2 +- Twitch/Socket/Handlers/NotificationHandler.cs | 14 +- .../Handlers/SessionKeepAliveHandler.cs | 12 + .../Handlers/SessionReconnectHandler.cs | 39 ++- .../Socket/Handlers/SessionWelcomeHandler.cs | 54 ++- .../Socket/Messages/ChannelAdBreakMessage.cs | 15 + .../Socket/Messages/ChannelFollowMessage.cs | 13 + .../Messages/ChannelResubscriptionMessage.cs | 10 + .../ChannelSubscriptionGiftMessage.cs | 9 + .../Messages/ChannelSubscriptionMessage.cs | 17 +- Twitch/Socket/Messages/EventResponse.cs | 5 + .../Messages/EventSubscriptionMessage.cs | 6 +- Twitch/Socket/Messages/NotificationMessage.cs | 2 +- .../Socket/Messages/SessionWelcomeMessage.cs | 2 +- Twitch/Socket/TwitchConnectionManager.cs | 119 +++++++ Twitch/Socket/TwitchWebsocketClient.cs | 131 ++++---- Twitch/TTSContext.cs | 29 -- Twitch/TwitchApiClient.cs | 43 +-- 60 files changed, 1064 insertions(+), 672 deletions(-) delete mode 100644 Chat/ChatMessageHandler.cs create mode 100644 Chat/Commands/CommandFactory.cs create mode 100644 Chat/Commands/ICommandFactory.cs create mode 100644 Chat/Commands/ICommandManager.cs create mode 100644 Twitch/Redemptions/IRedemptionManager.cs create mode 100644 Twitch/Socket/Handlers/ChannelAdBreakHandler.cs create mode 100644 Twitch/Socket/Handlers/ChannelFollowHandler.cs create mode 100644 Twitch/Socket/Handlers/ChannelResubscriptionHandler.cs create mode 100644 Twitch/Socket/Handlers/ChannelSubscriptionGiftHandler.cs create mode 100644 Twitch/Socket/Handlers/SessionKeepAliveHandler.cs create mode 100644 Twitch/Socket/Messages/ChannelAdBreakMessage.cs create mode 100644 Twitch/Socket/Messages/ChannelFollowMessage.cs create mode 100644 Twitch/Socket/Messages/ChannelResubscriptionMessage.cs create mode 100644 Twitch/Socket/Messages/ChannelSubscriptionGiftMessage.cs create mode 100644 Twitch/Socket/TwitchConnectionManager.cs delete mode 100644 Twitch/TTSContext.cs diff --git a/Chat/ChatMessageHandler.cs b/Chat/ChatMessageHandler.cs deleted file mode 100644 index 8d2fa89..0000000 --- a/Chat/ChatMessageHandler.cs +++ /dev/null @@ -1,310 +0,0 @@ -// using System.Text.RegularExpressions; -// using TwitchLib.Client.Events; -// using Serilog; -// using TwitchChatTTS; -// using TwitchChatTTS.Chat.Commands; -// using TwitchChatTTS.Hermes.Socket; -// using TwitchChatTTS.Chat.Groups.Permissions; -// using TwitchChatTTS.Chat.Groups; -// using TwitchChatTTS.Chat.Emotes; -// using Microsoft.Extensions.DependencyInjection; -// using CommonSocketLibrary.Common; -// using CommonSocketLibrary.Abstract; -// using TwitchChatTTS.OBS.Socket; - - -// public class ChatMessageHandler -// { -// private readonly User _user; -// private readonly TTSPlayer _player; -// private readonly CommandManager _commands; -// private readonly IGroupPermissionManager _permissionManager; -// private readonly IChatterGroupManager _chatterGroupManager; -// private readonly IEmoteDatabase _emotes; -// private readonly OBSSocketClient _obs; -// private readonly HermesSocketClient _hermes; -// private readonly Configuration _configuration; - -// private readonly ILogger _logger; - -// private Regex _sfxRegex; -// private HashSet _chatters; - -// public HashSet Chatters { get => _chatters; set => _chatters = value; } - - -// public ChatMessageHandler( -// User user, -// TTSPlayer player, -// CommandManager commands, -// IGroupPermissionManager permissionManager, -// IChatterGroupManager chatterGroupManager, -// IEmoteDatabase emotes, -// [FromKeyedServices("hermes")] SocketClient hermes, -// [FromKeyedServices("obs")] SocketClient obs, -// Configuration configuration, -// ILogger logger -// ) -// { -// _user = user; -// _player = player; -// _commands = commands; -// _permissionManager = permissionManager; -// _chatterGroupManager = chatterGroupManager; -// _emotes = emotes; -// _obs = (obs as OBSSocketClient)!; -// _hermes = (hermes as HermesSocketClient)!; -// _configuration = configuration; -// _logger = logger; - -// _chatters = new HashSet(); -// _sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)"); -// } - - -// public async Task Handle(OnMessageReceivedArgs e) -// { -// var m = e.ChatMessage; - -// if (_hermes.Connected && !_hermes.Ready) -// { -// _logger.Debug($"TTS is not yet ready. Ignoring chat messages [message id: {m.Id}]"); -// return new MessageResult(MessageStatus.NotReady, -1, -1); -// } -// if (_configuration.Twitch?.TtsWhenOffline != true && !_obs.Streaming) -// { -// _logger.Debug($"OBS is not streaming. Ignoring chat messages [message id: {m.Id}]"); -// return new MessageResult(MessageStatus.NotReady, -1, -1); -// } - - -// var msg = e.ChatMessage.Message; -// var chatterId = long.Parse(m.UserId); -// var tasks = new List(); - -// var checks = new bool[] { true, m.IsSubscriber, m.IsVip, m.IsModerator, m.IsBroadcaster }; -// var defaultGroups = new string[] { "everyone", "subscribers", "vip", "moderators", "broadcaster" }; -// var customGroups = _chatterGroupManager.GetGroupNamesFor(chatterId); -// var groups = defaultGroups.Where((e, i) => checks[i]).Union(customGroups); - -// try -// { -// var commandResult = await _commands.Execute(msg, m, groups); -// if (commandResult != ChatCommandResult.Unknown) -// return new MessageResult(MessageStatus.Command, -1, -1); -// } -// catch (Exception ex) -// { -// _logger.Error(ex, $"Failed executing a chat command [message: {msg}][chatter: {m.Username}][chatter id: {m.UserId}][message id: {m.Id}]"); -// } - -// var permissionPath = "tts.chat.messages.read"; -// if (!string.IsNullOrWhiteSpace(m.CustomRewardId)) -// permissionPath = "tts.chat.redemptions.read"; - -// var permission = chatterId == _user.OwnerId ? true : _permissionManager.CheckIfAllowed(groups, permissionPath); -// if (permission != true) -// { -// _logger.Debug($"Blocked message by {m.Username}: {msg}"); -// return new MessageResult(MessageStatus.Blocked, -1, -1); -// } - -// if (_obs.Streaming && !_chatters.Contains(chatterId)) -// { -// tasks.Add(_hermes.SendChatterDetails(chatterId, m.Username)); -// _chatters.Add(chatterId); -// } - -// // Filter highly repetitive words (like emotes) from the message. -// int totalEmoteUsed = 0; -// var emotesUsed = new HashSet(); -// var words = msg.Split(' '); -// var wordCounter = new Dictionary(); -// string filteredMsg = string.Empty; -// var newEmotes = new Dictionary(); -// foreach (var w in words) -// { -// if (wordCounter.ContainsKey(w)) -// wordCounter[w]++; -// else -// wordCounter.Add(w, 1); - -// var emoteId = _emotes.Get(w); -// if (emoteId == null) -// { -// emoteId = m.EmoteSet.Emotes.FirstOrDefault(e => e.Name == w)?.Id; -// if (emoteId != null) -// { -// newEmotes.Add(emoteId, w); -// _emotes.Add(w, emoteId); -// } -// } -// if (emoteId != null) -// { -// emotesUsed.Add(emoteId); -// totalEmoteUsed++; -// } - -// if (wordCounter[w] <= 4 && (emoteId == null || totalEmoteUsed <= 5)) -// filteredMsg += w + " "; -// } -// if (_obs.Streaming && newEmotes.Any()) -// tasks.Add(_hermes.SendEmoteDetails(newEmotes)); -// msg = filteredMsg; - -// // Replace filtered words. -// if (_user.RegexFilters != null) -// { -// foreach (var wf in _user.RegexFilters) -// { -// if (wf.Search == null || wf.Replace == null) -// continue; - -// if (wf.IsRegex) -// { -// try -// { -// var regex = new Regex(wf.Search); -// msg = regex.Replace(msg, wf.Replace); -// continue; -// } -// catch (Exception) -// { -// wf.IsRegex = false; -// } -// } - -// msg = msg.Replace(wf.Search, wf.Replace); -// } -// } - -// // Determine the priority of this message -// int priority = _chatterGroupManager.GetPriorityFor(groups) + m.SubscribedMonthCount * (m.IsSubscriber ? 10 : 5); - -// // Determine voice selected. -// string voiceSelected = _user.DefaultTTSVoice; -// if (_user.VoicesSelected?.ContainsKey(chatterId) == true) -// { -// var voiceId = _user.VoicesSelected[chatterId]; -// if (_user.VoicesAvailable.TryGetValue(voiceId, out string? voiceName) && voiceName != null) -// { -// if (_user.VoicesEnabled.Contains(voiceName) || chatterId == _user.OwnerId || m.IsStaff) -// { -// voiceSelected = voiceName; -// } -// } -// } - -// // Determine additional voices used -// var matches = _user.WordFilterRegex?.Matches(msg).ToArray(); -// if (matches == null || matches.FirstOrDefault() == null || matches.First().Index < 0) -// { -// HandlePartialMessage(priority, voiceSelected, msg.Trim(), e); -// return new MessageResult(MessageStatus.None, _user.TwitchUserId, chatterId, emotesUsed); -// } - -// HandlePartialMessage(priority, voiceSelected, msg.Substring(0, matches.First().Index).Trim(), e); -// foreach (Match match in matches) -// { -// var message = match.Groups[2].ToString(); -// if (string.IsNullOrWhiteSpace(message)) -// continue; - -// var voice = match.Groups[1].ToString(); -// voice = voice[0].ToString().ToUpper() + voice.Substring(1).ToLower(); -// HandlePartialMessage(priority, voice, message.Trim(), e); -// } - -// if (tasks.Any()) -// await Task.WhenAll(tasks); - -// return new MessageResult(MessageStatus.None, _user.TwitchUserId, chatterId, emotesUsed); -// } - -// private void HandlePartialMessage(int priority, string voice, string message, OnMessageReceivedArgs e) -// { -// if (string.IsNullOrWhiteSpace(message)) -// return; - -// var m = e.ChatMessage; -// var parts = _sfxRegex.Split(message); -// var badgesString = string.Join(", ", e.ChatMessage.Badges.Select(b => b.Key + " = " + b.Value)); - -// if (parts.Length == 1) -// { -// _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; Reward Id: {m.CustomRewardId}; {badgesString}"); -// _player.Add(new TTSMessage() -// { -// Voice = voice, -// Message = message, -// Timestamp = DateTime.UtcNow, -// Username = m.Username, -// //Bits = m.Bits, -// Badges = e.Badges, -// Priority = priority -// }); -// return; -// } - -// var sfxMatches = _sfxRegex.Matches(message); -// var sfxStart = sfxMatches.FirstOrDefault()?.Index ?? message.Length; - -// for (var i = 0; i < sfxMatches.Count; i++) -// { -// var sfxMatch = sfxMatches[i]; -// var sfxName = sfxMatch.Groups[1]?.ToString()?.ToLower(); - -// if (!File.Exists("sfx/" + sfxName + ".mp3")) -// { -// parts[i * 2 + 2] = parts[i * 2] + " (" + parts[i * 2 + 1] + ")" + parts[i * 2 + 2]; -// continue; -// } - -// if (!string.IsNullOrWhiteSpace(parts[i * 2])) -// { -// _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts[i * 2]}; Month: {m.SubscribedMonthCount}; {badgesString}"); -// _player.Add(new TTSMessage() -// { -// Voice = voice, -// Message = parts[i * 2], -// Moderator = m.IsModerator, -// Timestamp = DateTime.UtcNow, -// Username = m.Username, -// Bits = m.Bits, -// Badges = m.Badges, -// Priority = priority -// }); -// } - -// _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}"); -// _player.Add(new TTSMessage() -// { -// Voice = voice, -// Message = sfxName, -// File = $"sfx/{sfxName}.mp3", -// Moderator = m.IsModerator, -// Timestamp = DateTime.UtcNow, -// Username = m.Username, -// Bits = m.Bits, -// Badges = m.Badges, -// Priority = priority -// }); -// } - -// if (!string.IsNullOrWhiteSpace(parts.Last())) -// { -// _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; Month: {m.SubscribedMonthCount}; {badgesString}"); -// _player.Add(new TTSMessage() -// { -// Voice = voice, -// Message = parts.Last(), -// Moderator = m.IsModerator, -// Timestamp = DateTime.UtcNow, -// Username = m.Username, -// Bits = m.Bits, -// Badges = m.Badges, -// Priority = priority -// }); -// } -// } -// } \ No newline at end of file diff --git a/Chat/Commands/ChatCommand.cs b/Chat/Commands/ChatCommand.cs index faf4722..b677574 100644 --- a/Chat/Commands/ChatCommand.cs +++ b/Chat/Commands/ChatCommand.cs @@ -13,6 +13,6 @@ namespace TwitchChatTTS.Chat.Commands public interface IChatPartialCommand { bool AcceptCustomPermission { get; } - Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client); + Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes); } } \ No newline at end of file diff --git a/Chat/Commands/ChatCommandResult.cs b/Chat/Commands/ChatCommandResult.cs index f8c5ddf..bf12dc5 100644 --- a/Chat/Commands/ChatCommandResult.cs +++ b/Chat/Commands/ChatCommandResult.cs @@ -7,6 +7,7 @@ namespace TwitchChatTTS.Chat.Commands Success = 2, Permission = 3, Syntax = 4, - Fail = 5 + Fail = 5, + OtherRoom = 6, } } \ No newline at end of file diff --git a/Chat/Commands/CommandBuilder.cs b/Chat/Commands/CommandBuilder.cs index 613aee4..091f141 100644 --- a/Chat/Commands/CommandBuilder.cs +++ b/Chat/Commands/CommandBuilder.cs @@ -45,17 +45,18 @@ namespace TwitchChatTTS.Chat.Commands { if (_current == _root) throw new Exception("Cannot add permissions without a command name."); - + _current.AddPermission(path); return this; } - public ICommandBuilder AddAlias(string alias, string child) { + public ICommandBuilder AddAlias(string alias, string child) + { if (_current == _root) throw new Exception("Cannot add aliases without a command name."); if (_current.Children == null || !_current.Children.Any()) throw new Exception("Cannot add alias if this has no parameter."); - + _current.AddAlias(alias, child); return this; } @@ -327,7 +328,8 @@ namespace TwitchChatTTS.Chat.Commands Permissions = Permissions.Union([path]).ToArray(); } - public CommandNode AddAlias(string alias, string child) { + public CommandNode AddAlias(string alias, string child) + { var target = _children.FirstOrDefault(c => c.Parameter.Name == child); if (target == null) throw new Exception($"Cannot find child parameter [parameter: {child}][alias: {alias}]"); @@ -339,6 +341,8 @@ namespace TwitchChatTTS.Chat.Commands var clone = target.MemberwiseClone() as CommandNode; var node = new CommandNode(new StaticParameter(alias, alias, target.Parameter.Optional)); node._children = target._children; + node.Permissions = target.Permissions; + node.Command = target.Command; _children.Add(node); return this; } diff --git a/Chat/Commands/CommandFactory.cs b/Chat/Commands/CommandFactory.cs new file mode 100644 index 0000000..6ff316f --- /dev/null +++ b/Chat/Commands/CommandFactory.cs @@ -0,0 +1,41 @@ +using Serilog; +using static TwitchChatTTS.Chat.Commands.TTSCommands; + +namespace TwitchChatTTS.Chat.Commands +{ + public class CommandFactory : ICommandFactory + { + private readonly IEnumerable _commands; + private readonly ICommandBuilder _builder; + private readonly ILogger _logger; + + public CommandFactory( + IEnumerable commands, + ICommandBuilder builder, + ILogger logger + ) + { + _commands = commands; + _builder = builder; + _logger = logger; + } + + public ICommandSelector Build() + { + foreach (var command in _commands) + { + try + { + _logger.Debug($"Creating command tree for '{command.Name}'."); + command.Build(_builder); + } + catch (Exception e) + { + _logger.Error(e, $"Failed to properly load a chat command [command name: {command.Name}]"); + } + } + + return _builder.Build(); + } + } +} \ No newline at end of file diff --git a/Chat/Commands/CommandManager.cs b/Chat/Commands/CommandManager.cs index 5f9542f..8475f20 100644 --- a/Chat/Commands/CommandManager.cs +++ b/Chat/Commands/CommandManager.cs @@ -10,37 +10,30 @@ using static TwitchChatTTS.Chat.Commands.TTSCommands; namespace TwitchChatTTS.Chat.Commands { - public class CommandManager + public class CommandManager : ICommandManager { private readonly User _user; - private readonly ICommandSelector _commandSelector; + private ICommandSelector _commandSelector; private readonly HermesSocketClient _hermes; + //private readonly TwitchWebsocketClient _twitch; private readonly IGroupPermissionManager _permissionManager; private readonly ILogger _logger; private string CommandStartSign { get; } = "!"; public CommandManager( - IEnumerable commands, - ICommandBuilder commandBuilder, User user, - [FromKeyedServices("hermes")] SocketClient socketClient, + [FromKeyedServices("hermes")] SocketClient hermes, + //[FromKeyedServices("twitch")] SocketClient twitch, IGroupPermissionManager permissionManager, ILogger logger ) { _user = user; - _hermes = (socketClient as HermesSocketClient)!; + _hermes = (hermes as HermesSocketClient)!; + //_twitch = (twitch as TwitchWebsocketClient)!; _permissionManager = permissionManager; _logger = logger; - - foreach (var command in commands) - { - _logger.Debug($"Creating command tree for '{command.Name}'."); - command.Build(commandBuilder); - } - - _commandSelector = commandBuilder.Build(); } @@ -54,9 +47,13 @@ namespace TwitchChatTTS.Chat.Commands if (!arg.StartsWith(CommandStartSign)) return ChatCommandResult.Unknown; + if (message.BroadcasterUserId != _user.TwitchUserId.ToString()) + return ChatCommandResult.OtherRoom; + string[] parts = Regex.Matches(arg.Substring(CommandStartSign.Length), "(?[^\"\\n\\s]+|\"[^\"\\n]*\")") .Cast() .Select(m => m.Groups["match"].Value) + .Where(m => !string.IsNullOrEmpty(m)) .Select(m => m.StartsWith('"') && m.EndsWith('"') ? m.Substring(1, m.Length - 2) : m) .ToArray(); string[] args = parts.ToArray(); @@ -65,7 +62,7 @@ namespace TwitchChatTTS.Chat.Commands CommandSelectorResult selectorResult = _commandSelector.GetBestMatch(args, message); if (selectorResult.Command == null) { - _logger.Warning($"Could not match '{arg}' to any command."); + _logger.Warning($"Could not match '{arg}' to any command [chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]"); return ChatCommandResult.Missing; } @@ -111,10 +108,24 @@ namespace TwitchChatTTS.Chat.Commands return ChatCommandResult.Success; } + public void Update(ICommandFactory factory) + { + _commandSelector = factory.Build(); + } + private bool CanExecute(long chatterId, IEnumerable groups, string path, string[]? additionalPaths) { _logger.Debug($"Checking for permission [chatter id: {chatterId}][group: {string.Join(", ", groups)}][path: {path}]{(additionalPaths != null ? "[paths: " + string.Join('|', additionalPaths) + "]" : string.Empty)}"); - return _permissionManager.CheckIfAllowed(groups, path) != false && (additionalPaths == null || additionalPaths.All(p => _permissionManager.CheckIfAllowed(groups, p) != false)); + if (_permissionManager.CheckIfAllowed(groups, path) != false) + { + if (additionalPaths == null) + return true; + + // All direct allow must not be false and at least one of them must be true. + if (additionalPaths.All(p => _permissionManager.CheckIfDirectAllowed(groups, p) != false) && additionalPaths.Any(p => _permissionManager.CheckIfDirectAllowed(groups, p) == true)) + return true; + } + return false; } } } \ No newline at end of file diff --git a/Chat/Commands/ICommandFactory.cs b/Chat/Commands/ICommandFactory.cs new file mode 100644 index 0000000..31401e6 --- /dev/null +++ b/Chat/Commands/ICommandFactory.cs @@ -0,0 +1,9 @@ +using static TwitchChatTTS.Chat.Commands.TTSCommands; + +namespace TwitchChatTTS.Chat.Commands +{ + public interface ICommandFactory + { + ICommandSelector Build(); + } +} \ No newline at end of file diff --git a/Chat/Commands/ICommandManager.cs b/Chat/Commands/ICommandManager.cs new file mode 100644 index 0000000..6db13a2 --- /dev/null +++ b/Chat/Commands/ICommandManager.cs @@ -0,0 +1,9 @@ +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Chat.Commands +{ + public interface ICommandManager { + Task Execute(string arg, ChannelChatMessage message, IEnumerable groups); + void Update(ICommandFactory factory); + } +} \ No newline at end of file diff --git a/Chat/Commands/OBSCommand.cs b/Chat/Commands/OBSCommand.cs index 6ef09bd..0340b14 100644 --- a/Chat/Commands/OBSCommand.cs +++ b/Chat/Commands/OBSCommand.cs @@ -5,6 +5,7 @@ using Serilog; using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.OBS.Socket; using TwitchChatTTS.OBS.Socket.Data; +using TwitchChatTTS.Twitch.Socket; using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; @@ -71,7 +72,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { string sceneName = values["sceneName"]; string sourceName = values["sourceName"]; @@ -97,7 +98,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { string sceneName = values["sceneName"]; string sourceName = values["sourceName"]; @@ -133,7 +134,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { string sceneName = values["sceneName"]; string sourceName = values["sourceName"]; diff --git a/Chat/Commands/RefreshCommand.cs b/Chat/Commands/RefreshCommand.cs index 1e4482f..bcf96ec 100644 --- a/Chat/Commands/RefreshCommand.cs +++ b/Chat/Commands/RefreshCommand.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Serilog; using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.OBS.Socket; +using TwitchChatTTS.Twitch.Socket; using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; @@ -44,9 +45,9 @@ namespace TwitchChatTTS.Chat.Commands { public bool AcceptCustomPermission { get => true; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { - await client.FetchEnabledTTSVoices(); + await hermes.FetchEnabledTTSVoices(); } } @@ -54,9 +55,9 @@ namespace TwitchChatTTS.Chat.Commands { public bool AcceptCustomPermission { get => true; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { - await client.FetchTTSWordFilters(); + await hermes.FetchTTSWordFilters(); } } @@ -64,9 +65,9 @@ namespace TwitchChatTTS.Chat.Commands { public bool AcceptCustomPermission { get => true; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { - await client.FetchTTSChatterVoices(); + await hermes.FetchTTSChatterVoices(); } } @@ -74,9 +75,9 @@ namespace TwitchChatTTS.Chat.Commands { public bool AcceptCustomPermission { get => true; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { - await client.FetchDefaultTTSVoice(); + await hermes.FetchDefaultTTSVoice(); } } @@ -84,9 +85,9 @@ namespace TwitchChatTTS.Chat.Commands { public bool AcceptCustomPermission { get => true; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { - await client.FetchRedemptions(); + await hermes.FetchRedemptions(); } } @@ -97,12 +98,13 @@ namespace TwitchChatTTS.Chat.Commands public bool AcceptCustomPermission { get => true; } - public RefreshObs(OBSSocketClient obsManager, ILogger logger) { + public RefreshObs(OBSSocketClient obsManager, ILogger logger) + { _obsManager = obsManager; _logger = logger; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { _obsManager.ClearCache(); _logger.Information("Cleared the cache used for OBS."); @@ -114,9 +116,9 @@ namespace TwitchChatTTS.Chat.Commands public bool AcceptCustomPermission { get => true; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { - await client.FetchPermissions(); + await hermes.FetchPermissions(); } } } diff --git a/Chat/Commands/SkipCommand.cs b/Chat/Commands/SkipCommand.cs index 197b65e..6b03be1 100644 --- a/Chat/Commands/SkipCommand.cs +++ b/Chat/Commands/SkipCommand.cs @@ -1,5 +1,6 @@ using Serilog; using TwitchChatTTS.Hermes.Socket; +using TwitchChatTTS.Twitch.Socket; using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; @@ -51,7 +52,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { if (_player.Playing == null) return; @@ -78,7 +79,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { _player.RemoveAll(); diff --git a/Chat/Commands/TTSCommand.cs b/Chat/Commands/TTSCommand.cs index fe5c982..07b452b 100644 --- a/Chat/Commands/TTSCommand.cs +++ b/Chat/Commands/TTSCommand.cs @@ -1,5 +1,8 @@ +using CommonSocketLibrary.Abstract; +using Microsoft.Extensions.DependencyInjection; using Serilog; using TwitchChatTTS.Hermes.Socket; +using TwitchChatTTS.Twitch.Socket; using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; @@ -7,13 +10,21 @@ namespace TwitchChatTTS.Chat.Commands { public class TTSCommand : IChatCommand { + private readonly TwitchWebsocketClient _twitch; private readonly User _user; + private readonly TwitchApiClient _client; private readonly ILogger _logger; - public TTSCommand(User user, ILogger logger) + public TTSCommand( + [FromKeyedServices("twitch")] SocketClient twitch, + User user, + TwitchApiClient client, + ILogger logger) { + _twitch = (twitch as TwitchWebsocketClient)!; _user = user; + _client = client; _logger = logger; } @@ -51,7 +62,19 @@ namespace TwitchChatTTS.Chat.Commands }) .AddAlias("off", "disable") .AddAlias("disabled", "disable") - .AddAlias("false", "disable"); + .AddAlias("false", "disable") + .CreateStaticInputParameter("join", b => + { + b.CreateMentionParameter("mention", true) + .AddPermission("tts.commands.tts.join") + .CreateCommand(new JoinRoomCommand(_twitch, _client, _user, _logger)); + }) + .CreateStaticInputParameter("leave", b => + { + b.CreateMentionParameter("mention", true) + .AddPermission("tts.commands.tts.leave") + .CreateCommand(new LeaveRoomCommand(_twitch, _client, _user, _logger)); + }); }); } @@ -119,7 +142,8 @@ namespace TwitchChatTTS.Chat.Commands } var voiceId = _user.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceNameLower).Key; - if (voiceId == null) { + if (voiceId == null) + { _logger.Warning($"Could not find the identifier for the tts voice [voice name: {voiceName}]"); return; } @@ -157,5 +181,94 @@ namespace TwitchChatTTS.Chat.Commands _logger.Information($"Changed state for TTS voice [voice: {voiceName}][state: {_state}][invoker: {message.ChatterUserLogin}][id: {message.ChatterUserId}]"); } } + + private sealed class JoinRoomCommand : IChatPartialCommand + { + private readonly TwitchWebsocketClient _twitch; + private readonly TwitchApiClient _client; + private readonly User _user; + private ILogger _logger; + + public bool AcceptCustomPermission { get => true; } + + public JoinRoomCommand( + [FromKeyedServices("twitch")] SocketClient twitch, + TwitchApiClient client, + User user, + ILogger logger + ) + { + _twitch = (twitch as TwitchWebsocketClient)!; + _client = client; + _user = user; + _logger = logger; + } + + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + { + var mention = values["mention"].ToLower(); + var fragment = message.Message.Fragments.FirstOrDefault(f => f.Mention != null && f.Text.ToLower() == mention); + if (fragment == null) + { + _logger.Warning("Cannot find the channel to join chat with."); + return; + } + + await _client.CreateEventSubscription("channel.chat.message", "1", _twitch.SessionId, _user.TwitchUserId.ToString(), fragment.Mention!.UserId); + _logger.Information($"Joined chat room [channel: {fragment.Mention.UserLogin}][channel id: {fragment.Mention.UserId}][invoker: {message.ChatterUserLogin}][id: {message.ChatterUserId}]"); + } + } + + private sealed class LeaveRoomCommand : IChatPartialCommand + { + private readonly TwitchWebsocketClient _twitch; + private readonly TwitchApiClient _client; + private readonly User _user; + private ILogger _logger; + + public bool AcceptCustomPermission { get => true; } + + public LeaveRoomCommand( + [FromKeyedServices("twitch")] SocketClient twitch, + TwitchApiClient client, + User user, + ILogger logger + ) + { + _twitch = (twitch as TwitchWebsocketClient)!; + _client = client; + _user = user; + _logger = logger; + } + + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + { + var mention = values["mention"].ToLower(); + var fragment = message.Message.Fragments.FirstOrDefault(f => f.Mention != null && f.Text.ToLower() == mention); + if (fragment?.Mention == null) + { + _logger.Warning("Cannot find the channel to leave chat from."); + return; + } + + var subscriptionId = _twitch.GetSubscriptionId(_user.TwitchUserId.ToString(), "channel.chat.message"); + if (subscriptionId == null) + { + _logger.Warning("Cannot find the subscription for that channel."); + return; + } + + try + { + await _client.DeleteEventSubscription(subscriptionId); + _twitch.RemoveSubscription(fragment.Mention.UserId, "channel.chat.message"); + _logger.Information($"Joined chat room [channel: {fragment.Mention.UserLogin}][channel id: {fragment.Mention.UserId}][invoker: {message.ChatterUserLogin}][id: {message.ChatterUserId}]"); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to delete the subscription from Twitch."); + } + } + } } } \ No newline at end of file diff --git a/Chat/Commands/VersionCommand.cs b/Chat/Commands/VersionCommand.cs index 7e04445..624ea8b 100644 --- a/Chat/Commands/VersionCommand.cs +++ b/Chat/Commands/VersionCommand.cs @@ -1,6 +1,7 @@ using HermesSocketLibrary.Socket.Data; using Serilog; using TwitchChatTTS.Hermes.Socket; +using TwitchChatTTS.Twitch.Socket; using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; @@ -37,11 +38,11 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { _logger.Information($"TTS Version: {TTS.MAJOR_VERSION}.{TTS.MINOR_VERSION}"); - await client.SendLoggingMessage(HermesLoggingLevel.Info, $"{_user.TwitchUsername} [twitch id: {_user.TwitchUserId}] using version {TTS.MAJOR_VERSION}.{TTS.MINOR_VERSION}."); + await hermes.SendLoggingMessage(HermesLoggingLevel.Info, $"{_user.TwitchUsername} [twitch id: {_user.TwitchUserId}] using version {TTS.MAJOR_VERSION}.{TTS.MINOR_VERSION}."); } } } diff --git a/Chat/Commands/VoiceCommand.cs b/Chat/Commands/VoiceCommand.cs index 88a5d7b..1cea797 100644 --- a/Chat/Commands/VoiceCommand.cs +++ b/Chat/Commands/VoiceCommand.cs @@ -8,8 +8,6 @@ namespace TwitchChatTTS.Chat.Commands public class VoiceCommand : IChatCommand { private readonly User _user; - // TODO: get permissions - // TODO: validated parameter for username by including '@' and regex for username private readonly ILogger _logger; public VoiceCommand(User user, ILogger logger) @@ -26,7 +24,7 @@ namespace TwitchChatTTS.Chat.Commands { b.CreateVoiceNameParameter("voiceName", true) .CreateCommand(new TTSVoiceSelector(_user, _logger)) - .CreateUnvalidatedParameter("chatter", optional: true) + .CreateMentionParameter("chatter", enabled: true, optional: true) .AddPermission("tts.command.voice.admin") .CreateCommand(new TTSVoiceSelectorAdmin(_user, _logger)); }); @@ -45,7 +43,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { if (_user == null || _user.VoicesSelected == null) return; @@ -57,12 +55,12 @@ namespace TwitchChatTTS.Chat.Commands if (_user.VoicesSelected.ContainsKey(chatterId)) { - await client.UpdateTTSUser(chatterId, voice.Key); + await hermes.UpdateTTSUser(chatterId, voice.Key); _logger.Debug($"Sent request to create chat TTS voice [voice: {voice.Value}][username: {message.ChatterUserLogin}][reason: command]"); } else { - await client.CreateTTSUser(chatterId, voice.Key); + await hermes.CreateTTSUser(chatterId, voice.Key); _logger.Debug($"Sent request to update chat TTS voice [voice: {voice.Value}][username: {message.ChatterUserLogin}][reason: command]"); } } @@ -81,13 +79,12 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) { if (_user == null || _user.VoicesSelected == null) return; - var chatterLogin = values["chatter"].Substring(1); - var mention = message.Message.Fragments.FirstOrDefault(f => f.Mention != null && f.Mention.UserLogin == chatterLogin)?.Mention; + var mention = message.Message.Fragments.FirstOrDefault(f => f.Mention != null && f.Text == values["chatter"])?.Mention; if (mention == null) { _logger.Warning("Failed to find the chatter to apply voice command to."); @@ -101,12 +98,12 @@ namespace TwitchChatTTS.Chat.Commands if (_user.VoicesSelected.ContainsKey(chatterId)) { - await client.UpdateTTSUser(chatterId, voice.Key); + await hermes.UpdateTTSUser(chatterId, voice.Key); _logger.Debug($"Sent request to create chat TTS voice [voice: {voice.Value}][username: {mention.UserLogin}][reason: command]"); } else { - await client.CreateTTSUser(chatterId, voice.Key); + await hermes.CreateTTSUser(chatterId, voice.Key); _logger.Debug($"Sent request to update chat TTS voice [voice: {voice.Value}][username: {mention.UserLogin}][reason: command]"); } } diff --git a/Chat/Groups/ChatterGroupManager.cs b/Chat/Groups/ChatterGroupManager.cs index 6826211..123e48f 100644 --- a/Chat/Groups/ChatterGroupManager.cs +++ b/Chat/Groups/ChatterGroupManager.cs @@ -12,62 +12,75 @@ namespace TwitchChatTTS.Chat.Groups private readonly ILogger _logger; - public ChatterGroupManager(ILogger logger) { + public ChatterGroupManager(ILogger logger) + { _logger = logger; _groups = new ConcurrentDictionary(); _chatters = new ConcurrentDictionary>(); } - public void Add(Group group) { + public void Add(Group group) + { _groups.Add(group.Name, group); } - public void Add(long chatter, string groupName) { + public void Add(long chatter, string groupName) + { _chatters.Add(chatter, new List() { groupName }); } - public void Add(long chatter, ICollection groupNames) { - if (_chatters.TryGetValue(chatter, out var list)) { + public void Add(long chatter, ICollection groupNames) + { + if (_chatters.TryGetValue(chatter, out var list)) + { foreach (var group in groupNames) list.Add(group); - } else + } + else _chatters.Add(chatter, groupNames); } - public void Clear() { + public void Clear() + { _groups.Clear(); _chatters.Clear(); } - public Group? Get(string groupName) { + public Group? Get(string groupName) + { if (_groups.TryGetValue(groupName, out var group)) return group; return null; } - public IEnumerable GetGroupNamesFor(long chatter) { + public IEnumerable GetGroupNamesFor(long chatter) + { if (_chatters.TryGetValue(chatter, out var groups)) return groups.Select(g => _groups[g].Name); - + return Array.Empty(); } - public int GetPriorityFor(long chatter) { + public int GetPriorityFor(long chatter) + { if (!_chatters.TryGetValue(chatter, out var groups)) return 0; - + return GetPriorityFor(groups); } - public int GetPriorityFor(IEnumerable groupNames) { + public int GetPriorityFor(IEnumerable groupNames) + { var values = groupNames.Select(g => _groups.TryGetValue(g, out var group) ? group : null).Where(g => g != null); if (values.Any()) return values.Max(g => g.Priority); return 0; } - public bool Remove(long chatterId, string groupId) { - if (_chatters.TryGetValue(chatterId, out var groups)) { + public bool Remove(long chatterId, string groupId) + { + if (_chatters.TryGetValue(chatterId, out var groups)) + { groups.Remove(groupId); _logger.Debug($"Removed chatter from group [chatter id: {chatterId}][group name: {_groups[groupId]}][group id: {groupId}]"); return true; diff --git a/Chat/Groups/Permissions/GroupPermissionManager.cs b/Chat/Groups/Permissions/GroupPermissionManager.cs index 3938a59..ab4a34a 100644 --- a/Chat/Groups/Permissions/GroupPermissionManager.cs +++ b/Chat/Groups/Permissions/GroupPermissionManager.cs @@ -23,6 +23,13 @@ namespace TwitchChatTTS.Chat.Groups.Permissions return res; } + public bool? CheckIfDirectAllowed(string path) + { + var res = Get(path)?.DirectAllow; + _logger.Debug($"Permission Node GET {path} = {res?.ToString() ?? "null"} [direct]"); + return res; + } + public bool? CheckIfAllowed(IEnumerable groups, string path) { bool overall = false; @@ -37,6 +44,20 @@ namespace TwitchChatTTS.Chat.Groups.Permissions return overall ? true : null; } + public bool? CheckIfDirectAllowed(IEnumerable groups, string path) + { + bool overall = false; + foreach (var group in groups) + { + var result = CheckIfDirectAllowed($"{group}.{path}"); + if (result == false) + return false; + if (result == true) + overall = true; + } + return overall ? true : null; + } + public void Clear() { _root.Clear(); @@ -104,6 +125,7 @@ namespace TwitchChatTTS.Chat.Groups.Permissions } set => _allow = value; } + public bool? DirectAllow { get => _allow; } internal PermissionNode? Parent { get => _parent; } public IList? Children { get => _children == null ? null : new ReadOnlyCollection(_children); } diff --git a/Chat/Groups/Permissions/IGroupPermissionManager.cs b/Chat/Groups/Permissions/IGroupPermissionManager.cs index ee787dc..ceffc76 100644 --- a/Chat/Groups/Permissions/IGroupPermissionManager.cs +++ b/Chat/Groups/Permissions/IGroupPermissionManager.cs @@ -5,6 +5,8 @@ namespace TwitchChatTTS.Chat.Groups.Permissions void Set(string path, bool? allow); bool? CheckIfAllowed(string path); bool? CheckIfAllowed(IEnumerable groups, string path); + bool? CheckIfDirectAllowed(string path); + bool? CheckIfDirectAllowed(IEnumerable groups, string path); void Clear(); bool Remove(string path); } diff --git a/Chat/Speech/TTSPlayer.cs b/Chat/Speech/TTSPlayer.cs index 938419d..89ccd87 100644 --- a/Chat/Speech/TTSPlayer.cs +++ b/Chat/Speech/TTSPlayer.cs @@ -101,13 +101,14 @@ public class TTSPlayer } } - public void RemoveAll(long chatterId) + public void RemoveAll(long broadcasterId, long chatterId) { try { _mutex2.WaitOne(); - if (_buffer.UnorderedItems.Any(i => i.Element.ChatterId == chatterId)) { - var list = _buffer.UnorderedItems.Where(i => i.Element.ChatterId != chatterId).ToArray(); + if (_buffer.UnorderedItems.Any(i => i.Element.RoomId == broadcasterId && i.Element.ChatterId == chatterId)) + { + var list = _buffer.UnorderedItems.Where(i => i.Element.RoomId == broadcasterId && i.Element.ChatterId != chatterId).ToArray(); _buffer.Clear(); foreach (var item in list) _buffer.Enqueue(item.Element, item.Element.Priority); @@ -121,8 +122,9 @@ public class TTSPlayer try { _mutex.WaitOne(); - if (_messages.UnorderedItems.Any(i => i.Element.ChatterId == chatterId)) { - var list = _messages.UnorderedItems.Where(i => i.Element.ChatterId != chatterId).ToArray(); + if (_messages.UnorderedItems.Any(i => i.Element.RoomId == broadcasterId && i.Element.ChatterId == chatterId)) + { + var list = _messages.UnorderedItems.Where(i => i.Element.RoomId == broadcasterId && i.Element.ChatterId != chatterId).ToArray(); _messages.Clear(); foreach (var item in list) _messages.Enqueue(item.Element, item.Element.Priority); @@ -139,7 +141,8 @@ public class TTSPlayer try { _mutex2.WaitOne(); - if (_buffer.UnorderedItems.Any(i => i.Element.MessageId == messageId)) { + if (_buffer.UnorderedItems.Any(i => i.Element.MessageId == messageId)) + { var list = _buffer.UnorderedItems.Where(i => i.Element.MessageId != messageId).ToArray(); _buffer.Clear(); foreach (var item in list) @@ -155,7 +158,8 @@ public class TTSPlayer try { _mutex.WaitOne(); - if (_messages.UnorderedItems.Any(i => i.Element.MessageId == messageId)) { + if (_messages.UnorderedItems.Any(i => i.Element.MessageId == messageId)) + { var list = _messages.UnorderedItems.Where(i => i.Element.MessageId != messageId).ToArray(); _messages.Clear(); foreach (var item in list) @@ -182,6 +186,7 @@ public class TTSPlayer public class TTSMessage { public string? Voice { get; set; } + public long RoomId { get; set; } public long ChatterId { get; set; } public string MessageId { get; set; } public string? Message { get; set; } diff --git a/Helpers/WebClientWrap.cs b/Helpers/WebClientWrap.cs index de2f823..0d3363c 100644 --- a/Helpers/WebClientWrap.cs +++ b/Helpers/WebClientWrap.cs @@ -43,5 +43,15 @@ namespace TwitchChatTTS.Helpers { return await _client.PostAsJsonAsync(uri, new object(), _options); } + + public async Task Delete(string uri) + { + return await _client.DeleteFromJsonAsync(uri, _options); + } + + public async Task Delete(string uri) + { + return await _client.DeleteAsync(uri); + } } } \ No newline at end of file diff --git a/Hermes/Account.cs b/Hermes/Account.cs index 3022337..799efa8 100644 --- a/Hermes/Account.cs +++ b/Hermes/Account.cs @@ -1,4 +1,6 @@ public class Account { public string Id { get; set; } public string Username { get; set; } + public string Role { get; set; } + public string? BroadcasterId { get; set; } } \ No newline at end of file diff --git a/Hermes/CustomDataManager.cs b/Hermes/CustomDataManager.cs index 7e4a945..303e02f 100644 --- a/Hermes/CustomDataManager.cs +++ b/Hermes/CustomDataManager.cs @@ -1,6 +1,7 @@ namespace TwitchChatTTS.Hermes { - public interface ICustomDataManager { + public interface ICustomDataManager + { void Add(string key, object value, string type); void Change(string key, object value); void Delete(string key); @@ -11,7 +12,8 @@ namespace TwitchChatTTS.Hermes { private IDictionary _data; - public CustomDataManager() { + public CustomDataManager() + { _data = new Dictionary(); } @@ -37,8 +39,9 @@ namespace TwitchChatTTS.Hermes } } -// type: text (string), whole number (int), number (double), boolean, formula (string, data type of number) - public struct DataInfo { + // type: text (string), whole number (int), number (double), boolean, formula (string, data type of number) + public struct DataInfo + { public string Id { get; set; } public string Type { get; set; } public object Value { get; set; } diff --git a/Hermes/HermesApiClient.cs b/Hermes/HermesApiClient.cs index 711df6a..bdfa6bc 100644 --- a/Hermes/HermesApiClient.cs +++ b/Hermes/HermesApiClient.cs @@ -10,7 +10,7 @@ public class HermesApiClient private readonly TwitchBotAuth _token; private readonly WebClientWrap _web; private readonly ILogger _logger; - + public const string BASE_URL = "tomtospeech.com"; public HermesApiClient(TwitchBotAuth token, Configuration configuration, ILogger logger) @@ -31,7 +31,7 @@ public class HermesApiClient } - public async Task AuthorizeTwitch() + public async Task AuthorizeTwitch() { try { @@ -51,10 +51,9 @@ public class HermesApiClient else if (authorize != null) { _logger.Error("Twitch API Authorization failed: " + authorize.AccessToken + " | " + authorize.RefreshToken + " | " + authorize.UserId + " | " + authorize.BroadcasterId); - return false; + return null; } - _logger.Debug($"Authorized Twitch API."); - return true; + return _token; } catch (JsonException) { @@ -64,7 +63,7 @@ public class HermesApiClient { _logger.Error(e, "Failed to authorize to Twitch API."); } - return false; + return null; } public async Task GetLatestTTSVersion() @@ -74,7 +73,10 @@ public class HermesApiClient public async Task FetchHermesAccountDetails() { - var account = await _web.GetJson($"https://{BASE_URL}/api/account"); + var account = await _web.GetJson($"https://{BASE_URL}/api/account", new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); if (account == null || account.Id == null || account.Username == null) throw new NullReferenceException("Invalid value found while fetching for hermes account data."); return account; diff --git a/Hermes/Socket/Handlers/RequestAckHandler.cs b/Hermes/Socket/Handlers/RequestAckHandler.cs index 74a67eb..de11f2f 100644 --- a/Hermes/Socket/Handlers/RequestAckHandler.cs +++ b/Hermes/Socket/Handlers/RequestAckHandler.cs @@ -18,7 +18,6 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers public class RequestAckHandler : IWebSocketHandler { private User _user; - //private readonly RedemptionManager _redemptionManager; private readonly ICallbackManager _callbackManager; private readonly IServiceProvider _serviceProvider; private readonly JsonSerializerOptions _options; @@ -30,16 +29,16 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers public RequestAckHandler( - User user, ICallbackManager callbackManager, IServiceProvider serviceProvider, + User user, JsonSerializerOptions options, ILogger logger ) { - _user = user; _callbackManager = callbackManager; _serviceProvider = serviceProvider; + _user = user; _options = options; _logger = logger; } @@ -263,15 +262,15 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers re.Match(string.Empty); filter.Regex = re; } - catch (Exception e) { } + catch (Exception) { } } _user.RegexFilters = filters; _logger.Information($"TTS word filters [count: {_user.RegexFilters.Count()}] have been refreshed."); } else if (message.Request.Type == "update_tts_voice_state") { - string voiceId = message.Request.Data["voice"].ToString(); - bool state = message.Request.Data["state"].ToString().ToLower() == "true"; + string voiceId = message.Request.Data?["voice"].ToString()!; + bool state = message.Request.Data?["state"].ToString()!.ToLower() == "true"; if (!_user.VoicesAvailable.TryGetValue(voiceId, out string? voiceName) || voiceName == null) { @@ -305,14 +304,14 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers _logger.Warning("Failed to read the redeemable actions for redemptions."); return; } - if (hermesRequestData?.Data == null || !(hermesRequestData.Data["redemptions"] is IEnumerable redemptions)) + if (hermesRequestData?.Data == null || hermesRequestData.Data["redemptions"] is not IEnumerable redemptions) { _logger.Warning("Failed to read the redemptions while updating redemption actions."); return; } _logger.Information($"Redeemable actions [count: {actions.Count()}] loaded."); - var redemptionManager = _serviceProvider.GetRequiredService(); + var redemptionManager = _serviceProvider.GetRequiredService(); redemptionManager.Initialize(redemptions, actions.ToDictionary(a => a.Name, a => a)); } else if (message.Request.Type == "get_default_tts_voice") diff --git a/Hermes/Socket/HermesSocketClient.cs b/Hermes/Socket/HermesSocketClient.cs index 638aed6..c7e486f 100644 --- a/Hermes/Socket/HermesSocketClient.cs +++ b/Hermes/Socket/HermesSocketClient.cs @@ -104,7 +104,8 @@ namespace TwitchChatTTS.Hermes.Socket }); } - public async Task FetchChatterIdentifiers() { + public async Task FetchChatterIdentifiers() + { await Send(3, new RequestMessage() { Type = "get_chatter_ids", @@ -112,7 +113,8 @@ namespace TwitchChatTTS.Hermes.Socket }); } - public async Task FetchDefaultTTSVoice() { + public async Task FetchDefaultTTSVoice() + { await Send(3, new RequestMessage() { Type = "get_default_tts_voice", @@ -120,7 +122,8 @@ namespace TwitchChatTTS.Hermes.Socket }); } - public async Task FetchEmotes() { + public async Task FetchEmotes() + { await Send(3, new RequestMessage() { Type = "get_emotes", @@ -128,7 +131,8 @@ namespace TwitchChatTTS.Hermes.Socket }); } - public async Task FetchEnabledTTSVoices() { + public async Task FetchEnabledTTSVoices() + { await Send(3, new RequestMessage() { Type = "get_enabled_tts_voices", @@ -136,7 +140,8 @@ namespace TwitchChatTTS.Hermes.Socket }); } - public async Task FetchTTSVoices() { + public async Task FetchTTSVoices() + { await Send(3, new RequestMessage() { Type = "get_tts_voices", @@ -144,7 +149,8 @@ namespace TwitchChatTTS.Hermes.Socket }); } - public async Task FetchTTSChatterVoices() { + public async Task FetchTTSChatterVoices() + { await Send(3, new RequestMessage() { Type = "get_tts_users", @@ -152,7 +158,8 @@ namespace TwitchChatTTS.Hermes.Socket }); } - public async Task FetchTTSWordFilters() { + public async Task FetchTTSWordFilters() + { await Send(3, new RequestMessage() { Type = "get_tts_word_filters", diff --git a/OBS/Socket/OBSSocketClient.cs b/OBS/Socket/OBSSocketClient.cs index 8798dd9..0197f4a 100644 --- a/OBS/Socket/OBSSocketClient.cs +++ b/OBS/Socket/OBSSocketClient.cs @@ -23,7 +23,7 @@ namespace TwitchChatTTS.OBS.Socket public bool Identified { get; set; } public bool Streaming { get; set; } - + public OBSSocketClient( Configuration configuration, [FromKeyedServices("obs")] IEnumerable handlers, @@ -104,7 +104,8 @@ namespace TwitchChatTTS.OBS.Socket } } - public async Task ExecuteRequest(RequestResponseMessage message) { + public async Task ExecuteRequest(RequestResponseMessage message) + { if (!_handlers.TryGetValue(7, out var handler) || handler == null) { _logger.Error("Failed to find the request response handler for OBS."); diff --git a/Seven/Socket/Handlers/DispatchHandler.cs b/Seven/Socket/Handlers/DispatchHandler.cs index e1de2ac..60634e4 100644 --- a/Seven/Socket/Handlers/DispatchHandler.cs +++ b/Seven/Socket/Handlers/DispatchHandler.cs @@ -54,7 +54,8 @@ namespace TwitchChatTTS.Seven.Socket.Handlers { if (removing) { - if (_emotes.Get(o.Name) != o.Id) { + if (_emotes.Get(o.Name) != o.Id) + { _logger.Warning("Mismatched emote found while removing a 7tv emote."); continue; } @@ -63,7 +64,8 @@ namespace TwitchChatTTS.Seven.Socket.Handlers } else if (updater != null) { - if (_emotes.Get(o.Name) != o.Id) { + if (_emotes.Get(o.Name) != o.Id) + { _logger.Warning("Mismatched emote found while updating a 7tv emote."); continue; } diff --git a/Seven/Socket/SevenSocketClient.cs b/Seven/Socket/SevenSocketClient.cs index e5b91d3..ef84342 100644 --- a/Seven/Socket/SevenSocketClient.cs +++ b/Seven/Socket/SevenSocketClient.cs @@ -120,7 +120,7 @@ namespace TwitchChatTTS.Seven.Socket _logger.Warning($"Received end of stream message for 7tv websocket [reason: {_errorCodes[code]}][code: {code}]"); else _logger.Warning($"Received end of stream message for 7tv websocket [code: {code}]"); - + if (code < 0 || code >= _reconnectDelay.Length) await Task.Delay(TimeSpan.FromSeconds(30)); else if (_reconnectDelay[code] < 0) @@ -131,7 +131,8 @@ namespace TwitchChatTTS.Seven.Socket else if (_reconnectDelay[code] > 0) await Task.Delay(_reconnectDelay[code]); } - else { + else + { _logger.Warning("Unknown 7tv disconnection."); await Task.Delay(TimeSpan.FromSeconds(30)); } diff --git a/Startup.cs b/Startup.cs index 86c6764..8b2da3a 100644 --- a/Startup.cs +++ b/Startup.cs @@ -28,6 +28,7 @@ using static TwitchChatTTS.Chat.Commands.TTSCommands; using TwitchChatTTS.Twitch.Socket; using TwitchChatTTS.Twitch.Socket.Messages; using TwitchChatTTS.Twitch.Socket.Handlers; +using CommonSocketLibrary.Backoff; // dotnet publish -r linux-x64 -p:PublishSingleFile=true --self-contained true // dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained true @@ -43,13 +44,10 @@ var deserializer = new DeserializerBuilder() var configContent = File.ReadAllText("tts.config.yml"); var configuration = deserializer.Deserialize(configContent); -s.AddSingleton(configuration); +s.AddSingleton(configuration); var logger = new LoggerConfiguration() .MinimumLevel.Verbose() - //.MinimumLevel.Override("TwitchLib.Communication.Clients.WebSocketClient", LogEventLevel.Warning) - //.MinimumLevel.Override("TwitchLib.PubSub.TwitchPubSub", LogEventLevel.Warning) - .MinimumLevel.Override("TwitchLib", LogEventLevel.Warning) .MinimumLevel.Override("mariuszgromada", LogEventLevel.Error) .Enrich.FromLogContext() .WriteTo.File("logs/log-.log", restrictedToMinimumLevel: LogEventLevel.Debug, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 3) @@ -57,11 +55,11 @@ var logger = new LoggerConfiguration() .CreateLogger(); s.AddSerilog(logger); -s.AddSingleton(new User()); +s.AddSingleton(); s.AddSingleton(); s.AddSingleton, CallbackManager>(); -s.AddSingleton(new JsonSerializerOptions() +s.AddSingleton(new JsonSerializerOptions() { PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower @@ -77,10 +75,11 @@ s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); -s.AddSingleton(); +s.AddSingleton(); +s.AddSingleton(); s.AddSingleton(); -s.AddSingleton(); +s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); @@ -109,19 +108,33 @@ s.AddKeyedSingleton, SevenMessageTypeManag s.AddKeyedSingleton, SevenSocketClient>("7tv"); // twitch websocket -s.AddKeyedSingleton, TwitchWebsocketClient>("twitch"); +s.AddKeyedSingleton("twitch", new ExponentialBackoff(1000, 120 * 1000)); +s.AddSingleton(); +s.AddKeyedTransient, TwitchWebsocketClient>("twitch", (sp, _) => +{ + var factory = sp.GetRequiredService(); + var client = factory.GetWorkingClient(); + client.Connect().Wait(); + return client; +}); +s.AddKeyedTransient, TwitchWebsocketClient>("twitch-create"); +s.AddKeyedSingleton("twitch"); 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"); s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); // hermes websocket s.AddKeyedSingleton("hermes"); diff --git a/TTS.cs b/TTS.cs index 7001910..79a8ee0 100644 --- a/TTS.cs +++ b/TTS.cs @@ -13,6 +13,7 @@ using CommonSocketLibrary.Common; using TwitchChatTTS.OBS.Socket; using TwitchChatTTS.Twitch.Socket.Messages; using TwitchChatTTS.Twitch.Socket; +using TwitchChatTTS.Chat.Commands; namespace TwitchChatTTS { @@ -24,45 +25,51 @@ namespace TwitchChatTTS private readonly User _user; private readonly HermesApiClient _hermesApiClient; private readonly SevenApiClient _sevenApiClient; + private readonly TwitchApiClient _twitchApiClient; private readonly HermesSocketClient _hermes; private readonly OBSSocketClient _obs; private readonly SevenSocketClient _seven; private readonly TwitchWebsocketClient _twitch; + private readonly ICommandFactory _commandFactory; + private readonly ICommandManager _commandManager; private readonly IEmoteDatabase _emotes; - private readonly Configuration _configuration; private readonly TTSPlayer _player; private readonly AudioPlaybackEngine _playback; - private readonly IServiceProvider _serviceProvider; + private readonly Configuration _configuration; private readonly ILogger _logger; public TTS( User user, HermesApiClient hermesApiClient, SevenApiClient sevenApiClient, + TwitchApiClient twitchApiClient, [FromKeyedServices("hermes")] SocketClient hermes, [FromKeyedServices("obs")] SocketClient obs, [FromKeyedServices("7tv")] SocketClient seven, [FromKeyedServices("twitch")] SocketClient twitch, + ICommandFactory commandFactory, + ICommandManager commandManager, IEmoteDatabase emotes, - Configuration configuration, TTSPlayer player, AudioPlaybackEngine playback, - IServiceProvider serviceProvider, + Configuration configuration, ILogger logger ) { _user = user; _hermesApiClient = hermesApiClient; _sevenApiClient = sevenApiClient; + _twitchApiClient = twitchApiClient; _hermes = (hermes as HermesSocketClient)!; _obs = (obs as OBSSocketClient)!; _seven = (seven as SevenSocketClient)!; _twitch = (twitch as TwitchWebsocketClient)!; + _commandFactory = commandFactory; + _commandManager = commandManager; _emotes = emotes; _configuration = configuration; _player = player; _playback = playback; - _serviceProvider = serviceProvider; _logger = logger; } @@ -91,6 +98,7 @@ namespace TwitchChatTTS await Task.Delay(15 * 1000); } + await _twitch.Connect(); await InitializeHermesWebsocket(); try { @@ -98,29 +106,33 @@ namespace TwitchChatTTS _user.HermesUserId = hermesAccount.Id; _user.HermesUsername = hermesAccount.Username; _user.TwitchUsername = hermesAccount.Username; + _user.TwitchUserId = long.Parse(hermesAccount.BroadcasterId); + } + catch (ArgumentNullException) + { + _logger.Error("Ensure you have your Twitch account linked to TTS."); + await Task.Delay(TimeSpan.FromSeconds(30)); + return; + } + catch (FormatException) + { + _logger.Error("Ensure you have your Twitch account linked to TTS."); + await Task.Delay(TimeSpan.FromSeconds(30)); + return; } catch (Exception ex) { _logger.Error(ex, "Failed to initialize properly. Restart app please."); - await Task.Delay(30 * 1000); + await Task.Delay(TimeSpan.FromSeconds(30)); return; } - await _hermesApiClient.AuthorizeTwitch(); - var twitchBotToken = await _hermesApiClient.FetchTwitchBotToken(); - _user.TwitchUserId = long.Parse(twitchBotToken.BroadcasterId!); - _logger.Information($"Username: {_user.TwitchUsername} [id: {_user.TwitchUserId}]"); - - var twitchapiclient2 = _serviceProvider.GetRequiredService(); - twitchapiclient2.Initialize(twitchBotToken); - - _twitch.Initialize(); - await _twitch.Connect(); - var emoteSet = await _sevenApiClient.FetchChannelEmoteSet(_user.TwitchUserId.ToString()); if (emoteSet != null) _user.SevenEmoteSetId = emoteSet.Id; + _commandManager.Update(_commandFactory); + await InitializeEmotes(_sevenApiClient, emoteSet); await InitializeSevenTv(); await InitializeObs(); @@ -265,43 +277,6 @@ namespace TwitchChatTTS } } - // private async Task InitializeTwitchApiClient(string username) - // { - // _logger.Debug("Initializing twitch client."); - - // var hermesapiclient = _serviceProvider.GetRequiredService(); - // if (!await hermesapiclient.AuthorizeTwitch()) - // { - // _logger.Error("Cannot connect to Twitch API."); - // return null; - // } - - // var twitchapiclient = _serviceProvider.GetRequiredService(); - // var channels = _configuration.Twitch?.Channels ?? [username]; - // _logger.Information("Twitch channels: " + string.Join(", ", channels)); - // twitchapiclient.InitializeClient(username, channels); - // twitchapiclient.InitializePublisher(); - - // var handler = _serviceProvider.GetRequiredService(); - // twitchapiclient.AddOnNewMessageReceived(async (object? s, OnMessageReceivedArgs e) => - // { - // try - // { - // var result = await handler.Handle(e); - // if (result.Status != MessageStatus.None || result.Emotes == null || !result.Emotes.Any()) - // return; - - // await _hermes.SendEmoteUsage(e.ChatMessage.Id, result.ChatterId, result.Emotes); - // } - // catch (Exception ex) - // { - // _logger.Error(ex, "Unable to either execute a command or to send emote usage message."); - // } - // }); - - // return twitchapiclient; - // } - private async Task InitializeEmotes(SevenApiClient sevenapi, EmoteSet? channelEmotes) { var globalEmotes = await sevenapi.FetchGlobalSevenEmotes(); diff --git a/Twitch/Redemptions/IRedemptionManager.cs b/Twitch/Redemptions/IRedemptionManager.cs new file mode 100644 index 0000000..e0d13ef --- /dev/null +++ b/Twitch/Redemptions/IRedemptionManager.cs @@ -0,0 +1,11 @@ +using HermesSocketLibrary.Requests.Messages; + +namespace TwitchChatTTS.Twitch.Redemptions +{ + public interface IRedemptionManager + { + Task Execute(RedeemableAction action, string senderDisplayName, long senderId); + IList Get(string twitchRedemptionId); + void Initialize(IEnumerable redemptions, IDictionary actions); + } +} \ No newline at end of file diff --git a/Twitch/Redemptions/RedemptionManager.cs b/Twitch/Redemptions/RedemptionManager.cs index 8c6c212..9c9555d 100644 --- a/Twitch/Redemptions/RedemptionManager.cs +++ b/Twitch/Redemptions/RedemptionManager.cs @@ -11,7 +11,7 @@ using TwitchChatTTS.OBS.Socket.Data; namespace TwitchChatTTS.Twitch.Redemptions { - public class RedemptionManager + public class RedemptionManager : IRedemptionManager { private readonly IDictionary> _store; private readonly User _user; diff --git a/Twitch/Socket/Handlers/ChannelAdBreakHandler.cs b/Twitch/Socket/Handlers/ChannelAdBreakHandler.cs new file mode 100644 index 0000000..9a119a2 --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelAdBreakHandler.cs @@ -0,0 +1,57 @@ +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 redemable 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 redeeemable 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/ChannelBanHandler.cs b/Twitch/Socket/Handlers/ChannelBanHandler.cs index 0eee517..a8350b5 100644 --- a/Twitch/Socket/Handlers/ChannelBanHandler.cs +++ b/Twitch/Socket/Handlers/ChannelBanHandler.cs @@ -14,7 +14,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers _logger = logger; } - public Task Execute(TwitchWebsocketClient sender, object? data) + public Task Execute(TwitchWebsocketClient sender, object data) { if (data is not ChannelBanMessage message) return Task.CompletedTask; diff --git a/Twitch/Socket/Handlers/ChannelChatClearHandler.cs b/Twitch/Socket/Handlers/ChannelChatClearHandler.cs index 2f2f9b5..2c0dffa 100644 --- a/Twitch/Socket/Handlers/ChannelChatClearHandler.cs +++ b/Twitch/Socket/Handlers/ChannelChatClearHandler.cs @@ -18,7 +18,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers _logger = logger; } - public Task Execute(TwitchWebsocketClient sender, object? data) + public Task Execute(TwitchWebsocketClient sender, object data) { if (data is not ChannelChatClearMessage message) return Task.CompletedTask; diff --git a/Twitch/Socket/Handlers/ChannelChatClearUserHandler.cs b/Twitch/Socket/Handlers/ChannelChatClearUserHandler.cs index 717ff94..8bb0af4 100644 --- a/Twitch/Socket/Handlers/ChannelChatClearUserHandler.cs +++ b/Twitch/Socket/Handlers/ChannelChatClearUserHandler.cs @@ -18,14 +18,16 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers _logger = logger; } - public Task Execute(TwitchWebsocketClient sender, object? data) + public Task Execute(TwitchWebsocketClient sender, object data) { if (data is not ChannelChatClearUserMessage message) return Task.CompletedTask; - + + long broadcasterId = long.Parse(message.BroadcasterUserId); long chatterId = long.Parse(message.TargetUserId); - _player.RemoveAll(chatterId); - if (_player.Playing?.ChatterId == chatterId) { + _player.RemoveAll(broadcasterId, chatterId); + if (_player.Playing != null && _player.Playing.RoomId == broadcasterId && _player.Playing.ChatterId == chatterId) + { _playback.RemoveMixerInput(_player.Playing.Audio!); _player.Playing = null; } diff --git a/Twitch/Socket/Handlers/ChannelChatDeleteMessageHandler.cs b/Twitch/Socket/Handlers/ChannelChatDeleteMessageHandler.cs index b38f7d1..2d455fa 100644 --- a/Twitch/Socket/Handlers/ChannelChatDeleteMessageHandler.cs +++ b/Twitch/Socket/Handlers/ChannelChatDeleteMessageHandler.cs @@ -18,7 +18,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers _logger = logger; } - public Task Execute(TwitchWebsocketClient sender, object? data) + public Task Execute(TwitchWebsocketClient sender, object data) { if (data is not ChannelChatDeleteMessage message) return Task.CompletedTask; diff --git a/Twitch/Socket/Handlers/ChannelChatMessageHandler.cs b/Twitch/Socket/Handlers/ChannelChatMessageHandler.cs index d2f8c37..1503f4d 100644 --- a/Twitch/Socket/Handlers/ChannelChatMessageHandler.cs +++ b/Twitch/Socket/Handlers/ChannelChatMessageHandler.cs @@ -19,7 +19,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers private readonly User _user; private readonly TTSPlayer _player; - private readonly CommandManager _commands; + private readonly ICommandManager _commands; private readonly IGroupPermissionManager _permissionManager; private readonly IChatterGroupManager _chatterGroupManager; private readonly IEmoteDatabase _emotes; @@ -34,7 +34,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers public ChannelChatMessageHandler( User user, TTSPlayer player, - CommandManager commands, + ICommandManager commands, IGroupPermissionManager permissionManager, IChatterGroupManager chatterGroupManager, IEmoteDatabase emotes, @@ -59,15 +59,10 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers _logger = logger; } - public async Task Execute(TwitchWebsocketClient sender, object? data) + public async Task Execute(TwitchWebsocketClient sender, object data) { if (sender == null) return; - if (data == null) - { - _logger.Warning("Twitch websocket message data is null."); - return; - } if (data is not ChannelChatMessage message) return; @@ -231,6 +226,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers var parts = _sfxRegex.Split(message); var chatterId = long.Parse(e.ChatterUserId); + var broadcasterId = long.Parse(e.BroadcasterUserId); var badgesString = string.Join(", ", e.Badges.Select(b => b.SetId + '|' + b.Id + '=' + b.Info)); if (parts.Length == 1) @@ -241,6 +237,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers Voice = voice, Message = message, Timestamp = DateTime.UtcNow, + RoomId = broadcasterId, ChatterId = chatterId, MessageId = e.MessageId, Badges = e.Badges, @@ -271,6 +268,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers Voice = voice, Message = parts[i * 2], Timestamp = DateTime.UtcNow, + RoomId = broadcasterId, ChatterId = chatterId, MessageId = e.MessageId, Badges = e.Badges, @@ -284,6 +282,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers Voice = voice, File = $"sfx/{sfxName}.mp3", Timestamp = DateTime.UtcNow, + RoomId = broadcasterId, ChatterId = chatterId, MessageId = e.MessageId, Badges = e.Badges, @@ -299,6 +298,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers Voice = voice, Message = parts.Last(), Timestamp = DateTime.UtcNow, + RoomId = broadcasterId, ChatterId = chatterId, MessageId = e.MessageId, Badges = e.Badges, diff --git a/Twitch/Socket/Handlers/ChannelCustomRedemptionHandler.cs b/Twitch/Socket/Handlers/ChannelCustomRedemptionHandler.cs index b3f6538..92c88d2 100644 --- a/Twitch/Socket/Handlers/ChannelCustomRedemptionHandler.cs +++ b/Twitch/Socket/Handlers/ChannelCustomRedemptionHandler.cs @@ -8,11 +8,11 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers { public string Name => "channel.channel_points_custom_reward_redemption.add"; - private readonly RedemptionManager _redemptionManager; + private readonly IRedemptionManager _redemptionManager; private readonly ILogger _logger; public ChannelCustomRedemptionHandler( - RedemptionManager redemptionManager, + IRedemptionManager redemptionManager, ILogger logger ) { @@ -20,7 +20,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers _logger = logger; } - public async Task Execute(TwitchWebsocketClient sender, object? data) + public async Task Execute(TwitchWebsocketClient sender, object data) { if (data is not ChannelCustomRedemptionMessage message) return; diff --git a/Twitch/Socket/Handlers/ChannelFollowHandler.cs b/Twitch/Socket/Handlers/ChannelFollowHandler.cs new file mode 100644 index 0000000..072fa06 --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelFollowHandler.cs @@ -0,0 +1,52 @@ +using Serilog; +using TwitchChatTTS.Twitch.Redemptions; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class ChannelFollowHandler : ITwitchSocketHandler + { + public string Name => "channel.follow"; + + private readonly IRedemptionManager _redemptionManager; + private readonly ILogger _logger; + + public ChannelFollowHandler(IRedemptionManager redemptionManager, ILogger logger) + { + _redemptionManager = redemptionManager; + _logger = logger; + } + + public async Task Execute(TwitchWebsocketClient sender, object data) + { + if (data is not ChannelFollowMessage message) + return; + + _logger.Information($"User followed [chatter: {message.UserLogin}][chatter id: {message.UserId}]"); + try + { + var actions = _redemptionManager.Get("follow"); + if (!actions.Any()) + { + _logger.Debug($"No redemable actions for follow was found"); + return; + } + _logger.Debug($"Found {actions.Count} actions for this Twitch follow"); + + foreach (var action in actions) + try + { + await _redemptionManager.Execute(action, message.UserName, long.Parse(message.UserId)); + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to execute redeeemable action [action: {action.Name}][action type: {action.Type}][redeem: follow]"); + } + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to fetch the redeemable actions for follow"); + } + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ChannelResubscriptionHandler.cs b/Twitch/Socket/Handlers/ChannelResubscriptionHandler.cs new file mode 100644 index 0000000..a0ec660 --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelResubscriptionHandler.cs @@ -0,0 +1,52 @@ +using Serilog; +using TwitchChatTTS.Twitch.Redemptions; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class ChannelResubscriptionHandler : ITwitchSocketHandler + { + public string Name => "channel.subscription.message"; + + private readonly IRedemptionManager _redemptionManager; + private readonly ILogger _logger; + + public ChannelResubscriptionHandler(IRedemptionManager redemptionManager, ILogger logger) + { + _redemptionManager = redemptionManager; + _logger = logger; + } + + public async Task Execute(TwitchWebsocketClient sender, object data) + { + if (data is not ChannelResubscriptionMessage message) + return; + + _logger.Debug("Resubscription occured."); + try + { + var actions = _redemptionManager.Get("subscription"); + if (!actions.Any()) + { + _logger.Debug($"No redemable actions for this subscription was found [message: {message.Message.Text}]"); + return; + } + _logger.Debug($"Found {actions.Count} actions for this Twitch subscription [message: {message.Message.Text}]"); + + foreach (var action in actions) + try + { + await _redemptionManager.Execute(action, message.UserName, long.Parse(message.UserId)); + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to execute redeeemable action [action: {action.Name}][action type: {action.Type}][redeem: subscription][message: {message.Message.Text}]"); + } + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to fetch the redeemable actions for subscription [message: {message.Message.Text}]"); + } + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ChannelSubscriptionGiftHandler.cs b/Twitch/Socket/Handlers/ChannelSubscriptionGiftHandler.cs new file mode 100644 index 0000000..f494228 --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelSubscriptionGiftHandler.cs @@ -0,0 +1,52 @@ +using Serilog; +using TwitchChatTTS.Twitch.Redemptions; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class ChannelSubscriptionGiftHandler : ITwitchSocketHandler + { + public string Name => "channel.subscription.gift"; + + private readonly IRedemptionManager _redemptionManager; + private readonly ILogger _logger; + + public ChannelSubscriptionGiftHandler(IRedemptionManager redemptionManager, ILogger logger) + { + _redemptionManager = redemptionManager; + _logger = logger; + } + + public async Task Execute(TwitchWebsocketClient sender, object data) + { + if (data is not ChannelSubscriptionGiftMessage message) + return; + + _logger.Debug("Gifted subscription occured."); + try + { + var actions = _redemptionManager.Get("subscription.gift"); + if (!actions.Any()) + { + _logger.Debug($"No redemable actions for this gifted subscription was found"); + return; + } + _logger.Debug($"Found {actions.Count} actions for this Twitch gifted subscription [gifted: {message.UserLogin}][gifted id: {message.UserId}][Anonymous: {message.IsAnonymous}][cumulative: {message.CumulativeTotal ?? -1}]"); + + foreach (var action in actions) + try + { + await _redemptionManager.Execute(action, message.UserName, long.Parse(message.UserId)); + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to execute redeeemable action [action: {action.Name}][action type: {action.Type}][redeem: gifted subscription]"); + } + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to fetch the redeemable actions for gifted subscription"); + } + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ChannelSubscriptionHandler.cs b/Twitch/Socket/Handlers/ChannelSubscriptionHandler.cs index 7bf1abe..a2e1cd3 100644 --- a/Twitch/Socket/Handlers/ChannelSubscriptionHandler.cs +++ b/Twitch/Socket/Handlers/ChannelSubscriptionHandler.cs @@ -1,33 +1,54 @@ using Serilog; +using TwitchChatTTS.Twitch.Redemptions; using TwitchChatTTS.Twitch.Socket.Messages; namespace TwitchChatTTS.Twitch.Socket.Handlers { public class ChannelSubscriptionHandler : ITwitchSocketHandler { - public string Name => "channel.subscription.message"; + public string Name => "channel.subscription"; - private readonly TTSPlayer _player; + private readonly IRedemptionManager _redemptionManager; private readonly ILogger _logger; - public ChannelSubscriptionHandler(TTSPlayer player, ILogger logger) { - _player = player; + public ChannelSubscriptionHandler(IRedemptionManager redemptionManager, ILogger logger) + { + _redemptionManager = redemptionManager; _logger = logger; } - public async Task Execute(TwitchWebsocketClient sender, object? data) + public async Task Execute(TwitchWebsocketClient sender, object data) { - if (sender == null) - return; - if (data == null) - { - _logger.Warning("Twitch websocket message data is null."); - return; - } if (data is not ChannelSubscriptionMessage message) return; + if (message.IsGifted) + return; _logger.Debug("Subscription occured."); + try + { + var actions = _redemptionManager.Get("subscription"); + if (!actions.Any()) + { + _logger.Debug($"No redemable actions for this subscription was found [subscriber: {message.UserLogin}][subscriber id: {message.UserId}]"); + return; + } + _logger.Debug($"Found {actions.Count} actions for this Twitch subscription [subscriber: {message.UserLogin}][subscriber id: {message.UserId}]"); + + foreach (var action in actions) + try + { + await _redemptionManager.Execute(action, message.UserName, long.Parse(message.UserId)); + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to execute redeeemable action [action: {action.Name}][action type: {action.Type}][redeem: subscription][subscriber: {message.UserLogin}][subscriber id: {message.UserId}]"); + } + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to fetch the redeemable actions for subscription [subscriber: {message.UserLogin}][subscriber id: {message.UserId}]"); + } } } } \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ITwitchSocketHandler.cs b/Twitch/Socket/Handlers/ITwitchSocketHandler.cs index d5c2a38..ea1a167 100644 --- a/Twitch/Socket/Handlers/ITwitchSocketHandler.cs +++ b/Twitch/Socket/Handlers/ITwitchSocketHandler.cs @@ -3,6 +3,6 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers public interface ITwitchSocketHandler { string Name { get; } - Task Execute(TwitchWebsocketClient sender, object? data); + Task Execute(TwitchWebsocketClient sender, object data); } } \ No newline at end of file diff --git a/Twitch/Socket/Handlers/NotificationHandler.cs b/Twitch/Socket/Handlers/NotificationHandler.cs index a0e311f..d6aaa7f 100644 --- a/Twitch/Socket/Handlers/NotificationHandler.cs +++ b/Twitch/Socket/Handlers/NotificationHandler.cs @@ -23,30 +23,30 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers _handlers = handlers.ToDictionary(h => h.Name, h => h); _logger = logger; - _options = new JsonSerializerOptions() { + _options = new JsonSerializerOptions() + { PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; _messageTypes = new Dictionary(); + _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.message_delete", typeof(ChannelChatDeleteMessage)); _messageTypes.Add("channel.channel_points_custom_reward_redemption.add", typeof(ChannelCustomRedemptionMessage)); + _messageTypes.Add("channel.follow", typeof(ChannelFollowMessage)); + _messageTypes.Add("channel.resubscription", typeof(ChannelResubscriptionMessage)); _messageTypes.Add("channel.subscription.message", typeof(ChannelSubscriptionMessage)); + _messageTypes.Add("channel.subscription.gift", typeof(ChannelSubscriptionGiftMessage)); } - public async Task Execute(TwitchWebsocketClient sender, object? data) + public async Task Execute(TwitchWebsocketClient sender, object data) { if (sender == null) return; - if (data == null) - { - _logger.Warning("Twitch websocket message data is null."); - return; - } if (data is not NotificationMessage message) return; diff --git a/Twitch/Socket/Handlers/SessionKeepAliveHandler.cs b/Twitch/Socket/Handlers/SessionKeepAliveHandler.cs new file mode 100644 index 0000000..882b1b2 --- /dev/null +++ b/Twitch/Socket/Handlers/SessionKeepAliveHandler.cs @@ -0,0 +1,12 @@ +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class SessionKeepAliveHandler : ITwitchSocketHandler + { + public string Name => "session_keepalive"; + + public Task Execute(TwitchWebsocketClient sender, object data) + { + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/SessionReconnectHandler.cs b/Twitch/Socket/Handlers/SessionReconnectHandler.cs index f243451..4d4c7a6 100644 --- a/Twitch/Socket/Handlers/SessionReconnectHandler.cs +++ b/Twitch/Socket/Handlers/SessionReconnectHandler.cs @@ -8,40 +8,45 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers { public string Name => "session_reconnect"; - private readonly TwitchApiClient _api; + private readonly ITwitchConnectionManager _manager; private readonly ILogger _logger; - public SessionReconnectHandler(TwitchApiClient api, ILogger logger) + public SessionReconnectHandler(ITwitchConnectionManager manager, ILogger logger) { - _api = api; + _manager = manager; _logger = logger; } - public async Task Execute(TwitchWebsocketClient sender, object? data) + public async Task Execute(TwitchWebsocketClient sender, object data) { if (sender == null) return; - if (data == null) - { - _logger.Warning("Twitch websocket message data is null."); - return; - } if (data is not SessionWelcomeMessage message) return; - if (_api == null) - return; if (string.IsNullOrEmpty(message.Session.Id)) { - _logger.Warning($"No session info provided by Twitch [status: {message.Session.Status}]"); + _logger.Warning($"No session id provided by Twitch [status: {message.Session.Status}]"); return; } - // TODO: Be able to handle multiple websocket connections. - sender.URL = message.Session.ReconnectUrl; - await Task.Delay(TimeSpan.FromSeconds(29)); - await sender.DisconnectAsync(new SocketDisconnectionEventArgs("Close", "Twitch asking to reconnect.")); - await sender.Connect(); + if (message.Session.ReconnectUrl == null) + { + _logger.Warning($"No reconnection info provided by Twitch [status: {message.Session.Status}]"); + return; + } + + sender.ReceivedReconnecting = true; + + var backup = _manager.GetBackupClient(); + var identified = _manager.GetWorkingClient(); + if (identified != null && backup != identified) + { + await identified.DisconnectAsync(new SocketDisconnectionEventArgs("Closed", "Reconnection from another client.")); + } + + backup.URL = message.Session.ReconnectUrl; + await backup.Connect(); } } } \ No newline at end of file diff --git a/Twitch/Socket/Handlers/SessionWelcomeHandler.cs b/Twitch/Socket/Handlers/SessionWelcomeHandler.cs index ed3d0e0..daec156 100644 --- a/Twitch/Socket/Handlers/SessionWelcomeHandler.cs +++ b/Twitch/Socket/Handlers/SessionWelcomeHandler.cs @@ -1,4 +1,3 @@ -using CommonSocketLibrary.Abstract; using Serilog; using TwitchChatTTS.Twitch.Socket.Messages; @@ -8,26 +7,23 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers { public string Name => "session_welcome"; + private readonly HermesApiClient _hermes; private readonly TwitchApiClient _api; private readonly User _user; private readonly ILogger _logger; - public SessionWelcomeHandler(TwitchApiClient api, User user, ILogger logger) + public SessionWelcomeHandler(HermesApiClient hermes, TwitchApiClient api, User user, ILogger logger) { + _hermes = hermes; _api = api; _user = user; _logger = logger; } - public async Task Execute(TwitchWebsocketClient sender, object? data) + public async Task Execute(TwitchWebsocketClient sender, object data) { if (sender == null) return; - if (data == null) - { - _logger.Warning("Twitch websocket message data is null."); - return; - } if (data is not SessionWelcomeMessage message) return; if (_api == null) @@ -39,6 +35,11 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers return; } + await _hermes.AuthorizeTwitch(); + var token = await _hermes.FetchTwitchBotToken(); + _api.Initialize(token); + + string broadcasterId = _user.TwitchUserId.ToString(); string[] subscriptionsv1 = [ "channel.chat.message", "channel.chat.message_delete", @@ -53,17 +54,36 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers string[] subscriptionsv2 = [ "channel.follow", ]; - string broadcasterId = _user.TwitchUserId.ToString(); + + string? pagination = null; + int size = 0; + do + { + var subscriptionsData = await _api.GetSubscriptions(status: "enabled", broadcasterId: broadcasterId, after: pagination); + var subscriptionNames = subscriptionsData?.Data == null ? [] : subscriptionsData.Data.Select(s => s.Type).ToArray(); + + if (subscriptionNames.Length == 0) + break; + + foreach (var d in subscriptionsData!.Data!) + sender.AddSubscription(broadcasterId, d.Type, d.Id); + + subscriptionsv1 = subscriptionsv1.Except(subscriptionNames).ToArray(); + subscriptionsv2 = subscriptionsv2.Except(subscriptionNames).ToArray(); + + pagination = subscriptionsData?.Pagination?.Cursor; + size = subscriptionNames.Length; + } while (size >= 100 && pagination != null && subscriptionsv1.Length + subscriptionsv2.Length > 0); + foreach (var subscription in subscriptionsv1) - await Subscribe(subscription, message.Session.Id, broadcasterId, "1"); + await Subscribe(sender, subscription, message.Session.Id, broadcasterId, "1"); foreach (var subscription in subscriptionsv2) - await Subscribe(subscription, message.Session.Id, broadcasterId, "2"); - - sender.SessionId = message.Session.Id; - sender.Identified = sender.SessionId != null; + await Subscribe(sender, subscription, message.Session.Id, broadcasterId, "2"); + + sender.Identify(message.Session.Id); } - private async Task Subscribe(string subscriptionName, string sessionId, string broadcasterId, string version) + private async Task Subscribe(TwitchWebsocketClient sender, string subscriptionName, string sessionId, string broadcasterId, string version) { try { @@ -83,6 +103,10 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers _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) diff --git a/Twitch/Socket/Messages/ChannelAdBreakMessage.cs b/Twitch/Socket/Messages/ChannelAdBreakMessage.cs new file mode 100644 index 0000000..089c139 --- /dev/null +++ b/Twitch/Socket/Messages/ChannelAdBreakMessage.cs @@ -0,0 +1,15 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class ChannelAdBreakMessage + { + public string DurationSeconds { get; set; } + public DateTime StartedAt { get; set; } + public string IsAutomatic { get; set; } + public string BroadcasterUserId { get; set; } + public string BroadcasterUserLogin { get; set; } + public string BroadcasterUserName { get; set; } + public string RequesterUserId { get; set; } + public string RequesterUserLogin { get; set; } + public string RequesterUserName { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/ChannelFollowMessage.cs b/Twitch/Socket/Messages/ChannelFollowMessage.cs new file mode 100644 index 0000000..3522087 --- /dev/null +++ b/Twitch/Socket/Messages/ChannelFollowMessage.cs @@ -0,0 +1,13 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class ChannelFollowMessage + { + public string BroadcasterUserId { get; set; } + public string BroadcasterUserLogin { get; set; } + public string BroadcasterUserName { get; set; } + public string UserId { get; set; } + public string UserLogin { get; set; } + public string UserName { get; set; } + public DateTime FollowedAt { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/ChannelResubscriptionMessage.cs b/Twitch/Socket/Messages/ChannelResubscriptionMessage.cs new file mode 100644 index 0000000..405f03f --- /dev/null +++ b/Twitch/Socket/Messages/ChannelResubscriptionMessage.cs @@ -0,0 +1,10 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class ChannelResubscriptionMessage : ChannelSubscriptionData + { + public TwitchChatMessageInfo Message { get; set; } + public int CumulativeMonths { get; set; } + public int StreakMonths { get; set; } + public int DurationMonths { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/ChannelSubscriptionGiftMessage.cs b/Twitch/Socket/Messages/ChannelSubscriptionGiftMessage.cs new file mode 100644 index 0000000..b32c32f --- /dev/null +++ b/Twitch/Socket/Messages/ChannelSubscriptionGiftMessage.cs @@ -0,0 +1,9 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class ChannelSubscriptionGiftMessage : ChannelSubscriptionData + { + public int Total { get; set; } + public int? CumulativeTotal { get; set; } + public bool IsAnonymous { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/ChannelSubscriptionMessage.cs b/Twitch/Socket/Messages/ChannelSubscriptionMessage.cs index 769d103..d095bf2 100644 --- a/Twitch/Socket/Messages/ChannelSubscriptionMessage.cs +++ b/Twitch/Socket/Messages/ChannelSubscriptionMessage.cs @@ -1,17 +1,18 @@ namespace TwitchChatTTS.Twitch.Socket.Messages { - public class ChannelSubscriptionMessage + public class ChannelSubscriptionData { + public string UserId { get; set; } + public string UserLogin { get; set; } + public string UserName { get; set; } public string BroadcasterUserId { get; set; } public string BroadcasterUserLogin { get; set; } public string BroadcasterUserName { get; set; } - public string ChatterUserId { get; set; } - public string ChatterUserLogin { get; set; } - public string ChatterUserName { get; set; } public string Tier { get; set; } - public TwitchChatMessageInfo Message { get; set; } - public int CumulativeMonths { get; set; } - public int StreakMonths { get; set; } - public int DurationMonths { get; set; } + } + + public class ChannelSubscriptionMessage : ChannelSubscriptionData + { + public bool IsGifted { get; set; } } } \ No newline at end of file diff --git a/Twitch/Socket/Messages/EventResponse.cs b/Twitch/Socket/Messages/EventResponse.cs index aaa6549..095d5a1 100644 --- a/Twitch/Socket/Messages/EventResponse.cs +++ b/Twitch/Socket/Messages/EventResponse.cs @@ -6,5 +6,10 @@ namespace TwitchChatTTS.Twitch.Socket.Messages public int Total { get; set; } public int TotalCost { get; set; } public int MaxTotalCost { get; set; } + public EventResponsePagination? Pagination { get; set; } + } + + public class EventResponsePagination { + public string Cursor { get; set; } } } \ No newline at end of file diff --git a/Twitch/Socket/Messages/EventSubscriptionMessage.cs b/Twitch/Socket/Messages/EventSubscriptionMessage.cs index d9a6d7d..feea0f5 100644 --- a/Twitch/Socket/Messages/EventSubscriptionMessage.cs +++ b/Twitch/Socket/Messages/EventSubscriptionMessage.cs @@ -11,7 +11,8 @@ namespace TwitchChatTTS.Twitch.Socket.Messages [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? Cost { get; set; } - public EventSubscriptionMessage() { + public EventSubscriptionMessage() + { Type = string.Empty; Version = string.Empty; Condition = new Dictionary(); @@ -45,7 +46,8 @@ namespace TwitchChatTTS.Twitch.Socket.Messages [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? SessionId { get; } - public EventSubTransport() { + public EventSubTransport() + { Method = string.Empty; } diff --git a/Twitch/Socket/Messages/NotificationMessage.cs b/Twitch/Socket/Messages/NotificationMessage.cs index 7300baf..d500bfb 100644 --- a/Twitch/Socket/Messages/NotificationMessage.cs +++ b/Twitch/Socket/Messages/NotificationMessage.cs @@ -11,6 +11,6 @@ namespace TwitchChatTTS.Twitch.Socket.Messages public string Id { get; set; } public string Status { get; set; } public DateTime CreatedAt { get; set; } - public object Event { get; set; } + public object? Event { get; set; } } } \ No newline at end of file diff --git a/Twitch/Socket/Messages/SessionWelcomeMessage.cs b/Twitch/Socket/Messages/SessionWelcomeMessage.cs index 4562a51..5a0b419 100644 --- a/Twitch/Socket/Messages/SessionWelcomeMessage.cs +++ b/Twitch/Socket/Messages/SessionWelcomeMessage.cs @@ -8,7 +8,7 @@ namespace TwitchChatTTS.Twitch.Socket.Messages public string Id { get; set; } public string Status { get; set; } public DateTime ConnectedAt { get; set; } - public int KeepaliveTimeoutSeconds { get; set; } + public int? KeepaliveTimeoutSeconds { get; set; } public string? ReconnectUrl { get; set; } public string? RecoveryUrl { get; set; } } diff --git a/Twitch/Socket/TwitchConnectionManager.cs b/Twitch/Socket/TwitchConnectionManager.cs new file mode 100644 index 0000000..ef45b79 --- /dev/null +++ b/Twitch/Socket/TwitchConnectionManager.cs @@ -0,0 +1,119 @@ +using CommonSocketLibrary.Abstract; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket +{ + public interface ITwitchConnectionManager + { + TwitchWebsocketClient GetWorkingClient(); + TwitchWebsocketClient GetBackupClient(); + } + + public class TwitchConnectionManager : ITwitchConnectionManager + { + private TwitchWebsocketClient? _identified; + private TwitchWebsocketClient? _backup; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + private readonly object _lock; + + public TwitchConnectionManager(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + + _lock = new object(); + } + + + public TwitchWebsocketClient GetBackupClient() + { + lock (_lock) + { + if (_identified == null) + throw new InvalidOperationException("Cannot get backup Twitch client yet. Waiting for identification."); + if (_backup != null) + return _backup; + + return CreateNewClient(); + } + } + + public TwitchWebsocketClient GetWorkingClient() + { + lock (_lock) + { + if (_identified == null) + { + return CreateNewClient(); + } + + return _identified; + } + } + + private TwitchWebsocketClient CreateNewClient() + { + if (_backup != null) + return _backup; + + var client = (_serviceProvider.GetRequiredKeyedService>("twitch-create") as TwitchWebsocketClient)!; + client.Initialize(); + _backup = client; + + client.OnIdentified += async (s, e) => + { + bool clientDisconnect = false; + lock (_lock) + { + if (_identified == client) + { + _logger.Error("Twitch client has been re-identified."); + return; + } + if (_backup != client) + { + _logger.Warning("Twitch client has been identified, but isn't backup. Disconnecting."); + clientDisconnect = true; + return; + } + + if (_identified != null) + { + return; + } + + _identified = _backup; + _backup = null; + } + + if (clientDisconnect) + await client.DisconnectAsync(new SocketDisconnectionEventArgs("Closed", "No need for a tertiary client.")); + + _logger.Information("Twitch client has been identified."); + }; + client.OnDisconnected += (s, e) => + { + lock (_lock) + { + if (_identified == client) + { + _identified = null; + } + else if (_backup == client) + { + _backup = null; + } + else + _logger.Error("Twitch client disconnection from unknown source."); + } + }; + + _logger.Debug("Created a Twitch websocket client."); + return client; + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/TwitchWebsocketClient.cs b/Twitch/Socket/TwitchWebsocketClient.cs index f7f407b..8c71896 100644 --- a/Twitch/Socket/TwitchWebsocketClient.cs +++ b/Twitch/Socket/TwitchWebsocketClient.cs @@ -6,26 +6,32 @@ using System.Net.WebSockets; using TwitchChatTTS.Twitch.Socket.Messages; using System.Text; using TwitchChatTTS.Twitch.Socket.Handlers; +using CommonSocketLibrary.Backoff; namespace TwitchChatTTS.Twitch.Socket { public class TwitchWebsocketClient : SocketClient { + private readonly IDictionary _handlers; + private readonly IDictionary _messageTypes; + private readonly IDictionary _subscriptions; + private readonly IBackoff _backoff; + private DateTime _lastReceivedMessageTimestamp; + private bool _disconnected; + private readonly object _lock; + + public event EventHandler OnIdentified; + public string URL; - - private IDictionary _handlers; - private IDictionary _messageTypes; - private readonly Configuration _configuration; - private System.Timers.Timer _reconnectTimer; - - public bool Connected { get; set; } - public bool Identified { get; set; } - public string SessionId { get; set; } + public bool Connected { get; private set; } + public bool Identified { get; private set; } + public string SessionId { get; private set; } + public bool ReceivedReconnecting { get; set; } public TwitchWebsocketClient( - Configuration configuration, [FromKeyedServices("twitch")] IEnumerable handlers, + [FromKeyedServices("twitch")] IBackoff backoff, ILogger logger ) : base(logger, new JsonSerializerOptions() { @@ -34,14 +40,12 @@ namespace TwitchChatTTS.Twitch.Socket }) { _handlers = handlers.ToDictionary(h => h.Name, h => h); - _configuration = configuration; - - _reconnectTimer = new System.Timers.Timer(TimeSpan.FromSeconds(30)); - _reconnectTimer.AutoReset = false; - _reconnectTimer.Elapsed += async (sender, e) => await Reconnect(); - _reconnectTimer.Enabled = false; + _backoff = backoff; + _subscriptions = new Dictionary(); + _lock = new object(); _messageTypes = new Dictionary(); + _messageTypes.Add("session_keepalive", typeof(object)); _messageTypes.Add("session_welcome", typeof(SessionWelcomeMessage)); _messageTypes.Add("session_reconnect", typeof(SessionWelcomeMessage)); _messageTypes.Add("notification", typeof(NotificationMessage)); @@ -50,23 +54,56 @@ namespace TwitchChatTTS.Twitch.Socket } + public void AddSubscription(string broadcasterId, string type, string id) + { + if (_subscriptions.ContainsKey(broadcasterId + '|' + type)) + _subscriptions[broadcasterId + '|' + type] = id; + else + _subscriptions.Add(broadcasterId + '|' + type, id); + } + + public string? GetSubscriptionId(string broadcasterId, string type) + { + if (_subscriptions.TryGetValue(broadcasterId + '|' + type, out var id)) + return id; + return null; + } + + public void RemoveSubscription(string broadcasterId, string type) + { + _subscriptions.Remove(broadcasterId + '|' + type); + } + public void Initialize() { - _logger.Information($"Initializing OBS websocket client."); + _logger.Information($"Initializing Twitch websocket client."); OnConnected += (sender, e) => { Connected = true; - _reconnectTimer.Enabled = false; _logger.Information("Twitch websocket client connected."); + _disconnected = false; }; - OnDisconnected += (sender, e) => + OnDisconnected += async (sender, e) => { - _reconnectTimer.Enabled = Identified; - _logger.Information($"Twitch websocket client disconnected [status: {e.Status}][reason: {e.Reason}] " + (Identified ? "Will be attempting to reconnect every 30 seconds." : "Will not be attempting to reconnect.")); + lock (_lock) + { + if (_disconnected) + return; + + _disconnected = true; + } + + _logger.Information($"Twitch websocket client disconnected [status: {e.Status}][reason: {e.Reason}]"); Connected = false; Identified = false; + + if (!ReceivedReconnecting) + { + _logger.Information("Attempting to reconnect to Twitch websocket server."); + await Reconnect(_backoff, async () => await Connect()); + } }; } @@ -79,42 +116,14 @@ namespace TwitchChatTTS.Twitch.Socket } _logger.Debug($"Twitch websocket client attempting to connect to {URL}"); - try - { - await ConnectAsync(URL); - } - catch (Exception) - { - _logger.Warning("Connecting to twitch failed. Skipping Twitch websockets."); - } + await ConnectAsync(URL); } - private async Task Reconnect() + public void Identify(string sessionId) { - if (Connected) - { - try - { - await DisconnectAsync(new SocketDisconnectionEventArgs(WebSocketCloseStatus.Empty.ToString(), "")); - } - catch (Exception) - { - _logger.Error("Failed to disconnect from Twitch websocket server."); - } - } - - try - { - await Connect(); - } - catch (WebSocketException wse) when (wse.Message.Contains("502")) - { - _logger.Error("Twitch websocket server cannot be found."); - } - catch (Exception ex) - { - _logger.Error(ex, "Failed to reconnect to Twitch websocket server."); - } + Identified = true; + SessionId = sessionId; + OnIdentified?.Invoke(this, EventArgs.Empty); } protected TwitchWebsocketMessage GenerateMessage(string messageType, T data) @@ -134,14 +143,17 @@ namespace TwitchChatTTS.Twitch.Socket protected override async Task OnResponseReceived(TwitchWebsocketMessage? message) { - if (message == null || message.Metadata == null) { + if (message == null || message.Metadata == null) + { _logger.Information("Twitch message is null"); return; } + _lastReceivedMessageTimestamp = DateTime.UtcNow; + string content = message.Payload?.ToString() ?? string.Empty; if (message.Metadata.MessageType != "session_keepalive") - _logger.Information("Twitch RX #" + message.Metadata.MessageType + ": " + content); + _logger.Debug("Twitch RX #" + message.Metadata.MessageType + ": " + content); if (!_messageTypes.TryGetValue(message.Metadata.MessageType, out var type) || type == null) { @@ -156,6 +168,11 @@ namespace TwitchChatTTS.Twitch.Socket } var data = JsonSerializer.Deserialize(content, type, _options); + if (data == null) + { + _logger.Warning("Twitch websocket message payload is null."); + return; + } await handler.Execute(this, data); } @@ -180,7 +197,7 @@ namespace TwitchChatTTS.Twitch.Socket await _socket!.SendAsync(array, WebSocketMessageType.Text, current + size >= total, _cts!.Token); current += size; } - _logger.Information("TX #" + type + ": " + content); + _logger.Debug("Twitch TX #" + type + ": " + content); } catch (Exception e) { diff --git a/Twitch/TTSContext.cs b/Twitch/TTSContext.cs deleted file mode 100644 index fc03469..0000000 --- a/Twitch/TTSContext.cs +++ /dev/null @@ -1,29 +0,0 @@ -// using System.Text.RegularExpressions; -// using HermesSocketLibrary.Request.Message; -// using TwitchChatTTS.Hermes; - -// namespace TwitchChatTTS.Twitch -// { -// public class TTSContext -// { -// public string DefaultVoice; -// public IEnumerable? EnabledVoices; -// public IDictionary? UsernameFilters; -// public IEnumerable? WordFilters; -// public IList? AvailableVoices { get => _availableVoices; set { _availableVoices = value; EnabledVoicesRegex = GenerateEnabledVoicesRegex(); } } -// public IDictionary? SelectedVoices; -// public Regex? EnabledVoicesRegex; - -// private IList? _availableVoices; - - -// private Regex? GenerateEnabledVoicesRegex() { -// if (AvailableVoices == null || AvailableVoices.Count() <= 0) { -// return null; -// } - -// var enabledVoicesString = string.Join("|", AvailableVoices.Select(v => v.Name)); -// return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase); -// } -// } -// } \ No newline at end of file diff --git a/Twitch/TwitchApiClient.cs b/Twitch/TwitchApiClient.cs index a24e066..cd113d8 100644 --- a/Twitch/TwitchApiClient.cs +++ b/Twitch/TwitchApiClient.cs @@ -24,35 +24,40 @@ public class TwitchApiClient }); } - public async Task?> CreateEventSubscription(string type, string version, string userId) + 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", userId }, { "moderator_user_id", userId } }; - var subscriptionData = new EventSubscriptionMessage(type, version, "https://hermes.goblincaves.com/api/account/authorize", "isdnmjfopsdfmsf4390", conditions); - var response = await _web.Post("https://api.twitch.tv/helix/eventsub/subscriptions", subscriptionData); - if (response.StatusCode == HttpStatusCode.Accepted) - { - _logger.Debug("Twitch API call [type: create event subscription]: " + await response.Content.ReadAsStringAsync()); - return await response.Content.ReadFromJsonAsync(typeof(EventResponse)) as EventResponse; - } - _logger.Warning("Twitch api failed to create event subscription: " + await response.Content.ReadAsStringAsync()); - return null; - } - - public async Task?> CreateEventSubscription(string type, string version, string sessionId, string userId) - { - var conditions = new Dictionary() { { "user_id", userId }, { "broadcaster_user_id", userId }, { "moderator_user_id", userId } }; + 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 response = await _web.Post("https://api.twitch.tv/helix/eventsub/subscriptions", subscriptionData); if (response.StatusCode == HttpStatusCode.Accepted) { _logger.Debug("Twitch API call [type: create event subscription]: " + await response.Content.ReadAsStringAsync()); - return await response.Content.ReadFromJsonAsync(typeof(EventResponse)) as EventResponse; + return await response.Content.ReadFromJsonAsync(typeof(EventResponse)) as EventResponse; } - _logger.Error("Twitch api failed to create event subscription: " + await response.Content.ReadAsStringAsync()); + _logger.Error("Twitch api failed to create event subscription for websocket: " + await response.Content.ReadAsStringAsync()); return null; } - public void Initialize(TwitchBotToken token) { + public async Task DeleteEventSubscription(string subscriptionId) + { + await _web.Delete("https://api.twitch.tv/helix/eventsub/subscriptions?id=" + subscriptionId); + } + + public async Task?> GetSubscriptions(string? status = null, string? broadcasterId = null, string? after = null) + { + List queryParams = new List(); + if (!string.IsNullOrWhiteSpace(status)) + queryParams.Add("status=" + status); + if (!string.IsNullOrWhiteSpace(broadcasterId)) + queryParams.Add("user_id=" + broadcasterId); + if (!string.IsNullOrWhiteSpace(after)) + queryParams.Add("after=" + after); + var query = queryParams.Any() ? '?' + string.Join('&', queryParams) : string.Empty; + return await _web.GetJson>("https://api.twitch.tv/helix/eventsub/subscriptions" + query); + } + + public void Initialize(TwitchBotToken token) + { _web.AddHeader("Authorization", "Bearer " + token.AccessToken); _web.AddHeader("Client-Id", token.ClientId); }