Cleaned code up. Added OBS & 7tv ws support. Added dependency injection. App loads from yml file.
This commit is contained in:
		
							
								
								
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,5 +1,4 @@ | ||||
| TwitchChatTTS/bin/ | ||||
| TwitchChatTTS/obj/ | ||||
| .redeems | ||||
| .token | ||||
| .twitchchannels | ||||
| appsettings.json | ||||
| tts.config.yml | ||||
| obj/ | ||||
| bin/ | ||||
|   | ||||
| @@ -1,25 +1,44 @@ | ||||
| using System.Text.RegularExpressions; | ||||
| using TwitchLib.Client.Events; | ||||
| using TwitchChatTTS.Hermes; | ||||
| using TwitchChatTTS.OBS.Socket; | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.Twitch; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using TwitchChatTTS; | ||||
| using TwitchChatTTS.Seven; | ||||
| 
 | ||||
| 
 | ||||
| public class ChatMessageHandler { | ||||
|     private ILogger<ChatMessageHandler> Logger { get; } | ||||
|     private Configuration Configuration { get; } | ||||
|     public EmoteCounter EmoteCounter { get; } | ||||
|     private EmoteDatabase Emotes { get; } | ||||
|     private TTSPlayer Player { get; } | ||||
|     public string DefaultVoice { get; set; } | ||||
|     public IEnumerable<TTSVoice> EnabledVoices { get; } | ||||
|     public Dictionary<string, TTSUsernameFilter> UsernameFilters { get; } | ||||
|     public IEnumerable<TTSWordFilter> WordFilters { get; } | ||||
|     private OBSSocketClient? Client { get; } | ||||
|     private TTSContext Context { get; } | ||||
| 
 | ||||
|     private Regex voicesRegex; | ||||
|     private Regex? voicesRegex; | ||||
|     private Regex sfxRegex; | ||||
| 
 | ||||
| 
 | ||||
|     public ChatMessageHandler(TTSPlayer player, string defaultVoice, IEnumerable<TTSVoice> enabledVoices, Dictionary<string, TTSUsernameFilter> usernameFilters, IEnumerable<TTSWordFilter> wordFilters) { | ||||
|     public ChatMessageHandler( | ||||
|         ILogger<ChatMessageHandler> logger, | ||||
|         Configuration configuration, | ||||
|         EmoteCounter emoteCounter, | ||||
|         EmoteDatabase emotes, | ||||
|         TTSPlayer player, | ||||
|         [FromKeyedServices("obs")] SocketClient<WebSocketMessage> client, | ||||
|         TTSContext context | ||||
|     ) { | ||||
|         Logger = logger; | ||||
|         Configuration = configuration; | ||||
|         EmoteCounter = emoteCounter; | ||||
|         Emotes = emotes; | ||||
|         Player = player; | ||||
|         DefaultVoice = defaultVoice; | ||||
|         EnabledVoices = enabledVoices; | ||||
|         UsernameFilters = usernameFilters; | ||||
|         WordFilters = wordFilters; | ||||
|         Client = client as OBSSocketClient; | ||||
|         Context = context; | ||||
| 
 | ||||
|         voicesRegex = GenerateEnabledVoicesRegex(); | ||||
|         sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)"); | ||||
| @@ -27,23 +46,48 @@ public class ChatMessageHandler { | ||||
| 
 | ||||
| 
 | ||||
|     public MessageResult Handle(OnMessageReceivedArgs e) { | ||||
|         if (Configuration.Twitch?.TtsWhenOffline != true && Client?.Live != true) | ||||
|             return MessageResult.Blocked; | ||||
| 
 | ||||
|         var m = e.ChatMessage; | ||||
|         var msg = e.ChatMessage.Message; | ||||
| 
 | ||||
|          | ||||
|         // Skip TTS messages | ||||
|         if ((m.IsVip || m.IsModerator || m.IsBroadcaster) && (msg.ToLower().StartsWith("!skip ") || msg.ToLower() == "!skip")) { | ||||
|             return MessageResult.Skip; | ||||
|         if (m.IsVip || m.IsModerator || m.IsBroadcaster) { | ||||
|             if (msg.ToLower().StartsWith("!skip ") || msg.ToLower() == "!skip") | ||||
|                 return MessageResult.Skip; | ||||
|              | ||||
|             if (msg.ToLower().StartsWith("!skipall ") || msg.ToLower() == "!skipall") | ||||
|                 return MessageResult.SkipAll; | ||||
|         } | ||||
| 
 | ||||
|         if (UsernameFilters.TryGetValue(m.Username, out TTSUsernameFilter filter) && filter.tag == "blacklisted") { | ||||
|         if (Context.UsernameFilters.TryGetValue(m.Username, out TTSUsernameFilter? filter) && filter.Tag == "blacklisted") { | ||||
|             Logger.LogTrace($"Blocked message by {m.Username}: {msg}"); | ||||
|             return MessageResult.Blocked; | ||||
|         } | ||||
| 
 | ||||
|         // Ensure we can send it via the web. | ||||
|         var alphanumeric = new Regex(@"[^a-zA-Z0-9!@#$%&\^*+\-_(),+':;?.,\[\]\s\\/~`]"); | ||||
|         msg = alphanumeric.Replace(msg, ""); | ||||
|         // Replace filtered words. | ||||
|         if (Context.WordFilters is not null) { | ||||
|             foreach (var wf in Context.WordFilters) { | ||||
|                 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); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Filter highly repetitive words (like emotes) from the message. | ||||
|         var emotesUsed = new HashSet<string>(); | ||||
|         var words = msg.Split(" "); | ||||
|         var wordCounter = new Dictionary<string, int>(); | ||||
|         string filteredMsg = string.Empty; | ||||
| @@ -54,30 +98,28 @@ public class ChatMessageHandler { | ||||
|                 wordCounter.Add(w, 1); | ||||
|             } | ||||
| 
 | ||||
|             if (wordCounter[w] < 5) { | ||||
|             var emoteId = Emotes?.Get(w); | ||||
|             if (emoteId != null) | ||||
|                 emotesUsed.Add("7tv-" + emoteId); | ||||
| 
 | ||||
|             if (wordCounter[w] <= 4 && (emoteId == null || emotesUsed.Count <= 4)) | ||||
|                 filteredMsg += w + " "; | ||||
|             } | ||||
|         } | ||||
|         msg = filteredMsg; | ||||
| 
 | ||||
|         foreach (var wf in WordFilters) { | ||||
|             if (wf.IsRegex) { | ||||
|                 try { | ||||
|                     var regex = new Regex(wf.search); | ||||
|                     msg = regex.Replace(msg, wf.replace); | ||||
|                     continue; | ||||
|                 } catch (Exception ex) { | ||||
|                     wf.IsRegex = false; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             msg = msg.Replace(wf.search, wf.replace); | ||||
|         } | ||||
|         // Adding twitch emotes to the counter. | ||||
|         foreach (var emote in e.ChatMessage.EmoteSet.Emotes) | ||||
|             emotesUsed.Add("twitch-" + emote.Id); | ||||
|          | ||||
|         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)))); | ||||
| 
 | ||||
|         int priority = 0; | ||||
|         if (m.IsStaff) { | ||||
|             priority = int.MinValue; | ||||
|         } else if (filter?.tag == "priority") { | ||||
|         } else if (filter?.Tag == "priority") { | ||||
|             priority = int.MinValue + 1; | ||||
|         } else if (m.IsModerator) { | ||||
|             priority = -100; | ||||
| @@ -88,12 +130,12 @@ public class ChatMessageHandler { | ||||
|         } else if (m.IsHighlighted) { | ||||
|             priority = -1; | ||||
|         } | ||||
|         priority = (int) Math.Round(Math.Min(priority, -m.SubscribedMonthCount * (m.Badges.Any(b => b.Key == "subscriber" && b.Value == "1") ? 1.2 : 1))); | ||||
|         priority = (int) Math.Round(Math.Min(priority, -m.SubscribedMonthCount * (m.Badges.Any(b => b.Key == "subscriber") ? 1.2 : 1))); | ||||
| 
 | ||||
|         var matches = voicesRegex.Matches(msg); | ||||
|         var matches = voicesRegex?.Matches(msg).ToArray() ?? new Match[0]; | ||||
|         int defaultEnd = matches.FirstOrDefault()?.Index ?? msg.Length; | ||||
|         if (defaultEnd > 0) { | ||||
|             HandlePartialMessage(priority, DefaultVoice, msg.Substring(0, defaultEnd).Trim(), e); | ||||
|             HandlePartialMessage(priority, Context.DefaultVoice, msg.Substring(0, defaultEnd).Trim(), e); | ||||
|         } | ||||
| 
 | ||||
|         foreach (Match match in matches) { | ||||
| @@ -120,7 +162,7 @@ public class ChatMessageHandler { | ||||
|         var badgesString = string.Join(", ", e.ChatMessage.Badges.Select(b => b.Key + " = " + b.Value)); | ||||
|          | ||||
|         if (parts.Length == 1) { | ||||
|             Console.WriteLine($"Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; {badgesString}"); | ||||
|             Logger.LogInformation($"Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; {badgesString}"); | ||||
|             Player.Add(new TTSMessage() { | ||||
|                 Voice = voice, | ||||
|                 Message = message, | ||||
| @@ -147,7 +189,7 @@ public class ChatMessageHandler { | ||||
|             } | ||||
| 
 | ||||
|             if (!string.IsNullOrWhiteSpace(parts[i * 2])) { | ||||
|                 Console.WriteLine($"Voice: {voice}; Priority: {priority}; Message: {parts[i * 2]}; Month: {m.SubscribedMonthCount}; {badgesString}"); | ||||
|                 Logger.LogInformation($"Voice: {voice}; Priority: {priority}; Message: {parts[i * 2]}; Month: {m.SubscribedMonthCount}; {badgesString}"); | ||||
|                 Player.Add(new TTSMessage() { | ||||
|                     Voice = voice, | ||||
|                     Message = parts[i * 2], | ||||
| @@ -160,7 +202,7 @@ public class ChatMessageHandler { | ||||
|                 }); | ||||
|             } | ||||
| 
 | ||||
|             Console.WriteLine($"Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}"); | ||||
|             Logger.LogInformation($"Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}"); | ||||
|             Player.Add(new TTSMessage() { | ||||
|                 Voice = voice, | ||||
|                 Message = sfxName, | ||||
| @@ -175,7 +217,7 @@ public class ChatMessageHandler { | ||||
|         } | ||||
| 
 | ||||
|         if (!string.IsNullOrWhiteSpace(parts.Last())) { | ||||
|             Console.WriteLine($"Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; Month: {m.SubscribedMonthCount}; {badgesString}"); | ||||
|             Logger.LogInformation($"Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; Month: {m.SubscribedMonthCount}; {badgesString}"); | ||||
|             Player.Add(new TTSMessage() { | ||||
|                 Voice = voice, | ||||
|                 Message = parts.Last(), | ||||
| @@ -189,12 +231,12 @@ public class ChatMessageHandler { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private Regex GenerateEnabledVoicesRegex() { | ||||
|         if (EnabledVoices == null || EnabledVoices.Count() <= 0) { | ||||
|     private Regex? GenerateEnabledVoicesRegex() { | ||||
|         if (Context.EnabledVoices == null || Context.EnabledVoices.Count() <= 0) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         var enabledVoicesString = string.Join("|", EnabledVoices.Select(v => v.label)); | ||||
|         var enabledVoicesString = string.Join("|", Context.EnabledVoices.Select(v => v.Label)); | ||||
|         return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase); | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,6 @@ | ||||
| public enum MessageResult { | ||||
|     Skip = 1, | ||||
|     Blocked = 2, | ||||
|     SkipAll = 2, | ||||
|     Blocked = 3, | ||||
|     None = 0 | ||||
| } | ||||
| @@ -22,8 +22,11 @@ public class AudioPlaybackEngine : IDisposable | ||||
|         outputDevice.Play(); | ||||
|     } | ||||
| 
 | ||||
|     private ISampleProvider ConvertToRightChannelCount(ISampleProvider input) | ||||
|     private ISampleProvider ConvertToRightChannelCount(ISampleProvider? input) | ||||
|     { | ||||
|         if (input is null) | ||||
|             throw new NullReferenceException(nameof(input)); | ||||
| 
 | ||||
|         if (input.WaveFormat.Channels == mixer.WaveFormat.Channels) | ||||
|         { | ||||
|             return input; | ||||
| @@ -47,7 +50,7 @@ public class AudioPlaybackEngine : IDisposable | ||||
|     } | ||||
| 
 | ||||
|     public ISampleProvider ConvertSound(IWaveProvider provider) { | ||||
|         ISampleProvider converted = null; | ||||
|         ISampleProvider? converted = null; | ||||
|         if (provider.WaveFormat.Encoding == WaveFormatEncoding.Pcm) { | ||||
|             if (provider.WaveFormat.BitsPerSample == 8) { | ||||
|                 converted = new Pcm8BitToSampleProvider(provider); | ||||
| @@ -1,5 +1,4 @@ | ||||
| using NAudio.Wave; | ||||
| using System; | ||||
| 
 | ||||
| public class NetworkWavSound | ||||
| { | ||||
| @@ -10,7 +9,6 @@ public class NetworkWavSound | ||||
|     { | ||||
|         using (var mfr = new MediaFoundationReader(uri)) { | ||||
|             WaveFormat = mfr.WaveFormat; | ||||
|             //Console.WriteLine("W: " + WaveFormat.SampleRate + " C: " + WaveFormat.Channels + " B: " + WaveFormat.BitsPerSample + " E: " + WaveFormat.Encoding); | ||||
| 
 | ||||
|             byte[] buffer = new byte[4096]; | ||||
|             int read = 0; | ||||
| @@ -25,21 +23,20 @@ public class NetworkWavSound | ||||
| 
 | ||||
| public class CachedWavProvider : IWaveProvider | ||||
| { | ||||
|     private readonly NetworkWavSound sound; | ||||
|     private long position; | ||||
|     private readonly RawSourceWaveStream stream; | ||||
|     private readonly NetworkWavSound _sound; | ||||
|     private readonly RawSourceWaveStream _stream; | ||||
| 
 | ||||
|     public WaveFormat WaveFormat { get => sound.WaveFormat; } | ||||
|     public WaveFormat WaveFormat { get => _sound.WaveFormat; } | ||||
| 
 | ||||
| 
 | ||||
|     public CachedWavProvider(NetworkWavSound cachedSound) | ||||
|     { | ||||
|         sound = cachedSound; | ||||
|         stream = new RawSourceWaveStream(new MemoryStream(sound.AudioData), sound.WaveFormat); | ||||
|         _sound = cachedSound; | ||||
|         _stream = new RawSourceWaveStream(new MemoryStream(_sound.AudioData), _sound.WaveFormat); | ||||
|     } | ||||
| 
 | ||||
|     public int Read(byte[] buffer, int offset, int count) | ||||
|     { | ||||
|         return stream.Read(buffer, offset, count); | ||||
|         return _stream.Read(buffer, offset, count); | ||||
|     } | ||||
| } | ||||
| @@ -22,10 +22,10 @@ public class TTSPlayer { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public TTSMessage ReceiveReady() { | ||||
|     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; | ||||
| @@ -34,10 +34,10 @@ public class TTSPlayer { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public TTSMessage ReceiveBuffer() { | ||||
|     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; | ||||
| @@ -55,22 +55,38 @@ public class TTSPlayer { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public void RemoveAll() { | ||||
|         try { | ||||
|             _mutex2.WaitOne(); | ||||
|             _buffer.Clear(); | ||||
|         } finally { | ||||
|             _mutex2.ReleaseMutex(); | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             _mutex.WaitOne(); | ||||
|             _messages.Clear(); | ||||
|         } finally { | ||||
|             _mutex.ReleaseMutex(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public bool IsEmpty() { | ||||
|         return _messages.Count == 0; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| public class TTSMessage { | ||||
|     public string Voice { get; set; } | ||||
|     public string Channel { get; set; } | ||||
|     public string Username { get; set; } | ||||
|     public string Message { get; set; } | ||||
|     public string File { get; set; } | ||||
|     public string? Voice { get; set; } | ||||
|     public string? Channel { get; set; } | ||||
|     public string? Username { get; set; } | ||||
|     public string? Message { get; set; } | ||||
|     public string? File { get; set; } | ||||
|     public DateTime Timestamp { get; set; } | ||||
|     public bool Moderator { get; set; } | ||||
|     public bool Bot { get; set; } | ||||
|     public IEnumerable<KeyValuePair<string, string>> Badges { get; set; } | ||||
|     public IEnumerable<KeyValuePair<string, string>>? Badges { get; set; } | ||||
|     public int Bits { get; set; } | ||||
|     public int Priority { get; set; } | ||||
|     public ISampleProvider Audio { get; set; } | ||||
|     public ISampleProvider? Audio { get; set; } | ||||
| } | ||||
							
								
								
									
										48
									
								
								Configuration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								Configuration.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| using TwitchChatTTS.Seven.Socket.Context; | ||||
|  | ||||
| namespace TwitchChatTTS | ||||
| { | ||||
|     public class Configuration | ||||
|     { | ||||
|         public HermesConfiguration? Hermes; | ||||
|         public TwitchConfiguration? Twitch; | ||||
|         public EmotesConfiguration? Emotes; | ||||
|         public OBSConfiguration? Obs; | ||||
|         public SevenConfiguration? Seven; | ||||
|  | ||||
|  | ||||
|         public class HermesConfiguration { | ||||
|             public string? Token; | ||||
|         } | ||||
|  | ||||
|         public class TwitchConfiguration { | ||||
|             public IEnumerable<string>? Channels; | ||||
|             public IDictionary<string, RedeemConfiguration>? Redeems; | ||||
|             public bool? TtsWhenOffline; | ||||
|         } | ||||
|  | ||||
|         public class RedeemConfiguration { | ||||
|             public string? AudioFilePath; | ||||
|             public string? OutputFilePath; | ||||
|             public string? OutputContent; | ||||
|             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? Protocol; | ||||
|             public string? Url; | ||||
|  | ||||
|             public IEnumerable<SevenSubscriptionConfiguration>? InitialSubscriptions; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										39
									
								
								Helpers/WebClientWrap.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								Helpers/WebClientWrap.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| using System.Net.Http.Json; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace TwitchChatTTS.Helpers { | ||||
|     public class WebClientWrap { | ||||
|         private HttpClient _client; | ||||
|         private JsonSerializerOptions _options; | ||||
|  | ||||
|  | ||||
|         public WebClientWrap(JsonSerializerOptions options) { | ||||
|             _client = new HttpClient(); | ||||
|             _options = options; | ||||
|         } | ||||
|  | ||||
|  | ||||
|         public void AddHeader(string key, string? value) { | ||||
|             if (_client.DefaultRequestHeaders.Contains(key)) | ||||
|                 _client.DefaultRequestHeaders.Remove(key); | ||||
|             _client.DefaultRequestHeaders.Add(key, value); | ||||
|         } | ||||
|  | ||||
|         public async Task<T?> GetJson<T>(string uri) { | ||||
|             var response = await _client.GetAsync(uri); | ||||
|             return JsonSerializer.Deserialize<T>(await response.Content.ReadAsStreamAsync(), _options); | ||||
|         } | ||||
|  | ||||
|         public async Task<HttpResponseMessage> Get(string uri) { | ||||
|             return await _client.GetAsync(uri); | ||||
|         } | ||||
|  | ||||
|         public async Task<HttpResponseMessage> Post<T>(string uri, T data) { | ||||
|             return await _client.PostAsJsonAsync(uri, data); | ||||
|         } | ||||
|  | ||||
|         public async Task<HttpResponseMessage> Post(string uri) { | ||||
|             return await _client.PostAsJsonAsync(uri, new object()); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis; | ||||
| [Serializable] | ||||
| public class Account { | ||||
|   [AllowNull] | ||||
|   public string id { get; set; } | ||||
|   public string Id { get; set; } | ||||
|   [AllowNull] | ||||
|   public string username { get; set; } | ||||
|   public string Username { get; set; } | ||||
| } | ||||
| @@ -1,34 +1,36 @@ | ||||
| using System; | ||||
| using TwitchChatTTS.Helpers; | ||||
| using TwitchChatTTS; | ||||
| using TwitchChatTTS.Hermes; | ||||
| using System.Text.Json; | ||||
| 
 | ||||
| public class HermesClient { | ||||
|     private Account account; | ||||
|     private string key; | ||||
|     private WebHelper _web; | ||||
|     private Account? account; | ||||
|     private WebClientWrap _web; | ||||
|     private Configuration Configuration { get; } | ||||
| 
 | ||||
|     public string Id { get => account?.id; } | ||||
|     public string Username { get => account?.username; } | ||||
|     public string? Id { get => account?.Id; } | ||||
|     public string? Username { get => account?.Username; } | ||||
| 
 | ||||
| 
 | ||||
|     public HermesClient() { | ||||
|         // Read API Key from file. | ||||
|         if (!File.Exists(".token")) { | ||||
|     public HermesClient(Configuration configuration) { | ||||
|         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."); | ||||
|         } | ||||
| 
 | ||||
|         key = File.ReadAllText(".token")?.Trim(); | ||||
|         _web = new WebHelper(); | ||||
|         _web.AddHeader("x-api-key", key); | ||||
|         _web = new WebClientWrap(new JsonSerializerOptions() { | ||||
|             PropertyNameCaseInsensitive = false, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower | ||||
|         }); | ||||
|         _web.AddHeader("x-api-key", Configuration.Hermes.Token); | ||||
|     } | ||||
| 
 | ||||
|     public async Task UpdateHermesAccount() { | ||||
|         ValidateKey(); | ||||
|     public async Task FetchHermesAccountDetails() { | ||||
|         account = await _web.GetJson<Account>("https://hermes.goblincaves.com/api/account"); | ||||
|     } | ||||
| 
 | ||||
|     public async Task<TwitchBotToken> FetchTwitchBotToken() { | ||||
|         ValidateKey(); | ||||
| 
 | ||||
|         var token = await _web.GetJson<TwitchBotToken>("https://hermes.goblincaves.com/api/token/bot"); | ||||
|         if (token == null) { | ||||
|             throw new Exception("Failed to fetch Twitch API token from Hermes."); | ||||
| @@ -38,8 +40,6 @@ public class HermesClient { | ||||
|     } | ||||
| 
 | ||||
|     public async Task<IEnumerable<TTSUsernameFilter>> FetchTTSUsernameFilters() { | ||||
|         ValidateKey(); | ||||
| 
 | ||||
|         var filters = await _web.GetJson<IEnumerable<TTSUsernameFilter>>("https://hermes.goblincaves.com/api/settings/tts/filter/users"); | ||||
|         if (filters == null) { | ||||
|             throw new Exception("Failed to fetch TTS username filters from Hermes."); | ||||
| @@ -48,20 +48,16 @@ public class HermesClient { | ||||
|         return filters; | ||||
|     } | ||||
| 
 | ||||
|     public async Task<string> FetchTTSDefaultVoice() { | ||||
|         ValidateKey(); | ||||
| 
 | ||||
|     public async Task<string?> FetchTTSDefaultVoice() { | ||||
|         var data = await _web.GetJson<TTSVoice>("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.Label; | ||||
|     } | ||||
| 
 | ||||
|     public async Task<IEnumerable<TTSVoice>> FetchTTSEnabledVoices() { | ||||
|         ValidateKey(); | ||||
| 
 | ||||
|         var voices = await _web.GetJson<IEnumerable<TTSVoice>>("https://hermes.goblincaves.com/api/settings/tts"); | ||||
|         if (voices == null) { | ||||
|             throw new Exception("Failed to fetch TTS enabled voices from Hermes."); | ||||
| @@ -71,8 +67,6 @@ public class HermesClient { | ||||
|     } | ||||
| 
 | ||||
|     public async Task<IEnumerable<TTSWordFilter>> FetchTTSWordFilters() { | ||||
|         ValidateKey(); | ||||
| 
 | ||||
|         var filters = await _web.GetJson<IEnumerable<TTSWordFilter>>("https://hermes.goblincaves.com/api/settings/tts/filter/words"); | ||||
|         if (filters == null) { | ||||
|             throw new Exception("Failed to fetch TTS word filters from Hermes."); | ||||
| @@ -80,10 +74,4 @@ public class HermesClient { | ||||
| 
 | ||||
|         return filters; | ||||
|     } | ||||
| 
 | ||||
|     private void ValidateKey() { | ||||
|         if (string.IsNullOrWhiteSpace(key)) { | ||||
|             throw new InvalidOperationException("Hermes API key not provided."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										5
									
								
								Hermes/TTSUsernameFilter.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								Hermes/TTSUsernameFilter.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| public class TTSUsernameFilter { | ||||
|     public string? Username { get; set; } | ||||
|     public string? Tag { get; set; } | ||||
|     public string? UserId { get; set; } | ||||
| } | ||||
							
								
								
									
										6
									
								
								Hermes/TTSVoice.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								Hermes/TTSVoice.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| public class TTSVoice { | ||||
|   public string? Label { get; set; } | ||||
|   public int Value { get; set; } | ||||
|   public string? Gender { get; set; } | ||||
|   public string? Language { get; set; } | ||||
| } | ||||
| @@ -7,10 +7,10 @@ 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 string? Id { get; set; } | ||||
|         public string? Search { get; set; } | ||||
|         public string? Replace { get; set; } | ||||
|         public string? UserId { get; set; } | ||||
| 
 | ||||
|         public bool IsRegex { get; set; } | ||||
| 
 | ||||
							
								
								
									
										7
									
								
								Hermes/TwitchBotAuth.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								Hermes/TwitchBotAuth.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| [Serializable] | ||||
| public class TwitchBotAuth { | ||||
|   public string? UserId { get; set; } | ||||
|   public string? AccessToken { get; set; } | ||||
|   public string? RefreshToken { get; set; } | ||||
|   public string? BroadcasterId { get; set; } | ||||
| } | ||||
							
								
								
									
										8
									
								
								Hermes/TwitchBotToken.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Hermes/TwitchBotToken.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| [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; } | ||||
| } | ||||
							
								
								
									
										8
									
								
								Hermes/TwitchConnection.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Hermes/TwitchConnection.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| [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; } | ||||
| } | ||||
							
								
								
									
										10
									
								
								OBS/Socket/Context/HelloContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								OBS/Socket/Context/HelloContext.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| namespace TwitchChatTTS.OBS.Socket.Context | ||||
| { | ||||
|     [Serializable] | ||||
|     public class HelloContext | ||||
|     { | ||||
|         public string? Host { get; set; } | ||||
|         public short? Port { get; set; } | ||||
|         public string? Password { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										10
									
								
								OBS/Socket/Data/EventMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								OBS/Socket/Data/EventMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| namespace TwitchChatTTS.OBS.Socket.Data | ||||
| { | ||||
|     [Serializable] | ||||
|     public class EventMessage | ||||
|     { | ||||
|         public string eventType { get; set; } | ||||
|         public int eventIntent { get; set; } | ||||
|         public Dictionary<string, object> eventData { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										15
									
								
								OBS/Socket/Data/HelloMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								OBS/Socket/Data/HelloMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| namespace TwitchChatTTS.OBS.Socket.Data | ||||
| { | ||||
|     [Serializable] | ||||
|     public class HelloMessage | ||||
|     { | ||||
|         public string obsWebSocketVersion { get; set; } | ||||
|         public int rpcVersion { get; set; } | ||||
|         public AuthenticationMessage authentication { get; set; } | ||||
|     } | ||||
|  | ||||
|     public class AuthenticationMessage { | ||||
|         public string challenge { get; set; } | ||||
|         public string salt { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										8
									
								
								OBS/Socket/Data/IdentifiedMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								OBS/Socket/Data/IdentifiedMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| namespace TwitchChatTTS.OBS.Socket.Data | ||||
| { | ||||
|     [Serializable] | ||||
|     public class IdentifiedMessage | ||||
|     { | ||||
|         public int negotiatedRpcVersion { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								OBS/Socket/Data/IdentifyMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								OBS/Socket/Data/IdentifyMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| 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) { | ||||
|             rpcVersion = version; | ||||
|             authentication = auth; | ||||
|             eventSubscriptions = subscriptions; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								OBS/Socket/Data/RequestMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								OBS/Socket/Data/RequestMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| namespace TwitchChatTTS.OBS.Socket.Data | ||||
| { | ||||
|     [Serializable] | ||||
|     public class RequestMessage | ||||
|     { | ||||
|         public string requestType { get; set; } | ||||
|         public string requestId { get; set; } | ||||
|         public Dictionary<string, object> requestData { get; set; } | ||||
|  | ||||
|         public RequestMessage(string type, string id, Dictionary<string, object> data) { | ||||
|             requestType = type; | ||||
|             requestId = id; | ||||
|             requestData = data; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										11
									
								
								OBS/Socket/Data/RequestResponseMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								OBS/Socket/Data/RequestResponseMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| namespace TwitchChatTTS.OBS.Socket.Data | ||||
| { | ||||
|     [Serializable] | ||||
|     public class RequestResponseMessage | ||||
|     { | ||||
|         public string requestType { get; set; } | ||||
|         public string requestId { get; set; } | ||||
|         public object requestStatus { get; set; } | ||||
|         public Dictionary<string, object> responseData { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										48
									
								
								OBS/Socket/Handlers/EventMessageHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								OBS/Socket/Handlers/EventMessageHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.OBS.Socket.Data; | ||||
|  | ||||
| namespace TwitchChatTTS.OBS.Socket.Handlers | ||||
| { | ||||
|     public class EventMessageHandler : IWebSocketHandler | ||||
|     { | ||||
|         private ILogger Logger { get; } | ||||
|         private IServiceProvider ServiceProvider { get; } | ||||
|         public int OperationCode { get; set; } = 5; | ||||
|  | ||||
|         public EventMessageHandler(ILogger<EventMessageHandler> logger, IServiceProvider serviceProvider) { | ||||
|             Logger = logger; | ||||
|             ServiceProvider = serviceProvider; | ||||
|         } | ||||
|  | ||||
|         public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message) | ||||
|         { | ||||
|             if (message is not EventMessage obj || obj == null) | ||||
|                 return; | ||||
|  | ||||
|             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 + "."); | ||||
|  | ||||
|                     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])); | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private void OnStreamEnd() { | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										55
									
								
								OBS/Socket/Handlers/HelloHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								OBS/Socket/Handlers/HelloHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.OBS.Socket.Data; | ||||
| using TwitchChatTTS.OBS.Socket.Context; | ||||
|  | ||||
| namespace TwitchChatTTS.OBS.Socket.Handlers | ||||
| { | ||||
|     public class HelloHandler : IWebSocketHandler | ||||
|     { | ||||
|         private ILogger Logger { get; } | ||||
|         public int OperationCode { get; set; } = 0; | ||||
|         private HelloContext Context { get; } | ||||
|  | ||||
|         public HelloHandler(ILogger<HelloHandler> logger, HelloContext context) { | ||||
|             Logger = logger; | ||||
|             Context = context; | ||||
|         } | ||||
|  | ||||
|         public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message) | ||||
|         { | ||||
|             if (message is not HelloMessage obj || obj == null) | ||||
|                 return; | ||||
|  | ||||
|             Logger.LogTrace("OBS websocket password: " + Context.Password); | ||||
|             if (obj.authentication is null || Context.Password is null) // TODO: send re-identify message. | ||||
|                 return; | ||||
|              | ||||
|             var salt = obj.authentication.salt; | ||||
|             var challenge = obj.authentication.challenge; | ||||
|             Logger.LogTrace("Salt: " + salt); | ||||
|             Logger.LogTrace("Challenge: " + challenge); | ||||
|              | ||||
|              | ||||
|             string secret = Context.Password + salt; | ||||
|             byte[] bytes = Encoding.UTF8.GetBytes(secret); | ||||
|             string hash = null; | ||||
|             using (var sha = SHA256.Create()) { | ||||
|                 bytes = sha.ComputeHash(bytes); | ||||
|                 hash = Convert.ToBase64String(bytes); | ||||
|  | ||||
|                 secret = hash + challenge; | ||||
|                 bytes = Encoding.UTF8.GetBytes(secret); | ||||
|                 bytes = sha.ComputeHash(bytes); | ||||
|                 hash = Convert.ToBase64String(bytes); | ||||
|             } | ||||
|  | ||||
|             Logger.LogTrace("Final hash: " + hash); | ||||
|             //await sender.Send(1, new IdentifyMessage(obj.rpcVersion, hash, 1023 | 262144 | 524288)); | ||||
|             await sender.Send(1, new IdentifyMessage(obj.rpcVersion, hash, 1023 | 262144)); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										26
									
								
								OBS/Socket/Handlers/IdentifiedHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								OBS/Socket/Handlers/IdentifiedHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.OBS.Socket.Data; | ||||
|  | ||||
| namespace TwitchChatTTS.OBS.Socket.Handlers | ||||
| { | ||||
|     public class IdentifiedHandler : IWebSocketHandler | ||||
|     { | ||||
|         private ILogger Logger { get; } | ||||
|         public int OperationCode { get; set; } = 2; | ||||
|  | ||||
|         public IdentifiedHandler(ILogger<IdentifiedHandler> logger) { | ||||
|             Logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message) | ||||
|         { | ||||
|             if (message is not IdentifiedMessage obj || obj == null) | ||||
|                 return; | ||||
|              | ||||
|             sender.Connected = true; | ||||
|             Logger.LogInformation("Connected to OBS via rpc version " + obj.negotiatedRpcVersion + "."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										35
									
								
								OBS/Socket/Handlers/RequestResponseHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								OBS/Socket/Handlers/RequestResponseHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.OBS.Socket.Data; | ||||
|  | ||||
| namespace TwitchChatTTS.OBS.Socket.Handlers | ||||
| { | ||||
|     public class RequestResponseHandler : IWebSocketHandler | ||||
|     { | ||||
|         private ILogger Logger { get; } | ||||
|         public int OperationCode { get; set; } = 7; | ||||
|  | ||||
|         public RequestResponseHandler(ILogger<RequestResponseHandler> logger) { | ||||
|             Logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message) | ||||
|         { | ||||
|             if (message is not RequestResponseMessage obj || obj == null) | ||||
|                 return; | ||||
|              | ||||
|             switch (obj.requestType) { | ||||
|                 case "GetOutputStatus": | ||||
|                     if (sender is not OBSSocketClient client) | ||||
|                         return; | ||||
|                      | ||||
|                     if (obj.requestId == "stream") { | ||||
|                         client.Live = obj.responseData["outputActive"].ToString() == "True"; | ||||
|                         Logger.LogWarning("Updated stream's live status to " + client.Live); | ||||
|                     } | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										31
									
								
								OBS/Socket/Manager/OBSHandlerManager.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								OBS/Socket/Manager/OBSHandlerManager.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using CommonSocketLibrary.Socket.Manager; | ||||
| using CommonSocketLibrary.Common; | ||||
|  | ||||
| namespace TwitchChatTTS.OBS.Socket.Manager | ||||
| { | ||||
|     public class OBSHandlerManager : WebSocketHandlerManager | ||||
|     { | ||||
|         public OBSHandlerManager(ILogger<OBSHandlerManager> 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)  { | ||||
|                 var key = "obs-" + type.Name.Replace("Handlers", "Hand#lers") | ||||
|                         .Replace("Handler", "") | ||||
|                         .Replace("Hand#lers", "Handlers") | ||||
|                         .ToLower(); | ||||
|                 var handler = provider.GetKeyedService<IWebSocketHandler>(key); | ||||
|                 if (handler == null) { | ||||
|                     logger.LogError("Failed to find obs websocket handler: " + type.AssemblyQualifiedName); | ||||
|                     continue; | ||||
|                 } | ||||
|                  | ||||
|                 Logger.LogDebug($"Linked type {type.AssemblyQualifiedName} to obs websocket handler {handler.GetType().AssemblyQualifiedName}."); | ||||
|                 Add(handler); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										19
									
								
								OBS/Socket/Manager/OBSHandlerTypeManager.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								OBS/Socket/Manager/OBSHandlerTypeManager.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using CommonSocketLibrary.Socket.Manager; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
|  | ||||
| namespace TwitchChatTTS.OBS.Socket.Manager | ||||
| { | ||||
|     public class OBSHandlerTypeManager : WebSocketHandlerTypeManager | ||||
|     { | ||||
|         public OBSHandlerTypeManager( | ||||
|             ILogger<OBSHandlerTypeManager> factory, | ||||
|             [FromKeyedServices("obs")] HandlerManager<WebSocketClient, | ||||
|             IWebSocketHandler> handlers | ||||
|         ) : base(factory, handlers) | ||||
|         { | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										31
									
								
								OBS/Socket/OBSSocketClient.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								OBS/Socket/OBSSocketClient.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| using TwitchChatTTS.OBS.Socket.Manager; | ||||
| using CommonSocketLibrary.Common; | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace TwitchChatTTS.OBS.Socket | ||||
| { | ||||
|     public class OBSSocketClient : WebSocketClient { | ||||
|         private bool _live; | ||||
|         public bool? Live { | ||||
|             get => Connected ? _live : null; | ||||
|             set { | ||||
|                 if (value.HasValue) | ||||
|                     _live = value.Value; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public OBSSocketClient( | ||||
|             ILogger<OBSSocketClient> logger, | ||||
|             [FromKeyedServices("obs")] HandlerManager<WebSocketClient, IWebSocketHandler> handlerManager, | ||||
|             [FromKeyedServices("obs")] HandlerTypeManager<WebSocketClient, IWebSocketHandler> typeManager | ||||
|         ) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() { | ||||
|             PropertyNameCaseInsensitive = false, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.CamelCase | ||||
|         }) { | ||||
|             _live = false; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										22
									
								
								Projects.sln
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								Projects.sln
									
									
									
									
									
								
							| @@ -1,22 +0,0 @@ | ||||
|  | ||||
| Microsoft Visual Studio Solution File, Format Version 12.00 | ||||
| # Visual Studio Version 17 | ||||
| VisualStudioVersion = 17.0.31903.59 | ||||
| MinimumVisualStudioVersion = 10.0.40219.1 | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchChatTTS", "TwitchChatTTS\TwitchChatTTS.csproj", "{7A371F54-F9D5-49C9-BE2D-819C60A0D621}" | ||||
| EndProject | ||||
| Global | ||||
| 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| 		Debug|Any CPU = Debug|Any CPU | ||||
| 		Release|Any CPU = Release|Any CPU | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(SolutionProperties) = preSolution | ||||
| 		HideSolutionNode = FALSE | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(ProjectConfigurationPlatforms) = postSolution | ||||
| 		{7A371F54-F9D5-49C9-BE2D-819C60A0D621}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{7A371F54-F9D5-49C9-BE2D-819C60A0D621}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{7A371F54-F9D5-49C9-BE2D-819C60A0D621}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{7A371F54-F9D5-49C9-BE2D-819C60A0D621}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 	EndGlobalSection | ||||
| EndGlobal | ||||
							
								
								
									
										84
									
								
								Seven/Emotes.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								Seven/Emotes.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| using System.Collections.Concurrent; | ||||
|  | ||||
| namespace TwitchChatTTS.Seven | ||||
| { | ||||
|     public class EmoteCounter { | ||||
|         public IDictionary<string, IDictionary<long, int>> Counters { get; set; } | ||||
|  | ||||
|         public EmoteCounter() { | ||||
|             Counters = new ConcurrentDictionary<string, IDictionary<long, int>>(); | ||||
|         } | ||||
|  | ||||
|         public void Add(long userId, IEnumerable<string> emoteIds) { | ||||
|             foreach (var emote in emoteIds) { | ||||
|                 if (Counters.TryGetValue(emote, out IDictionary<long, int>? subcounters)) { | ||||
|                     if (subcounters.TryGetValue(userId, out int counter)) | ||||
|                         subcounters[userId] = counter + 1; | ||||
|                     else | ||||
|                         subcounters.Add(userId, 1); | ||||
|                 } else { | ||||
|                     Counters.Add(emote, new ConcurrentDictionary<long, int>()); | ||||
|                     Counters[emote].Add(userId, 1); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public void Clear() { | ||||
|             Counters.Clear(); | ||||
|         } | ||||
|  | ||||
|         public int Get(long userId, string emoteId) { | ||||
|             if (Counters.TryGetValue(emoteId, out IDictionary<long, int>? subcounters)) { | ||||
|                 if (subcounters.TryGetValue(userId, out int counter)) | ||||
|                     return counter; | ||||
|             } | ||||
|             return -1; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public class EmoteDatabase { | ||||
|         private IDictionary<string, string> Emotes { get; } | ||||
|  | ||||
|         public EmoteDatabase() { | ||||
|             Emotes = new Dictionary<string, string>(); | ||||
|         } | ||||
|  | ||||
|         public void Add(string emoteName, string emoteId) { | ||||
|             if (Emotes.ContainsKey(emoteName)) | ||||
|                 Emotes[emoteName] = emoteId; | ||||
|             else | ||||
|                 Emotes.Add(emoteName, emoteId); | ||||
|         } | ||||
|  | ||||
|         public void Clear() { | ||||
|             Emotes.Clear(); | ||||
|         } | ||||
|  | ||||
|         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 class EmoteSet { | ||||
|         public string Id { get; set; } | ||||
|         public string Name { get; set; } | ||||
|         public int Flags { get; set; } | ||||
|         public bool Immutable { get; set; } | ||||
|         public bool Privileged { get; set; } | ||||
|         public IList<Emote> Emotes { get; set; } | ||||
|         public int EmoteCount { get; set; } | ||||
|         public int Capacity { get; set; } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     public class Emote { | ||||
|         public string Id { get; set; } | ||||
|         public string Name { get; set; } | ||||
|         public int Flags { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										47
									
								
								Seven/SevenApiClient.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								Seven/SevenApiClient.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| using System.Text.Json; | ||||
| using TwitchChatTTS.Helpers; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS; | ||||
| using TwitchChatTTS.Seven; | ||||
|  | ||||
| public class SevenApiClient { | ||||
|     private WebClientWrap Web { get; } | ||||
|     private Configuration Configuration { get; } | ||||
|     private ILogger<SevenApiClient> Logger { get; } | ||||
|     private long? Id { get; } | ||||
|  | ||||
|  | ||||
|     public SevenApiClient(Configuration configuration, ILogger<SevenApiClient> logger, TwitchBotToken token) { | ||||
|         Configuration = configuration; | ||||
|         Logger = logger; | ||||
|         Id = long.TryParse(token?.BroadcasterId, out long id) ? id : -1; | ||||
|  | ||||
|         Web = new WebClientWrap(new JsonSerializerOptions() { | ||||
|             PropertyNameCaseInsensitive = false, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     public async Task<EmoteDatabase?> GetSevenEmotes() { | ||||
|         if (Id is null) | ||||
|             throw new NullReferenceException(nameof(Id)); | ||||
|          | ||||
|         try { | ||||
|             var details = await Web.GetJson<UserDetails>("https://7tv.io/v3/users/twitch/" + Id); | ||||
|             if (details is null) | ||||
|                 return null; | ||||
|              | ||||
|             var emotes = new EmoteDatabase(); | ||||
|             if (details.EmoteSet is not 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."); | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										9
									
								
								Seven/Socket/Context/ReconnectContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Seven/Socket/Context/ReconnectContext.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Context | ||||
| { | ||||
|     public class ReconnectContext | ||||
|     { | ||||
|         public string? Protocol; | ||||
|         public string Url; | ||||
|         public string? SessionId; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										12
									
								
								Seven/Socket/Context/SevenHelloContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								Seven/Socket/Context/SevenHelloContext.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Context | ||||
| { | ||||
|     public class SevenHelloContext | ||||
|     { | ||||
|         public IEnumerable<SevenSubscriptionConfiguration>? Subscriptions; | ||||
|     } | ||||
|  | ||||
|     public class SevenSubscriptionConfiguration { | ||||
|         public string? Type; | ||||
|         public IDictionary<string, string>? Condition; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										30
									
								
								Seven/Socket/Data/ChangeMapMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								Seven/Socket/Data/ChangeMapMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Data | ||||
| { | ||||
|     public class ChangeMapMessage | ||||
|     { | ||||
|         public object Id { get; set; } | ||||
|         public byte Kind { get; set; } | ||||
|         public bool? Contextual { get; set; } | ||||
|         public object Actor { get; set; } | ||||
|         public IEnumerable<ChangeField>? Added { get; set; } | ||||
|         public IEnumerable<ChangeField>? Updated { get; set; } | ||||
|         public IEnumerable<ChangeField>? Removed { get; set; } | ||||
|         public IEnumerable<ChangeField>? Pushed { get; set; } | ||||
|         public IEnumerable<ChangeField>? Pulled { get; set; } | ||||
|     } | ||||
|  | ||||
|     public class ChangeField { | ||||
|         public string Key { get; set; } | ||||
|         public int? Index { get; set; } | ||||
|         public bool Nested { get; set; } | ||||
|         public object OldValue { get; set; } | ||||
|         public object Value { get; set; } | ||||
|     } | ||||
|  | ||||
|     public class EmoteField { | ||||
|         public string Id { get; set; } | ||||
|         public string Name { get; set; } | ||||
|         public string ActorId { get; set; } | ||||
|         public int Flags { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										8
									
								
								Seven/Socket/Data/DispatchMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Seven/Socket/Data/DispatchMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Data | ||||
| { | ||||
|     public class DispatchMessage | ||||
|     { | ||||
|         public object EventType { get; set; } | ||||
|         public ChangeMapMessage Body { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										8
									
								
								Seven/Socket/Data/EndOfStreamMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Seven/Socket/Data/EndOfStreamMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Data | ||||
| { | ||||
|     public class EndOfStreamMessage | ||||
|     { | ||||
|         public int Code { get; set; } | ||||
|         public string Message { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										7
									
								
								Seven/Socket/Data/ErrorMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								Seven/Socket/Data/ErrorMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Data | ||||
| { | ||||
|     public class ErrorMessage | ||||
|     { | ||||
|          | ||||
|     } | ||||
| } | ||||
							
								
								
									
										7
									
								
								Seven/Socket/Data/IdentifyMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								Seven/Socket/Data/IdentifyMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Data | ||||
| { | ||||
|     public class IdentifyMessage | ||||
|     { | ||||
|          | ||||
|     } | ||||
| } | ||||
							
								
								
									
										7
									
								
								Seven/Socket/Data/ReconnectMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								Seven/Socket/Data/ReconnectMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Data | ||||
| { | ||||
|     public class ReconnectMessage | ||||
|     { | ||||
|         public string Reason { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										7
									
								
								Seven/Socket/Data/ResumeMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								Seven/Socket/Data/ResumeMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Data | ||||
| { | ||||
|     public class ResumeMessage | ||||
|     { | ||||
|         public string SessionId { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										9
									
								
								Seven/Socket/Data/SevenHelloMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Seven/Socket/Data/SevenHelloMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Data | ||||
| { | ||||
|     public class SevenHelloMessage | ||||
|     { | ||||
|         public uint HeartbeatInterval { get; set; } | ||||
|         public string SessionId { get; set; } | ||||
|         public int SubscriptionLimit { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										8
									
								
								Seven/Socket/Data/SubscribeMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Seven/Socket/Data/SubscribeMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Data | ||||
| { | ||||
|     public class SubscribeMessage | ||||
|     { | ||||
|         public string? Type { get; set; } | ||||
|         public IDictionary<string, string>? Condition { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										8
									
								
								Seven/Socket/Data/UnsubscribeMessage.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Seven/Socket/Data/UnsubscribeMessage.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| namespace TwitchChatTTS.Seven.Socket.Data | ||||
| { | ||||
|     public class UnsubscribeMessage | ||||
|     { | ||||
|         public string Type { get; set; } | ||||
|         public IDictionary<string, string>? Condition { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										46
									
								
								Seven/Socket/Handlers/DispatchHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								Seven/Socket/Handlers/DispatchHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| using System.Text.Json; | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.Seven.Socket.Data; | ||||
|  | ||||
| namespace TwitchChatTTS.Seven.Socket.Handlers | ||||
| { | ||||
|     public class DispatchHandler : IWebSocketHandler | ||||
|     { | ||||
|         private ILogger Logger { get; } | ||||
|         private IServiceProvider ServiceProvider { get; } | ||||
|         public int OperationCode { get; set; } = 0; | ||||
|  | ||||
|         public DispatchHandler(ILogger<DispatchHandler> logger, IServiceProvider serviceProvider) { | ||||
|             Logger = logger; | ||||
|             ServiceProvider = serviceProvider; | ||||
|         } | ||||
|  | ||||
|         public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message) | ||||
|         { | ||||
|             if (message is not DispatchMessage obj || obj == null) | ||||
|                 return; | ||||
|              | ||||
|             Do(obj?.Body?.Pulled, cf => cf.OldValue); | ||||
|             Do(obj?.Body?.Pushed, cf => cf.Value); | ||||
|         } | ||||
|  | ||||
|         private void Do(IEnumerable<ChangeField>? fields, Func<ChangeField, object> getter) { | ||||
|             if (fields is null) | ||||
|                 return; | ||||
|              | ||||
|             //ServiceProvider.GetRequiredService<EmoteDatabase>() | ||||
|             foreach (var val in fields) { | ||||
|                 if (getter(val) == null) | ||||
|                     continue; | ||||
|                  | ||||
|                 var o = JsonSerializer.Deserialize<EmoteField>(val.OldValue.ToString(), new JsonSerializerOptions() { | ||||
|                     PropertyNameCaseInsensitive = false, | ||||
|                     PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										88
									
								
								Seven/Socket/Handlers/EndOfStreamHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								Seven/Socket/Handlers/EndOfStreamHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.Seven.Socket.Context; | ||||
| using TwitchChatTTS.Seven.Socket.Data; | ||||
|  | ||||
| namespace TwitchChatTTS.Seven.Socket.Handlers | ||||
| { | ||||
|     public class EndOfStreamHandler : IWebSocketHandler | ||||
|     { | ||||
|         private ILogger Logger { get; } | ||||
|         private IServiceProvider ServiceProvider { get; } | ||||
|         private string[] ErrorCodes { get; } | ||||
|         private int[] ReconnectDelay { get; } | ||||
|  | ||||
|         public int OperationCode { get; set; } = 7; | ||||
|          | ||||
|  | ||||
|         public EndOfStreamHandler(ILogger<EndOfStreamHandler> logger, IServiceProvider serviceProvider) { | ||||
|             Logger = logger; | ||||
|             ServiceProvider = serviceProvider; | ||||
|  | ||||
|             ErrorCodes = [ | ||||
|                 "Server Error", | ||||
|                 "Unknown Operation", | ||||
|                 "Invalid Payload", | ||||
|                 "Auth Failure", | ||||
|                 "Already Identified", | ||||
|                 "Rate Limited", | ||||
|                 "Restart", | ||||
|                 "Maintenance", | ||||
|                 "Timeout", | ||||
|                 "Already Subscribed", | ||||
|                 "Not Subscribed", | ||||
|                 "Insufficient Privilege", | ||||
|                 "Inactivity?" | ||||
|             ]; | ||||
|             ReconnectDelay = [ | ||||
|                 1000, | ||||
|                 -1, | ||||
|                 -1, | ||||
|                 -1, | ||||
|                 -1, | ||||
|                 3000, | ||||
|                 1000, | ||||
|                 300000, | ||||
|                 1000, | ||||
|                 -1, | ||||
|                 -1, | ||||
|                 1000, | ||||
|                 1000 | ||||
|             ]; | ||||
|         } | ||||
|  | ||||
|         public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message) | ||||
|         { | ||||
|             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})."); | ||||
|             else | ||||
|                 Logger.LogWarning($"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."); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             var context = ServiceProvider.GetRequiredService<ReconnectContext>(); | ||||
|             await Task.Delay(ReconnectDelay[code]); | ||||
|  | ||||
|             Logger.LogInformation($"7tv client reconnecting."); | ||||
|             await sender.ConnectAsync($"{context.Protocol ?? "wss"}://{context.Url}"); | ||||
|             if (context.SessionId is null) { | ||||
|                 await sender.Send(33, new object()); | ||||
|             } else { | ||||
|                 await sender.Send(34, new ResumeMessage() { | ||||
|                     SessionId = context.SessionId | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										23
									
								
								Seven/Socket/Handlers/ErrorHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								Seven/Socket/Handlers/ErrorHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.Seven.Socket.Data; | ||||
|  | ||||
| namespace TwitchChatTTS.Seven.Socket.Handlers | ||||
| { | ||||
|     public class ErrorHandler : IWebSocketHandler | ||||
|     { | ||||
|         private ILogger Logger { get; } | ||||
|         public int OperationCode { get; set; } = 6; | ||||
|  | ||||
|         public ErrorHandler(ILogger<ErrorHandler> logger) { | ||||
|             Logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message) | ||||
|         { | ||||
|             if (message is not ErrorMessage obj || obj == null) | ||||
|                 return; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										25
									
								
								Seven/Socket/Handlers/ReconnectHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								Seven/Socket/Handlers/ReconnectHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.Seven.Socket.Data; | ||||
|  | ||||
| namespace TwitchChatTTS.Seven.Socket.Handlers | ||||
| { | ||||
|     public class ReconnectHandler : IWebSocketHandler | ||||
|     { | ||||
|         private ILogger Logger { get; } | ||||
|         public int OperationCode { get; set; } = 4; | ||||
|  | ||||
|         public ReconnectHandler(ILogger<ReconnectHandler> logger) { | ||||
|             Logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message) | ||||
|         { | ||||
|             if (message is not ReconnectMessage obj || obj == null) | ||||
|                 return; | ||||
|  | ||||
|             Logger.LogInformation($"7tv server wants us to reconnect (reason: {obj.Reason})."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										56
									
								
								Seven/Socket/Handlers/SevenHelloHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								Seven/Socket/Handlers/SevenHelloHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.Seven.Socket.Context; | ||||
| using TwitchChatTTS.Seven.Socket.Data; | ||||
|  | ||||
| namespace TwitchChatTTS.Seven.Socket.Handlers | ||||
| { | ||||
|     public class SevenHelloHandler : IWebSocketHandler | ||||
|     { | ||||
|         private ILogger Logger { get; } | ||||
|         private SevenHelloContext Context { get; } | ||||
|         public int OperationCode { get; set; } = 1; | ||||
|  | ||||
|         public SevenHelloHandler(ILogger<SevenHelloHandler> logger, SevenHelloContext context) { | ||||
|             Logger = logger; | ||||
|             Context = context; | ||||
|         } | ||||
|  | ||||
|         public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message) | ||||
|         { | ||||
|             if (message is not SevenHelloMessage obj || obj == null) | ||||
|                 return; | ||||
|  | ||||
|             if (sender is not SevenSocketClient seven || seven == null) | ||||
|                 return; | ||||
|              | ||||
|             seven.Connected = true; | ||||
|             seven.ConnectionDetails = obj; | ||||
|  | ||||
|             // if (Context.Subscriptions == null || !Context.Subscriptions.Any()) { | ||||
|             //     Logger.LogWarning("No subscriptions have been set for the 7tv websocket client."); | ||||
|             //     return; | ||||
|             // } | ||||
|  | ||||
|             //await Task.Delay(TimeSpan.FromMilliseconds(1000)); | ||||
|             //await sender.Send(33, new IdentifyMessage()); | ||||
|             //await Task.Delay(TimeSpan.FromMilliseconds(5000)); | ||||
|             //await sender.SendRaw("{\"op\":35,\"d\":{\"type\":\"emote_set.*\",\"condition\":{\"object_id\":\"64505914b9fc508169ffe7cc\"}}}"); | ||||
|             //await sender.SendRaw(File.ReadAllText("test.txt")); | ||||
|  | ||||
|             // foreach (var sub in Context.Subscriptions) { | ||||
|             //     if (string.IsNullOrWhiteSpace(sub.Type)) { | ||||
|             //         Logger.LogWarning("Non-existent or empty subscription type found on the 7tv websocket client."); | ||||
|             //         continue; | ||||
|             //     } | ||||
|  | ||||
|             //     Logger.LogDebug($"Subscription Type: {sub.Type} | Condition: {string.Join(", ", sub.Condition?.Select(e => e.Key + "=" + e.Value) ?? new string[0])}"); | ||||
|             //     await sender.Send(35, new SubscribeMessage() { | ||||
|             //         Type = sub.Type, | ||||
|             //         Condition = sub.Condition | ||||
|             //     }); | ||||
|             // } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										35
									
								
								Seven/Socket/Manager/SevenHandlerManager.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								Seven/Socket/Manager/SevenHandlerManager.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| using Microsoft.Extensions.Logging; | ||||
| using CommonSocketLibrary.Socket.Manager; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
|  | ||||
| namespace TwitchChatTTS.Seven.Socket.Manager | ||||
| { | ||||
|     public class SevenHandlerManager : WebSocketHandlerManager | ||||
|     { | ||||
|         public SevenHandlerManager(ILogger<SevenHandlerManager> 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)  { | ||||
|                     var key = "7tv-" + type.Name.Replace("Handlers", "Hand#lers") | ||||
|                             .Replace("Handler", "") | ||||
|                             .Replace("Hand#lers", "Handlers") | ||||
|                             .ToLower(); | ||||
|                     var handler = provider.GetKeyedService<IWebSocketHandler>(key); | ||||
|                     if (handler == null) { | ||||
|                         logger.LogError("Failed to find 7tv websocket handler: " + type.AssemblyQualifiedName); | ||||
|                         continue; | ||||
|                     } | ||||
|                      | ||||
|                     Logger.LogDebug($"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."); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										19
									
								
								Seven/Socket/Manager/SevenHandlerTypeManager.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								Seven/Socket/Manager/SevenHandlerTypeManager.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using CommonSocketLibrary.Socket.Manager; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
|  | ||||
| namespace TwitchChatTTS.Seven.Socket.Manager | ||||
| { | ||||
|     public class SevenHandlerTypeManager : WebSocketHandlerTypeManager | ||||
|     { | ||||
|         public SevenHandlerTypeManager( | ||||
|             ILogger<SevenHandlerTypeManager> factory, | ||||
|             [FromKeyedServices("7tv")] HandlerManager<WebSocketClient, | ||||
|             IWebSocketHandler> handlers | ||||
|         ) : base(factory, handlers) | ||||
|         { | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										24
									
								
								Seven/Socket/SevenSocketClient.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								Seven/Socket/SevenSocketClient.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| using CommonSocketLibrary.Common; | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.Seven.Socket.Data; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace TwitchChatTTS.Seven.Socket | ||||
| { | ||||
|     public class SevenSocketClient : WebSocketClient { | ||||
|         public SevenHelloMessage? ConnectionDetails { get; set; } | ||||
|  | ||||
|         public SevenSocketClient( | ||||
|             ILogger<SevenSocketClient> logger, | ||||
|             [FromKeyedServices("7tv")] HandlerManager<WebSocketClient, IWebSocketHandler> handlerManager, | ||||
|             [FromKeyedServices("7tv")] HandlerTypeManager<WebSocketClient, IWebSocketHandler> typeManager | ||||
|         ) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() { | ||||
|             PropertyNameCaseInsensitive = false, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower | ||||
|         }) { | ||||
|             ConnectionDetails = null; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										12
									
								
								Seven/UserDetails.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								Seven/UserDetails.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| namespace TwitchChatTTS.Seven | ||||
| { | ||||
|     public class UserDetails | ||||
|     { | ||||
|         public string Id { get; set; } | ||||
|         public string Platform { get; set; } | ||||
|         public string Username { get; set; } | ||||
|         public int EmoteCapacity { get; set; } | ||||
|         public int? EmoteSetId { get; set; } | ||||
|         public EmoteSet EmoteSet { get; set; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										183
									
								
								Startup.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								Startup.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,183 @@ | ||||
| using TwitchChatTTS.OBS.Socket.Manager; | ||||
| using TwitchChatTTS.OBS.Socket; | ||||
| using Microsoft.Extensions.Hosting; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using TwitchChatTTS; | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using YamlDotNet.Serialization; | ||||
| using YamlDotNet.Serialization.NamingConventions; | ||||
| using TwitchChatTTS.Twitch; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS.Seven.Socket.Manager; | ||||
| using TwitchChatTTS.Seven.Socket; | ||||
| using TwitchChatTTS.OBS.Socket.Handlers; | ||||
| using TwitchChatTTS.Seven.Socket.Handlers; | ||||
| using TwitchChatTTS.Seven.Socket.Context; | ||||
| using TwitchChatTTS.Seven; | ||||
| using TwitchChatTTS.OBS.Socket.Context; | ||||
|  | ||||
| /** | ||||
| Future handshake/connection procedure: | ||||
| - GET all tts config data | ||||
| - Continuous connection to server to receive commands from tom & send logs/errors (med priority, though tough task) | ||||
|  | ||||
| Ideas: | ||||
| - Filter messages by badges. | ||||
| - Speed up TTS based on message queue size? | ||||
| - Cut TTS off shortly after raid (based on size of raid)? | ||||
| - Limit duration of TTS | ||||
| **/ | ||||
|  | ||||
| // dotnet publish -r linux-x64 -p:PublishSingleFile=true --self-contained true | ||||
| // dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained true | ||||
| // SE voices: https://api.streamelements.com/kappa/v2/speech?voice=brian&text=hello | ||||
|  | ||||
| // TODO: | ||||
| // Fix OBS/7tv websocket connections when not available. | ||||
| // Make it possible to do things at end of streams. | ||||
| // Update emote database with twitch emotes. | ||||
| // Event Subscription for emote usage? | ||||
|  | ||||
| HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); | ||||
| var s = builder.Services; | ||||
|  | ||||
| var deserializer = new DeserializerBuilder() | ||||
|     .WithNamingConvention(HyphenatedNamingConvention.Instance) | ||||
|     .Build(); | ||||
|  | ||||
| var configContent = File.ReadAllText("tts.config.yml"); | ||||
| var configuration = deserializer.Deserialize<Configuration>(configContent); | ||||
| var redeemKeys = configuration.Twitch?.Redeems?.Keys; | ||||
| if (redeemKeys is not null) { | ||||
|     foreach (var key in redeemKeys) { | ||||
|         if (key != key.ToLower() && configuration.Twitch?.Redeems != null) | ||||
|             configuration.Twitch.Redeems.Add(key.ToLower(), configuration.Twitch.Redeems[key]); | ||||
|     } | ||||
| } | ||||
| s.AddSingleton<Configuration>(configuration); | ||||
|  | ||||
| s.AddLogging(); | ||||
|  | ||||
| s.AddSingleton<TTSContext>(sp => { | ||||
|     var context = new TTSContext(); | ||||
|     var logger = sp.GetRequiredService<ILogger<TTSContext>>(); | ||||
|     var hermes = sp.GetRequiredService<HermesClient>(); | ||||
|  | ||||
|     logger.LogInformation("Fetching TTS username filters..."); | ||||
|     var usernameFiltersList = hermes.FetchTTSUsernameFilters(); | ||||
|     usernameFiltersList.Wait(); | ||||
|     context.UsernameFilters = usernameFiltersList.Result.Where(x => x.Username != null).ToDictionary(x => x.Username ?? "", x => x); | ||||
|     logger.LogInformation($"{context.UsernameFilters.Where(f => f.Value.Tag == "blacklisted").Count()} username(s) have been blocked."); | ||||
|     logger.LogInformation($"{context.UsernameFilters.Where(f => f.Value.Tag == "priority").Count()} user(s) have been prioritized."); | ||||
|  | ||||
|     var enabledVoices = hermes.FetchTTSEnabledVoices(); | ||||
|     enabledVoices.Wait(); | ||||
|     context.EnabledVoices = enabledVoices.Result; | ||||
|     logger.LogInformation($"{context.EnabledVoices.Count()} TTS voices enabled."); | ||||
|  | ||||
|     var wordFilters = hermes.FetchTTSWordFilters(); | ||||
|     wordFilters.Wait(); | ||||
|     context.WordFilters = wordFilters.Result; | ||||
|     logger.LogInformation($"{context.WordFilters.Count()} TTS word filters."); | ||||
|  | ||||
|     var defaultVoice = hermes.FetchTTSDefaultVoice(); | ||||
|     defaultVoice.Wait(); | ||||
|     context.DefaultVoice = defaultVoice.Result ?? "Brian"; | ||||
|     logger.LogInformation("Default Voice: " + context.DefaultVoice); | ||||
|  | ||||
|     return context; | ||||
| }); | ||||
| s.AddSingleton<TTSPlayer>(); | ||||
| s.AddSingleton<ChatMessageHandler>(); | ||||
| s.AddSingleton<HermesClient>(); | ||||
| s.AddTransient<TwitchBotToken>(sp => { | ||||
|     var hermes = sp.GetRequiredService<HermesClient>(); | ||||
|     var task = hermes.FetchTwitchBotToken(); | ||||
|     task.Wait(); | ||||
|     return task.Result; | ||||
| }); | ||||
| s.AddSingleton<TwitchApiClient>(); | ||||
|  | ||||
| s.AddSingleton<SevenApiClient>(); | ||||
| s.AddSingleton<EmoteDatabase>(sp => { | ||||
|     var api = sp.GetRequiredService<SevenApiClient>(); | ||||
|     var task = api.GetSevenEmotes(); | ||||
|     task.Wait(); | ||||
|     return task.Result; | ||||
| }); | ||||
| var emoteCounter = new EmoteCounter(); | ||||
| if (!string.IsNullOrWhiteSpace(configuration.Emotes?.CounterFilePath) && File.Exists(configuration.Emotes.CounterFilePath.Trim())) { | ||||
|     var d = new DeserializerBuilder() | ||||
|         .WithNamingConvention(HyphenatedNamingConvention.Instance) | ||||
|         .Build(); | ||||
|     emoteCounter = deserializer.Deserialize<EmoteCounter>(File.ReadAllText(configuration.Emotes.CounterFilePath.Trim())); | ||||
| } | ||||
| s.AddSingleton<EmoteCounter>(emoteCounter); | ||||
|  | ||||
| // OBS websocket | ||||
| s.AddSingleton<HelloContext>(sp => | ||||
|     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.AddKeyedSingleton<IWebSocketHandler, HelloHandler>("obs-hello"); | ||||
| s.AddKeyedSingleton<IWebSocketHandler, IdentifiedHandler>("obs-identified"); | ||||
| s.AddKeyedSingleton<IWebSocketHandler, RequestResponseHandler>("obs-requestresponse"); | ||||
| s.AddKeyedSingleton<IWebSocketHandler, EventMessageHandler>("obs-eventmessage"); | ||||
|  | ||||
| s.AddKeyedSingleton<HandlerManager<WebSocketClient, IWebSocketHandler>, OBSHandlerManager>("obs"); | ||||
| s.AddKeyedSingleton<HandlerTypeManager<WebSocketClient, IWebSocketHandler>, OBSHandlerTypeManager>("obs"); | ||||
| s.AddKeyedSingleton<SocketClient<WebSocketMessage>, OBSSocketClient>("obs"); | ||||
|  | ||||
| // 7tv websocket | ||||
| s.AddTransient(sp => { | ||||
|     var logger = sp.GetRequiredService<ILogger<ReconnectContext>>(); | ||||
|     var configuration = sp.GetRequiredService<Configuration>(); | ||||
|     var client = sp.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("7tv") as SevenSocketClient; | ||||
|     if (client == null) { | ||||
|         logger.LogError("7tv client is null."); | ||||
|         return new ReconnectContext() { | ||||
|             Protocol = configuration.Seven?.Protocol, | ||||
|             Url = configuration.Seven?.Url, | ||||
|             SessionId = null | ||||
|         }; | ||||
|     } | ||||
|     if (client.ConnectionDetails == null) { | ||||
|         logger.LogError("Connection details in 7tv client is null."); | ||||
|         return new ReconnectContext() { | ||||
|             Protocol = configuration.Seven?.Protocol, | ||||
|             Url = configuration.Seven?.Url, | ||||
|             SessionId = null | ||||
|         }; | ||||
|     } | ||||
|     return new ReconnectContext() { | ||||
|         Protocol = configuration.Seven?.Protocol, | ||||
|         Url = configuration.Seven?.Url, | ||||
|         SessionId = client.ConnectionDetails.SessionId | ||||
|     }; | ||||
| }); | ||||
| s.AddSingleton<SevenHelloContext>(sp => { | ||||
|     return new SevenHelloContext() { | ||||
|         Subscriptions = configuration.Seven?.InitialSubscriptions | ||||
|     }; | ||||
| }); | ||||
| s.AddKeyedSingleton<IWebSocketHandler, SevenHelloHandler>("7tv-sevenhello"); | ||||
| s.AddKeyedSingleton<IWebSocketHandler, HelloHandler>("7tv-hello"); | ||||
| s.AddKeyedSingleton<IWebSocketHandler, DispatchHandler>("7tv-dispatch"); | ||||
| s.AddKeyedSingleton<IWebSocketHandler, ReconnectHandler>("7tv-reconnect"); | ||||
| s.AddKeyedSingleton<IWebSocketHandler, ErrorHandler>("7tv-error"); | ||||
| s.AddKeyedSingleton<IWebSocketHandler, EndOfStreamHandler>("7tv-endofstream"); | ||||
|  | ||||
| s.AddKeyedSingleton<HandlerManager<WebSocketClient, IWebSocketHandler>, SevenHandlerManager>("7tv"); | ||||
| s.AddKeyedSingleton<HandlerTypeManager<WebSocketClient, IWebSocketHandler>, SevenHandlerTypeManager>("7tv"); | ||||
| s.AddKeyedSingleton<SocketClient<WebSocketMessage>, SevenSocketClient>("7tv"); | ||||
|  | ||||
| s.AddHostedService<TTS>(); | ||||
|  | ||||
| using IHost host = builder.Build(); | ||||
| using IServiceScope scope = host.Services.CreateAsyncScope(); | ||||
| IServiceProvider provider = scope.ServiceProvider; | ||||
| await host.RunAsync(); | ||||
							
								
								
									
										219
									
								
								TTS.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								TTS.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,219 @@ | ||||
| using System.Runtime.InteropServices; | ||||
| using System.Web; | ||||
| using CommonSocketLibrary.Abstract; | ||||
| using CommonSocketLibrary.Common; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Hosting; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using NAudio.Wave; | ||||
| using NAudio.Wave.SampleProviders; | ||||
| using TwitchLib.Client.Events; | ||||
| using YamlDotNet.Serialization; | ||||
| using YamlDotNet.Serialization.NamingConventions; | ||||
|  | ||||
| namespace TwitchChatTTS | ||||
| { | ||||
|     public class TTS : IHostedService | ||||
|     { | ||||
|         private ILogger Logger { get; } | ||||
|         private Configuration Configuration { get; } | ||||
|         private TTSPlayer Player { get; } | ||||
|         private IServiceProvider ServiceProvider { get; } | ||||
|         private ISampleProvider? Playing { get; set; } | ||||
|  | ||||
|         public TTS(ILogger<TTS> logger, Configuration configuration, TTSPlayer player, IServiceProvider serviceProvider) { | ||||
|             Logger = logger; | ||||
|             Configuration = configuration; | ||||
|             Player = player; | ||||
|             ServiceProvider = serviceProvider; | ||||
|         } | ||||
|  | ||||
|         public async Task StartAsync(CancellationToken cancellationToken) { | ||||
|             Console.Title = "TTS - Twitch Chat"; | ||||
|              | ||||
|             await InitializeSevenTv(); | ||||
|             await InitializeObs(); | ||||
|              | ||||
|             try { | ||||
|                 var hermes = await InitializeHermes(); | ||||
|                 var twitchapiclient = await InitializeTwitchApiClient(hermes); | ||||
|  | ||||
|                 AudioPlaybackEngine.Instance.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) => { | ||||
|                     if (e.SampleProvider == Playing) { | ||||
|                         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() || 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); | ||||
|                             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."); | ||||
|             } | ||||
|             Console.ReadLine(); | ||||
|         } | ||||
|  | ||||
|         public async Task StopAsync(CancellationToken cancellationToken) | ||||
|         { | ||||
|             if (cancellationToken.IsCancellationRequested) | ||||
|                 Logger.LogWarning("Application has stopped due to cancellation token."); | ||||
|             else | ||||
|                 Logger.LogWarning("Application has stopped."); | ||||
|         } | ||||
|  | ||||
|         private async Task InitializeSevenTv() { | ||||
|             Logger.LogInformation("Initializing 7tv client."); | ||||
|             var sevenClient = ServiceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("7tv"); | ||||
|             if (Configuration.Seven is not null && !string.IsNullOrWhiteSpace(Configuration.Seven.Url)) { | ||||
|                 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]) + ">")); | ||||
|                 Logger.LogDebug($"Attempting to connect to {Configuration.Seven.Protocol?.Trim() ?? "wss"}://{Configuration.Seven.Url.Trim()}{base_url}"); | ||||
|                 await sevenClient.ConnectAsync($"{Configuration.Seven.Protocol?.Trim() ?? "wss"}://{Configuration.Seven.Url.Trim()}{base_url}"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task InitializeObs() { | ||||
|             Logger.LogInformation("Initializing obs client."); | ||||
|             var obsClient = ServiceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("obs"); | ||||
|             if (Configuration.Obs is not null && !string.IsNullOrWhiteSpace(Configuration.Obs.Host) && Configuration.Obs.Port.HasValue && Configuration.Obs.Port.Value >= 0) { | ||||
|                 Logger.LogDebug($"Attempting to connect to ws://{Configuration.Obs.Host.Trim()}:{Configuration.Obs.Port}"); | ||||
|                 await obsClient.ConnectAsync($"ws://{Configuration.Obs.Host.Trim()}:{Configuration.Obs.Port}"); | ||||
|                 await Task.Delay(500); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task<HermesClient> InitializeHermes() { | ||||
|             // Fetch id and username based on api key given. | ||||
|             Logger.LogInformation("Initializing hermes client."); | ||||
|             var hermes = ServiceProvider.GetRequiredService<HermesClient>(); | ||||
|             await hermes.FetchHermesAccountDetails(); | ||||
|  | ||||
|             if (hermes.Username == null) | ||||
|                 throw new Exception("Username fetched from Hermes is invalid."); | ||||
|  | ||||
|             Logger.LogInformation("Username: " + hermes.Username); | ||||
|             return hermes; | ||||
|         } | ||||
|  | ||||
|         private async Task<TwitchApiClient> InitializeTwitchApiClient(HermesClient hermes) { | ||||
|             Logger.LogInformation("Initializing twitch client."); | ||||
|             var twitchapiclient = ServiceProvider.GetRequiredService<TwitchApiClient>(); | ||||
|             await twitchapiclient.Authorize(); | ||||
|  | ||||
|             var channels = Configuration.Twitch?.Channels ?? [hermes.Username]; | ||||
|             Logger.LogInformation("Twitch channels: " + string.Join(", ", channels)); | ||||
|             twitchapiclient.InitializeClient(hermes, channels); | ||||
|             twitchapiclient.InitializePublisher(); | ||||
|  | ||||
|             var handler = ServiceProvider.GetRequiredService<ChatMessageHandler>(); | ||||
|             twitchapiclient.AddOnNewMessageReceived(async Task (object? s, OnMessageReceivedArgs e) => { | ||||
|                 var result = handler.Handle(e); | ||||
|  | ||||
|                 switch (result) { | ||||
|                     case MessageResult.Skip: | ||||
|                         if (Playing != null) { | ||||
|                             AudioPlaybackEngine.Instance.RemoveMixerInput(Playing); | ||||
|                             Playing = null; | ||||
|                         } | ||||
|                         break; | ||||
|                     case MessageResult.SkipAll: | ||||
|                         Player.RemoveAll(); | ||||
|                         if (Playing != null) { | ||||
|                             AudioPlaybackEngine.Instance.RemoveMixerInput(Playing); | ||||
|                             Playing = null; | ||||
|                         } | ||||
|                         break; | ||||
|                     default: | ||||
|                         break; | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             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<ChatMessageHandler>(); | ||||
|                         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."); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										12
									
								
								Twitch/TTSContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								Twitch/TTSContext.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| using TwitchChatTTS.Hermes; | ||||
|  | ||||
| namespace TwitchChatTTS.Twitch | ||||
| { | ||||
|     public class TTSContext | ||||
|     { | ||||
|         public string DefaultVoice; | ||||
|         public IEnumerable<TTSVoice>? EnabledVoices; | ||||
|         public IDictionary<string, TTSUsernameFilter>? UsernameFilters; | ||||
|         public IEnumerable<TTSWordFilter>? WordFilters; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										184
									
								
								Twitch/TwitchApiClient.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								Twitch/TwitchApiClient.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,184 @@ | ||||
| using System.Text.Json; | ||||
| using TwitchChatTTS.Helpers; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using TwitchChatTTS; | ||||
| using TwitchLib.Api.Core.Exceptions; | ||||
| using TwitchLib.Client; | ||||
| using TwitchLib.Client.Events; | ||||
| using TwitchLib.Client.Models; | ||||
| using TwitchLib.Communication.Clients; | ||||
| using TwitchLib.Communication.Events; | ||||
| using TwitchLib.PubSub; | ||||
| using static TwitchChatTTS.Configuration; | ||||
|  | ||||
| public class TwitchApiClient { | ||||
|     private TwitchBotToken Token { get; } | ||||
|     private TwitchClient Client { get; } | ||||
|     private TwitchPubSub Publisher { get; } | ||||
|     private WebClientWrap Web { get; } | ||||
|     private Configuration Configuration { get; } | ||||
|     private ILogger<TwitchApiClient> Logger { get; } | ||||
|     private bool Initialized { get; set; } | ||||
|  | ||||
|  | ||||
|     public TwitchApiClient(Configuration configuration, ILogger<TwitchApiClient> logger, TwitchBotToken token) { | ||||
|         Configuration = configuration; | ||||
|         Logger = logger; | ||||
|         Client = new TwitchClient(new WebSocketClient()); | ||||
|         Publisher = new TwitchPubSub(); | ||||
|         Initialized = false; | ||||
|         Token = token; | ||||
|  | ||||
|         Web = new WebClientWrap(new JsonSerializerOptions() { | ||||
|             PropertyNameCaseInsensitive = false, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower | ||||
|         }); | ||||
|         if (!string.IsNullOrWhiteSpace(Configuration.Hermes?.Token)) | ||||
|             Web.AddHeader("x-api-key", Configuration.Hermes?.Token); | ||||
|     } | ||||
|  | ||||
|     public async Task Authorize() { | ||||
|         try { | ||||
|             var authorize = await Web.GetJson<TwitchBotAuth>("https://hermes.goblincaves.com/api/account/reauthorize"); | ||||
|             if (authorize != null && Token.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."); | ||||
|             } | ||||
|         } 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."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task Connect() { | ||||
|         Client.Connect(); | ||||
|         await Publisher.ConnectAsync(); | ||||
|     } | ||||
|  | ||||
|     public void InitializeClient(HermesClient hermes, IEnumerable<string> channels) { | ||||
|         ConnectionCredentials credentials = new ConnectionCredentials(hermes.Username, Token?.AccessToken); | ||||
|         Client.Initialize(credentials, channels.Distinct().ToList()); | ||||
|  | ||||
|         if (Initialized) { | ||||
|             Logger.LogDebug("Twitch API client has already been initialized."); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         Initialized = true; | ||||
|  | ||||
|         Client.OnJoinedChannel += async Task (object? s, OnJoinedChannelArgs e) => { | ||||
|             Logger.LogInformation("Joined channel: " + e.Channel); | ||||
|         }; | ||||
|  | ||||
|         Client.OnConnected += async Task (object? s, OnConnectedArgs e) => { | ||||
|             Logger.LogInformation("-----------------------------------------------------------"); | ||||
|         }; | ||||
|  | ||||
|         Client.OnIncorrectLogin += async Task (object? s, OnIncorrectLoginArgs e) => { | ||||
|             Logger.LogError(e.Exception, "Incorrect Login on Twitch API client."); | ||||
|  | ||||
|             Logger.LogInformation("Attempting to re-authorize."); | ||||
|             await Authorize(); | ||||
|         }; | ||||
|  | ||||
|         Client.OnConnectionError += async Task (object? s, OnConnectionErrorArgs e) => { | ||||
|             Logger.LogError("Connection Error: " + e.Error.Message + " (" + e.Error.GetType().Name + ")"); | ||||
|         }; | ||||
|  | ||||
|         Client.OnError += async Task (object? s, OnErrorEventArgs e) => { | ||||
|             Logger.LogError(e.Exception, "Twitch API client error."); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     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."); | ||||
|         }; | ||||
|  | ||||
|         Publisher.OnFollow += (s, e) => { | ||||
|             Logger.LogInformation("Follow: " + e.DisplayName); | ||||
|         }; | ||||
|  | ||||
|         Publisher.OnChannelPointsRewardRedeemed += (s, e) => { | ||||
|             Logger.LogInformation($"Channel Point Reward Redeemed: {e.RewardRedeemed.Redemption.Reward.Title} (id: {e.RewardRedeemed.Redemption.Id})"); | ||||
|  | ||||
|             if (Configuration.Twitch?.Redeems is null) { | ||||
|                 Logger.LogDebug("No redeems found in the configuration."); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             var redeemName = e.RewardRedeemed.Redemption.Reward.Title.ToLower().Trim().Replace(" ", "-"); | ||||
|             if (!Configuration.Twitch.Redeems.TryGetValue(redeemName, out RedeemConfiguration? redeem)) | ||||
|                 return; | ||||
|  | ||||
|             if (redeem is null) | ||||
|                 return; | ||||
|              | ||||
|             // Write or append to file if needed. | ||||
|             var outputFile = string.IsNullOrWhiteSpace(redeem.OutputFilePath) ? null : redeem.OutputFilePath.Trim(); | ||||
|             if (outputFile is null) { | ||||
|                 Logger.LogDebug($"No output file was provided for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'."); | ||||
|             } else { | ||||
|                 var outputContent = string.IsNullOrWhiteSpace(redeem.OutputContent) ? null : redeem.OutputContent.Trim().Replace("%USER%", e.RewardRedeemed.Redemption.User.DisplayName).Replace("\\n", "\n"); | ||||
|                 if (outputContent is null) { | ||||
|                     Logger.LogWarning($"No output content was provided for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'."); | ||||
|                 } else { | ||||
|                     if (redeem.OutputAppend == true) { | ||||
|                         File.AppendAllText(outputFile, outputContent + "\n"); | ||||
|                     } else { | ||||
|                         File.WriteAllText(outputFile, outputContent); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Play audio file if needed. | ||||
|             var audioFile = string.IsNullOrWhiteSpace(redeem.AudioFilePath) ? null : redeem.AudioFilePath.Trim(); | ||||
|             if (audioFile is null) { | ||||
|                 Logger.LogDebug($"No audio file was provided for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'."); | ||||
|                 return; | ||||
|             } | ||||
|             if (!File.Exists(audioFile)) { | ||||
|                 Logger.LogWarning($"Cannot find audio file @ {audioFile} for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'."); | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
|             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<TwitchBotToken>("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<OnMessageReceivedArgs> handler) { | ||||
|         Client.OnMessageReceived += handler; | ||||
|     } | ||||
| } | ||||
| @@ -2,15 +2,20 @@ | ||||
| 
 | ||||
|   <PropertyGroup> | ||||
|     <OutputType>Exe</OutputType> | ||||
|     <TargetFramework>net6.0</TargetFramework> | ||||
|     <TargetFramework>net8.0</TargetFramework> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|   </PropertyGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Configuration" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" /> | ||||
|     <PackageReference Include="NAudio" Version="2.2.1" /> | ||||
|     <PackageReference Include="NAudio.Extras" Version="2.2.1" /> | ||||
|     <PackageReference Include="System.Text.Json" Version="8.0.1" /> | ||||
|     <PackageReference Include="System.Threading.Tasks.Dataflow" Version="8.0.0" /> | ||||
|     <PackageReference Include="TwitchLib.Api.Core" Version="3.10.0-preview-e47ba7f" /> | ||||
|     <PackageReference Include="TwitchLib.Api.Core.Enums" Version="3.10.0-preview-e47ba7f" /> | ||||
| @@ -25,6 +30,10 @@ | ||||
|     <PackageReference Include="TwitchLib.PubSub" Version="4.0.0-preview-f833b1ab1ebef37618dba3fbb1e0a661ff183af5" /> | ||||
|     <PackageReference Include="NAudio.Core" Version="2.2.1" /> | ||||
|     <PackageReference Include="TwitchLib.Api" Version="3.10.0-preview-e47ba7f" /> | ||||
|     <PackageReference Include="YamlDotNet" Version="15.1.2" /> | ||||
|   </ItemGroup> | ||||
| 
 | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\CommonSocketLibrary\CommonSocketLibrary.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -1,5 +0,0 @@ | ||||
| public class TTSUsernameFilter { | ||||
|     public string username { get; set; } | ||||
|     public string tag { get; set; } | ||||
|     public string userId { get; set; } | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| public class TTSVoice { | ||||
|   public string label { get; set; } | ||||
|   public int value { get; set; } | ||||
|   public string gender { get; set; } | ||||
|   public string language { get; set; } | ||||
| } | ||||
| @@ -1,8 +0,0 @@ | ||||
| [Serializable] | ||||
| public class TwitchBotToken { | ||||
|   public string client_id { get; set; } | ||||
|   public string client_secret { get; set; } | ||||
|   public string access_token { get; set; } | ||||
|   public string refresh_token { get; set; } | ||||
|   public string broadcaster_id { get; set; } | ||||
| } | ||||
| @@ -1,8 +0,0 @@ | ||||
| [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; } | ||||
| } | ||||
| @@ -1,146 +0,0 @@ | ||||
| using System.Runtime.InteropServices; | ||||
| using System.Text.RegularExpressions; | ||||
| using NAudio.Wave; | ||||
| using TwitchLib.Api; | ||||
| using TwitchLib.Client; | ||||
| using TwitchLib.Client.Events; | ||||
| using TwitchLib.Client.Models; | ||||
| using TwitchLib.Communication.Clients; | ||||
| using TwitchLib.Communication.Events; | ||||
| using TwitchLib.PubSub; | ||||
| using TwitchLib.PubSub.Events; | ||||
| using NAudio.Wave.SampleProviders; | ||||
|  | ||||
| /** | ||||
| Future handshake/connection procedure: | ||||
| - GET all tts config data | ||||
| - Continuous connection to server to receive commands from tom & send logs/errors (med priority, though tough task) | ||||
|  | ||||
| Ideas: | ||||
| - Filter messages by badges, username, ..., etc. | ||||
| - Filter messages by content. | ||||
| - Speed up TTS based on message queue size? | ||||
| - Cut TTS off shortly after raid (based on size of raid)? | ||||
| - Limit duration of TTS | ||||
| - Voice selection for channel and per user. | ||||
| **/ | ||||
|  | ||||
| // dotnet publish -r linux-x64 -p:PublishSingleFile=true --self-contained true | ||||
| // dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained true | ||||
| // SE voices: https://api.streamelements.com/kappa/v2/speech?voice=brian&text=hello | ||||
|  | ||||
| // Read redeems from file. | ||||
| var redeems = File.Exists(".redeems") ? await File.ReadAllLinesAsync(".redeems") : new string[0]; | ||||
|  | ||||
| // Fetch id and username based on api key given. | ||||
| HermesClient hermes = new HermesClient(); | ||||
| Console.WriteLine("Fetching Hermes account details..."); | ||||
| await hermes.UpdateHermesAccount(); | ||||
|  | ||||
| Console.WriteLine("Username: " + hermes.Username); | ||||
| Console.WriteLine(); | ||||
|  | ||||
| Console.WriteLine("Fetching Twitch API details from Hermes..."); | ||||
| TwitchApiClient twitchapiclient = new TwitchApiClient(await hermes.FetchTwitchBotToken()); | ||||
| await twitchapiclient.Authorize(); | ||||
|  | ||||
| Console.WriteLine("Fetching TTS username filters..."); | ||||
| var usernameFilters = (await hermes.FetchTTSUsernameFilters()) | ||||
|                             .ToDictionary(x => x.username, x => x); | ||||
| Console.WriteLine($"{usernameFilters.Where(f => f.Value.tag == "blacklisted").Count()} username(s) have been blocked."); | ||||
| Console.WriteLine($"{usernameFilters.Where(f => f.Value.tag == "priority").Count()} user(s) have been prioritized."); | ||||
|  | ||||
| var enabledVoices = await hermes.FetchTTSEnabledVoices(); | ||||
| Console.WriteLine($"{enabledVoices.Count()} TTS voices enabled."); | ||||
|  | ||||
| var wordFilters = await hermes.FetchTTSWordFilters(); | ||||
| Console.WriteLine($"{wordFilters.Count()} TTS word filters."); | ||||
|  | ||||
| var defaultVoice = await hermes.FetchTTSDefaultVoice(); | ||||
| Console.WriteLine("Default Voice: " + defaultVoice); | ||||
|  | ||||
| TTSPlayer player = new TTSPlayer(); | ||||
| ISampleProvider playing = null; | ||||
|  | ||||
| var handler = new ChatMessageHandler(player, defaultVoice, enabledVoices, usernameFilters, wordFilters); | ||||
|  | ||||
| var channels = File.Exists(".twitchchannels") ? File.ReadAllLines(".twitchchannels") : new string[] { hermes.Username }; | ||||
| Console.WriteLine("Twitch channels: " + string.Join(", ", channels)); | ||||
| twitchapiclient.InitializeClient(hermes, channels); | ||||
| twitchapiclient.InitializePublisher(player, redeems); | ||||
|  | ||||
|  | ||||
| twitchapiclient.AddOnNewMessageReceived(async Task (object? s, OnMessageReceivedArgs e) => { | ||||
|     var result = handler.Handle(e); | ||||
|  | ||||
|     switch (result) { | ||||
|         case MessageResult.Skip: | ||||
|             AudioPlaybackEngine.Instance.RemoveMixerInput(playing); | ||||
|             playing = null; | ||||
|             break; | ||||
|         default: | ||||
|             break; | ||||
|     } | ||||
| }); | ||||
|  | ||||
| AudioPlaybackEngine.Instance.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) => { | ||||
|     if (e.SampleProvider == playing) { | ||||
|         playing = null; | ||||
|     } | ||||
| }); | ||||
|  | ||||
| Task.Run(async () => { | ||||
|     while (true) { | ||||
|         try { | ||||
|             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={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); | ||||
|  | ||||
|             m.Audio = resampled; | ||||
|             player.Ready(m); | ||||
|         } catch (COMException e) { | ||||
|             Console.WriteLine(e.GetType().Name + ": " + e.Message + " (HResult: " + e.HResult + ")"); | ||||
|         } catch (Exception e) { | ||||
|             Console.WriteLine(e.GetType().Name + ": " + e.Message); | ||||
|         } | ||||
|     } | ||||
| }); | ||||
|  | ||||
| Task.Run(async () => { | ||||
|     while (true) { | ||||
|         try { | ||||
|             while (player.IsEmpty() || playing != null) { | ||||
|                 await Task.Delay(200); | ||||
|             } | ||||
|             var m = player.ReceiveReady(); | ||||
|             if (m == null) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(m.File) && File.Exists(m.File)) { | ||||
|                 Console.WriteLine("Playing sfx: " + m.File); | ||||
|                 AudioPlaybackEngine.Instance.PlaySound(m.File); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             Console.WriteLine("Playing message: " + m.Message); | ||||
|             playing = m.Audio; | ||||
|             AudioPlaybackEngine.Instance.AddMixerInput(m.Audio); | ||||
|         } catch (Exception e) { | ||||
|             Console.WriteLine(e.GetType().Name + ": " + e.Message); | ||||
|         } | ||||
|     } | ||||
| }); | ||||
|  | ||||
| Console.WriteLine("Twitch API client connecting..."); | ||||
| twitchapiclient.Connect(); | ||||
| Console.ReadLine(); | ||||
| Console.ReadLine(); | ||||
| @@ -1,122 +0,0 @@ | ||||
| using TwitchLib.Api; | ||||
| using TwitchLib.Client; | ||||
| using TwitchLib.Client.Events; | ||||
| using TwitchLib.Client.Models; | ||||
| using TwitchLib.Communication.Clients; | ||||
| using TwitchLib.Communication.Events; | ||||
| using TwitchLib.PubSub; | ||||
| using TwitchLib.PubSub.Events; | ||||
|  | ||||
| public class TwitchApiClient { | ||||
|     private TwitchBotToken token; | ||||
|     private TwitchClient client; | ||||
|     private TwitchPubSub publisher; | ||||
|     private WebHelper web; | ||||
|     private bool initialized; | ||||
|  | ||||
|  | ||||
|     public TwitchApiClient(TwitchBotToken token) { | ||||
|         client = new TwitchClient(new WebSocketClient()); | ||||
|         publisher = new TwitchPubSub(); | ||||
|         web = new WebHelper(); | ||||
|         initialized = false; | ||||
|         this.token = token; | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> Authorize() { | ||||
|         var authorize = await web.Get("https://hermes.goblincaves.com/api/account/reauthorize"); | ||||
|         var status = (int) authorize.StatusCode; | ||||
|         return status == 200 || status == 201; | ||||
|     } | ||||
|  | ||||
|     public async Task Connect() { | ||||
|         client.Connect(); | ||||
|         await publisher.ConnectAsync(); | ||||
|     } | ||||
|  | ||||
|     public void InitializeClient(HermesClient hermes, IEnumerable<string> channels) { | ||||
|         ConnectionCredentials credentials = new ConnectionCredentials(hermes.Username, token.access_token); | ||||
|         client.Initialize(credentials, channels.Distinct().ToList()); | ||||
|  | ||||
|         if (initialized) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         initialized = true; | ||||
|  | ||||
|         client.OnJoinedChannel += async Task (object? s, OnJoinedChannelArgs e) => { | ||||
|             Console.WriteLine("Joined Channel: " + e.Channel); | ||||
|         }; | ||||
|  | ||||
|         client.OnConnected += async Task (object? s, OnConnectedArgs e) => { | ||||
|             Console.WriteLine("-----------------------------------------------------------"); | ||||
|         }; | ||||
|  | ||||
|         client.OnError += async Task (object? s, OnErrorEventArgs e) => { | ||||
|             Console.WriteLine("Log: " + e.Exception.Message + " (" + e.Exception.GetType().Name + ")"); | ||||
|         }; | ||||
|  | ||||
|         client.OnIncorrectLogin += async Task (object? s, OnIncorrectLoginArgs e) => { | ||||
|             Console.WriteLine("Incorrect Login: " + e.Exception.Message + " (" + e.Exception.GetType().Name + ")"); | ||||
|         }; | ||||
|  | ||||
|         client.OnConnectionError += async Task (object? s, OnConnectionErrorArgs e) => { | ||||
|             Console.WriteLine("Connection Error: " + e.Error.Message + " (" + e.Error.GetType().Name + ")"); | ||||
|         }; | ||||
|  | ||||
|         client.OnError += async Task (object? s, OnErrorEventArgs e) => { | ||||
|             Console.WriteLine("Error: " + e.Exception.Message + " (" + e.Exception.GetType().Name + ")"); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public void InitializePublisher(TTSPlayer player, IEnumerable<string> redeems) { | ||||
|         publisher.OnPubSubServiceConnected += async (s, e) => { | ||||
|             publisher.ListenToChannelPoints(token.broadcaster_id); | ||||
|  | ||||
|             await publisher.SendTopicsAsync(token.access_token); | ||||
|             Console.WriteLine("Twitch PubSub has been connected."); | ||||
|         }; | ||||
|  | ||||
|         publisher.OnFollow += (s, e) => { | ||||
|             Console.WriteLine("Follow: " + e.DisplayName); | ||||
|         }; | ||||
|  | ||||
|         publisher.OnChannelPointsRewardRedeemed += (s, e) => { | ||||
|             Console.WriteLine($"Channel Point Reward Redeemed: {e.RewardRedeemed.Redemption.Reward.Title}  (id: {e.RewardRedeemed.Redemption.Id})"); | ||||
|  | ||||
|             if (!redeems.Any(r => r.ToLower() == e.RewardRedeemed.Redemption.Reward.Title.ToLower())) | ||||
|               return; | ||||
|              | ||||
|             player.Add(new TTSMessage() { | ||||
|                 Voice = "Brian", | ||||
|                 Message = e.RewardRedeemed.Redemption.Reward.Title, | ||||
|                 File = $"redeems/{e.RewardRedeemed.Redemption.Reward.Title.ToLower()}.mp3", | ||||
|                 Priority = -50 | ||||
|             }); | ||||
|         }; | ||||
|  | ||||
|         /*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<TwitchBotToken>("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<OnMessageReceivedArgs> handler) { | ||||
|         client.OnMessageReceived += handler; | ||||
|     } | ||||
| } | ||||
| @@ -1,28 +0,0 @@ | ||||
|  | ||||
|  | ||||
| using System.Net; | ||||
| using System.Net.Http.Json; | ||||
|  | ||||
| public class WebHelper { | ||||
|     private static HttpClient _client = new HttpClient(); | ||||
|  | ||||
|     public void AddHeader(string key, string? value) { | ||||
|         _client.DefaultRequestHeaders.Add(key, value); | ||||
|     } | ||||
|  | ||||
|     public async Task<T?> GetJson<T>(string uri) { | ||||
|         return (T) await _client.GetFromJsonAsync(uri, typeof(T)); | ||||
|     } | ||||
|  | ||||
|     public async Task<HttpResponseMessage> Get(string uri) { | ||||
|         return await _client.GetAsync(uri); | ||||
|     } | ||||
|  | ||||
|     public async Task<HttpResponseMessage> Post<T>(string uri, T data) { | ||||
|         return await _client.PostAsJsonAsync(uri, data); | ||||
|     } | ||||
|  | ||||
|     public async Task<HttpResponseMessage> Post(string uri) { | ||||
|         return await _client.PostAsJsonAsync(uri, new object()); | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user