diff --git a/Chat/ChatMessageHandler.cs b/Chat/ChatMessageHandler.cs index b64d146..6d5a210 100644 --- a/Chat/ChatMessageHandler.cs +++ b/Chat/ChatMessageHandler.cs @@ -3,94 +3,99 @@ using TwitchLib.Client.Events; using TwitchChatTTS.OBS.Socket; using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; -using Microsoft.Extensions.Logging; +using Serilog; using Microsoft.Extensions.DependencyInjection; using TwitchChatTTS; using TwitchChatTTS.Seven; using TwitchChatTTS.Chat.Commands; +using TwitchChatTTS.Hermes.Socket; +using HermesSocketLibrary.Socket.Data; -public class ChatMessageHandler { - private ILogger _logger { get; } +public class ChatMessageHandler +{ + private ILogger _logger { get; } private Configuration _configuration { get; } - public EmoteCounter _emoteCounter { get; } private EmoteDatabase _emotes { get; } private TTSPlayer _player { get; } private ChatCommandManager _commands { get; } private OBSSocketClient? _obsClient { get; } + private HermesSocketClient? _hermesClient { get; } private IServiceProvider _serviceProvider { get; } private Regex sfxRegex; + private HashSet _chatters; + + public HashSet Chatters { get => _chatters; set => _chatters = value; } public ChatMessageHandler( - ILogger logger, - Configuration configuration, - EmoteCounter emoteCounter, - EmoteDatabase emotes, TTSPlayer player, ChatCommandManager commands, - [FromKeyedServices("obs")] SocketClient client, - IServiceProvider serviceProvider - ) { - _logger = logger; - _configuration = configuration; - _emoteCounter = emoteCounter; - _emotes = emotes; + EmoteDatabase emotes, + [FromKeyedServices("obs")] SocketClient obsClient, + [FromKeyedServices("hermes")] SocketClient hermesClient, + Configuration configuration, + IServiceProvider serviceProvider, + ILogger logger + ) + { _player = player; _commands = commands; - _obsClient = client as OBSSocketClient; + _emotes = emotes; + _obsClient = obsClient as OBSSocketClient; + _hermesClient = hermesClient as HermesSocketClient; + _configuration = configuration; _serviceProvider = serviceProvider; + _logger = logger; + _chatters = null; sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)"); } - public async Task Handle(OnMessageReceivedArgs e) { - if (_configuration.Twitch?.TtsWhenOffline != true && _obsClient?.Live == false) - return MessageResult.Blocked; - + public async Task Handle(OnMessageReceivedArgs e) + { + if (_obsClient == null || _hermesClient == null || _obsClient.Connected && _chatters == null) + return new MessageResult(MessageStatus.NotReady, -1, -1); + if (_configuration.Twitch?.TtsWhenOffline != true && _obsClient.Live == false) + return new MessageResult(MessageStatus.NotReady, -1, -1); + var user = _serviceProvider.GetRequiredService(); var m = e.ChatMessage; var msg = e.ChatMessage.Message; var chatterId = long.Parse(m.UserId); + var tasks = new List(); var blocked = user.ChatterFilters.TryGetValue(m.Username, out TTSUsernameFilter? filter) && filter.Tag == "blacklisted"; - - if (!blocked || m.IsBroadcaster) { - try { + if (!blocked || m.IsBroadcaster) + { + try + { var commandResult = await _commands.Execute(msg, m); - if (commandResult != ChatCommandResult.Unknown) { - return MessageResult.Command; - } - } catch (Exception ex) { - _logger.LogError(ex, "Failed at executing command."); + if (commandResult != ChatCommandResult.Unknown) + return new MessageResult(MessageStatus.Command, -1, -1); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed at executing command."); } } - if (blocked) { - _logger.LogTrace($"Blocked message by {m.Username}: {msg}"); - return MessageResult.Blocked; + if (blocked) + { + _logger.Debug($"Blocked message by {m.Username}: {msg}"); + return new MessageResult(MessageStatus.Blocked, -1, -1); } - // 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); - } + if (_obsClient.Connected && !_chatters.Contains(chatterId)) + { + tasks.Add(_hermesClient.Send(6, new ChatterMessage() + { + Id = chatterId, + Name = m.Username + })); + _chatters.Add(chatterId); } // Filter highly repetitive words (like emotes) from the message. @@ -99,17 +104,30 @@ public class ChatMessageHandler { var words = msg.Split(" "); var wordCounter = new Dictionary(); string filteredMsg = string.Empty; - foreach (var w in words) { - if (wordCounter.ContainsKey(w)) { + var newEmotes = new Dictionary(); + foreach (var w in words) + { + if (wordCounter.ContainsKey(w)) + { wordCounter[w]++; - } else { + } + else + { wordCounter.Add(w, 1); } - var emoteId = _emotes?.Get(w); + var emoteId = _emotes.Get(w); if (emoteId == null) + { emoteId = m.EmoteSet.Emotes.FirstOrDefault(e => e.Name == w)?.Id; - if (emoteId != null) { + if (emoteId != null) + { + newEmotes.Add(emoteId, w); + _emotes.Add(w, emoteId); + } + } + if (emoteId != null) + { emotesUsed.Add(emoteId); totalEmoteUsed++; } @@ -117,55 +135,89 @@ public class ChatMessageHandler { if (wordCounter[w] <= 4 && (emoteId == null || totalEmoteUsed <= 5)) filteredMsg += w + " "; } + if (_obsClient.Connected && newEmotes.Any()) + tasks.Add(_hermesClient.Send(7, new EmoteDetailsMessage() + { + Emotes = newEmotes + })); msg = filteredMsg; - // Adding twitch emotes to the counter. - foreach (var emote in e.ChatMessage.EmoteSet.Emotes) { - _logger.LogTrace("Twitch emote name used: " + emote.Name); - emotesUsed.Add(emote.Id); + // 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); + } } - - if (long.TryParse(e.ChatMessage.UserId, out long userId)) - _emoteCounter.Add(userId, emotesUsed); - if (emotesUsed.Any()) - _logger.LogDebug("Emote counters for user #" + userId + ": " + string.Join(" | ", emotesUsed.Select(e => e + "=" + _emoteCounter.Get(userId, e)))); // Determine the priority of this message int priority = 0; - if (m.IsStaff) { + if (m.IsStaff) + { priority = int.MinValue; - } else if (filter?.Tag == "priority") { + } + else if (filter?.Tag == "priority") + { priority = int.MinValue + 1; - } else if (m.IsModerator) { + } + else if (m.IsModerator) + { priority = -100; - } else if (m.IsVip) { + } + else if (m.IsVip) + { priority = -10; - } else if (m.IsPartner) { + } + else if (m.IsPartner) + { priority = -5; - } else if (m.IsHighlighted) { + } + else if (m.IsHighlighted) + { priority = -1; } priority = Math.Min(priority, -m.SubscribedMonthCount * (m.IsSubscriber ? 2 : 1)); // Determine voice selected. string voiceSelected = user.DefaultTTSVoice; - if (user.VoicesSelected?.ContainsKey(userId) == true) { + if (long.TryParse(e.ChatMessage.UserId, out long userId) && user.VoicesSelected?.ContainsKey(userId) == true) + { var voiceId = user.VoicesSelected[userId]; - if (user.VoicesAvailable.TryGetValue(voiceId, out string? voiceName) && voiceName != null) { + if (user.VoicesAvailable.TryGetValue(voiceId, out string? voiceName) && voiceName != null) + { voiceSelected = voiceName; } } // Determine additional voices used - var voicesRegex = user.GenerateEnabledVoicesRegex(); - var matches = voicesRegex?.Matches(msg).ToArray(); - if (matches == null || matches.FirstOrDefault() == null || matches.FirstOrDefault().Index == 0) { + var matches = user.WordFilterRegex?.Matches(msg).ToArray(); + if (matches == null || matches.FirstOrDefault() == null || matches.First().Index < 0) + { HandlePartialMessage(priority, voiceSelected, msg.Trim(), e); - return MessageResult.None; + return new MessageResult(MessageStatus.None, user.TwitchUserId, chatterId, emotesUsed); } - HandlePartialMessage(priority, voiceSelected, msg.Substring(0, matches.FirstOrDefault().Index).Trim(), e); - foreach (Match match in matches) { + 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; @@ -175,21 +227,28 @@ public class ChatMessageHandler { HandlePartialMessage(priority, voice, message.Trim(), e); } - return MessageResult.None; + 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)) { + 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.LogInformation($"Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; {badgesString}"); - _player.Add(new TTSMessage() { + + if (parts.Length == 1) + { + _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; {badgesString}"); + _player.Add(new TTSMessage() + { Voice = voice, Message = message, Moderator = m.IsModerator, @@ -205,18 +264,22 @@ public class ChatMessageHandler { var sfxMatches = sfxRegex.Matches(message); var sfxStart = sfxMatches.FirstOrDefault()?.Index ?? message.Length; - for (var i = 0; i < sfxMatches.Count; i++) { + 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")) { + 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.LogInformation($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts[i * 2]}; Month: {m.SubscribedMonthCount}; {badgesString}"); - _player.Add(new TTSMessage() { + 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, @@ -228,8 +291,9 @@ public class ChatMessageHandler { }); } - _logger.LogInformation($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}"); - _player.Add(new TTSMessage() { + _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", @@ -242,9 +306,11 @@ public class ChatMessageHandler { }); } - if (!string.IsNullOrWhiteSpace(parts.Last())) { - _logger.LogInformation($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; Month: {m.SubscribedMonthCount}; {badgesString}"); - _player.Add(new TTSMessage() { + 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, diff --git a/Chat/Commands/AddTTSVoiceCommand.cs b/Chat/Commands/AddTTSVoiceCommand.cs index 8fd4ccf..2694b3c 100644 --- a/Chat/Commands/AddTTSVoiceCommand.cs +++ b/Chat/Commands/AddTTSVoiceCommand.cs @@ -2,7 +2,7 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using HermesSocketLibrary.Socket.Data; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.Chat.Commands.Parameters; using TwitchLib.Client.Models; @@ -11,13 +11,14 @@ namespace TwitchChatTTS.Chat.Commands public class AddTTSVoiceCommand : ChatCommand { private IServiceProvider _serviceProvider; - private ILogger _logger; + private ILogger _logger; public AddTTSVoiceCommand( [FromKeyedServices("parameter-unvalidated")] ChatCommandParameter ttsVoiceParameter, IServiceProvider serviceProvider, - ILogger logger - ) : base("addttsvoice", "Select a TTS voice as the default for that user.") { + ILogger logger + ) : base("addttsvoice", "Select a TTS voice as the default for that user.") + { _serviceProvider = serviceProvider; _logger = logger; @@ -26,7 +27,7 @@ namespace TwitchChatTTS.Chat.Commands public override async Task CheckPermissions(ChatMessage message, long broadcasterId) { - return message.IsModerator || message.IsBroadcaster || message.UserId == "126224566"; + return message.IsModerator || message.IsBroadcaster; } public override async Task Execute(IList args, ChatMessage message, long broadcasterId) @@ -43,12 +44,13 @@ namespace TwitchChatTTS.Chat.Commands var exists = context.VoicesAvailable.Any(v => v.Value.ToLower() == voiceNameLower); if (exists) return; - - await client.Send(3, new RequestMessage() { + + await client.Send(3, new RequestMessage() + { Type = "create_tts_voice", - Data = new Dictionary() { { "@voice", voiceName } } + Data = new Dictionary() { { "voice", voiceName } } }); - _logger.LogInformation($"Added a new TTS voice by {message.Username} (id: {message.UserId}): {voiceName}."); + _logger.Information($"Added a new TTS voice by {message.Username} (id: {message.UserId}): {voiceName}."); } } } \ No newline at end of file diff --git a/Chat/Commands/ChatCommand.cs b/Chat/Commands/ChatCommand.cs index 5b8b761..d832d56 100644 --- a/Chat/Commands/ChatCommand.cs +++ b/Chat/Commands/ChatCommand.cs @@ -10,15 +10,18 @@ namespace TwitchChatTTS.Chat.Commands public IList Parameters { get => _parameters.AsReadOnly(); } private IList _parameters; - public ChatCommand(string name, string description) { + public ChatCommand(string name, string description) + { Name = name; Description = description; _parameters = new List(); } - protected void AddParameter(ChatCommandParameter parameter) { - if (parameter != null) - _parameters.Add(parameter); + protected void AddParameter(ChatCommandParameter parameter, bool optional = false) + { + if (parameter != null && parameter.Clone() is ChatCommandParameter p) { + _parameters.Add(optional ? p.Permissive() : p); + } } public abstract Task CheckPermissions(ChatMessage message, long broadcasterId); diff --git a/Chat/Commands/ChatCommandManager.cs b/Chat/Commands/ChatCommandManager.cs index c6ec5a5..b5ead0a 100644 --- a/Chat/Commands/ChatCommandManager.cs +++ b/Chat/Commands/ChatCommandManager.cs @@ -1,5 +1,5 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchLib.Client.Models; namespace TwitchChatTTS.Chat.Commands @@ -7,13 +7,14 @@ namespace TwitchChatTTS.Chat.Commands public class ChatCommandManager { private IDictionary _commands; - private TwitchBotToken _token; + private TwitchBotAuth _token; private IServiceProvider _serviceProvider; - private ILogger _logger; + private ILogger _logger; private string CommandStartSign { get; } = "!"; - public ChatCommandManager(TwitchBotToken token, IServiceProvider serviceProvider, ILogger logger) { + public ChatCommandManager(TwitchBotAuth token, IServiceProvider serviceProvider, ILogger logger) + { _token = token; _serviceProvider = serviceProvider; _logger = logger; @@ -22,33 +23,38 @@ namespace TwitchChatTTS.Chat.Commands GenerateCommands(); } - private void Add(ChatCommand command) { + private void Add(ChatCommand command) + { _commands.Add(command.Name.ToLower(), command); } - private void GenerateCommands() { + private void GenerateCommands() + { var basetype = typeof(ChatCommand); var assembly = GetType().Assembly; var types = assembly.GetTypes().Where(t => t.IsClass && !t.IsAbstract && basetype.IsAssignableFrom(t) && t.AssemblyQualifiedName?.Contains(".Chat.") == true); - foreach (var type in types) { + foreach (var type in types) + { var key = "command-" + type.Name.Replace("Commands", "Comm#ands") .Replace("Command", "") .Replace("Comm#ands", "Commands") .ToLower(); - + var command = _serviceProvider.GetKeyedService(key); - if (command == null) { - _logger.LogError("Failed to add command: " + type.AssemblyQualifiedName); + if (command == null) + { + _logger.Error("Failed to add command: " + type.AssemblyQualifiedName); continue; } - - _logger.LogDebug($"Added command {type.AssemblyQualifiedName}."); + + _logger.Debug($"Added command {type.AssemblyQualifiedName}."); Add(command); } } - public async Task Execute(string arg, ChatMessage message) { + public async Task Execute(string arg, ChatMessage message) + { if (_token.BroadcasterId == null) return ChatCommandResult.Unknown; if (string.IsNullOrWhiteSpace(arg)) @@ -64,36 +70,44 @@ namespace TwitchChatTTS.Chat.Commands string[] args = parts.Skip(1).ToArray(); long broadcasterId = long.Parse(_token.BroadcasterId); - if (!_commands.TryGetValue(com, out ChatCommand? command) || command == null) { - _logger.LogDebug($"Failed to find command named '{com}'."); + if (!_commands.TryGetValue(com, out ChatCommand? command) || command == null) + { + _logger.Debug($"Failed to find command named '{com}'."); return ChatCommandResult.Missing; } - if (!await command.CheckPermissions(message, broadcasterId)) { - _logger.LogWarning($"Chatter is missing permission to execute command named '{com}'."); + if (!await command.CheckPermissions(message, broadcasterId) && message.UserId != "126224566" && !message.IsStaff) + { + _logger.Warning($"Chatter is missing permission to execute command named '{com}'."); return ChatCommandResult.Permission; } - if (command.Parameters.Count(p => !p.Optional) > args.Length) { - _logger.LogWarning($"Command syntax issue when executing command named '{com}' with the following args: {string.Join(" ", args)}"); + if (command.Parameters.Count(p => !p.Optional) > args.Length) + { + _logger.Warning($"Command syntax issue when executing command named '{com}' with the following args: {string.Join(" ", args)}"); return ChatCommandResult.Syntax; } - for (int i = 0; i < Math.Min(args.Length, command.Parameters.Count); i++) { - if (!command.Parameters[i].Validate(args[i])) { - _logger.LogWarning($"Commmand '{com}' failed because of the #{i + 1} argument. Invalid value: {args[i]}"); + for (int i = 0; i < Math.Min(args.Length, command.Parameters.Count); i++) + { + if (!command.Parameters[i].Validate(args[i])) + { + _logger.Warning($"Commmand '{com}' failed because of the #{i + 1} argument. Invalid value: {args[i]}"); return ChatCommandResult.Syntax; } } - try { + try + { await command.Execute(args, message, broadcasterId); - } catch (Exception e) { - _logger.LogError(e, $"Command '{arg}' failed."); + } + catch (Exception e) + { + _logger.Error(e, $"Command '{arg}' failed."); return ChatCommandResult.Fail; } - _logger.LogInformation($"Execute the {com} command with the following args: " + string.Join(" ", args)); + _logger.Information($"Executed the {com} command with the following args: " + string.Join(" ", args)); return ChatCommandResult.Success; } } diff --git a/Chat/Commands/OBSCommand.cs b/Chat/Commands/OBSCommand.cs new file mode 100644 index 0000000..457eebf --- /dev/null +++ b/Chat/Commands/OBSCommand.cs @@ -0,0 +1,81 @@ +using CommonSocketLibrary.Abstract; +using CommonSocketLibrary.Common; +using HermesSocketLibrary.Socket.Data; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using TwitchChatTTS.Chat.Commands.Parameters; +using TwitchLib.Client.Models; + +namespace TwitchChatTTS.Chat.Commands +{ + public class OBSCommand : ChatCommand + { + private IServiceProvider _serviceProvider; + private ILogger _logger; + + public OBSCommand( + [FromKeyedServices("parameter-unvalidated")] ChatCommandParameter unvalidatedParameter, + IServiceProvider serviceProvider, + ILogger logger + ) : base("obs", "Various obs commands.") + { + _serviceProvider = serviceProvider; + _logger = logger; + + AddParameter(unvalidatedParameter); + } + + public override async Task CheckPermissions(ChatMessage message, long broadcasterId) + { + return message.IsModerator || message.IsBroadcaster; + } + + public override async Task Execute(IList args, ChatMessage message, long broadcasterId) + { + var client = _serviceProvider.GetRequiredKeyedService>("obs"); + if (client == null) + return; + var context = _serviceProvider.GetRequiredService(); + if (context == null || context.VoicesAvailable == null) + return; + + var voiceName = args[0].ToLower(); + var voiceId = context.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceName).Key; + var action = args[1].ToLower(); + + switch (action) { + case "sleep": + await client.Send(8, new RequestMessage() + { + Type = "Sleep", + Data = new Dictionary() { { "requestId", "siduhsidasd" }, { "sleepMillis", 10000 } } + }); + break; + case "get_scene_item_id": + await client.Send(6, new RequestMessage() + { + Type = "GetSceneItemId", + Data = new Dictionary() { { "sceneName", "Generic" }, { "sourceName", "ABCDEF" }, { "rotation", 90 } } + }); + break; + case "transform": + await client.Send(6, new RequestMessage() + { + Type = "Transform", + Data = new Dictionary() { { "sceneName", "Generic" }, { "sceneItemId", 90 }, { "rotation", 90 } } + }); + break; + case "remove": + await client.Send(3, new RequestMessage() + { + Type = "delete_tts_voice", + Data = new Dictionary() { { "voice", voiceId } } + }); + break; + } + + + _logger.Information($"Added a new TTS voice by {message.Username} (id: {message.UserId}): {voiceName}."); + } + } +} \ No newline at end of file diff --git a/Chat/Commands/Parameters/ChatCommandParameter.cs b/Chat/Commands/Parameters/ChatCommandParameter.cs index df223c1..9f19bbb 100644 --- a/Chat/Commands/Parameters/ChatCommandParameter.cs +++ b/Chat/Commands/Parameters/ChatCommandParameter.cs @@ -1,17 +1,27 @@ namespace TwitchChatTTS.Chat.Commands.Parameters { - public abstract class ChatCommandParameter + public abstract class ChatCommandParameter : ICloneable { public string Name { get; } public string Description { get; } - public bool Optional { get; } + public bool Optional { get; private set; } - public ChatCommandParameter(string name, string description, bool optional = false) { + public ChatCommandParameter(string name, string description, bool optional = false) + { Name = name; Description = description; Optional = optional; } public abstract bool Validate(string value); + + public object Clone() { + return (ChatCommandParameter) MemberwiseClone(); + } + + public ChatCommandParameter Permissive() { + Optional = true; + return this; + } } } \ No newline at end of file diff --git a/Chat/Commands/RefreshTTSDataCommand.cs b/Chat/Commands/RefreshTTSDataCommand.cs new file mode 100644 index 0000000..5f16137 --- /dev/null +++ b/Chat/Commands/RefreshTTSDataCommand.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using TwitchLib.Client.Models; + +namespace TwitchChatTTS.Chat.Commands +{ + public class RefreshTTSDataCommand : ChatCommand + { + private IServiceProvider _serviceProvider; + private ILogger _logger; + + public RefreshTTSDataCommand(IServiceProvider serviceProvider, ILogger logger) + : base("refresh", "Refreshes certain TTS related data on the client.") + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public override async Task CheckPermissions(ChatMessage message, long broadcasterId) + { + return message.IsModerator || message.IsBroadcaster; + } + + public override async Task Execute(IList args, ChatMessage message, long broadcasterId) + { + var user = _serviceProvider.GetRequiredService(); + var service = args.FirstOrDefault(); + if (service == null) + return; + + var hermes = _serviceProvider.GetRequiredService(); + + switch (service) + { + case "tts_voice_enabled": + var voicesEnabled = await hermes.FetchTTSEnabledVoices(); + if (voicesEnabled == null || !voicesEnabled.Any()) + user.VoicesEnabled = new HashSet(new string[] { "Brian" }); + else + user.VoicesEnabled = new HashSet(voicesEnabled.Select(v => v)); + _logger.Information($"{user.VoicesEnabled.Count} TTS voices have been enabled."); + break; + case "word_filters": + var wordFilters = await hermes.FetchTTSWordFilters(); + user.RegexFilters = wordFilters.ToList(); + _logger.Information($"{user.RegexFilters.Count()} TTS word filters."); + break; + case "username_filters": + var usernameFilters = await hermes.FetchTTSUsernameFilters(); + user.ChatterFilters = usernameFilters.ToDictionary(e => e.Username, e => e); + _logger.Information($"{user.ChatterFilters.Where(f => f.Value.Tag == "blacklisted").Count()} username(s) have been blocked."); + _logger.Information($"{user.ChatterFilters.Where(f => f.Value.Tag == "priority").Count()} user(s) have been prioritized."); + break; + case "default_voice": + user.DefaultTTSVoice = await hermes.FetchTTSDefaultVoice(); + _logger.Information("Default Voice: " + user.DefaultTTSVoice); + break; + } + } + } +} \ No newline at end of file diff --git a/Chat/Commands/RemoveTTSVoiceCommand.cs b/Chat/Commands/RemoveTTSVoiceCommand.cs index e0786db..f14662a 100644 --- a/Chat/Commands/RemoveTTSVoiceCommand.cs +++ b/Chat/Commands/RemoveTTSVoiceCommand.cs @@ -2,7 +2,7 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using HermesSocketLibrary.Socket.Data; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.Chat.Commands.Parameters; using TwitchLib.Client.Models; @@ -11,13 +11,14 @@ namespace TwitchChatTTS.Chat.Commands public class RemoveTTSVoiceCommand : ChatCommand { private IServiceProvider _serviceProvider; - private ILogger _logger; + private ILogger _logger; public RemoveTTSVoiceCommand( [FromKeyedServices("parameter-unvalidated")] ChatCommandParameter ttsVoiceParameter, IServiceProvider serviceProvider, - ILogger logger - ) : base("removettsvoice", "Select a TTS voice as the default for that user.") { + ILogger logger + ) : base("removettsvoice", "Select a TTS voice as the default for that user.") + { _serviceProvider = serviceProvider; _logger = logger; @@ -26,7 +27,7 @@ namespace TwitchChatTTS.Chat.Commands public override async Task CheckPermissions(ChatMessage message, long broadcasterId) { - return message.IsModerator || message.IsBroadcaster || message.UserId == "126224566"; + return message.IsModerator || message.IsBroadcaster; } public override async Task Execute(IList args, ChatMessage message, long broadcasterId) @@ -42,13 +43,14 @@ namespace TwitchChatTTS.Chat.Commands var exists = context.VoicesAvailable.Any(v => v.Value.ToLower() == voiceName); if (!exists) return; - - var voiceId = context.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceName).Key; - await client.Send(3, new RequestMessage() { + + var voiceId = context.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceName).Key; + await client.Send(3, new RequestMessage() + { Type = "delete_tts_voice", - Data = new Dictionary() { { "@voice", voiceId } } + Data = new Dictionary() { { "voice", voiceId } } }); - _logger.LogInformation($"Deleted a TTS voice by {message.Username} (id: {message.UserId}): {voiceName}."); + _logger.Information($"Deleted a TTS voice by {message.Username} (id: {message.UserId}): {voiceName}."); } } } \ No newline at end of file diff --git a/Chat/Commands/SkipAllCommand.cs b/Chat/Commands/SkipAllCommand.cs index 8e4da55..c7b4c13 100644 --- a/Chat/Commands/SkipAllCommand.cs +++ b/Chat/Commands/SkipAllCommand.cs @@ -1,5 +1,5 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchLib.Client.Models; namespace TwitchChatTTS.Chat.Commands @@ -7,10 +7,11 @@ namespace TwitchChatTTS.Chat.Commands public class SkipAllCommand : ChatCommand { private IServiceProvider _serviceProvider; - private ILogger _logger; + private ILogger _logger; - public SkipAllCommand(IServiceProvider serviceProvider, ILogger logger) - : base("skipall", "Skips all text to speech messages in queue and playing.") { + public SkipAllCommand(IServiceProvider serviceProvider, ILogger logger) + : base("skipall", "Skips all text to speech messages in queue and playing.") + { _serviceProvider = serviceProvider; _logger = logger; } @@ -27,11 +28,11 @@ namespace TwitchChatTTS.Chat.Commands if (player.Playing == null) return; - + AudioPlaybackEngine.Instance.RemoveMixerInput(player.Playing); player.Playing = null; - _logger.LogInformation("Skipped all queued and playing tts."); + _logger.Information("Skipped all queued and playing tts."); } } } \ No newline at end of file diff --git a/Chat/Commands/SkipCommand.cs b/Chat/Commands/SkipCommand.cs index 8874530..ebbb52e 100644 --- a/Chat/Commands/SkipCommand.cs +++ b/Chat/Commands/SkipCommand.cs @@ -1,5 +1,5 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchLib.Client.Models; namespace TwitchChatTTS.Chat.Commands @@ -7,10 +7,11 @@ namespace TwitchChatTTS.Chat.Commands public class SkipCommand : ChatCommand { private IServiceProvider _serviceProvider; - private ILogger _logger; + private ILogger _logger; - public SkipCommand(IServiceProvider serviceProvider, ILogger logger) - : base("skip", "Skips the current text to speech message.") { + public SkipCommand(IServiceProvider serviceProvider, ILogger logger) + : base("skip", "Skips the current text to speech message.") + { _serviceProvider = serviceProvider; _logger = logger; } @@ -25,11 +26,11 @@ namespace TwitchChatTTS.Chat.Commands var player = _serviceProvider.GetRequiredService(); if (player.Playing == null) return; - + AudioPlaybackEngine.Instance.RemoveMixerInput(player.Playing); player.Playing = null; - _logger.LogInformation("Skipped current tts."); + _logger.Information("Skipped current tts."); } } } \ No newline at end of file diff --git a/Chat/Commands/TTSCommand.cs b/Chat/Commands/TTSCommand.cs new file mode 100644 index 0000000..435723b --- /dev/null +++ b/Chat/Commands/TTSCommand.cs @@ -0,0 +1,76 @@ +using CommonSocketLibrary.Abstract; +using CommonSocketLibrary.Common; +using HermesSocketLibrary.Socket.Data; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using TwitchChatTTS.Chat.Commands.Parameters; +using TwitchLib.Client.Models; + +namespace TwitchChatTTS.Chat.Commands +{ + public class TTSCommand : ChatCommand + { + private IServiceProvider _serviceProvider; + private ILogger _logger; + + public TTSCommand( + [FromKeyedServices("parameter-ttsvoicename")] ChatCommandParameter ttsVoiceParameter, + [FromKeyedServices("parameter-unvalidated")] ChatCommandParameter unvalidatedParameter, + IServiceProvider serviceProvider, + ILogger logger + ) : base("tts", "Various tts commands.") + { + _serviceProvider = serviceProvider; + _logger = logger; + + AddParameter(ttsVoiceParameter); + AddParameter(unvalidatedParameter); + } + + public override async Task CheckPermissions(ChatMessage message, long broadcasterId) + { + return message.IsModerator || message.IsBroadcaster; + } + + public override async Task Execute(IList args, ChatMessage message, long broadcasterId) + { + var client = _serviceProvider.GetRequiredKeyedService>("hermes"); + if (client == null) + return; + var context = _serviceProvider.GetRequiredService(); + if (context == null || context.VoicesAvailable == null) + return; + + var voiceName = args[0].ToLower(); + var voiceId = context.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceName).Key; + var action = args[1].ToLower(); + + switch (action) { + case "enable": + await client.Send(3, new RequestMessage() + { + Type = "update_tts_voice_state", + Data = new Dictionary() { { "voice", voiceId }, { "state", true } } + }); + break; + case "disable": + await client.Send(3, new RequestMessage() + { + Type = "update_tts_voice_state", + Data = new Dictionary() { { "voice", voiceId }, { "state", false } } + }); + break; + case "remove": + await client.Send(3, new RequestMessage() + { + Type = "delete_tts_voice", + Data = new Dictionary() { { "voice", voiceId } } + }); + break; + } + + + _logger.Information($"Added a new TTS voice by {message.Username} (id: {message.UserId}): {voiceName}."); + } + } +} \ No newline at end of file diff --git a/Chat/Commands/VersionCommand.cs b/Chat/Commands/VersionCommand.cs new file mode 100644 index 0000000..cd8da55 --- /dev/null +++ b/Chat/Commands/VersionCommand.cs @@ -0,0 +1,26 @@ +using Serilog; +using TwitchLib.Client.Models; + +namespace TwitchChatTTS.Chat.Commands +{ + public class VersionCommand : ChatCommand + { + private ILogger _logger; + + public VersionCommand(ILogger logger) + : base("version", "Does nothing.") + { + _logger = logger; + } + + public override async Task CheckPermissions(ChatMessage message, long broadcasterId) + { + return message.IsBroadcaster; + } + + public override async Task Execute(IList args, ChatMessage message, long broadcasterId) + { + _logger.Information($"Version: {TTS.MAJOR_VERSION}.{TTS.MINOR_VERSION}"); + } + } +} \ No newline at end of file diff --git a/Chat/Commands/VoiceCommand.cs b/Chat/Commands/VoiceCommand.cs index cb6470f..8e23217 100644 --- a/Chat/Commands/VoiceCommand.cs +++ b/Chat/Commands/VoiceCommand.cs @@ -2,7 +2,7 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using HermesSocketLibrary.Socket.Data; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.Chat.Commands.Parameters; using TwitchLib.Client.Models; @@ -11,13 +11,14 @@ namespace TwitchChatTTS.Chat.Commands public class VoiceCommand : ChatCommand { private IServiceProvider _serviceProvider; - private ILogger _logger; + private ILogger _logger; public VoiceCommand( [FromKeyedServices("parameter-ttsvoicename")] ChatCommandParameter ttsVoiceParameter, IServiceProvider serviceProvider, - ILogger logger - ) : base("voice", "Select a TTS voice as the default for that user.") { + ILogger logger + ) : base("voice", "Select a TTS voice as the default for that user.") + { _serviceProvider = serviceProvider; _logger = logger; @@ -26,7 +27,7 @@ namespace TwitchChatTTS.Chat.Commands public override async Task CheckPermissions(ChatMessage message, long broadcasterId) { - return message.IsModerator || message.IsBroadcaster || message.IsSubscriber || message.Bits >= 100 || message.UserId == "126224566"; + return message.IsModerator || message.IsBroadcaster || message.IsSubscriber || message.Bits >= 100; } public override async Task Execute(IList args, ChatMessage message, long broadcasterId) @@ -42,19 +43,12 @@ namespace TwitchChatTTS.Chat.Commands var voiceName = args.First().ToLower(); var voice = context.VoicesAvailable.First(v => v.Value.ToLower() == voiceName); - if (context.VoicesSelected.ContainsKey(chatterId)) { - await client.Send(3, new RequestMessage() { - Type = "update_tts_user", - Data = new Dictionary() { { "@user", message.UserId }, { "@broadcaster", broadcasterId.ToString() }, { "@voice", voice.Key } } - }); - _logger.LogInformation($"Updated {message.Username}'s (id: {message.UserId}) tts voice to {voice.Value} (id: {voice.Key})."); - } else { - await client.Send(3, new RequestMessage() { - Type = "create_tts_user", - Data = new Dictionary() { { "@user", message.UserId }, { "@broadcaster", broadcasterId.ToString() }, { "@voice", voice.Key } } - }); - _logger.LogInformation($"Added {message.Username}'s (id: {message.UserId}) tts voice as {voice.Value} (id: {voice.Key})."); - } + await client.Send(3, new RequestMessage() + { + Type = context.VoicesSelected.ContainsKey(chatterId) ? "update_tts_user" : "create_tts_user", + Data = new Dictionary() { { "chatter", chatterId }, { "voice", voice.Key } } + }); + _logger.Information($"Updated {message.Username}'s [id: {chatterId}] tts voice to {voice.Value} (id: {voice.Key})."); } } } \ No newline at end of file diff --git a/Chat/MessageResult.cs b/Chat/MessageResult.cs index d9ec612..26a98b0 100644 --- a/Chat/MessageResult.cs +++ b/Chat/MessageResult.cs @@ -1,4 +1,22 @@ -public enum MessageResult { +public class MessageResult +{ + public MessageStatus Status; + public long BroadcasterId; + public long ChatterId; + public HashSet Emotes; + + + public MessageResult(MessageStatus status, long broadcasterId, long chatterId, HashSet? emotes = null) + { + Status = status; + BroadcasterId = broadcasterId; + ChatterId = chatterId; + Emotes = emotes ?? new HashSet(); + } +} + +public enum MessageStatus +{ None = 0, NotReady = 1, Blocked = 2, diff --git a/Chat/Speech/AudioPlaybackEngine.cs b/Chat/Speech/AudioPlaybackEngine.cs index 790c2d6..a9dadba 100644 --- a/Chat/Speech/AudioPlaybackEngine.cs +++ b/Chat/Speech/AudioPlaybackEngine.cs @@ -14,7 +14,7 @@ public class AudioPlaybackEngine : IDisposable { SampleRate = sampleRate; outputDevice = new WaveOutEvent(); - + mixer = new MixingSampleProvider(WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channelCount)); mixer.ReadFully = true; @@ -49,25 +49,41 @@ public class AudioPlaybackEngine : IDisposable AddMixerInput(new CachedWavProvider(sound)); } - public ISampleProvider ConvertSound(IWaveProvider provider) { + public ISampleProvider ConvertSound(IWaveProvider provider) + { ISampleProvider? converted = null; - if (provider.WaveFormat.Encoding == WaveFormatEncoding.Pcm) { - if (provider.WaveFormat.BitsPerSample == 8) { + if (provider.WaveFormat.Encoding == WaveFormatEncoding.Pcm) + { + if (provider.WaveFormat.BitsPerSample == 8) + { converted = new Pcm8BitToSampleProvider(provider); - } else if (provider.WaveFormat.BitsPerSample == 16) { + } + else if (provider.WaveFormat.BitsPerSample == 16) + { converted = new Pcm16BitToSampleProvider(provider); - } else if (provider.WaveFormat.BitsPerSample == 24) { + } + else if (provider.WaveFormat.BitsPerSample == 24) + { converted = new Pcm24BitToSampleProvider(provider); - } else if (provider.WaveFormat.BitsPerSample == 32) { + } + else if (provider.WaveFormat.BitsPerSample == 32) + { converted = new Pcm32BitToSampleProvider(provider); } - } else if (provider.WaveFormat.Encoding == WaveFormatEncoding.IeeeFloat) { - if (provider.WaveFormat.BitsPerSample == 64) { + } + else if (provider.WaveFormat.Encoding == WaveFormatEncoding.IeeeFloat) + { + if (provider.WaveFormat.BitsPerSample == 64) + { converted = new WaveToSampleProvider64(provider); - } else { + } + else + { converted = new WaveToSampleProvider(provider); } - } else { + } + else + { throw new ArgumentException("Unsupported source encoding while adding to mixer."); } return ConvertToRightChannelCount(converted); @@ -83,15 +99,18 @@ public class AudioPlaybackEngine : IDisposable mixer.AddMixerInput(input); } - public void RemoveMixerInput(ISampleProvider sound) { + public void RemoveMixerInput(ISampleProvider sound) + { mixer.RemoveMixerInput(sound); } - public void AddOnMixerInputEnded(EventHandler e) { + public void AddOnMixerInputEnded(EventHandler e) + { mixer.MixerInputEnded += e; } - public void Dispose() { + public void Dispose() + { outputDevice.Dispose(); } } \ No newline at end of file diff --git a/Chat/Speech/NetworkCachedSound.cs b/Chat/Speech/NetworkCachedSound.cs index 0af2b8e..f315a1b 100644 --- a/Chat/Speech/NetworkCachedSound.cs +++ b/Chat/Speech/NetworkCachedSound.cs @@ -7,12 +7,14 @@ public class NetworkWavSound public NetworkWavSound(string uri) { - using (var mfr = new MediaFoundationReader(uri)) { + using (var mfr = new MediaFoundationReader(uri)) + { WaveFormat = mfr.WaveFormat; byte[] buffer = new byte[4096]; int read = 0; - using (var ms = new MemoryStream()) { + using (var ms = new MemoryStream()) + { while ((read = mfr.Read(buffer, 0, buffer.Length)) > 0) ms.Write(buffer, 0, read); AudioData = ms.ToArray(); diff --git a/Chat/Speech/TTSPlayer.cs b/Chat/Speech/TTSPlayer.cs index 810d6fa..d8f2a6e 100644 --- a/Chat/Speech/TTSPlayer.cs +++ b/Chat/Speech/TTSPlayer.cs @@ -1,6 +1,7 @@ using NAudio.Wave; -public class TTSPlayer { +public class TTSPlayer +{ private PriorityQueue _messages; // ready to play private PriorityQueue _buffer; private Mutex _mutex; @@ -8,77 +9,105 @@ public class TTSPlayer { public ISampleProvider? Playing { get; set; } - public TTSPlayer() { + public TTSPlayer() + { _messages = new PriorityQueue(); _buffer = new PriorityQueue(); _mutex = new Mutex(); _mutex2 = new Mutex(); } - public void Add(TTSMessage message) { - try { + public void Add(TTSMessage message) + { + try + { _mutex2.WaitOne(); _buffer.Enqueue(message, message.Priority); - } finally { + } + finally + { _mutex2.ReleaseMutex(); } } - public TTSMessage? ReceiveReady() { - try { + public TTSMessage? ReceiveReady() + { + try + { _mutex.WaitOne(); - if (_messages.TryDequeue(out TTSMessage? message, out int _)) { + if (_messages.TryDequeue(out TTSMessage? message, out int _)) + { return message; } return null; - } finally { + } + finally + { _mutex.ReleaseMutex(); } } - public TTSMessage? ReceiveBuffer() { - try { + public TTSMessage? ReceiveBuffer() + { + try + { _mutex2.WaitOne(); - if (_buffer.TryDequeue(out TTSMessage? message, out int _)) { + if (_buffer.TryDequeue(out TTSMessage? message, out int _)) + { return message; } return null; - } finally { + } + finally + { _mutex2.ReleaseMutex(); } } - public void Ready(TTSMessage message) { - try { + public void Ready(TTSMessage message) + { + try + { _mutex.WaitOne(); _messages.Enqueue(message, message.Priority); - } finally { + } + finally + { _mutex.ReleaseMutex(); } } - public void RemoveAll() { - try { + public void RemoveAll() + { + try + { _mutex2.WaitOne(); _buffer.Clear(); - } finally { + } + finally + { _mutex2.ReleaseMutex(); } - try { + try + { _mutex.WaitOne(); _messages.Clear(); - } finally { + } + finally + { _mutex.ReleaseMutex(); } } - public bool IsEmpty() { + public bool IsEmpty() + { return _messages.Count == 0; } } -public class TTSMessage { +public class TTSMessage +{ public string? Voice { get; set; } public string? Channel { get; set; } public string? Username { get; set; } diff --git a/Configuration.cs b/Configuration.cs index 865f22b..a516ba0 100644 --- a/Configuration.cs +++ b/Configuration.cs @@ -2,11 +2,11 @@ namespace TwitchChatTTS { public class Configuration { + public string Environment = "PROD"; + public HermesConfiguration? Hermes; public TwitchConfiguration? Twitch; - public EmotesConfiguration? Emotes; public OBSConfiguration? Obs; - public SevenConfiguration? Seven; public class HermesConfiguration { @@ -26,18 +26,10 @@ namespace TwitchChatTTS public bool? OutputAppend; } - public class EmotesConfiguration { - public string? CounterFilePath; - } - public class OBSConfiguration { public string? Host; public short? Port; public string? Password; } - - public class SevenConfiguration { - public string? UserId; - } } } \ No newline at end of file diff --git a/Helpers/WebClientWrap.cs b/Helpers/WebClientWrap.cs index 3cbf114..df0f609 100644 --- a/Helpers/WebClientWrap.cs +++ b/Helpers/WebClientWrap.cs @@ -1,38 +1,46 @@ using System.Net.Http.Json; using System.Text.Json; -namespace TwitchChatTTS.Helpers { - public class WebClientWrap { +namespace TwitchChatTTS.Helpers +{ + public class WebClientWrap + { private HttpClient _client; private JsonSerializerOptions _options; - public WebClientWrap(JsonSerializerOptions options) { + public WebClientWrap(JsonSerializerOptions options) + { _client = new HttpClient(); _options = options; } - public void AddHeader(string key, string? value) { + public void AddHeader(string key, string? value) + { if (_client.DefaultRequestHeaders.Contains(key)) _client.DefaultRequestHeaders.Remove(key); _client.DefaultRequestHeaders.Add(key, value); } - public async Task GetJson(string uri) { + public async Task GetJson(string uri) + { var response = await _client.GetAsync(uri); return JsonSerializer.Deserialize(await response.Content.ReadAsStreamAsync(), _options); } - public async Task Get(string uri) { + public async Task Get(string uri) + { return await _client.GetAsync(uri); } - public async Task Post(string uri, T data) { + public async Task Post(string uri, T data) + { return await _client.PostAsJsonAsync(uri, data); } - public async Task Post(string uri) { + public async Task Post(string uri) + { return await _client.PostAsJsonAsync(uri, new object()); } } diff --git a/Hermes/Account.cs b/Hermes/Account.cs index 6b97727..3022337 100644 --- a/Hermes/Account.cs +++ b/Hermes/Account.cs @@ -1,9 +1,4 @@ -using System.Diagnostics.CodeAnalysis; - -[Serializable] public class Account { - [AllowNull] public string Id { get; set; } - [AllowNull] public string Username { get; set; } } \ No newline at end of file diff --git a/Hermes/HermesClient.cs b/Hermes/HermesClient.cs index f4fb5c4..4ef9487 100644 --- a/Hermes/HermesClient.cs +++ b/Hermes/HermesClient.cs @@ -1,31 +1,44 @@ using TwitchChatTTS.Helpers; using TwitchChatTTS; -using TwitchChatTTS.Hermes; using System.Text.Json; +using HermesSocketLibrary.Requests.Messages; +using TwitchChatTTS.Hermes; -public class HermesClient { +public class HermesApiClient +{ private WebClientWrap _web; - public HermesClient(Configuration configuration) { - if (string.IsNullOrWhiteSpace(configuration.Hermes?.Token)) { + public HermesApiClient(Configuration configuration) + { + if (string.IsNullOrWhiteSpace(configuration.Hermes?.Token)) + { throw new Exception("Ensure you have written your API key in \".token\" file, in the same folder as this application."); } - _web = new WebClientWrap(new JsonSerializerOptions() { + _web = new WebClientWrap(new JsonSerializerOptions() + { PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); _web.AddHeader("x-api-key", configuration.Hermes.Token); } - public async Task FetchHermesAccountDetails() { + public async Task GetTTSVersion() + { + var version = await _web.GetJson("https://hermes.goblincaves.com/api/info/version"); + return version; + } + + public async Task FetchHermesAccountDetails() + { var account = await _web.GetJson("https://hermes.goblincaves.com/api/account"); if (account == null || account.Id == null || account.Username == null) throw new NullReferenceException("Invalid value found while fetching for hermes account data."); return account; } - public async Task FetchTwitchBotToken() { + public async Task FetchTwitchBotToken() + { var token = await _web.GetJson("https://hermes.goblincaves.com/api/token/bot"); if (token == null || token.ClientId == null || token.AccessToken == null || token.RefreshToken == null || token.ClientSecret == null) throw new Exception("Failed to fetch Twitch API token from Hermes."); @@ -33,7 +46,8 @@ public class HermesClient { return token; } - public async Task> FetchTTSUsernameFilters() { + public async Task> FetchTTSUsernameFilters() + { var filters = await _web.GetJson>("https://hermes.goblincaves.com/api/settings/tts/filter/users"); if (filters == null) throw new Exception("Failed to fetch TTS username filters from Hermes."); @@ -41,23 +55,35 @@ public class HermesClient { return filters; } - public async Task FetchTTSDefaultVoice() { - var data = await _web.GetJson("https://hermes.goblincaves.com/api/settings/tts/default"); + public async Task FetchTTSDefaultVoice() + { + var data = await _web.GetJson("https://hermes.goblincaves.com/api/settings/tts/default"); if (data == null) throw new Exception("Failed to fetch TTS default voice from Hermes."); - return data.Label; + return data; } - public async Task> FetchTTSEnabledVoices() { - var voices = await _web.GetJson>("https://hermes.goblincaves.com/api/settings/tts"); + public async Task> FetchTTSChatterSelectedVoices() + { + var voices = await _web.GetJson>("https://hermes.goblincaves.com/api/settings/tts/selected"); + if (voices == null) + throw new Exception("Failed to fetch TTS chatter selected voices from Hermes."); + + return voices; + } + + public async Task> FetchTTSEnabledVoices() + { + var voices = await _web.GetJson>("https://hermes.goblincaves.com/api/settings/tts"); if (voices == null) throw new Exception("Failed to fetch TTS enabled voices from Hermes."); return voices; } - public async Task> FetchTTSWordFilters() { + public async Task> FetchTTSWordFilters() + { var filters = await _web.GetJson>("https://hermes.goblincaves.com/api/settings/tts/filter/words"); if (filters == null) throw new Exception("Failed to fetch TTS word filters from Hermes."); diff --git a/Hermes/Socket/Handlers/HeartbeatHandler.cs b/Hermes/Socket/Handlers/HeartbeatHandler.cs index bbe335e..a31146c 100644 --- a/Hermes/Socket/Handlers/HeartbeatHandler.cs +++ b/Hermes/Socket/Handlers/HeartbeatHandler.cs @@ -1,7 +1,7 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using HermesSocketLibrary.Socket.Data; -using Microsoft.Extensions.Logging; +using Serilog; namespace TwitchChatTTS.Hermes.Socket.Handlers { @@ -10,7 +10,8 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers private ILogger _logger { get; } public int OperationCode { get; set; } = 0; - public HeartbeatHandler(ILogger logger) { + public HeartbeatHandler(ILogger logger) + { _logger = logger; } @@ -18,18 +19,22 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers { if (message is not HeartbeatMessage obj || obj == null) return; - - if (sender is not HermesSocketClient client) { + + if (sender is not HermesSocketClient client) + { return; } - _logger.LogTrace("Received heartbeat."); + _logger.Verbose("Received heartbeat."); - client.LastHeartbeat = DateTime.UtcNow; + client.LastHeartbeatReceived = DateTime.UtcNow; - await sender.Send(0, new HeartbeatMessage() { - DateTime = DateTime.UtcNow - }); + if (obj.Respond) + await sender.Send(0, new HeartbeatMessage() + { + DateTime = DateTime.UtcNow, + Respond = false + }); } } } \ No newline at end of file diff --git a/Hermes/Socket/Handlers/LoginAckHandler.cs b/Hermes/Socket/Handlers/LoginAckHandler.cs index 0d328d8..3c70bc2 100644 --- a/Hermes/Socket/Handlers/LoginAckHandler.cs +++ b/Hermes/Socket/Handlers/LoginAckHandler.cs @@ -1,16 +1,20 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using HermesSocketLibrary.Socket.Data; -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using Serilog; namespace TwitchChatTTS.Hermes.Socket.Handlers { public class LoginAckHandler : IWebSocketHandler { - private ILogger _logger { get; } + private IServiceProvider _serviceProvider; + private ILogger _logger; public int OperationCode { get; set; } = 2; - public LoginAckHandler(ILogger logger) { + public LoginAckHandler(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; _logger = logger; } @@ -18,17 +22,45 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers { if (message is not LoginAckMessage obj || obj == null) return; - - if (sender is not HermesSocketClient client) { + + if (sender is not HermesSocketClient client) return; + + if (obj.AnotherClient) + { + _logger.Warning("Another client has connected to the same account."); + } + else + { + var user = _serviceProvider.GetRequiredService(); + client.UserId = obj.UserId; + _logger.Information($"Logged in as {user.TwitchUsername} (id: {client.UserId})."); } - if (obj.AnotherClient) { - _logger.LogWarning("Another client has connected to the same account."); - } else { - client.UserId = obj.UserId; - _logger.LogInformation($"Logged in as {client.UserId}."); - } + await client.Send(3, new RequestMessage() + { + Type = "get_tts_voices", + Data = null + }); + + var token = _serviceProvider.GetRequiredService(); + await client.Send(3, new RequestMessage() + { + Type = "get_tts_users", + Data = new Dictionary() { { "user", token.HermesUserId } } + }); + + await client.Send(3, new RequestMessage() + { + Type = "get_chatter_ids", + Data = null + }); + + await client.Send(3, new RequestMessage() + { + Type = "get_emotes", + Data = null + }); } } } \ No newline at end of file diff --git a/Hermes/Socket/Handlers/RequestAckHandler.cs b/Hermes/Socket/Handlers/RequestAckHandler.cs index 2f904a3..1cdc995 100644 --- a/Hermes/Socket/Handlers/RequestAckHandler.cs +++ b/Hermes/Socket/Handlers/RequestAckHandler.cs @@ -5,7 +5,8 @@ using CommonSocketLibrary.Common; using HermesSocketLibrary.Requests.Messages; using HermesSocketLibrary.Socket.Data; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; +using TwitchChatTTS.Seven; namespace TwitchChatTTS.Hermes.Socket.Handlers { @@ -14,9 +15,13 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers private readonly IServiceProvider _serviceProvider; private readonly JsonSerializerOptions _options; private readonly ILogger _logger; + + private readonly object _voicesAvailableLock = new object(); + public int OperationCode { get; set; } = 4; - public RequestAckHandler(IServiceProvider serviceProvider, JsonSerializerOptions options, ILogger logger) { + public RequestAckHandler(IServiceProvider serviceProvider, JsonSerializerOptions options, ILogger logger) + { _serviceProvider = serviceProvider; _options = options; _logger = logger; @@ -32,74 +37,144 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers if (context == null) return; - if (obj.Request.Type == "get_tts_voices") { - _logger.LogDebug("Updating all available voices."); + if (obj.Request.Type == "get_tts_voices") + { + _logger.Verbose("Updating all available voices for TTS."); var voices = JsonSerializer.Deserialize>(obj.Data.ToString(), _options); if (voices == null) return; - - context.VoicesAvailable = voices.ToDictionary(e => e.Id, e => e.Name); - _logger.LogInformation("Updated all available voices."); - } else if (obj.Request.Type == "create_tts_user") { - _logger.LogDebug("Creating new tts voice."); - if (!long.TryParse(obj.Request.Data["@user"], out long userId)) + + lock (_voicesAvailableLock) + { + context.VoicesAvailable = voices.ToDictionary(e => e.Id, e => e.Name); + } + _logger.Information("Updated all available voices for TTS."); + } + else if (obj.Request.Type == "create_tts_user") + { + _logger.Verbose("Adding new tts voice for user."); + if (!long.TryParse(obj.Request.Data["user"].ToString(), out long chatterId)) return; - string broadcasterId = obj.Request.Data["@broadcaster"].ToString(); - // TODO: validate broadcaster id. - string voice = obj.Request.Data["@voice"].ToString(); - - context.VoicesSelected.Add(userId, voice); - _logger.LogInformation("Created new tts user."); - } else if (obj.Request.Type == "update_tts_user") { - _logger.LogDebug("Updating user's voice"); - if (!long.TryParse(obj.Request.Data["@user"], out long userId)) + string userId = obj.Request.Data["user"].ToString(); + string voice = obj.Request.Data["voice"].ToString(); + + context.VoicesSelected.Add(chatterId, voice); + _logger.Information($"Added new TTS voice [voice: {voice}] for user [user id: {userId}]"); + } + else if (obj.Request.Type == "update_tts_user") + { + _logger.Verbose("Updating user's voice"); + if (!long.TryParse(obj.Request.Data["chatter"].ToString(), out long chatterId)) return; - string broadcasterId = obj.Request.Data["@broadcaster"].ToString(); - string voice = obj.Request.Data["@voice"].ToString(); - - context.VoicesSelected[userId] = voice; - _logger.LogInformation($"Updated user's voice to {voice}."); - } else if (obj.Request.Type == "create_tts_voice") { - _logger.LogDebug("Creating new tts voice."); - string? voice = obj.Request.Data["@voice"]; + string userId = obj.Request.Data["user"].ToString(); + string voice = obj.Request.Data["voice"].ToString(); + + context.VoicesSelected[chatterId] = voice; + _logger.Information($"Updated TTS voice [voice: {voice}] for user [user id: {userId}]"); + } + else if (obj.Request.Type == "create_tts_voice") + { + _logger.Verbose("Creating new tts voice."); + string? voice = obj.Request.Data["voice"].ToString(); string? voiceId = obj.Data.ToString(); if (voice == null || voiceId == null) return; - context.VoicesAvailable.Add(voiceId, voice); - _logger.LogInformation($"Created new tts voice named {voice} (id: {voiceId})."); - } else if (obj.Request.Type == "delete_tts_voice") { - _logger.LogDebug("Deleting tts voice."); + lock (_voicesAvailableLock) + { + var list = context.VoicesAvailable.ToDictionary(k => k.Key, v => v.Value); + list.Add(voiceId, voice); + context.VoicesAvailable = list; + } + _logger.Information($"Created new tts voice [voice: {voice}][id: {voiceId}]."); + } + else if (obj.Request.Type == "delete_tts_voice") + { + _logger.Verbose("Deleting tts voice."); + var voice = obj.Request.Data["voice"].ToString(); + if (!context.VoicesAvailable.TryGetValue(voice, out string voiceName) || voiceName == null) + return; - var voice = obj.Request.Data["@voice"]; - if (!context.VoicesAvailable.TryGetValue(voice, out string voiceName) || voiceName == null) { - return; + lock (_voicesAvailableLock) + { + var dict = context.VoicesAvailable.ToDictionary(k => k.Key, v => v.Value); + dict.Remove(voice); + context.VoicesAvailable.Remove(voice); } - - context.VoicesAvailable.Remove(voice); - _logger.LogInformation("Deleted a voice, named " + voiceName + "."); - } else if (obj.Request.Type == "update_tts_voice") { - _logger.LogDebug("Updating tts voice."); - string voiceId = obj.Request.Data["@idd"].ToString(); - string voice = obj.Request.Data["@voice"].ToString(); - - if (!context.VoicesAvailable.TryGetValue(voiceId, out string voiceName) || voiceName == null) { + _logger.Information($"Deleted a voice [voice: {voiceName}]"); + } + else if (obj.Request.Type == "update_tts_voice") + { + _logger.Verbose("Updating TTS voice."); + string voiceId = obj.Request.Data["idd"].ToString(); + string voice = obj.Request.Data["voice"].ToString(); + + if (!context.VoicesAvailable.TryGetValue(voiceId, out string voiceName) || voiceName == null) return; - } - + context.VoicesAvailable[voiceId] = voice; - _logger.LogInformation("Update tts voice: " + voice); - } else if (obj.Request.Type == "get_tts_users") { - _logger.LogDebug("Attempting to update all chatters' selected voice."); + _logger.Information($"Updated TTS voice [voice: {voice}][id: {voiceId}]"); + } + else if (obj.Request.Type == "get_tts_users") + { + _logger.Verbose("Updating all chatters' selected voice."); var users = JsonSerializer.Deserialize>(obj.Data.ToString(), _options); if (users == null) return; - + var temp = new ConcurrentDictionary(); foreach (var entry in users) temp.TryAdd(entry.Key, entry.Value); context.VoicesSelected = temp; - _logger.LogInformation($"Fetched {temp.Count()} chatters' selected voice."); + _logger.Information($"Updated {temp.Count()} chatters' selected voice."); + } + else if (obj.Request.Type == "get_chatter_ids") + { + _logger.Verbose("Fetching all chatters' id."); + var chatters = JsonSerializer.Deserialize>(obj.Data.ToString(), _options); + if (chatters == null) + return; + + var client = _serviceProvider.GetRequiredService(); + client.Chatters = [.. chatters]; + _logger.Information($"Fetched {chatters.Count()} chatters' id."); + } + else if (obj.Request.Type == "get_emotes") + { + _logger.Verbose("Updating emotes."); + var emotes = JsonSerializer.Deserialize>(obj.Data.ToString(), _options); + if (emotes == null) + return; + + var emoteDb = _serviceProvider.GetRequiredService(); + var count = 0; + foreach (var emote in emotes) + { + if (emoteDb.Get(emote.Name) == null) + { + emoteDb.Add(emote.Name, emote.Id); + count++; + } + } + _logger.Information($"Fetched {count} emotes from various sources."); + } + else if (obj.Request.Type == "update_tts_voice_state") + { + _logger.Verbose("Updating TTS voice states."); + string voiceId = obj.Request.Data["voice"].ToString(); + bool state = obj.Request.Data["state"].ToString() == "true"; + + if (!context.VoicesAvailable.TryGetValue(voiceId, out string voiceName) || voiceName == null) + { + _logger.Warning($"Failed to find voice [id: {voiceId}]"); + return; + } + + if (state) + context.VoicesEnabled.Add(voiceId); + else + context.VoicesEnabled.Remove(voiceId); + _logger.Information($"Updated voice state [voice: {voiceName}][new state: {(state ? "enabled" : "disabled")}]"); } } } diff --git a/Hermes/Socket/HermesSocketClient.cs b/Hermes/Socket/HermesSocketClient.cs index 9969657..4ccf4f1 100644 --- a/Hermes/Socket/HermesSocketClient.cs +++ b/Hermes/Socket/HermesSocketClient.cs @@ -1,24 +1,108 @@ using System.Text.Json; +using System.Timers; using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; +using HermesSocketLibrary.Socket.Data; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; namespace TwitchChatTTS.Hermes.Socket { - public class HermesSocketClient : WebSocketClient { - public DateTime LastHeartbeat { get; set; } - public string? UserId { get; set; } + public class HermesSocketClient : WebSocketClient + { + private Configuration _configuration; + public DateTime LastHeartbeatReceived { get; set; } + public DateTime LastHeartbeatSent { get; set; } + public string? UserId { get; set; } + private System.Timers.Timer _heartbeatTimer; + private System.Timers.Timer _reconnectTimer; public HermesSocketClient( - ILogger logger, + Configuration configuration, [FromKeyedServices("hermes")] HandlerManager handlerManager, - [FromKeyedServices("hermes")] HandlerTypeManager typeManager - ) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() { + [FromKeyedServices("hermes")] HandlerTypeManager typeManager, + ILogger logger + ) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() + { PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower - }) { - + }) + { + _configuration = configuration; + + _heartbeatTimer = new System.Timers.Timer(TimeSpan.FromSeconds(15)); + _heartbeatTimer.Elapsed += async (sender, e) => await HandleHeartbeat(e); + + _reconnectTimer = new System.Timers.Timer(TimeSpan.FromSeconds(15)); + _reconnectTimer.Elapsed += async (sender, e) => await Reconnect(e); + + LastHeartbeatReceived = LastHeartbeatSent = DateTime.UtcNow; + } + + protected override async Task OnConnection() + { + _heartbeatTimer.Enabled = true; + } + + private async Task HandleHeartbeat(ElapsedEventArgs e) + { + var signalTime = e.SignalTime.ToUniversalTime(); + + if (signalTime - LastHeartbeatReceived > TimeSpan.FromSeconds(60)) + { + if (LastHeartbeatReceived > LastHeartbeatSent) + { + _logger.Verbose("Sending heartbeat..."); + LastHeartbeatSent = DateTime.UtcNow; + try + { + await Send(0, new HeartbeatMessage() { DateTime = LastHeartbeatSent }); + } + catch (Exception) + { + } + } + else if (signalTime - LastHeartbeatReceived > TimeSpan.FromSeconds(120)) + { + try + { + await DisconnectAsync(); + } + catch (Exception) + { + } + UserId = null; + _heartbeatTimer.Enabled = false; + + _logger.Information("Logged off due to disconnection. Attempting to reconnect..."); + _reconnectTimer.Enabled = true; + } + } + } + + private async Task Reconnect(ElapsedEventArgs e) + { + try + { + await ConnectAsync($"wss://hermes-ws.goblincaves.com"); + Connected = true; + } + catch (Exception) + { + } + finally + { + if (Connected) + { + _logger.Information("Reconnected."); + _reconnectTimer.Enabled = false; + _heartbeatTimer.Enabled = true; + LastHeartbeatReceived = DateTime.UtcNow; + + if (_configuration.Hermes?.Token != null) + await Send(1, new HermesLoginMessage() { ApiKey = _configuration.Hermes.Token }); + } + } } } } \ No newline at end of file diff --git a/Hermes/Socket/Managers/HermesHandlerManager.cs b/Hermes/Socket/Managers/HermesHandlerManager.cs index 4c4897f..8f5dc8c 100644 --- a/Hermes/Socket/Managers/HermesHandlerManager.cs +++ b/Hermes/Socket/Managers/HermesHandlerManager.cs @@ -1,35 +1,41 @@ using CommonSocketLibrary.Common; using CommonSocketLibrary.Socket.Manager; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; namespace TwitchChatTTS.Hermes.Socket.Managers { public class HermesHandlerManager : WebSocketHandlerManager { - public HermesHandlerManager(ILogger logger, IServiceProvider provider) : base(logger) { + public HermesHandlerManager(ILogger logger, IServiceProvider provider) : base(logger) + { //Add(provider.GetRequiredService()); - try { + try + { var basetype = typeof(IWebSocketHandler); var assembly = GetType().Assembly; var types = assembly.GetTypes().Where(t => t.IsClass && basetype.IsAssignableFrom(t) && t.AssemblyQualifiedName?.Contains(".Hermes.") == true); - foreach (var type in types) { + foreach (var type in types) + { var key = "hermes-" + type.Name.Replace("Handlers", "Hand#lers") .Replace("Handler", "") .Replace("Hand#lers", "Handlers") .ToLower(); var handler = provider.GetKeyedService(key); - if (handler == null) { - logger.LogError("Failed to find hermes websocket handler: " + type.AssemblyQualifiedName); + if (handler == null) + { + logger.Error("Failed to find hermes websocket handler: " + type.AssemblyQualifiedName); continue; } - - Logger.LogDebug($"Linked type {type.AssemblyQualifiedName} to hermes websocket handlers."); + + Logger.Debug($"Linked type {type.AssemblyQualifiedName} to hermes websocket handlers."); Add(handler); } - } catch (Exception e) { - Logger.LogError(e, "Failed to load hermes websocket handler types."); + } + catch (Exception e) + { + Logger.Error(e, "Failed to load hermes websocket handler types."); } } } diff --git a/Hermes/Socket/Managers/HermesHandlerTypeManager.cs b/Hermes/Socket/Managers/HermesHandlerTypeManager.cs index b19374f..f57174b 100644 --- a/Hermes/Socket/Managers/HermesHandlerTypeManager.cs +++ b/Hermes/Socket/Managers/HermesHandlerTypeManager.cs @@ -3,14 +3,14 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using CommonSocketLibrary.Socket.Manager; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; namespace TwitchChatTTS.Hermes.Socket.Managers { public class HermesHandlerTypeManager : WebSocketHandlerTypeManager { public HermesHandlerTypeManager( - ILogger factory, + ILogger factory, [FromKeyedServices("hermes")] HandlerManager handlers ) : base(factory, handlers) { @@ -20,12 +20,12 @@ namespace TwitchChatTTS.Hermes.Socket.Managers { if (handlerType == null) return null; - + var name = handlerType.Namespace + "." + handlerType.Name; name = name.Replace(".Handlers.", ".Data.") .Replace("Handler", "Message") .Replace("TwitchChatTTS.Hermes.", "HermesSocketLibrary."); - + return Assembly.Load("HermesSocketLibrary").GetType(name); } } diff --git a/Hermes/TTSVersion.cs b/Hermes/TTSVersion.cs new file mode 100644 index 0000000..355d95d --- /dev/null +++ b/Hermes/TTSVersion.cs @@ -0,0 +1,10 @@ +namespace TwitchChatTTS.Hermes +{ + public class TTSVersion + { + public int MajorVersion { get; set; } + public int MinorVersion { get; set; } + public string Download { get; set; } + public string Changelog { get; set; } + } +} \ No newline at end of file diff --git a/Hermes/TTSVoice.cs b/Hermes/TTSVoice.cs index 76b98ee..f98d682 100644 --- a/Hermes/TTSVoice.cs +++ b/Hermes/TTSVoice.cs @@ -1,6 +1,13 @@ -public class TTSVoice { - public string Label { get; set; } - public int Value { get; set; } - public string? Gender { get; set; } - public string? Language { get; set; } +public class TTSVoice +{ + public string Label { get; set; } + public int Value { get; set; } + public string? Gender { get; set; } + public string? Language { get; set; } +} + +public class TTSChatterSelectedVoice +{ + public long ChatterId { get; set; } + public string Voice { get; set; } } \ No newline at end of file diff --git a/Hermes/TTSWordFilter.cs b/Hermes/TTSWordFilter.cs deleted file mode 100644 index b79d60f..0000000 --- a/Hermes/TTSWordFilter.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace TwitchChatTTS.Hermes -{ - public class TTSWordFilter - { - public string? Id { get; set; } - public string? Search { get; set; } - public string? Replace { get; set; } - public string? UserId { get; set; } - - public bool IsRegex { get; set; } - - - public TTSWordFilter() { - IsRegex = true; - } - } -} \ No newline at end of file diff --git a/Hermes/TwitchBotAuth.cs b/Hermes/TwitchBotAuth.cs index e0ed6ef..6576217 100644 --- a/Hermes/TwitchBotAuth.cs +++ b/Hermes/TwitchBotAuth.cs @@ -1,7 +1,6 @@ -[Serializable] public class TwitchBotAuth { - public string? UserId { get; set; } - public string? AccessToken { get; set; } - public string? RefreshToken { get; set; } - public string? BroadcasterId { get; set; } + public string? UserId { get; set; } + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public string? BroadcasterId { get; set; } } \ No newline at end of file diff --git a/Hermes/TwitchBotToken.cs b/Hermes/TwitchBotToken.cs index dff611c..acda7c5 100644 --- a/Hermes/TwitchBotToken.cs +++ b/Hermes/TwitchBotToken.cs @@ -1,8 +1,7 @@ -[Serializable] public class TwitchBotToken { - public string? ClientId { get; set; } - public string? ClientSecret { get; set; } - public string? AccessToken { get; set; } - public string? RefreshToken { get; set; } - public string? BroadcasterId { get; set; } + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public string? BroadcasterId { get; set; } } \ No newline at end of file diff --git a/Hermes/TwitchConnection.cs b/Hermes/TwitchConnection.cs index 7c179ab..3a02b83 100644 --- a/Hermes/TwitchConnection.cs +++ b/Hermes/TwitchConnection.cs @@ -1,8 +1,7 @@ -[Serializable] public class TwitchConnection { - public string? Id { get; set; } - public string? Secret { get; set; } - public string? BroadcasterId { get; set; } - public string? Username { get; set; } - public string? UserId { get; set; } + public string? Id { get; set; } + public string? Secret { get; set; } + public string? BroadcasterId { get; set; } + public string? Username { get; set; } + public string? UserId { get; set; } } \ No newline at end of file diff --git a/OBS/Socket/Context/HelloContext.cs b/OBS/Socket/Context/HelloContext.cs index 194f0cf..2a22961 100644 --- a/OBS/Socket/Context/HelloContext.cs +++ b/OBS/Socket/Context/HelloContext.cs @@ -1,6 +1,5 @@ namespace TwitchChatTTS.OBS.Socket.Context { - [Serializable] public class HelloContext { public string? Host { get; set; } diff --git a/OBS/Socket/Data/EventMessage.cs b/OBS/Socket/Data/EventMessage.cs index 8632252..28e075d 100644 --- a/OBS/Socket/Data/EventMessage.cs +++ b/OBS/Socket/Data/EventMessage.cs @@ -1,6 +1,5 @@ namespace TwitchChatTTS.OBS.Socket.Data { - [Serializable] public class EventMessage { public string EventType { get; set; } diff --git a/OBS/Socket/Data/HelloMessage.cs b/OBS/Socket/Data/HelloMessage.cs index 69009be..4217e5f 100644 --- a/OBS/Socket/Data/HelloMessage.cs +++ b/OBS/Socket/Data/HelloMessage.cs @@ -1,6 +1,5 @@ namespace TwitchChatTTS.OBS.Socket.Data { - [Serializable] public class HelloMessage { public string ObsWebSocketVersion { get; set; } diff --git a/OBS/Socket/Data/IdentifiedMessage.cs b/OBS/Socket/Data/IdentifiedMessage.cs index beebf58..2534680 100644 --- a/OBS/Socket/Data/IdentifiedMessage.cs +++ b/OBS/Socket/Data/IdentifiedMessage.cs @@ -1,6 +1,5 @@ namespace TwitchChatTTS.OBS.Socket.Data { - [Serializable] public class IdentifiedMessage { public int NegotiatedRpcVersion { get; set; } diff --git a/OBS/Socket/Data/IdentifyMessage.cs b/OBS/Socket/Data/IdentifyMessage.cs index bf7a1e3..d1c6c34 100644 --- a/OBS/Socket/Data/IdentifyMessage.cs +++ b/OBS/Socket/Data/IdentifyMessage.cs @@ -1,13 +1,13 @@ namespace TwitchChatTTS.OBS.Socket.Data { - [Serializable] public class IdentifyMessage { public int RpcVersion { get; set; } public string? Authentication { get; set; } public int EventSubscriptions { get; set; } - public IdentifyMessage(int version, string auth, int subscriptions) { + public IdentifyMessage(int version, string auth, int subscriptions) + { RpcVersion = version; Authentication = auth; EventSubscriptions = subscriptions; diff --git a/OBS/Socket/Data/RequestBatchMessage.cs b/OBS/Socket/Data/RequestBatchMessage.cs new file mode 100644 index 0000000..cdf52bf --- /dev/null +++ b/OBS/Socket/Data/RequestBatchMessage.cs @@ -0,0 +1,25 @@ +namespace TwitchChatTTS.OBS.Socket.Data +{ + public class RequestBatchMessage + { + public string RequestId { get; set; } + public bool HaltOnFailure { get; set; } + public RequestBatchExecutionType ExecutionType { get; set; } + public IEnumerable Requests { get; set;} + + public RequestBatchMessage(string id, IEnumerable requests, bool haltOnFailure = false, RequestBatchExecutionType executionType = RequestBatchExecutionType.SerialRealtime) + { + RequestId = id; + Requests = requests; + HaltOnFailure = haltOnFailure; + ExecutionType = executionType; + } + } + + public enum RequestBatchExecutionType { + None = -1, + SerialRealtime = 0, + SerialFrame = 1, + Parallel = 2 + } +} \ No newline at end of file diff --git a/OBS/Socket/Data/RequestBatchResponseMessage.cs b/OBS/Socket/Data/RequestBatchResponseMessage.cs new file mode 100644 index 0000000..bc82cf8 --- /dev/null +++ b/OBS/Socket/Data/RequestBatchResponseMessage.cs @@ -0,0 +1,8 @@ +namespace TwitchChatTTS.OBS.Socket.Data +{ + public class RequestBatchResponseMessage + { + public string RequestId { get; set; } + public IEnumerable Results { get; set; } + } +} \ No newline at end of file diff --git a/OBS/Socket/Data/RequestMessage.cs b/OBS/Socket/Data/RequestMessage.cs index edaab05..d1bb8c4 100644 --- a/OBS/Socket/Data/RequestMessage.cs +++ b/OBS/Socket/Data/RequestMessage.cs @@ -1,13 +1,13 @@ namespace TwitchChatTTS.OBS.Socket.Data { - [Serializable] public class RequestMessage { public string RequestType { get; set; } public string RequestId { get; set; } public Dictionary RequestData { get; set; } - public RequestMessage(string type, string id, Dictionary data) { + public RequestMessage(string type, string id, Dictionary data) + { RequestType = type; RequestId = id; RequestData = data; diff --git a/OBS/Socket/Data/RequestResponseMessage.cs b/OBS/Socket/Data/RequestResponseMessage.cs index 4648277..40fcf65 100644 --- a/OBS/Socket/Data/RequestResponseMessage.cs +++ b/OBS/Socket/Data/RequestResponseMessage.cs @@ -1,6 +1,5 @@ namespace TwitchChatTTS.OBS.Socket.Data { - [Serializable] public class RequestResponseMessage { public string RequestType { get; set; } diff --git a/OBS/Socket/Handlers/EventMessageHandler.cs b/OBS/Socket/Handlers/EventMessageHandler.cs index 170d62d..a6c7d04 100644 --- a/OBS/Socket/Handlers/EventMessageHandler.cs +++ b/OBS/Socket/Handlers/EventMessageHandler.cs @@ -1,19 +1,20 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.OBS.Socket.Data; namespace TwitchChatTTS.OBS.Socket.Handlers { public class EventMessageHandler : IWebSocketHandler { - private ILogger Logger { get; } - private IServiceProvider ServiceProvider { get; } + private ILogger _logger { get; } + private IServiceProvider _serviceProvider { get; } public int OperationCode { get; set; } = 5; - public EventMessageHandler(ILogger logger, IServiceProvider serviceProvider) { - Logger = logger; - ServiceProvider = serviceProvider; + public EventMessageHandler(ILogger logger, IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; } public async Task Execute(SocketClient sender, Data message) @@ -21,28 +22,31 @@ namespace TwitchChatTTS.OBS.Socket.Handlers if (message is not EventMessage obj || obj == null) return; - switch (obj.EventType) { + switch (obj.EventType) + { case "StreamStateChanged": case "RecordStateChanged": if (sender is not OBSSocketClient client) return; - + string? raw_state = obj.EventData["outputState"].ToString(); string? state = raw_state?.Substring(21).ToLower(); client.Live = obj.EventData["outputActive"].ToString() == "True"; - Logger.LogWarning("Stream " + (state != null && state.EndsWith("ing") ? "is " : "has ") + state + "."); + _logger.Warning("Stream " + (state != null && state.EndsWith("ing") ? "is " : "has ") + state + "."); - if (client.Live == false && state != null && !state.EndsWith("ing")) { + if (client.Live == false && state != null && !state.EndsWith("ing")) + { OnStreamEnd(); } break; default: - Logger.LogDebug(obj.EventType + " EVENT: " + string.Join(" | ", obj.EventData?.Select(x => x.Key + "=" + x.Value?.ToString()) ?? new string[0])); + _logger.Debug(obj.EventType + " EVENT: " + string.Join(" | ", obj.EventData?.Select(x => x.Key + "=" + x.Value?.ToString()) ?? new string[0])); break; } } - private void OnStreamEnd() { + private void OnStreamEnd() + { } } } \ No newline at end of file diff --git a/OBS/Socket/Handlers/HelloHandler.cs b/OBS/Socket/Handlers/HelloHandler.cs index 1432bfb..12a26d8 100644 --- a/OBS/Socket/Handlers/HelloHandler.cs +++ b/OBS/Socket/Handlers/HelloHandler.cs @@ -2,7 +2,7 @@ using System.Security.Cryptography; using System.Text; using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.OBS.Socket.Data; using TwitchChatTTS.OBS.Socket.Context; @@ -10,13 +10,14 @@ namespace TwitchChatTTS.OBS.Socket.Handlers { public class HelloHandler : IWebSocketHandler { - private ILogger Logger { get; } + private ILogger _logger { get; } public int OperationCode { get; set; } = 0; - private HelloContext Context { get; } + private HelloContext _context { get; } - public HelloHandler(ILogger logger, HelloContext context) { - Logger = logger; - Context = context; + public HelloHandler(ILogger logger, HelloContext context) + { + _logger = logger; + _context = context; } public async Task Execute(SocketClient sender, Data message) @@ -24,19 +25,23 @@ namespace TwitchChatTTS.OBS.Socket.Handlers if (message is not HelloMessage obj || obj == null) return; - Logger.LogTrace("OBS websocket password: " + Context.Password); - if (obj.Authentication == null || Context.Password == null) // TODO: send re-identify message. + _logger.Verbose("OBS websocket password: " + _context.Password); + if (obj.Authentication == null || string.IsNullOrWhiteSpace(_context.Password)) + { + await sender.Send(1, new IdentifyMessage(obj.RpcVersion, string.Empty, 1023 | 262144)); return; - + } + var salt = obj.Authentication.Salt; var challenge = obj.Authentication.Challenge; - Logger.LogTrace("Salt: " + salt); - Logger.LogTrace("Challenge: " + challenge); - - string secret = Context.Password + salt; + _logger.Verbose("Salt: " + salt); + _logger.Verbose("Challenge: " + challenge); + + string secret = _context.Password + salt; byte[] bytes = Encoding.UTF8.GetBytes(secret); string hash = null; - using (var sha = SHA256.Create()) { + using (var sha = SHA256.Create()) + { bytes = sha.ComputeHash(bytes); hash = Convert.ToBase64String(bytes); @@ -46,7 +51,7 @@ namespace TwitchChatTTS.OBS.Socket.Handlers hash = Convert.ToBase64String(bytes); } - Logger.LogTrace("Final hash: " + hash); + _logger.Verbose("Final hash: " + hash); await sender.Send(1, new IdentifyMessage(obj.RpcVersion, hash, 1023 | 262144)); } } diff --git a/OBS/Socket/Handlers/IdentifiedHandler.cs b/OBS/Socket/Handlers/IdentifiedHandler.cs index 6fe7a27..9995a43 100644 --- a/OBS/Socket/Handlers/IdentifiedHandler.cs +++ b/OBS/Socket/Handlers/IdentifiedHandler.cs @@ -1,6 +1,6 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.OBS.Socket.Data; namespace TwitchChatTTS.OBS.Socket.Handlers @@ -10,7 +10,8 @@ namespace TwitchChatTTS.OBS.Socket.Handlers private ILogger Logger { get; } public int OperationCode { get; set; } = 2; - public IdentifiedHandler(ILogger logger) { + public IdentifiedHandler(ILogger logger) + { Logger = logger; } @@ -18,9 +19,9 @@ namespace TwitchChatTTS.OBS.Socket.Handlers { if (message is not IdentifiedMessage obj || obj == null) return; - + sender.Connected = true; - Logger.LogInformation("Connected to OBS via rpc version " + obj.NegotiatedRpcVersion + "."); + Logger.Information("Connected to OBS via rpc version " + obj.NegotiatedRpcVersion + "."); } } } \ No newline at end of file diff --git a/OBS/Socket/Handlers/RequestBatchResponseHandler.cs b/OBS/Socket/Handlers/RequestBatchResponseHandler.cs new file mode 100644 index 0000000..84fe243 --- /dev/null +++ b/OBS/Socket/Handlers/RequestBatchResponseHandler.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using CommonSocketLibrary.Abstract; +using CommonSocketLibrary.Common; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Serilog.Context; +using TwitchChatTTS.OBS.Socket.Data; +using TwitchChatTTS.OBS.Socket.Manager; + +namespace TwitchChatTTS.OBS.Socket.Handlers +{ + public class RequestBatchResponseHandler : IWebSocketHandler + { + private OBSRequestBatchManager _manager { get; } + private IServiceProvider _serviceProvider { get; } + private ILogger _logger { get; } + private JsonSerializerOptions _options; + public int OperationCode { get; set; } = 9; + + public RequestBatchResponseHandler(OBSRequestBatchManager manager, JsonSerializerOptions options, IServiceProvider serviceProvider, ILogger logger) + { + _manager = manager; + _serviceProvider = serviceProvider; + _logger = logger; + _options = options; + } + + public async Task Execute(SocketClient sender, Data data) + { + if (data is not RequestBatchResponseMessage message || message == null) + return; + + using (LogContext.PushProperty("obsrid", message.RequestId)) + { + + var results = message.Results.ToList(); + _logger.Debug($"Received request batch response of {results.Count} messages."); + + var requestData = _manager.Take(message.RequestId); + if (requestData == null || !results.Any()) + { + _logger.Verbose($"Received request batch response of {results.Count} messages."); + return; + } + + IList tasks = new List(); + int count = Math.Min(results.Count, requestData.RequestTypes.Count); + for (int i = 0; i < count; i++) + { + Type type = requestData.RequestTypes[i]; + + using (LogContext.PushProperty("type", type.Name)) + { + try + { + var handler = GetResponseHandlerForRequestType(type); + _logger.Verbose($"Request handled by {handler.GetType().Name}."); + tasks.Add(handler.Execute(sender, results[i])); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to process an item in a request batch message."); + } + } + } + + _logger.Verbose($"Waiting for processing to complete."); + await Task.WhenAll(tasks); + + _logger.Debug($"Finished processing all request in this batch."); + } + } + + private IWebSocketHandler? GetResponseHandlerForRequestType(Type type) + { + if (type == typeof(RequestMessage)) + return _serviceProvider.GetRequiredKeyedService("obs-requestresponse"); + else if (type == typeof(RequestBatchMessage)) + return _serviceProvider.GetRequiredKeyedService("obs-requestbatcresponse"); + else if (type == typeof(IdentifyMessage)) + return _serviceProvider.GetRequiredKeyedService("obs-identified"); + return null; + } + } +} \ No newline at end of file diff --git a/OBS/Socket/Handlers/RequestResponseHandler.cs b/OBS/Socket/Handlers/RequestResponseHandler.cs index bfb221e..a7851da 100644 --- a/OBS/Socket/Handlers/RequestResponseHandler.cs +++ b/OBS/Socket/Handlers/RequestResponseHandler.cs @@ -1,6 +1,6 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.OBS.Socket.Data; namespace TwitchChatTTS.OBS.Socket.Handlers @@ -10,7 +10,8 @@ namespace TwitchChatTTS.OBS.Socket.Handlers private ILogger Logger { get; } public int OperationCode { get; set; } = 7; - public RequestResponseHandler(ILogger logger) { + public RequestResponseHandler(ILogger logger) + { Logger = logger; } @@ -18,15 +19,17 @@ namespace TwitchChatTTS.OBS.Socket.Handlers { if (message is not RequestResponseMessage obj || obj == null) return; - - switch (obj.RequestType) { + + switch (obj.RequestType) + { case "GetOutputStatus": if (sender is not OBSSocketClient client) return; - - if (obj.RequestId == "stream") { + + if (obj.RequestId == "stream") + { client.Live = obj.ResponseData["outputActive"].ToString() == "True"; - Logger.LogWarning("Updated stream's live status to " + client.Live); + Logger.Warning("Updated stream's live status to " + client.Live); } break; } diff --git a/OBS/Socket/Manager/OBSBatchRequestManager.cs b/OBS/Socket/Manager/OBSBatchRequestManager.cs new file mode 100644 index 0000000..ee477f6 --- /dev/null +++ b/OBS/Socket/Manager/OBSBatchRequestManager.cs @@ -0,0 +1,60 @@ +using CommonSocketLibrary.Abstract; +using CommonSocketLibrary.Common; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using TwitchChatTTS.OBS.Socket.Data; + +namespace TwitchChatTTS.OBS.Socket.Manager +{ + public class OBSRequestBatchManager + { + private IDictionary _requests; + private IServiceProvider _serviceProvider; + private ILogger _logger; + + public OBSRequestBatchManager(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + + public async Task Send(long broadcasterId, IEnumerable messages) { + string uid = GenerateUniqueIdentifier(); + var data = new OBSRequestBatchData(broadcasterId, uid, new List()); + _logger.Debug($"Sending request batch of {messages.Count()} messages."); + + foreach (WebSocketMessage message in messages) + data.RequestTypes.Add(message.GetType()); + + var client = _serviceProvider.GetRequiredKeyedService>("obs"); + await client.Send(8, new RequestBatchMessage(uid, messages)); + } + + public OBSRequestBatchData? Take(string id) { + if (_requests.TryGetValue(id, out var request)) { + _requests.Remove(id); + return request; + } + return null; + } + + private string GenerateUniqueIdentifier() + { + return Guid.NewGuid().ToString("X"); + } + } + + public class OBSRequestBatchData + { + public long BroadcasterId { get; } + public string RequestId { get; } + public IList RequestTypes { get; } + + public OBSRequestBatchData(long bid, string rid, IList types) { + BroadcasterId = bid; + RequestId = rid; + RequestTypes = types; + } + } +} \ No newline at end of file diff --git a/OBS/Socket/Manager/OBSHandlerManager.cs b/OBS/Socket/Manager/OBSHandlerManager.cs index 125ede9..6b1f8ee 100644 --- a/OBS/Socket/Manager/OBSHandlerManager.cs +++ b/OBS/Socket/Manager/OBSHandlerManager.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Serilog; using Microsoft.Extensions.DependencyInjection; using CommonSocketLibrary.Socket.Manager; using CommonSocketLibrary.Common; @@ -7,23 +7,26 @@ namespace TwitchChatTTS.OBS.Socket.Manager { public class OBSHandlerManager : WebSocketHandlerManager { - public OBSHandlerManager(ILogger logger, IServiceProvider provider) : base(logger) { + public OBSHandlerManager(ILogger logger, IServiceProvider provider) : base(logger) + { var basetype = typeof(IWebSocketHandler); var assembly = GetType().Assembly; var types = assembly.GetTypes().Where(t => t.IsClass && basetype.IsAssignableFrom(t) && t.AssemblyQualifiedName?.Contains(".OBS.") == true); - foreach (var type in types) { + foreach (var type in types) + { var key = "obs-" + type.Name.Replace("Handlers", "Hand#lers") .Replace("Handler", "") .Replace("Hand#lers", "Handlers") .ToLower(); var handler = provider.GetKeyedService(key); - if (handler == null) { - logger.LogError("Failed to find obs websocket handler: " + type.AssemblyQualifiedName); + if (handler == null) + { + logger.Error("Failed to find obs websocket handler: " + type.AssemblyQualifiedName); continue; } - - Logger.LogDebug($"Linked type {type.AssemblyQualifiedName} to obs websocket handler {handler.GetType().AssemblyQualifiedName}."); + + Logger.Debug($"Linked type {type.AssemblyQualifiedName} to obs websocket handler {handler.GetType().AssemblyQualifiedName}."); Add(handler); } } diff --git a/OBS/Socket/Manager/OBSHandlerTypeManager.cs b/OBS/Socket/Manager/OBSHandlerTypeManager.cs index 9ac5549..a484a28 100644 --- a/OBS/Socket/Manager/OBSHandlerTypeManager.cs +++ b/OBS/Socket/Manager/OBSHandlerTypeManager.cs @@ -2,14 +2,14 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using CommonSocketLibrary.Socket.Manager; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; namespace TwitchChatTTS.OBS.Socket.Manager { public class OBSHandlerTypeManager : WebSocketHandlerTypeManager { public OBSHandlerTypeManager( - ILogger factory, + ILogger factory, [FromKeyedServices("obs")] HandlerManager handlers ) : base(factory, handlers) { diff --git a/OBS/Socket/OBSSocketClient.cs b/OBS/Socket/OBSSocketClient.cs index 5bc51e6..d5e0648 100644 --- a/OBS/Socket/OBSSocketClient.cs +++ b/OBS/Socket/OBSSocketClient.cs @@ -1,29 +1,34 @@ using CommonSocketLibrary.Common; using CommonSocketLibrary.Abstract; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; using System.Text.Json; namespace TwitchChatTTS.OBS.Socket { - public class OBSSocketClient : WebSocketClient { + public class OBSSocketClient : WebSocketClient + { private bool _live; - public bool? Live { + public bool? Live + { get => Connected ? _live : null; - set { + set + { if (value.HasValue) _live = value.Value; } } public OBSSocketClient( - ILogger logger, + ILogger logger, [FromKeyedServices("obs")] HandlerManager handlerManager, [FromKeyedServices("obs")] HandlerTypeManager typeManager - ) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() { + ) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() + { PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }) { + }) + { _live = false; } } diff --git a/Seven/Emotes.cs b/Seven/Emotes.cs index 7c0cdce..be0d68f 100644 --- a/Seven/Emotes.cs +++ b/Seven/Emotes.cs @@ -1,70 +1,42 @@ -using System.Collections.Concurrent; - namespace TwitchChatTTS.Seven { - public class EmoteCounter { - public IDictionary> Counters { get; set; } + public class EmoteDatabase + { + private readonly IDictionary _emotes; + public IDictionary Emotes { get => _emotes.AsReadOnly(); } - public EmoteCounter() { - Counters = new ConcurrentDictionary>(); + public EmoteDatabase() + { + _emotes = new Dictionary(); } - public void Add(long userId, IEnumerable emoteIds) { - foreach (var emote in emoteIds) { - if (Counters.TryGetValue(emote, out IDictionary? subcounters)) { - if (subcounters.TryGetValue(userId, out int counter)) - subcounters[userId] = counter + 1; - else - subcounters.Add(userId, 1); - } else { - Counters.Add(emote, new ConcurrentDictionary()); - Counters[emote].Add(userId, 1); - } - } - } - - public void Clear() { - Counters.Clear(); - } - - public int Get(long userId, string emoteId) { - if (Counters.TryGetValue(emoteId, out IDictionary? subcounters)) { - if (subcounters.TryGetValue(userId, out int counter)) - return counter; - } - return -1; - } - } - - public class EmoteDatabase { - private IDictionary Emotes { get; } - - public EmoteDatabase() { - Emotes = new Dictionary(); - } - - public void Add(string emoteName, string emoteId) { - if (Emotes.ContainsKey(emoteName)) - Emotes[emoteName] = emoteId; + public void Add(string emoteName, string emoteId) + { + if (_emotes.ContainsKey(emoteName)) + _emotes[emoteName] = emoteId; else - Emotes.Add(emoteName, emoteId); + _emotes.Add(emoteName, emoteId); } - public void Clear() { - Emotes.Clear(); + public void Clear() + { + _emotes.Clear(); } - public string? Get(string emoteName) { - return Emotes.TryGetValue(emoteName, out string? emoteId) ? emoteId : null; + public string? Get(string emoteName) + { + return _emotes.TryGetValue(emoteName, out string? emoteId) ? emoteId : null; } - public void Remove(string emoteName) { - if (Emotes.ContainsKey(emoteName)) - Emotes.Remove(emoteName); + public void Remove(string emoteName) + { + if (_emotes.ContainsKey(emoteName)) + _emotes.Remove(emoteName); } } - public class EmoteSet { + public class EmoteSet + { public string Id { get; set; } public string Name { get; set; } public int Flags { get; set; } @@ -75,7 +47,8 @@ namespace TwitchChatTTS.Seven public int Capacity { get; set; } } - public class Emote { + public class Emote + { public string Id { get; set; } public string Name { get; set; } public int Flags { get; set; } diff --git a/Seven/SevenApiClient.cs b/Seven/SevenApiClient.cs index 17c99af..e68070f 100644 --- a/Seven/SevenApiClient.cs +++ b/Seven/SevenApiClient.cs @@ -1,46 +1,59 @@ using System.Text.Json; using TwitchChatTTS.Helpers; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.Seven; +using TwitchChatTTS; -public class SevenApiClient { +public class SevenApiClient +{ public static readonly string API_URL = "https://7tv.io/v3"; public static readonly string WEBSOCKET_URL = "wss://events.7tv.io/v3"; private WebClientWrap Web { get; } - private ILogger Logger { get; } - private long? Id { get; } + private ILogger Logger { get; } - public SevenApiClient(ILogger logger, TwitchBotToken token) { + public SevenApiClient(ILogger logger) + { Logger = logger; - Id = long.TryParse(token?.BroadcasterId, out long id) ? id : -1; - - Web = new WebClientWrap(new JsonSerializerOptions() { + Web = new WebClientWrap(new JsonSerializerOptions() + { PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); } - public async Task GetSevenEmotes() { - if (Id == null) - throw new NullReferenceException(nameof(Id)); - - try { - var details = await Web.GetJson($"{API_URL}/users/twitch/" + Id); - if (details == null) - return null; - - var emotes = new EmoteDatabase(); - if (details.EmoteSet != null) - foreach (var emote in details.EmoteSet.Emotes) - emotes.Add(emote.Name, emote.Id); - Logger.LogInformation($"Loaded {details.EmoteSet?.Emotes.Count() ?? 0} emotes from 7tv."); - return emotes; - } catch (JsonException e) { - Logger.LogError(e, "Failed to fetch emotes from 7tv. 2"); - } catch (Exception e) { - Logger.LogError(e, "Failed to fetch emotes from 7tv."); + public async Task FetchChannelEmoteSet(string twitchId) { + try + { + var details = await Web.GetJson($"{API_URL}/users/twitch/" + twitchId); + return details?.EmoteSet; + } + catch (JsonException e) + { + Logger.Error(e, "Failed to fetch emotes from 7tv due to improper JSON."); + } + catch (Exception e) + { + Logger.Error(e, "Failed to fetch emotes from 7tv."); + } + return null; + } + + public async Task?> FetchGlobalSevenEmotes() + { + try + { + var emoteSet = await Web.GetJson($"{API_URL}/emote-sets/6353512c802a0e34bac96dd2"); + return emoteSet?.Emotes; + } + catch (JsonException e) + { + Logger.Error(e, "Failed to fetch emotes from 7tv due to improper JSON."); + } + catch (Exception e) + { + Logger.Error(e, "Failed to fetch emotes from 7tv."); } return null; } diff --git a/Seven/Socket/Handlers/DispatchHandler.cs b/Seven/Socket/Handlers/DispatchHandler.cs index e36cfb1..3ad91fb 100644 --- a/Seven/Socket/Handlers/DispatchHandler.cs +++ b/Seven/Socket/Handlers/DispatchHandler.cs @@ -2,7 +2,7 @@ using System.Text.Json; using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.Seven.Socket.Data; namespace TwitchChatTTS.Seven.Socket.Handlers @@ -11,9 +11,11 @@ namespace TwitchChatTTS.Seven.Socket.Handlers { private ILogger Logger { get; } private EmoteDatabase Emotes { get; } + private object _lock = new object(); public int OperationCode { get; set; } = 0; - public DispatchHandler(ILogger logger, EmoteDatabase emotes) { + public DispatchHandler(ILogger logger, EmoteDatabase emotes) + { Logger = logger; Emotes = emotes; } @@ -22,33 +24,82 @@ namespace TwitchChatTTS.Seven.Socket.Handlers { if (message is not DispatchMessage obj || obj == null) return; - + ApplyChanges(obj?.Body?.Pulled, cf => cf.OldValue, true); ApplyChanges(obj?.Body?.Pushed, cf => cf.Value, false); + ApplyChanges(obj?.Body?.Removed, cf => cf.OldValue, true); + ApplyChanges(obj?.Body?.Updated, cf => cf.OldValue, false, cf => cf.Value); } - private void ApplyChanges(IEnumerable? fields, Func getter, bool removing) { - if (fields == null) + private void ApplyChanges(IEnumerable? fields, Func getter, bool removing, Func? updater = null) + { + if (fields == null || !fields.Any() || removing && updater != null) return; - - foreach (var val in fields) { + + foreach (var val in fields) + { var value = getter(val); if (value == null) continue; - - var o = JsonSerializer.Deserialize(value.ToString(), new JsonSerializerOptions() { + + var o = JsonSerializer.Deserialize(value.ToString(), new JsonSerializerOptions() + { PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); + if (o == null) + continue; - if (removing) { - Emotes.Remove(o.Name); - Logger.LogInformation($"Removed 7tv emote: {o.Name} (id: {o.Id})"); - } else { - Emotes.Add(o.Name, o.Id); - Logger.LogInformation($"Added 7tv emote: {o.Name} (id: {o.Id})"); + lock (_lock) + { + if (removing) + { + RemoveEmoteById(o.Id); + Logger.Information($"Removed 7tv emote: {o.Name} (id: {o.Id})"); + } + else if (updater != null) + { + RemoveEmoteById(o.Id); + var update = updater(val); + + var u = JsonSerializer.Deserialize(update.ToString(), new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = false, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }); + + if (u != null) + { + Emotes.Add(u.Name, u.Id); + Logger.Information($"Updated 7tv emote: from '{o.Name}' to '{u.Name}' (id: {u.Id})"); + } + else + { + Logger.Warning("Failed to update 7tv emote."); + } + } + else + { + Emotes.Add(o.Name, o.Id); + Logger.Information($"Added 7tv emote: {o.Name} (id: {o.Id})"); + } } } } + + private void RemoveEmoteById(string id) + { + string? key = null; + foreach (var e in Emotes.Emotes) + { + if (e.Value == id) + { + key = e.Key; + break; + } + } + if (key != null) + Emotes.Remove(key); + } } } \ No newline at end of file diff --git a/Seven/Socket/Handlers/EndOfStreamHandler.cs b/Seven/Socket/Handlers/EndOfStreamHandler.cs index 841a35d..4ba614b 100644 --- a/Seven/Socket/Handlers/EndOfStreamHandler.cs +++ b/Seven/Socket/Handlers/EndOfStreamHandler.cs @@ -1,7 +1,7 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.Seven.Socket.Context; using TwitchChatTTS.Seven.Socket.Data; @@ -10,17 +10,18 @@ namespace TwitchChatTTS.Seven.Socket.Handlers public class EndOfStreamHandler : IWebSocketHandler { private ILogger Logger { get; } - private Configuration Configuration { get; } + private User User { get; } private IServiceProvider ServiceProvider { get; } private string[] ErrorCodes { get; } private int[] ReconnectDelay { get; } public int OperationCode { get; set; } = 7; - - public EndOfStreamHandler(ILogger logger, Configuration configuration, IServiceProvider serviceProvider) { + + public EndOfStreamHandler(ILogger logger, User user, IServiceProvider serviceProvider) + { Logger = logger; - Configuration = configuration; + User = user; ServiceProvider = serviceProvider; ErrorCodes = [ @@ -59,37 +60,40 @@ namespace TwitchChatTTS.Seven.Socket.Handlers { if (message is not EndOfStreamMessage obj || obj == null) return; - + var code = obj.Code - 4000; if (code >= 0 && code < ErrorCodes.Length) - Logger.LogWarning($"Received end of stream message (reason: {ErrorCodes[code]}, code: {obj.Code}, message: {obj.Message})."); + Logger.Warning($"Received end of stream message (reason: {ErrorCodes[code]}, code: {obj.Code}, message: {obj.Message})."); else - Logger.LogWarning($"Received end of stream message (code: {obj.Code}, message: {obj.Message})."); - + Logger.Warning($"Received end of stream message (code: {obj.Code}, message: {obj.Message})."); + await sender.DisconnectAsync(); - if (code >= 0 && code < ReconnectDelay.Length && ReconnectDelay[code] < 0) { - Logger.LogError($"7tv client will remain disconnected due to a bad client implementation."); + if (code >= 0 && code < ReconnectDelay.Length && ReconnectDelay[code] < 0) + { + Logger.Error($"7tv client will remain disconnected due to a bad client implementation."); return; } - if (string.IsNullOrWhiteSpace(Configuration.Seven?.UserId)) + if (string.IsNullOrWhiteSpace(User.SevenEmoteSetId)) return; var context = ServiceProvider.GetRequiredService(); await Task.Delay(ReconnectDelay[code]); - //var base_url = "@" + string.Join(",", Configuration.Seven.SevenId.Select(sub => sub.Type + "<" + string.Join(",", sub.Condition?.Select(e => e.Key + "=" + e.Value) ?? new string[0]) + ">")); - var base_url = $"@emote_set.*"; + var base_url = $"@emote_set.*"; string url = $"{SevenApiClient.WEBSOCKET_URL}{base_url}"; - Logger.LogDebug($"7tv websocket reconnecting to {url}."); + Logger.Debug($"7tv websocket reconnecting to {url}."); await sender.ConnectAsync(url); - if (context.SessionId != null) { + if (context.SessionId != null) + { await sender.Send(34, new ResumeMessage() { SessionId = context.SessionId }); - Logger.LogInformation("Resumed connection to 7tv websocket."); - } else { - Logger.LogDebug("7tv websocket session id not available."); + Logger.Information("Resumed connection to 7tv websocket."); + } + else + { + Logger.Information("Resumed connection to 7tv websocket on a different session."); } } } diff --git a/Seven/Socket/Handlers/ErrorHandler.cs b/Seven/Socket/Handlers/ErrorHandler.cs index 394ab40..47f18d9 100644 --- a/Seven/Socket/Handlers/ErrorHandler.cs +++ b/Seven/Socket/Handlers/ErrorHandler.cs @@ -1,6 +1,6 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.Seven.Socket.Data; namespace TwitchChatTTS.Seven.Socket.Handlers @@ -10,7 +10,8 @@ namespace TwitchChatTTS.Seven.Socket.Handlers private ILogger Logger { get; } public int OperationCode { get; set; } = 6; - public ErrorHandler(ILogger logger) { + public ErrorHandler(ILogger logger) + { Logger = logger; } diff --git a/Seven/Socket/Handlers/ReconnectHandler.cs b/Seven/Socket/Handlers/ReconnectHandler.cs index 4742ad9..8255204 100644 --- a/Seven/Socket/Handlers/ReconnectHandler.cs +++ b/Seven/Socket/Handlers/ReconnectHandler.cs @@ -1,6 +1,6 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.Seven.Socket.Data; namespace TwitchChatTTS.Seven.Socket.Handlers @@ -10,7 +10,8 @@ namespace TwitchChatTTS.Seven.Socket.Handlers private ILogger Logger { get; } public int OperationCode { get; set; } = 4; - public ReconnectHandler(ILogger logger) { + public ReconnectHandler(ILogger logger) + { Logger = logger; } @@ -19,7 +20,7 @@ namespace TwitchChatTTS.Seven.Socket.Handlers if (message is not ReconnectMessage obj || obj == null) return; - Logger.LogInformation($"7tv server wants us to reconnect (reason: {obj.Reason})."); + Logger.Information($"7tv server wants us to reconnect (reason: {obj.Reason})."); } } } \ No newline at end of file diff --git a/Seven/Socket/Handlers/SevenHelloHandler.cs b/Seven/Socket/Handlers/SevenHelloHandler.cs index 811c297..87447b3 100644 --- a/Seven/Socket/Handlers/SevenHelloHandler.cs +++ b/Seven/Socket/Handlers/SevenHelloHandler.cs @@ -1,6 +1,6 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.Seven.Socket.Data; namespace TwitchChatTTS.Seven.Socket.Handlers @@ -11,7 +11,8 @@ namespace TwitchChatTTS.Seven.Socket.Handlers private Configuration Configuration { get; } public int OperationCode { get; set; } = 1; - public SevenHelloHandler(ILogger logger, Configuration configuration) { + public SevenHelloHandler(ILogger logger, Configuration configuration) + { Logger = logger; Configuration = configuration; } @@ -23,10 +24,10 @@ namespace TwitchChatTTS.Seven.Socket.Handlers if (sender is not SevenSocketClient seven || seven == null) return; - + seven.Connected = true; seven.ConnectionDetails = obj; - Logger.LogInformation("Connected to 7tv websockets."); + Logger.Information("Connected to 7tv websockets."); } } } \ No newline at end of file diff --git a/Seven/Socket/Managers/SevenHandlerManager.cs b/Seven/Socket/Managers/SevenHandlerManager.cs index 8000bb4..2601e63 100644 --- a/Seven/Socket/Managers/SevenHandlerManager.cs +++ b/Seven/Socket/Managers/SevenHandlerManager.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Serilog; using CommonSocketLibrary.Socket.Manager; using CommonSocketLibrary.Common; using Microsoft.Extensions.DependencyInjection; @@ -7,28 +7,34 @@ namespace TwitchChatTTS.Seven.Socket.Managers { public class SevenHandlerManager : WebSocketHandlerManager { - public SevenHandlerManager(ILogger logger, IServiceProvider provider) : base(logger) { - try { + public SevenHandlerManager(ILogger logger, IServiceProvider provider) : base(logger) + { + try + { var basetype = typeof(IWebSocketHandler); var assembly = GetType().Assembly; var types = assembly.GetTypes().Where(t => t.IsClass && basetype.IsAssignableFrom(t) && t.AssemblyQualifiedName?.Contains(".Seven.") == true); - foreach (var type in types) { + foreach (var type in types) + { var key = "7tv-" + type.Name.Replace("Handlers", "Hand#lers") .Replace("Handler", "") .Replace("Hand#lers", "Handlers") .ToLower(); var handler = provider.GetKeyedService(key); - if (handler == null) { - logger.LogError("Failed to find 7tv websocket handler: " + type.AssemblyQualifiedName); + if (handler == null) + { + logger.Error("Failed to find 7tv websocket handler: " + type.AssemblyQualifiedName); continue; } - - Logger.LogDebug($"Linked type {type.AssemblyQualifiedName} to 7tv websocket handler {handler.GetType().AssemblyQualifiedName}."); + + Logger.Debug($"Linked type {type.AssemblyQualifiedName} to 7tv websocket handler {handler.GetType().AssemblyQualifiedName}."); Add(handler); } - } catch (Exception e) { - Logger.LogError(e, "Failed to load 7tv websocket handler types."); + } + catch (Exception e) + { + Logger.Error(e, "Failed to load 7tv websocket handler types."); } } } diff --git a/Seven/Socket/Managers/SevenHandlerTypeManager.cs b/Seven/Socket/Managers/SevenHandlerTypeManager.cs index e99bdc6..b2430ce 100644 --- a/Seven/Socket/Managers/SevenHandlerTypeManager.cs +++ b/Seven/Socket/Managers/SevenHandlerTypeManager.cs @@ -2,14 +2,14 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using CommonSocketLibrary.Socket.Manager; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; namespace TwitchChatTTS.Seven.Socket.Managers { public class SevenHandlerTypeManager : WebSocketHandlerTypeManager { public SevenHandlerTypeManager( - ILogger factory, + ILogger factory, [FromKeyedServices("7tv")] HandlerManager handlers ) : base(factory, handlers) diff --git a/Seven/Socket/SevenSocketClient.cs b/Seven/Socket/SevenSocketClient.cs index e350219..dc54435 100644 --- a/Seven/Socket/SevenSocketClient.cs +++ b/Seven/Socket/SevenSocketClient.cs @@ -1,23 +1,26 @@ using CommonSocketLibrary.Common; using CommonSocketLibrary.Abstract; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS.Seven.Socket.Data; using System.Text.Json; namespace TwitchChatTTS.Seven.Socket { - public class SevenSocketClient : WebSocketClient { + public class SevenSocketClient : WebSocketClient + { public SevenHelloMessage? ConnectionDetails { get; set; } public SevenSocketClient( - ILogger logger, + ILogger logger, [FromKeyedServices("7tv")] HandlerManager handlerManager, [FromKeyedServices("7tv")] HandlerTypeManager typeManager - ) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() { + ) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() + { PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower - }) { + }) + { ConnectionDetails = null; } } diff --git a/Seven/UserDetails.cs b/Seven/UserDetails.cs index 9704930..40c104e 100644 --- a/Seven/UserDetails.cs +++ b/Seven/UserDetails.cs @@ -8,5 +8,12 @@ namespace TwitchChatTTS.Seven public int EmoteCapacity { get; set; } public int? EmoteSetId { get; set; } public EmoteSet EmoteSet { get; set; } + public SevenUser User { get; set; } + } + + public class SevenUser + { + public string Id { get; set; } + public string Username { get; set; } } } \ No newline at end of file diff --git a/Startup.cs b/Startup.cs index 8162b8f..fed9c4d 100644 --- a/Startup.cs +++ b/Startup.cs @@ -7,7 +7,6 @@ using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; -using Microsoft.Extensions.Logging; using TwitchChatTTS.Seven.Socket; using TwitchChatTTS.OBS.Socket.Handlers; using TwitchChatTTS.Seven.Socket.Handlers; @@ -26,6 +25,8 @@ using TwitchChatTTS.Hermes.Socket.Managers; using TwitchChatTTS.Chat.Commands.Parameters; using TwitchChatTTS.Chat.Commands; using System.Text.Json; +using Serilog; +using Serilog.Events; // dotnet publish -r linux-x64 -p:PublishSingleFile=true --self-contained true // dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained true @@ -41,18 +42,31 @@ var deserializer = new DeserializerBuilder() var configContent = File.ReadAllText("tts.config.yml"); var configuration = deserializer.Deserialize(configContent); var redeemKeys = configuration.Twitch?.Redeems?.Keys; -if (redeemKeys != null) { - foreach (var key in redeemKeys) { - if (key != key.ToLower() && configuration.Twitch?.Redeems != null) +if (redeemKeys != null && redeemKeys.Any()) +{ + foreach (var key in redeemKeys) + { + if (key != key.ToLower()) configuration.Twitch.Redeems.Add(key.ToLower(), configuration.Twitch.Redeems[key]); } } s.AddSingleton(configuration); -s.AddLogging(); +var logger = new LoggerConfiguration() + #if DEBUG + .MinimumLevel.Debug() + #else + .MinimumLevel.Information() + #endif + .WriteTo.File("logs/log.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7) + .WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information) + .CreateLogger(); + +s.AddSerilog(logger); s.AddSingleton(new User()); -s.AddSingleton(new JsonSerializerOptions() { +s.AddSingleton(new JsonSerializerOptions() +{ PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); @@ -65,46 +79,38 @@ s.AddKeyedSingleton("command-skip"); s.AddKeyedSingleton("command-voice"); s.AddKeyedSingleton("command-addttsvoice"); s.AddKeyedSingleton("command-removettsvoice"); +s.AddKeyedSingleton("command-refreshttsdata"); +s.AddKeyedSingleton("command-obs"); +s.AddKeyedSingleton("command-tts"); +s.AddKeyedSingleton("command-version"); s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); -s.AddSingleton(); -s.AddSingleton(sp => { - var hermes = sp.GetRequiredService(); - var task = hermes.FetchTwitchBotToken(); - task.Wait(); - return task.Result; -}); +s.AddSingleton(); +s.AddSingleton(new TwitchBotAuth()); s.AddTransient(); s.AddTransient(); s.AddTransient(); s.AddSingleton(); s.AddSingleton(); -s.AddSingleton(sp => { - var api = sp.GetRequiredService(); - var task = api.GetSevenEmotes(); - task.Wait(); - return task.Result; -}); -s.AddSingleton(sp => { - if (!string.IsNullOrWhiteSpace(configuration.Emotes?.CounterFilePath) && File.Exists(configuration.Emotes.CounterFilePath.Trim())) - return deserializer.Deserialize(File.ReadAllText(configuration.Emotes.CounterFilePath.Trim())); - return new EmoteCounter(); -}); +s.AddSingleton(new EmoteDatabase()); // OBS websocket s.AddSingleton(sp => - new HelloContext() { + new HelloContext() + { Host = string.IsNullOrWhiteSpace(configuration.Obs?.Host) ? null : configuration.Obs.Host.Trim(), Port = configuration.Obs?.Port, Password = string.IsNullOrWhiteSpace(configuration.Obs?.Password) ? null : configuration.Obs.Password.Trim() } ); +s.AddSingleton(); s.AddKeyedSingleton("obs-hello"); s.AddKeyedSingleton("obs-identified"); s.AddKeyedSingleton("obs-requestresponse"); +s.AddKeyedSingleton("obs-requestbatchresponse"); s.AddKeyedSingleton("obs-eventmessage"); s.AddKeyedSingleton, OBSHandlerManager>("obs"); @@ -112,15 +118,18 @@ s.AddKeyedSingleton, OBSH s.AddKeyedSingleton, OBSSocketClient>("obs"); // 7tv websocket -s.AddTransient(sp => { - var logger = sp.GetRequiredService>(); +s.AddTransient(sp => +{ + var logger = sp.GetRequiredService(); var client = sp.GetRequiredKeyedService>("7tv") as SevenSocketClient; - if (client == null) { - logger.LogError("7tv client == null."); + if (client == null) + { + logger.Error("7tv client == null."); return new ReconnectContext() { SessionId = null }; } - if (client.ConnectionDetails == null) { - logger.LogError("Connection details in 7tv client == null."); + if (client.ConnectionDetails == null) + { + logger.Error("Connection details in 7tv client == null."); return new ReconnectContext() { SessionId = null }; } return new ReconnectContext() { SessionId = client.ConnectionDetails.SessionId }; @@ -147,6 +156,5 @@ s.AddKeyedSingleton, Herm s.AddKeyedSingleton, HermesSocketClient>("hermes"); s.AddHostedService(); - using IHost host = builder.Build(); await host.RunAsync(); \ No newline at end of file diff --git a/TTS.cs b/TTS.cs index cc77dc3..8bfce1a 100644 --- a/TTS.cs +++ b/TTS.cs @@ -5,263 +5,341 @@ using CommonSocketLibrary.Common; using HermesSocketLibrary.Socket.Data; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; +using Serilog; using NAudio.Wave.SampleProviders; using TwitchChatTTS.Hermes.Socket; +using TwitchChatTTS.Seven; using TwitchLib.Client.Events; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; namespace TwitchChatTTS { public class TTS : IHostedService { + public const int MAJOR_VERSION = 3; + public const int MINOR_VERSION = 3; + private readonly ILogger _logger; private readonly Configuration _configuration; private readonly TTSPlayer _player; private readonly IServiceProvider _serviceProvider; - public TTS(ILogger logger, Configuration configuration, TTSPlayer player, IServiceProvider serviceProvider) { + public TTS(ILogger logger, Configuration configuration, TTSPlayer player, IServiceProvider serviceProvider) + { _logger = logger; _configuration = configuration; _player = player; _serviceProvider = serviceProvider; } - public async Task StartAsync(CancellationToken cancellationToken) { + public async Task StartAsync(CancellationToken cancellationToken) + { Console.Title = "TTS - Twitch Chat"; - + var user = _serviceProvider.GetRequiredService(); - var hermes = await InitializeHermes(); - - var hermesAccount = await hermes.FetchHermesAccountDetails(); - user.HermesUserId = hermesAccount.Id; - user.TwitchUsername = hermesAccount.Username; + var hermes = _serviceProvider.GetRequiredService(); + var seven = _serviceProvider.GetRequiredService(); - var twitchBotToken = await hermes.FetchTwitchBotToken(); - user.TwitchUserId = long.Parse(twitchBotToken.BroadcasterId); - _logger.LogInformation($"Username: {user.TwitchUsername} (id: {user.TwitchUserId})"); - - user.DefaultTTSVoice = await hermes.FetchTTSDefaultVoice(); - _logger.LogInformation("Default Voice: " + user.DefaultTTSVoice); - - var wordFilters = await hermes.FetchTTSWordFilters(); - user.RegexFilters = wordFilters.ToList(); - _logger.LogInformation($"{user.RegexFilters.Count()} TTS word filters."); - - var usernameFilters = await hermes.FetchTTSUsernameFilters(); - user.ChatterFilters = usernameFilters.ToDictionary(e => e.Username, e => e); - _logger.LogInformation($"{user.ChatterFilters.Where(f => f.Value.Tag == "blacklisted").Count()} username(s) have been blocked."); - _logger.LogInformation($"{user.ChatterFilters.Where(f => f.Value.Tag == "priority").Count()} user(s) have been prioritized."); - - var twitchapiclient = await InitializeTwitchApiClient(user.TwitchUsername); - - await InitializeHermesWebsocket(user); - await InitializeSevenTv(); - await InitializeObs(); - - try { - AudioPlaybackEngine.Instance.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) => { - if (e.SampleProvider == _player.Playing) { - _player.Playing = null; - } - }); - - Task.Run(async () => { - while (true) { - try { - if (cancellationToken.IsCancellationRequested) { - _logger.LogWarning("TTS Buffer - Cancellation token was canceled."); - return; - } - - var m = _player.ReceiveBuffer(); - if (m == null) { - await Task.Delay(200); - continue; - } - - string url = $"https://api.streamelements.com/kappa/v2/speech?voice={m.Voice}&text={HttpUtility.UrlEncode(m.Message)}"; - var sound = new NetworkWavSound(url); - var provider = new CachedWavProvider(sound); - var data = AudioPlaybackEngine.Instance.ConvertSound(provider); - var resampled = new WdlResamplingSampleProvider(data, AudioPlaybackEngine.Instance.SampleRate); - _logger.LogDebug("Fetched TTS audio data."); - - m.Audio = resampled; - _player.Ready(m); - } catch (COMException e) { - _logger.LogError(e, "Failed to send request for TTS (HResult: " + e.HResult + ")."); - } catch (Exception e) { - _logger.LogError(e, "Failed to send request for TTS."); - } - } - }); - - Task.Run(async () => { - while (true) { - try { - if (cancellationToken.IsCancellationRequested) { - _logger.LogWarning("TTS Queue - Cancellation token was canceled."); - return; - } - while (_player.IsEmpty() || _player.Playing != null) { - await Task.Delay(200); - continue; - } - var m = _player.ReceiveReady(); - if (m == null) { - continue; - } - - if (!string.IsNullOrWhiteSpace(m.File) && File.Exists(m.File)) { - _logger.LogInformation("Playing message: " + m.File); - AudioPlaybackEngine.Instance.PlaySound(m.File); - continue; - } - - _logger.LogInformation("Playing message: " + m.Message); - _player.Playing = m.Audio; - if (m.Audio != null) - AudioPlaybackEngine.Instance.AddMixerInput(m.Audio); - } catch (Exception e) { - _logger.LogError(e, "Failed to play a TTS audio message"); - } - } - }); - - StartSavingEmoteCounter(); - - _logger.LogInformation("Twitch API client connecting..."); - await twitchapiclient.Connect(); - } catch (Exception e) { - _logger.LogError(e, "Failed to initialize."); + var hermesVersion = await hermes.GetTTSVersion(); + if (hermesVersion.MajorVersion > TTS.MAJOR_VERSION || hermesVersion.MajorVersion == TTS.MAJOR_VERSION && hermesVersion.MinorVersion > TTS.MINOR_VERSION) + { + _logger.Information($"A new update for TTS is avaiable! Version {hermesVersion.MajorVersion}.{hermesVersion.MinorVersion} is available at {hermesVersion.Download}"); + var changes = hermesVersion.Changelog.Split("\n"); + if (changes != null && changes.Any()) + _logger.Information("Changelogs:\n - " + string.Join("\n - ", changes) + "\n\n"); + await Task.Delay(15 * 1000); } - Console.ReadLine(); + + try + { + await FetchUserData(user, hermes, seven); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to initialize properly."); + await Task.Delay(30 * 1000); + } + + var twitchapiclient = await InitializeTwitchApiClient(user.TwitchUsername, user.TwitchUserId.ToString()); + if (twitchapiclient == null) + { + await Task.Delay(30 * 1000); + return; + } + + var emoteSet = await seven.FetchChannelEmoteSet(user.TwitchUserId.ToString()); + user.SevenEmoteSetId = emoteSet?.Id; + + await InitializeEmotes(seven, emoteSet); + await InitializeHermesWebsocket(); + await InitializeSevenTv(emoteSet.Id); + await InitializeObs(); + + AudioPlaybackEngine.Instance.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) => + { + if (e.SampleProvider == _player.Playing) + { + _player.Playing = null; + } + }); + + Task.Run(async () => + { + while (true) + { + try + { + if (cancellationToken.IsCancellationRequested) + { + _logger.Warning("TTS Buffer - Cancellation token was canceled."); + return; + } + + var m = _player.ReceiveBuffer(); + if (m == null) + { + await Task.Delay(200); + continue; + } + + string url = $"https://api.streamelements.com/kappa/v2/speech?voice={m.Voice}&text={HttpUtility.UrlEncode(m.Message)}"; + var sound = new NetworkWavSound(url); + var provider = new CachedWavProvider(sound); + var data = AudioPlaybackEngine.Instance.ConvertSound(provider); + var resampled = new WdlResamplingSampleProvider(data, AudioPlaybackEngine.Instance.SampleRate); + _logger.Debug("Fetched TTS audio data."); + + m.Audio = resampled; + _player.Ready(m); + } + catch (COMException e) + { + _logger.Error(e, "Failed to send request for TTS (HResult: " + e.HResult + ")."); + } + catch (Exception e) + { + _logger.Error(e, "Failed to send request for TTS."); + } + } + }); + + Task.Run(async () => + { + while (true) + { + try + { + if (cancellationToken.IsCancellationRequested) + { + _logger.Warning("TTS Queue - Cancellation token was canceled."); + return; + } + while (_player.IsEmpty() || _player.Playing != null) + { + await Task.Delay(200); + continue; + } + var m = _player.ReceiveReady(); + if (m == null) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(m.File) && File.Exists(m.File)) + { + _logger.Information("Playing message: " + m.File); + AudioPlaybackEngine.Instance.PlaySound(m.File); + continue; + } + + _logger.Information("Playing message: " + m.Message); + _player.Playing = m.Audio; + if (m.Audio != null) + AudioPlaybackEngine.Instance.AddMixerInput(m.Audio); + } + catch (Exception e) + { + _logger.Error(e, "Failed to play a TTS audio message"); + } + } + }); + + _logger.Information("Twitch websocket client connecting..."); + await twitchapiclient.Connect(); } public async Task StopAsync(CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) - _logger.LogWarning("Application has stopped due to cancellation token."); + _logger.Warning("Application has stopped due to cancellation token."); else - _logger.LogWarning("Application has stopped."); + _logger.Warning("Application has stopped."); } - private async Task InitializeHermesWebsocket(User user) { - if (_configuration.Hermes?.Token == null) { - _logger.LogDebug("No api token given to hermes. Skipping hermes websockets."); - return; - } + private async Task FetchUserData(User user, HermesApiClient hermes, SevenApiClient seven) + { + var hermesAccount = await hermes.FetchHermesAccountDetails(); + if (hermesAccount == null) + throw new Exception("Cannot connect to Hermes. Ensure your token is valid."); - try { - _logger.LogInformation("Initializing hermes websocket client."); + user.HermesUserId = hermesAccount.Id; + user.HermesUsername = hermesAccount.Username; + user.TwitchUsername = hermesAccount.Username; + + var twitchBotToken = await hermes.FetchTwitchBotToken(); + user.TwitchUserId = long.Parse(twitchBotToken.BroadcasterId); + _logger.Information($"Username: {user.TwitchUsername} (id: {user.TwitchUserId})"); + + user.DefaultTTSVoice = await hermes.FetchTTSDefaultVoice(); + _logger.Information("Default Voice: " + user.DefaultTTSVoice); + + var wordFilters = await hermes.FetchTTSWordFilters(); + user.RegexFilters = wordFilters.ToList(); + _logger.Information($"{user.RegexFilters.Count()} TTS word filters."); + + var usernameFilters = await hermes.FetchTTSUsernameFilters(); + user.ChatterFilters = usernameFilters.ToDictionary(e => e.Username, e => e); + _logger.Information($"{user.ChatterFilters.Where(f => f.Value.Tag == "blacklisted").Count()} username(s) have been blocked."); + _logger.Information($"{user.ChatterFilters.Where(f => f.Value.Tag == "priority").Count()} user(s) have been prioritized."); + + var voicesSelected = await hermes.FetchTTSChatterSelectedVoices(); + user.VoicesSelected = voicesSelected.ToDictionary(s => s.ChatterId, s => s.Voice); + _logger.Information($"{user.VoicesSelected.Count} TTS voices have been selected for specific chatters."); + + var voicesEnabled = await hermes.FetchTTSEnabledVoices(); + if (voicesEnabled == null || !voicesEnabled.Any()) + user.VoicesEnabled = new HashSet(new string[] { "Brian" }); + else + user.VoicesEnabled = new HashSet(voicesEnabled.Select(v => v)); + _logger.Information($"{user.VoicesEnabled.Count} TTS voices have been enabled."); + + var defaultedChatters = voicesSelected.Where(item => item.Voice == null || !user.VoicesEnabled.Contains(item.Voice)); + _logger.Information($"{defaultedChatters.Count()} chatters will have their TTS voice set to default due to having selected a disabled TTS voice."); + } + + private async Task InitializeHermesWebsocket() + { + try + { + _logger.Information("Initializing hermes websocket client."); var hermesClient = _serviceProvider.GetRequiredKeyedService>("hermes") as HermesSocketClient; var url = "wss://hermes-ws.goblincaves.com"; - _logger.LogDebug($"Attempting to connect to {url}"); + _logger.Debug($"Attempting to connect to {url}"); await hermesClient.ConnectAsync(url); - await hermesClient.Send(1, new HermesLoginMessage() { + hermesClient.Connected = true; + await hermesClient.Send(1, new HermesLoginMessage() + { ApiKey = _configuration.Hermes.Token }); - - while (hermesClient.UserId == null) - await Task.Delay(TimeSpan.FromMilliseconds(200)); - - await hermesClient.Send(3, new RequestMessage() { - Type = "get_tts_voices", - Data = null - }); - var token = _serviceProvider.GetRequiredService(); - await hermesClient.Send(3, new RequestMessage() { - Type = "get_tts_users", - Data = new Dictionary() { { "@broadcaster", token.BroadcasterId } } - }); - } catch (Exception) { - _logger.LogWarning("Connecting to hermes failed. Skipping hermes websockets."); + } + catch (Exception) + { + _logger.Warning("Connecting to hermes failed. Skipping hermes websockets."); } } - private async Task InitializeSevenTv() { - if (_configuration.Seven?.UserId == null) { - _logger.LogDebug("No user id given to 7tv. Skipping 7tv websockets."); - return; - } - - try { - _logger.LogInformation("Initializing 7tv websocket client."); + private async Task InitializeSevenTv(string emoteSetId) + { + try + { + _logger.Information("Initializing 7tv websocket client."); var sevenClient = _serviceProvider.GetRequiredKeyedService>("7tv"); - //var base_url = "@" + string.Join(",", Configuration.Seven.InitialSubscriptions.Select(sub => sub.Type + "<" + string.Join(",", sub.Condition?.Select(e => e.Key + "=" + e.Value) ?? new string[0]) + ">")); - var url = $"{SevenApiClient.WEBSOCKET_URL}@emote_set.*"; - _logger.LogDebug($"Attempting to connect to {url}"); + if (string.IsNullOrWhiteSpace(emoteSetId)) + { + _logger.Warning("Could not fetch 7tv emotes."); + return; + } + var url = $"{SevenApiClient.WEBSOCKET_URL}@emote_set.*"; + _logger.Debug($"Attempting to connect to {url}"); await sevenClient.ConnectAsync($"{url}"); - } catch (Exception) { - _logger.LogWarning("Connecting to 7tv failed. Skipping 7tv websockets."); + } + catch (Exception) + { + _logger.Warning("Connecting to 7tv failed. Skipping 7tv websockets."); } } - private async Task InitializeObs() { - if (_configuration.Obs == null || string.IsNullOrWhiteSpace(_configuration.Obs.Host) || !_configuration.Obs.Port.HasValue || _configuration.Obs.Port.Value < 0) { - _logger.LogDebug("Lacking obs connection info. Skipping obs websockets."); + private async Task InitializeObs() + { + if (_configuration.Obs == null || string.IsNullOrWhiteSpace(_configuration.Obs.Host) || !_configuration.Obs.Port.HasValue || _configuration.Obs.Port.Value < 0) + { + _logger.Warning("Lacking OBS connection info. Skipping OBS websockets."); return; } - try { - _logger.LogInformation("Initializing obs websocket client."); + try + { var obsClient = _serviceProvider.GetRequiredKeyedService>("obs"); var url = $"ws://{_configuration.Obs.Host.Trim()}:{_configuration.Obs.Port}"; - _logger.LogDebug($"Attempting to connect to {url}"); + _logger.Debug($"Initializing OBS websocket client. Attempting to connect to {url}"); await obsClient.ConnectAsync(url); - } catch (Exception) { - _logger.LogWarning("Connecting to obs failed. Skipping obs websockets."); + } + catch (Exception) + { + _logger.Warning("Connecting to obs failed. Skipping obs websockets."); } } - private async Task InitializeHermes() { - // Fetch id and username based on api key given. - _logger.LogInformation("Initializing hermes client."); - var hermes = _serviceProvider.GetRequiredService(); - await hermes.FetchHermesAccountDetails(); - return hermes; - } - - private async Task InitializeTwitchApiClient(string username) { - _logger.LogInformation("Initializing twitch client."); + private async Task InitializeTwitchApiClient(string username, string broadcasterId) + { + _logger.Debug("Initializing twitch client."); var twitchapiclient = _serviceProvider.GetRequiredService(); - await twitchapiclient.Authorize(); + if (!await twitchapiclient.Authorize(broadcasterId)) + { + _logger.Error("Cannot connect to Twitch API."); + return null; + } var channels = _configuration.Twitch.Channels ?? [username]; - _logger.LogInformation("Twitch channels: " + string.Join(", ", channels)); + _logger.Information("Twitch channels: " + string.Join(", ", channels)); twitchapiclient.InitializeClient(username, channels); twitchapiclient.InitializePublisher(); var handler = _serviceProvider.GetRequiredService(); - twitchapiclient.AddOnNewMessageReceived(async Task (object? s, OnMessageReceivedArgs e) => { - var result = await handler.Handle(e); + 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; + + var ws = _serviceProvider.GetRequiredKeyedService>("hermes"); + await ws.Send(8, new EmoteUsageMessage() + { + MessageId = e.ChatMessage.Id, + DateTime = DateTime.UtcNow, + BroadcasterId = result.BroadcasterId, + ChatterId = result.ChatterId, + Emotes = result.Emotes + }); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send emote usage message."); + } }); return twitchapiclient; } - - private async Task StartSavingEmoteCounter() { - Task.Run(async () => { - while (true) { - try { - await Task.Delay(TimeSpan.FromSeconds(300)); - var serializer = new SerializerBuilder() - .WithNamingConvention(HyphenatedNamingConvention.Instance) - .Build(); - - var chathandler = _serviceProvider.GetRequiredService(); - using (TextWriter writer = File.CreateText(_configuration.Emotes.CounterFilePath.Trim())) - { - await writer.WriteAsync(serializer.Serialize(chathandler._emoteCounter)); - } - } catch (Exception e) { - _logger.LogError(e, "Failed to save the emote counter."); - } - } - }); + private async Task InitializeEmotes(SevenApiClient sevenapi, EmoteSet emoteSet) + { + var emotes = _serviceProvider.GetRequiredService(); + var channelEmotes = emoteSet; + var globalEmotes = await sevenapi.FetchGlobalSevenEmotes(); + + if (channelEmotes != null && channelEmotes.Emotes.Any()) + { + _logger.Information($"Loaded {channelEmotes.Emotes.Count()} 7tv channel emotes."); + foreach (var entry in channelEmotes.Emotes) + emotes.Add(entry.Name, entry.Id); + } + if (globalEmotes != null && globalEmotes.Any()) + { + _logger.Information($"Loaded {globalEmotes.Count()} 7tv global emotes."); + foreach (var entry in globalEmotes) + emotes.Add(entry.Name, entry.Id); + } } } } \ No newline at end of file diff --git a/Twitch/TwitchApiClient.cs b/Twitch/TwitchApiClient.cs index 3e2491c..3f7984a 100644 --- a/Twitch/TwitchApiClient.cs +++ b/Twitch/TwitchApiClient.cs @@ -1,6 +1,6 @@ using System.Text.Json; using TwitchChatTTS.Helpers; -using Microsoft.Extensions.Logging; +using Serilog; using TwitchChatTTS; using TwitchLib.Api.Core.Exceptions; using TwitchLib.Client.Events; @@ -14,133 +14,164 @@ using TwitchLib.PubSub.Interfaces; using TwitchLib.Client.Interfaces; using TwitchChatTTS.OBS.Socket; -public class TwitchApiClient { +public class TwitchApiClient +{ private readonly Configuration _configuration; - private readonly ILogger _logger; - private readonly TwitchBotToken _token; + private readonly ILogger _logger; + private TwitchBotAuth _token; private readonly ITwitchClient _client; private readonly ITwitchPubSub _publisher; - private readonly WebClientWrap Web; + private readonly WebClientWrap _web; private readonly IServiceProvider _serviceProvider; - private bool Initialized; + private bool _initialized; + private string _broadcasterId; public TwitchApiClient( Configuration configuration, - ILogger logger, - TwitchBotToken token, + TwitchBotAuth token, ITwitchClient twitchClient, ITwitchPubSub twitchPublisher, - IServiceProvider serviceProvider - ) { + IServiceProvider serviceProvider, + ILogger logger + ) + { _configuration = configuration; - _logger = logger; _token = token; _client = twitchClient; _publisher = twitchPublisher; _serviceProvider = serviceProvider; - Initialized = false; + _logger = logger; + _initialized = false; - Web = new WebClientWrap(new JsonSerializerOptions() { + _web = new WebClientWrap(new JsonSerializerOptions() + { PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); if (!string.IsNullOrWhiteSpace(_configuration.Hermes?.Token)) - Web.AddHeader("x-api-key", _configuration.Hermes.Token.Trim()); + _web.AddHeader("x-api-key", _configuration.Hermes.Token.Trim()); } - public async Task Authorize() { - try { - var authorize = await Web.GetJson("https://hermes.goblincaves.com/api/account/reauthorize"); - if (authorize != null && _token.BroadcasterId == authorize.BroadcasterId) { + public async Task Authorize(string broadcasterId) + { + try + { + var authorize = await _web.GetJson("https://hermes.goblincaves.com/api/account/reauthorize"); + if (authorize != null && broadcasterId == authorize.BroadcasterId) + { _token.AccessToken = authorize.AccessToken; _token.RefreshToken = authorize.RefreshToken; - _logger.LogInformation("Updated Twitch API tokens."); - } else if (authorize != null) { - _logger.LogError("Twitch API Authorization failed."); + _token.UserId = authorize.UserId; + _token.BroadcasterId = authorize.BroadcasterId; + _logger.Information("Updated Twitch API tokens."); } - } catch (HttpResponseException e) { - if (string.IsNullOrWhiteSpace(_configuration.Hermes?.Token)) - _logger.LogError("No Hermes API key found. Enter it into the configuration file."); - else - _logger.LogError("Invalid Hermes API key. Double check the token. HTTP Error Code: " + e.HttpResponse.StatusCode); - } catch (JsonException) { - } catch (Exception e) { - _logger.LogError(e, "Failed to authorize to Twitch API."); + else if (authorize != null) + { + _logger.Error("Twitch API Authorization failed: " + authorize.AccessToken + " | " + authorize.RefreshToken + " | " + authorize.UserId + " | " + authorize.BroadcasterId); + return false; + } + _broadcasterId = broadcasterId; + return true; } + catch (HttpResponseException e) + { + if (string.IsNullOrWhiteSpace(_configuration.Hermes?.Token)) + _logger.Error("No Hermes API key found. Enter it into the configuration file."); + else + _logger.Error("Invalid Hermes API key. Double check the token. HTTP Error Code: " + e.HttpResponse.StatusCode); + } + catch (JsonException) + { + } + catch (Exception e) + { + _logger.Error(e, "Failed to authorize to Twitch API."); + } + return false; } - public async Task Connect() { + public async Task Connect() + { _client.Connect(); await _publisher.ConnectAsync(); } - public void InitializeClient(string username, IEnumerable channels) { + public void InitializeClient(string username, IEnumerable channels) + { ConnectionCredentials credentials = new ConnectionCredentials(username, _token?.AccessToken); _client.Initialize(credentials, channels.Distinct().ToList()); - if (Initialized) { - _logger.LogDebug("Twitch API client has already been initialized."); + if (_initialized) + { + _logger.Debug("Twitch API client has already been initialized."); return; } - Initialized = true; + _initialized = true; - _client.OnJoinedChannel += async Task (object? s, OnJoinedChannelArgs e) => { - _logger.LogInformation("Joined channel: " + e.Channel); + _client.OnJoinedChannel += async Task (object? s, OnJoinedChannelArgs e) => + { + _logger.Information("Joined channel: " + e.Channel); }; - _client.OnConnected += async Task (object? s, OnConnectedArgs e) => { - _logger.LogInformation("-----------------------------------------------------------"); + _client.OnConnected += async Task (object? s, OnConnectedArgs e) => + { + _logger.Information("-----------------------------------------------------------"); }; - _client.OnIncorrectLogin += async Task (object? s, OnIncorrectLoginArgs e) => { - _logger.LogError(e.Exception, "Incorrect Login on Twitch API client."); + _client.OnIncorrectLogin += async Task (object? s, OnIncorrectLoginArgs e) => + { + _logger.Error(e.Exception, "Incorrect Login on Twitch API client."); - _logger.LogInformation("Attempting to re-authorize."); - await Authorize(); + _logger.Information("Attempting to re-authorize."); + await Authorize(_broadcasterId); }; - _client.OnConnectionError += async Task (object? s, OnConnectionErrorArgs e) => { - _logger.LogError("Connection Error: " + e.Error.Message + " (" + e.Error.GetType().Name + ")"); + _client.OnConnectionError += async Task (object? s, OnConnectionErrorArgs e) => + { + _logger.Error("Connection Error: " + e.Error.Message + " (" + e.Error.GetType().Name + ")"); - _logger.LogInformation("Attempting to re-authorize."); - await Authorize(); + _logger.Information("Attempting to re-authorize."); + await Authorize(_broadcasterId); }; - _client.OnError += async Task (object? s, OnErrorEventArgs e) => { - _logger.LogError(e.Exception, "Twitch API client error."); + _client.OnError += async Task (object? s, OnErrorEventArgs e) => + { + _logger.Error(e.Exception, "Twitch API client error."); }; } - public void InitializePublisher() { - _publisher.OnPubSubServiceConnected += async (s, e) => { + public void InitializePublisher() + { + _publisher.OnPubSubServiceConnected += async (s, e) => + { _publisher.ListenToChannelPoints(_token.BroadcasterId); _publisher.ListenToFollows(_token.BroadcasterId); await _publisher.SendTopicsAsync(_token.AccessToken); - _logger.LogInformation("Twitch PubSub has been connected."); + _logger.Information("Twitch PubSub has been connected."); }; - _publisher.OnFollow += (s, e) => { - var client = _serviceProvider.GetRequiredKeyedService>("obs") as OBSSocketClient; - if (_configuration.Twitch?.TtsWhenOffline != true && client?.Live == false) - return; - - _logger.LogInformation("Follow: " + e.DisplayName); - }; - - _publisher.OnChannelPointsRewardRedeemed += (s, e) => { + _publisher.OnFollow += (s, e) => + { var client = _serviceProvider.GetRequiredKeyedService>("obs") as OBSSocketClient; if (_configuration.Twitch?.TtsWhenOffline != true && client?.Live == false) return; - _logger.LogInformation($"Channel Point Reward Redeemed: {e.RewardRedeemed.Redemption.Reward.Title} (id: {e.RewardRedeemed.Redemption.Id})"); + _logger.Information("Follow: " + e.DisplayName); + }; - if (_configuration.Twitch?.Redeems == null) { - _logger.LogDebug("No redeems found in the configuration."); + _publisher.OnChannelPointsRewardRedeemed += (s, e) => + { + var client = _serviceProvider.GetRequiredKeyedService>("obs") as OBSSocketClient; + if (_configuration.Twitch?.TtsWhenOffline != true && client?.Live == false) + return; + + _logger.Information($"Channel Point Reward Redeemed [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][id: {e.RewardRedeemed.Redemption.Id}]"); + + if (_configuration.Twitch?.Redeems == null) return; - } var redeemName = e.RewardRedeemed.Redemption.Reward.Title.ToLower().Trim().Replace(" ", "-"); if (!_configuration.Twitch.Redeems.TryGetValue(redeemName, out RedeemConfiguration? redeem)) @@ -148,19 +179,28 @@ public class TwitchApiClient { if (redeem == null) return; - + // Write or append to file if needed. var outputFile = string.IsNullOrWhiteSpace(redeem.OutputFilePath) ? null : redeem.OutputFilePath.Trim(); - if (outputFile == null) { - _logger.LogDebug($"No output file was provided for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'."); - } else { + if (outputFile == null) + { + _logger.Debug($"No output file was provided for redeem [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][id: {e.RewardRedeemed.Redemption.Id}]"); + } + else + { var outputContent = string.IsNullOrWhiteSpace(redeem.OutputContent) ? null : redeem.OutputContent.Trim().Replace("%USER%", e.RewardRedeemed.Redemption.User.DisplayName).Replace("\\n", "\n"); - if (outputContent == null) { - _logger.LogWarning($"No output content was provided for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'."); - } else { - if (redeem.OutputAppend == true) { + if (outputContent == null) + { + _logger.Warning($"No output content was provided for redeem [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][id: {e.RewardRedeemed.Redemption.Id}]"); + } + else + { + if (redeem.OutputAppend == true) + { File.AppendAllText(outputFile, outputContent + "\n"); - } else { + } + else + { File.WriteAllText(outputFile, outputContent); } } @@ -168,40 +208,23 @@ public class TwitchApiClient { // Play audio file if needed. var audioFile = string.IsNullOrWhiteSpace(redeem.AudioFilePath) ? null : redeem.AudioFilePath.Trim(); - if (audioFile == null) { - _logger.LogDebug($"No audio file was provided for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'."); - return; + if (audioFile == null) + { + _logger.Debug($"No audio file was provided for redeem [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][id: {e.RewardRedeemed.Redemption.Id}]"); } - if (!File.Exists(audioFile)) { - _logger.LogWarning($"Cannot find audio file @ {audioFile} for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'."); - return; + else if (!File.Exists(audioFile)) + { + _logger.Warning($"Cannot find audio file [location: {audioFile}] for redeem [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][id: {e.RewardRedeemed.Redemption.Id}]"); + } + else + { + AudioPlaybackEngine.Instance.PlaySound(audioFile); } - - AudioPlaybackEngine.Instance.PlaySound(audioFile); }; - - /*int psConnectionFailures = 0; - publisher.OnPubSubServiceError += async (s, e) => { - Console.WriteLine("PubSub ran into a service error. Attempting to connect again."); - await Task.Delay(Math.Min(3000 + (1 << psConnectionFailures), 120000)); - var connect = await WebHelper.Get("https://hermes.goblincaves.com/api/account/reauthorize"); - if ((int) connect.StatusCode == 200 || (int) connect.StatusCode == 201) { - psConnectionFailures = 0; - } else { - psConnectionFailures++; - } - - var twitchBotData2 = await WebHelper.GetJson("https://hermes.goblincaves.com/api/token/bot"); - if (twitchBotData2 == null) { - Console.WriteLine("The API is down. Contact the owner."); - return; - } - twitchBotData.access_token = twitchBotData2.access_token; - await pubsub.ConnectAsync(); - };*/ } - public void AddOnNewMessageReceived(AsyncEventHandler handler) { + public void AddOnNewMessageReceived(AsyncEventHandler handler) + { _client.OnMessageReceived += handler; } } \ No newline at end of file diff --git a/TwitchChatTTS.csproj b/TwitchChatTTS.csproj index 83eda40..b09d161 100644 --- a/TwitchChatTTS.csproj +++ b/TwitchChatTTS.csproj @@ -10,11 +10,21 @@ - - - + + + + + + + + + + + + + diff --git a/User.cs b/User.cs index a61839d..0b171bb 100644 --- a/User.cs +++ b/User.cs @@ -1,5 +1,6 @@ +using System.Text.Json.Serialization; using System.Text.RegularExpressions; -using TwitchChatTTS.Hermes; +using HermesSocketLibrary.Requests.Messages; namespace TwitchChatTTS { @@ -7,29 +8,38 @@ namespace TwitchChatTTS { // Hermes user id public string HermesUserId { get; set; } + public string HermesUsername { get; set; } public long TwitchUserId { get; set; } public string TwitchUsername { get; set; } - + public string SevenEmoteSetId { get; set; } + public string DefaultTTSVoice { get; set; } // voice id -> voice name - public IDictionary VoicesAvailable { get; set; } + public IDictionary VoicesAvailable { get => _voicesAvailable; set { _voicesAvailable = value; WordFilterRegex = GenerateEnabledVoicesRegex(); } } // chatter/twitch id -> voice name public IDictionary VoicesSelected { get; set; } - public HashSet VoicesEnabled { get; set; } + // voice names + public HashSet VoicesEnabled { get => _voicesEnabled; set { _voicesEnabled = value; WordFilterRegex = GenerateEnabledVoicesRegex(); } } public IDictionary ChatterFilters { get; set; } - public IList RegexFilters { get; set; } + public IList RegexFilters { get; set; } + [JsonIgnore] + public Regex? WordFilterRegex { get; set; } + + private IDictionary _voicesAvailable; + private HashSet _voicesEnabled; - public User() { - + public User() + { } - public Regex? GenerateEnabledVoicesRegex() { + private Regex? GenerateEnabledVoicesRegex() + { if (VoicesAvailable == null || VoicesAvailable.Count() <= 0) return null; - var enabledVoicesString = string.Join("|", VoicesAvailable.Select(v => v.Value)); + var enabledVoicesString = string.Join("|", VoicesAvailable.Where(v => VoicesEnabled == null || !VoicesEnabled.Any() || VoicesEnabled.Contains(v.Value)).Select(v => v.Value)); return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase); } }