Cleaned code up. Added OBS & 7tv ws support. Added dependency injection. App loads from yml file.

This commit is contained in:
Tom 2024-03-12 18:05:27 +00:00
parent 9cd6725570
commit b5cc6b5706
66 changed files with 1795 additions and 456 deletions

9
.gitignore vendored
View File

@ -1,5 +1,4 @@
TwitchChatTTS/bin/ appsettings.json
TwitchChatTTS/obj/ tts.config.yml
.redeems obj/
.token bin/
.twitchchannels

View File

@ -1,25 +1,44 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using TwitchLib.Client.Events; 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 { public class ChatMessageHandler {
private ILogger<ChatMessageHandler> Logger { get; }
private Configuration Configuration { get; }
public EmoteCounter EmoteCounter { get; }
private EmoteDatabase Emotes { get; }
private TTSPlayer Player { get; } private TTSPlayer Player { get; }
public string DefaultVoice { get; set; } private OBSSocketClient? Client { get; }
public IEnumerable<TTSVoice> EnabledVoices { get; } private TTSContext Context { get; }
public Dictionary<string, TTSUsernameFilter> UsernameFilters { get; }
public IEnumerable<TTSWordFilter> WordFilters { get; }
private Regex voicesRegex; private Regex? voicesRegex;
private Regex sfxRegex; 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; Player = player;
DefaultVoice = defaultVoice; Client = client as OBSSocketClient;
EnabledVoices = enabledVoices; Context = context;
UsernameFilters = usernameFilters;
WordFilters = wordFilters;
voicesRegex = GenerateEnabledVoicesRegex(); voicesRegex = GenerateEnabledVoicesRegex();
sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)"); sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)");
@ -27,23 +46,48 @@ public class ChatMessageHandler {
public MessageResult Handle(OnMessageReceivedArgs e) { public MessageResult Handle(OnMessageReceivedArgs e) {
if (Configuration.Twitch?.TtsWhenOffline != true && Client?.Live != true)
return MessageResult.Blocked;
var m = e.ChatMessage; var m = e.ChatMessage;
var msg = e.ChatMessage.Message; var msg = e.ChatMessage.Message;
// Skip TTS messages // Skip TTS messages
if ((m.IsVip || m.IsModerator || m.IsBroadcaster) && (msg.ToLower().StartsWith("!skip ") || msg.ToLower() == "!skip")) { if (m.IsVip || m.IsModerator || m.IsBroadcaster) {
return MessageResult.Skip; 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; return MessageResult.Blocked;
} }
// Ensure we can send it via the web. // Replace filtered words.
var alphanumeric = new Regex(@"[^a-zA-Z0-9!@#$%&\^*+\-_(),+':;?.,\[\]\s\\/~`]"); if (Context.WordFilters is not null) {
msg = alphanumeric.Replace(msg, ""); 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. // Filter highly repetitive words (like emotes) from the message.
var emotesUsed = new HashSet<string>();
var words = msg.Split(" "); var words = msg.Split(" ");
var wordCounter = new Dictionary<string, int>(); var wordCounter = new Dictionary<string, int>();
string filteredMsg = string.Empty; string filteredMsg = string.Empty;
@ -54,30 +98,28 @@ public class ChatMessageHandler {
wordCounter.Add(w, 1); 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 + " "; filteredMsg += w + " ";
}
} }
msg = filteredMsg; msg = filteredMsg;
foreach (var wf in WordFilters) { // Adding twitch emotes to the counter.
if (wf.IsRegex) { foreach (var emote in e.ChatMessage.EmoteSet.Emotes)
try { emotesUsed.Add("twitch-" + emote.Id);
var regex = new Regex(wf.search);
msg = regex.Replace(msg, wf.replace); if (long.TryParse(e.ChatMessage.UserId, out long userId))
continue; EmoteCounter.Add(userId, emotesUsed);
} catch (Exception ex) { if (emotesUsed.Any())
wf.IsRegex = false; Logger.LogDebug("Emote counters for user #" + userId + ": " + string.Join(" | ", emotesUsed.Select(e => e + "=" + EmoteCounter.Get(userId, e))));
}
}
msg = msg.Replace(wf.search, wf.replace);
}
int priority = 0; int priority = 0;
if (m.IsStaff) { if (m.IsStaff) {
priority = int.MinValue; priority = int.MinValue;
} else if (filter?.tag == "priority") { } else if (filter?.Tag == "priority") {
priority = int.MinValue + 1; priority = int.MinValue + 1;
} else if (m.IsModerator) { } else if (m.IsModerator) {
priority = -100; priority = -100;
@ -88,12 +130,12 @@ public class ChatMessageHandler {
} else if (m.IsHighlighted) { } else if (m.IsHighlighted) {
priority = -1; 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; int defaultEnd = matches.FirstOrDefault()?.Index ?? msg.Length;
if (defaultEnd > 0) { 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) { 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)); var badgesString = string.Join(", ", e.ChatMessage.Badges.Select(b => b.Key + " = " + b.Value));
if (parts.Length == 1) { 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() { Player.Add(new TTSMessage() {
Voice = voice, Voice = voice,
Message = message, Message = message,
@ -147,7 +189,7 @@ public class ChatMessageHandler {
} }
if (!string.IsNullOrWhiteSpace(parts[i * 2])) { 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() { Player.Add(new TTSMessage() {
Voice = voice, Voice = voice,
Message = parts[i * 2], 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() { Player.Add(new TTSMessage() {
Voice = voice, Voice = voice,
Message = sfxName, Message = sfxName,
@ -175,7 +217,7 @@ public class ChatMessageHandler {
} }
if (!string.IsNullOrWhiteSpace(parts.Last())) { 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() { Player.Add(new TTSMessage() {
Voice = voice, Voice = voice,
Message = parts.Last(), Message = parts.Last(),
@ -189,12 +231,12 @@ public class ChatMessageHandler {
} }
} }
private Regex GenerateEnabledVoicesRegex() { private Regex? GenerateEnabledVoicesRegex() {
if (EnabledVoices == null || EnabledVoices.Count() <= 0) { if (Context.EnabledVoices == null || Context.EnabledVoices.Count() <= 0) {
return null; 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); return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase);
} }
} }

View File

@ -1,5 +1,6 @@
public enum MessageResult { public enum MessageResult {
Skip = 1, Skip = 1,
Blocked = 2, SkipAll = 2,
Blocked = 3,
None = 0 None = 0
} }

View File

@ -22,8 +22,11 @@ public class AudioPlaybackEngine : IDisposable
outputDevice.Play(); 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) if (input.WaveFormat.Channels == mixer.WaveFormat.Channels)
{ {
return input; return input;
@ -47,7 +50,7 @@ public class AudioPlaybackEngine : IDisposable
} }
public ISampleProvider ConvertSound(IWaveProvider provider) { public ISampleProvider ConvertSound(IWaveProvider provider) {
ISampleProvider converted = null; ISampleProvider? converted = null;
if (provider.WaveFormat.Encoding == WaveFormatEncoding.Pcm) { if (provider.WaveFormat.Encoding == WaveFormatEncoding.Pcm) {
if (provider.WaveFormat.BitsPerSample == 8) { if (provider.WaveFormat.BitsPerSample == 8) {
converted = new Pcm8BitToSampleProvider(provider); converted = new Pcm8BitToSampleProvider(provider);

View File

@ -1,5 +1,4 @@
using NAudio.Wave; using NAudio.Wave;
using System;
public class NetworkWavSound public class NetworkWavSound
{ {
@ -10,7 +9,6 @@ public class NetworkWavSound
{ {
using (var mfr = new MediaFoundationReader(uri)) { using (var mfr = new MediaFoundationReader(uri)) {
WaveFormat = mfr.WaveFormat; WaveFormat = mfr.WaveFormat;
//Console.WriteLine("W: " + WaveFormat.SampleRate + " C: " + WaveFormat.Channels + " B: " + WaveFormat.BitsPerSample + " E: " + WaveFormat.Encoding);
byte[] buffer = new byte[4096]; byte[] buffer = new byte[4096];
int read = 0; int read = 0;
@ -25,21 +23,20 @@ public class NetworkWavSound
public class CachedWavProvider : IWaveProvider public class CachedWavProvider : IWaveProvider
{ {
private readonly NetworkWavSound sound; private readonly NetworkWavSound _sound;
private long position; private readonly RawSourceWaveStream _stream;
private readonly RawSourceWaveStream stream;
public WaveFormat WaveFormat { get => sound.WaveFormat; } public WaveFormat WaveFormat { get => _sound.WaveFormat; }
public CachedWavProvider(NetworkWavSound cachedSound) public CachedWavProvider(NetworkWavSound cachedSound)
{ {
sound = cachedSound; _sound = cachedSound;
stream = new RawSourceWaveStream(new MemoryStream(sound.AudioData), sound.WaveFormat); _stream = new RawSourceWaveStream(new MemoryStream(_sound.AudioData), _sound.WaveFormat);
} }
public int Read(byte[] buffer, int offset, int count) public int Read(byte[] buffer, int offset, int count)
{ {
return stream.Read(buffer, offset, count); return _stream.Read(buffer, offset, count);
} }
} }

View File

@ -22,10 +22,10 @@ public class TTSPlayer {
} }
} }
public TTSMessage ReceiveReady() { public TTSMessage? ReceiveReady() {
try { try {
_mutex.WaitOne(); _mutex.WaitOne();
if (_messages.TryDequeue(out TTSMessage message, out int _)) { if (_messages.TryDequeue(out TTSMessage? message, out int _)) {
return message; return message;
} }
return null; return null;
@ -34,10 +34,10 @@ public class TTSPlayer {
} }
} }
public TTSMessage ReceiveBuffer() { public TTSMessage? ReceiveBuffer() {
try { try {
_mutex2.WaitOne(); _mutex2.WaitOne();
if (_buffer.TryDequeue(out TTSMessage message, out int _)) { if (_buffer.TryDequeue(out TTSMessage? message, out int _)) {
return message; return message;
} }
return null; 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() { public bool IsEmpty() {
return _messages.Count == 0; return _messages.Count == 0;
} }
} }
public class TTSMessage { public class TTSMessage {
public string Voice { get; set; } public string? Voice { get; set; }
public string Channel { get; set; } public string? Channel { get; set; }
public string Username { get; set; } public string? Username { get; set; }
public string Message { get; set; } public string? Message { get; set; }
public string File { get; set; } public string? File { get; set; }
public DateTime Timestamp { get; set; } public DateTime Timestamp { get; set; }
public bool Moderator { get; set; } public bool Moderator { get; set; }
public bool Bot { 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 Bits { get; set; }
public int Priority { get; set; } public int Priority { get; set; }
public ISampleProvider Audio { get; set; } public ISampleProvider? Audio { get; set; }
} }

48
Configuration.cs Normal file
View 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
View 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());
}
}
}

View File

@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis;
[Serializable] [Serializable]
public class Account { public class Account {
[AllowNull] [AllowNull]
public string id { get; set; } public string Id { get; set; }
[AllowNull] [AllowNull]
public string username { get; set; } public string Username { get; set; }
} }

View File

@ -1,34 +1,36 @@
using System; using TwitchChatTTS.Helpers;
using TwitchChatTTS;
using TwitchChatTTS.Hermes; using TwitchChatTTS.Hermes;
using System.Text.Json;
public class HermesClient { public class HermesClient {
private Account account; private Account? account;
private string key; private WebClientWrap _web;
private WebHelper _web; private Configuration Configuration { get; }
public string Id { get => account?.id; } public string? Id { get => account?.Id; }
public string Username { get => account?.username; } public string? Username { get => account?.Username; }
public HermesClient() { public HermesClient(Configuration configuration) {
// Read API Key from file. Configuration = configuration;
if (!File.Exists(".token")) {
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."); 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 WebClientWrap(new JsonSerializerOptions() {
_web = new WebHelper(); PropertyNameCaseInsensitive = false,
_web.AddHeader("x-api-key", key); PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
_web.AddHeader("x-api-key", Configuration.Hermes.Token);
} }
public async Task UpdateHermesAccount() { public async Task FetchHermesAccountDetails() {
ValidateKey();
account = await _web.GetJson<Account>("https://hermes.goblincaves.com/api/account"); account = await _web.GetJson<Account>("https://hermes.goblincaves.com/api/account");
} }
public async Task<TwitchBotToken> FetchTwitchBotToken() { public async Task<TwitchBotToken> FetchTwitchBotToken() {
ValidateKey();
var token = await _web.GetJson<TwitchBotToken>("https://hermes.goblincaves.com/api/token/bot"); var token = await _web.GetJson<TwitchBotToken>("https://hermes.goblincaves.com/api/token/bot");
if (token == null) { if (token == null) {
throw new Exception("Failed to fetch Twitch API token from Hermes."); throw new Exception("Failed to fetch Twitch API token from Hermes.");
@ -38,8 +40,6 @@ public class HermesClient {
} }
public async Task<IEnumerable<TTSUsernameFilter>> FetchTTSUsernameFilters() { public async Task<IEnumerable<TTSUsernameFilter>> FetchTTSUsernameFilters() {
ValidateKey();
var filters = await _web.GetJson<IEnumerable<TTSUsernameFilter>>("https://hermes.goblincaves.com/api/settings/tts/filter/users"); var filters = await _web.GetJson<IEnumerable<TTSUsernameFilter>>("https://hermes.goblincaves.com/api/settings/tts/filter/users");
if (filters == null) { if (filters == null) {
throw new Exception("Failed to fetch TTS username filters from Hermes."); throw new Exception("Failed to fetch TTS username filters from Hermes.");
@ -48,20 +48,16 @@ public class HermesClient {
return filters; return filters;
} }
public async Task<string> FetchTTSDefaultVoice() { public async Task<string?> FetchTTSDefaultVoice() {
ValidateKey();
var data = await _web.GetJson<TTSVoice>("https://hermes.goblincaves.com/api/settings/tts/default"); var data = await _web.GetJson<TTSVoice>("https://hermes.goblincaves.com/api/settings/tts/default");
if (data == null) { if (data == null) {
throw new Exception("Failed to fetch TTS default voice from Hermes."); throw new Exception("Failed to fetch TTS default voice from Hermes.");
} }
return data.label; return data.Label;
} }
public async Task<IEnumerable<TTSVoice>> FetchTTSEnabledVoices() { public async Task<IEnumerable<TTSVoice>> FetchTTSEnabledVoices() {
ValidateKey();
var voices = await _web.GetJson<IEnumerable<TTSVoice>>("https://hermes.goblincaves.com/api/settings/tts"); var voices = await _web.GetJson<IEnumerable<TTSVoice>>("https://hermes.goblincaves.com/api/settings/tts");
if (voices == null) { if (voices == null) {
throw new Exception("Failed to fetch TTS enabled voices from Hermes."); throw new Exception("Failed to fetch TTS enabled voices from Hermes.");
@ -71,8 +67,6 @@ public class HermesClient {
} }
public async Task<IEnumerable<TTSWordFilter>> FetchTTSWordFilters() { public async Task<IEnumerable<TTSWordFilter>> FetchTTSWordFilters() {
ValidateKey();
var filters = await _web.GetJson<IEnumerable<TTSWordFilter>>("https://hermes.goblincaves.com/api/settings/tts/filter/words"); var filters = await _web.GetJson<IEnumerable<TTSWordFilter>>("https://hermes.goblincaves.com/api/settings/tts/filter/words");
if (filters == null) { if (filters == null) {
throw new Exception("Failed to fetch TTS word filters from Hermes."); throw new Exception("Failed to fetch TTS word filters from Hermes.");
@ -80,10 +74,4 @@ public class HermesClient {
return filters; return filters;
} }
private void ValidateKey() {
if (string.IsNullOrWhiteSpace(key)) {
throw new InvalidOperationException("Hermes API key not provided.");
}
}
} }

View 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
View 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; }
}

View File

@ -7,10 +7,10 @@ namespace TwitchChatTTS.Hermes
{ {
public class TTSWordFilter public class TTSWordFilter
{ {
public string id { get; set; } public string? Id { get; set; }
public string search { get; set; } public string? Search { get; set; }
public string replace { get; set; } public string? Replace { get; set; }
public string userId { get; set; } public string? UserId { get; set; }
public bool IsRegex { get; set; } public bool IsRegex { get; set; }

7
Hermes/TwitchBotAuth.cs Normal file
View 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
View 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; }
}

View 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; }
}

View 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; }
}
}

View 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; }
}
}

View 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; }
}
}

View File

@ -0,0 +1,8 @@
namespace TwitchChatTTS.OBS.Socket.Data
{
[Serializable]
public class IdentifiedMessage
{
public int negotiatedRpcVersion { get; set; }
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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; }
}
}

View 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() {
}
}
}

View 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));
}
}
}

View 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 + ".");
}
}
}

View 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;
}
}
}
}

View 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);
}
}
}
}

View 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)
{
}
}
}

View 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;
}
}
}

View File

@ -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
View 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
View 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;
}
}

View File

@ -0,0 +1,9 @@
namespace TwitchChatTTS.Seven.Socket.Context
{
public class ReconnectContext
{
public string? Protocol;
public string Url;
public string? SessionId;
}
}

View 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;
}
}

View 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; }
}
}

View File

@ -0,0 +1,8 @@
namespace TwitchChatTTS.Seven.Socket.Data
{
public class DispatchMessage
{
public object EventType { get; set; }
public ChangeMapMessage Body { get; set; }
}
}

View File

@ -0,0 +1,8 @@
namespace TwitchChatTTS.Seven.Socket.Data
{
public class EndOfStreamMessage
{
public int Code { get; set; }
public string Message { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace TwitchChatTTS.Seven.Socket.Data
{
public class ErrorMessage
{
}
}

View File

@ -0,0 +1,7 @@
namespace TwitchChatTTS.Seven.Socket.Data
{
public class IdentifyMessage
{
}
}

View File

@ -0,0 +1,7 @@
namespace TwitchChatTTS.Seven.Socket.Data
{
public class ReconnectMessage
{
public string Reason { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace TwitchChatTTS.Seven.Socket.Data
{
public class ResumeMessage
{
public string SessionId { get; set; }
}
}

View 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; }
}
}

View 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; }
}
}

View 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; }
}
}

View 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
});
}
}
}
}

View 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
});
}
}
}
}

View 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;
}
}
}

View 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}).");
}
}
}

View 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
// });
// }
}
}
}

View 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.");
}
}
}
}

View 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)
{
}
}
}

View 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
View 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
View 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
View 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
View 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
View 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;
}
}

View File

@ -2,15 +2,20 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" /> <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" Version="2.2.1" />
<PackageReference Include="NAudio.Extras" 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="System.Threading.Tasks.Dataflow" Version="8.0.0" />
<PackageReference Include="TwitchLib.Api.Core" Version="3.10.0-preview-e47ba7f" /> <PackageReference Include="TwitchLib.Api.Core" Version="3.10.0-preview-e47ba7f" />
<PackageReference Include="TwitchLib.Api.Core.Enums" 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="TwitchLib.PubSub" Version="4.0.0-preview-f833b1ab1ebef37618dba3fbb1e0a661ff183af5" />
<PackageReference Include="NAudio.Core" Version="2.2.1" /> <PackageReference Include="NAudio.Core" Version="2.2.1" />
<PackageReference Include="TwitchLib.Api" Version="3.10.0-preview-e47ba7f" /> <PackageReference Include="TwitchLib.Api" Version="3.10.0-preview-e47ba7f" />
<PackageReference Include="YamlDotNet" Version="15.1.2" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CommonSocketLibrary\CommonSocketLibrary.csproj" />
</ItemGroup>
</Project> </Project>

View File

@ -1,5 +0,0 @@
public class TTSUsernameFilter {
public string username { get; set; }
public string tag { get; set; }
public string userId { get; set; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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();

View File

@ -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;
}
}

View File

@ -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());
}
}