Cleaned code up. Added OBS & 7tv ws support. Added dependency injection. App loads from yml file.
This commit is contained in:
parent
9cd6725570
commit
b5cc6b5706
9
.gitignore
vendored
9
.gitignore
vendored
@ -1,5 +1,4 @@
|
|||||||
TwitchChatTTS/bin/
|
appsettings.json
|
||||||
TwitchChatTTS/obj/
|
tts.config.yml
|
||||||
.redeems
|
obj/
|
||||||
.token
|
bin/
|
||||||
.twitchchannels
|
|
||||||
|
@ -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) {
|
||||||
|
if (msg.ToLower().StartsWith("!skip ") || msg.ToLower() == "!skip")
|
||||||
return MessageResult.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);
|
|
||||||
continue;
|
|
||||||
} catch (Exception ex) {
|
|
||||||
wf.IsRegex = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msg = msg.Replace(wf.search, wf.replace);
|
if (long.TryParse(e.ChatMessage.UserId, out long userId))
|
||||||
}
|
EmoteCounter.Add(userId, emotesUsed);
|
||||||
|
if (emotesUsed.Any())
|
||||||
|
Logger.LogDebug("Emote counters for user #" + userId + ": " + string.Join(" | ", emotesUsed.Select(e => e + "=" + EmoteCounter.Get(userId, e))));
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,5 +1,6 @@
|
|||||||
public enum MessageResult {
|
public enum MessageResult {
|
||||||
Skip = 1,
|
Skip = 1,
|
||||||
Blocked = 2,
|
SkipAll = 2,
|
||||||
|
Blocked = 3,
|
||||||
None = 0
|
None = 0
|
||||||
}
|
}
|
@ -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);
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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
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]
|
[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; }
|
||||||
}
|
}
|
@ -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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
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 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
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>
|
<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>
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user