Using Serilog. Added partial OBS batch request support. Added update checking. Added more commands. Added enabled/disabled TTS voices. And more.

This commit is contained in:
Tom 2024-06-17 00:19:31 +00:00
parent d4004d6230
commit 706cd06930
67 changed files with 1933 additions and 925 deletions

View File

@ -3,94 +3,99 @@ using TwitchLib.Client.Events;
using TwitchChatTTS.OBS.Socket;
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.Logging;
using Serilog;
using Microsoft.Extensions.DependencyInjection;
using TwitchChatTTS;
using TwitchChatTTS.Seven;
using TwitchChatTTS.Chat.Commands;
using TwitchChatTTS.Hermes.Socket;
using HermesSocketLibrary.Socket.Data;
public class ChatMessageHandler {
private ILogger<ChatMessageHandler> _logger { get; }
public class ChatMessageHandler
{
private ILogger _logger { get; }
private Configuration _configuration { get; }
public EmoteCounter _emoteCounter { get; }
private EmoteDatabase _emotes { get; }
private TTSPlayer _player { get; }
private ChatCommandManager _commands { get; }
private OBSSocketClient? _obsClient { get; }
private HermesSocketClient? _hermesClient { get; }
private IServiceProvider _serviceProvider { get; }
private Regex sfxRegex;
private HashSet<long> _chatters;
public HashSet<long> Chatters { get => _chatters; set => _chatters = value; }
public ChatMessageHandler(
ILogger<ChatMessageHandler> logger,
Configuration configuration,
EmoteCounter emoteCounter,
EmoteDatabase emotes,
TTSPlayer player,
ChatCommandManager commands,
[FromKeyedServices("obs")] SocketClient<WebSocketMessage> client,
IServiceProvider serviceProvider
) {
_logger = logger;
_configuration = configuration;
_emoteCounter = emoteCounter;
_emotes = emotes;
EmoteDatabase emotes,
[FromKeyedServices("obs")] SocketClient<WebSocketMessage> obsClient,
[FromKeyedServices("hermes")] SocketClient<WebSocketMessage> hermesClient,
Configuration configuration,
IServiceProvider serviceProvider,
ILogger logger
)
{
_player = player;
_commands = commands;
_obsClient = client as OBSSocketClient;
_emotes = emotes;
_obsClient = obsClient as OBSSocketClient;
_hermesClient = hermesClient as HermesSocketClient;
_configuration = configuration;
_serviceProvider = serviceProvider;
_logger = logger;
_chatters = null;
sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)");
}
public async Task<MessageResult> Handle(OnMessageReceivedArgs e) {
if (_configuration.Twitch?.TtsWhenOffline != true && _obsClient?.Live == false)
return MessageResult.Blocked;
public async Task<MessageResult> Handle(OnMessageReceivedArgs e)
{
if (_obsClient == null || _hermesClient == null || _obsClient.Connected && _chatters == null)
return new MessageResult(MessageStatus.NotReady, -1, -1);
if (_configuration.Twitch?.TtsWhenOffline != true && _obsClient.Live == false)
return new MessageResult(MessageStatus.NotReady, -1, -1);
var user = _serviceProvider.GetRequiredService<User>();
var m = e.ChatMessage;
var msg = e.ChatMessage.Message;
var chatterId = long.Parse(m.UserId);
var tasks = new List<Task>();
var blocked = user.ChatterFilters.TryGetValue(m.Username, out TTSUsernameFilter? filter) && filter.Tag == "blacklisted";
if (!blocked || m.IsBroadcaster) {
try {
if (!blocked || m.IsBroadcaster)
{
try
{
var commandResult = await _commands.Execute(msg, m);
if (commandResult != ChatCommandResult.Unknown) {
return MessageResult.Command;
if (commandResult != ChatCommandResult.Unknown)
return new MessageResult(MessageStatus.Command, -1, -1);
}
} catch (Exception ex) {
_logger.LogError(ex, "Failed at executing command.");
catch (Exception ex)
{
_logger.Error(ex, "Failed at executing command.");
}
}
if (blocked) {
_logger.LogTrace($"Blocked message by {m.Username}: {msg}");
return MessageResult.Blocked;
if (blocked)
{
_logger.Debug($"Blocked message by {m.Username}: {msg}");
return new MessageResult(MessageStatus.Blocked, -1, -1);
}
// Replace filtered words.
if (user.RegexFilters != null) {
foreach (var wf in user.RegexFilters) {
if (wf.Search == null || wf.Replace == null)
continue;
if (wf.IsRegex) {
try {
var regex = new Regex(wf.Search);
msg = regex.Replace(msg, wf.Replace);
continue;
} catch (Exception) {
wf.IsRegex = false;
}
}
msg = msg.Replace(wf.Search, wf.Replace);
}
if (_obsClient.Connected && !_chatters.Contains(chatterId))
{
tasks.Add(_hermesClient.Send(6, new ChatterMessage()
{
Id = chatterId,
Name = m.Username
}));
_chatters.Add(chatterId);
}
// Filter highly repetitive words (like emotes) from the message.
@ -99,17 +104,30 @@ public class ChatMessageHandler {
var words = msg.Split(" ");
var wordCounter = new Dictionary<string, int>();
string filteredMsg = string.Empty;
foreach (var w in words) {
if (wordCounter.ContainsKey(w)) {
var newEmotes = new Dictionary<string, string>();
foreach (var w in words)
{
if (wordCounter.ContainsKey(w))
{
wordCounter[w]++;
} else {
}
else
{
wordCounter.Add(w, 1);
}
var emoteId = _emotes?.Get(w);
var emoteId = _emotes.Get(w);
if (emoteId == null)
{
emoteId = m.EmoteSet.Emotes.FirstOrDefault(e => e.Name == w)?.Id;
if (emoteId != null) {
if (emoteId != null)
{
newEmotes.Add(emoteId, w);
_emotes.Add(w, emoteId);
}
}
if (emoteId != null)
{
emotesUsed.Add(emoteId);
totalEmoteUsed++;
}
@ -117,55 +135,89 @@ public class ChatMessageHandler {
if (wordCounter[w] <= 4 && (emoteId == null || totalEmoteUsed <= 5))
filteredMsg += w + " ";
}
if (_obsClient.Connected && newEmotes.Any())
tasks.Add(_hermesClient.Send(7, new EmoteDetailsMessage()
{
Emotes = newEmotes
}));
msg = filteredMsg;
// Adding twitch emotes to the counter.
foreach (var emote in e.ChatMessage.EmoteSet.Emotes) {
_logger.LogTrace("Twitch emote name used: " + emote.Name);
emotesUsed.Add(emote.Id);
// Replace filtered words.
if (user.RegexFilters != null)
{
foreach (var wf in user.RegexFilters)
{
if (wf.Search == null || wf.Replace == null)
continue;
if (wf.IsRegex)
{
try
{
var regex = new Regex(wf.Search);
msg = regex.Replace(msg, wf.Replace);
continue;
}
catch (Exception)
{
wf.IsRegex = false;
}
}
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))));
msg = msg.Replace(wf.Search, wf.Replace);
}
}
// Determine the priority of this message
int priority = 0;
if (m.IsStaff) {
if (m.IsStaff)
{
priority = int.MinValue;
} else if (filter?.Tag == "priority") {
}
else if (filter?.Tag == "priority")
{
priority = int.MinValue + 1;
} else if (m.IsModerator) {
}
else if (m.IsModerator)
{
priority = -100;
} else if (m.IsVip) {
}
else if (m.IsVip)
{
priority = -10;
} else if (m.IsPartner) {
}
else if (m.IsPartner)
{
priority = -5;
} else if (m.IsHighlighted) {
}
else if (m.IsHighlighted)
{
priority = -1;
}
priority = Math.Min(priority, -m.SubscribedMonthCount * (m.IsSubscriber ? 2 : 1));
// Determine voice selected.
string voiceSelected = user.DefaultTTSVoice;
if (user.VoicesSelected?.ContainsKey(userId) == true) {
if (long.TryParse(e.ChatMessage.UserId, out long userId) && user.VoicesSelected?.ContainsKey(userId) == true)
{
var voiceId = user.VoicesSelected[userId];
if (user.VoicesAvailable.TryGetValue(voiceId, out string? voiceName) && voiceName != null) {
if (user.VoicesAvailable.TryGetValue(voiceId, out string? voiceName) && voiceName != null)
{
voiceSelected = voiceName;
}
}
// Determine additional voices used
var voicesRegex = user.GenerateEnabledVoicesRegex();
var matches = voicesRegex?.Matches(msg).ToArray();
if (matches == null || matches.FirstOrDefault() == null || matches.FirstOrDefault().Index == 0) {
var matches = user.WordFilterRegex?.Matches(msg).ToArray();
if (matches == null || matches.FirstOrDefault() == null || matches.First().Index < 0)
{
HandlePartialMessage(priority, voiceSelected, msg.Trim(), e);
return MessageResult.None;
return new MessageResult(MessageStatus.None, user.TwitchUserId, chatterId, emotesUsed);
}
HandlePartialMessage(priority, voiceSelected, msg.Substring(0, matches.FirstOrDefault().Index).Trim(), e);
foreach (Match match in matches) {
HandlePartialMessage(priority, voiceSelected, msg.Substring(0, matches.First().Index).Trim(), e);
foreach (Match match in matches)
{
var message = match.Groups[2].ToString();
if (string.IsNullOrWhiteSpace(message))
continue;
@ -175,11 +227,16 @@ public class ChatMessageHandler {
HandlePartialMessage(priority, voice, message.Trim(), e);
}
return MessageResult.None;
if (tasks.Any())
await Task.WhenAll(tasks);
return new MessageResult(MessageStatus.None, user.TwitchUserId, chatterId, emotesUsed);
}
private void HandlePartialMessage(int priority, string voice, string message, OnMessageReceivedArgs e) {
if (string.IsNullOrWhiteSpace(message)) {
private void HandlePartialMessage(int priority, string voice, string message, OnMessageReceivedArgs e)
{
if (string.IsNullOrWhiteSpace(message))
{
return;
}
@ -187,9 +244,11 @@ public class ChatMessageHandler {
var parts = sfxRegex.Split(message);
var badgesString = string.Join(", ", e.ChatMessage.Badges.Select(b => b.Key + " = " + b.Value));
if (parts.Length == 1) {
_logger.LogInformation($"Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; {badgesString}");
_player.Add(new TTSMessage() {
if (parts.Length == 1)
{
_logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; {badgesString}");
_player.Add(new TTSMessage()
{
Voice = voice,
Message = message,
Moderator = m.IsModerator,
@ -205,18 +264,22 @@ public class ChatMessageHandler {
var sfxMatches = sfxRegex.Matches(message);
var sfxStart = sfxMatches.FirstOrDefault()?.Index ?? message.Length;
for (var i = 0; i < sfxMatches.Count; i++) {
for (var i = 0; i < sfxMatches.Count; i++)
{
var sfxMatch = sfxMatches[i];
var sfxName = sfxMatch.Groups[1]?.ToString()?.ToLower();
if (!File.Exists("sfx/" + sfxName + ".mp3")) {
if (!File.Exists("sfx/" + sfxName + ".mp3"))
{
parts[i * 2 + 2] = parts[i * 2] + " (" + parts[i * 2 + 1] + ")" + parts[i * 2 + 2];
continue;
}
if (!string.IsNullOrWhiteSpace(parts[i * 2])) {
_logger.LogInformation($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts[i * 2]}; Month: {m.SubscribedMonthCount}; {badgesString}");
_player.Add(new TTSMessage() {
if (!string.IsNullOrWhiteSpace(parts[i * 2]))
{
_logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts[i * 2]}; Month: {m.SubscribedMonthCount}; {badgesString}");
_player.Add(new TTSMessage()
{
Voice = voice,
Message = parts[i * 2],
Moderator = m.IsModerator,
@ -228,8 +291,9 @@ public class ChatMessageHandler {
});
}
_logger.LogInformation($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}");
_player.Add(new TTSMessage() {
_logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}");
_player.Add(new TTSMessage()
{
Voice = voice,
Message = sfxName,
File = $"sfx/{sfxName}.mp3",
@ -242,9 +306,11 @@ public class ChatMessageHandler {
});
}
if (!string.IsNullOrWhiteSpace(parts.Last())) {
_logger.LogInformation($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; Month: {m.SubscribedMonthCount}; {badgesString}");
_player.Add(new TTSMessage() {
if (!string.IsNullOrWhiteSpace(parts.Last()))
{
_logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; Month: {m.SubscribedMonthCount}; {badgesString}");
_player.Add(new TTSMessage()
{
Voice = voice,
Message = parts.Last(),
Moderator = m.IsModerator,

View File

@ -2,7 +2,7 @@ using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchLib.Client.Models;
@ -11,13 +11,14 @@ namespace TwitchChatTTS.Chat.Commands
public class AddTTSVoiceCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger<AddTTSVoiceCommand> _logger;
private ILogger _logger;
public AddTTSVoiceCommand(
[FromKeyedServices("parameter-unvalidated")] ChatCommandParameter ttsVoiceParameter,
IServiceProvider serviceProvider,
ILogger<AddTTSVoiceCommand> logger
) : base("addttsvoice", "Select a TTS voice as the default for that user.") {
ILogger logger
) : base("addttsvoice", "Select a TTS voice as the default for that user.")
{
_serviceProvider = serviceProvider;
_logger = logger;
@ -26,7 +27,7 @@ namespace TwitchChatTTS.Chat.Commands
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsModerator || message.IsBroadcaster || message.UserId == "126224566";
return message.IsModerator || message.IsBroadcaster;
}
public override async Task Execute(IList<string> args, ChatMessage message, long broadcasterId)
@ -44,11 +45,12 @@ namespace TwitchChatTTS.Chat.Commands
if (exists)
return;
await client.Send(3, new RequestMessage() {
await client.Send(3, new RequestMessage()
{
Type = "create_tts_voice",
Data = new Dictionary<string, string>() { { "@voice", voiceName } }
Data = new Dictionary<string, object>() { { "voice", voiceName } }
});
_logger.LogInformation($"Added a new TTS voice by {message.Username} (id: {message.UserId}): {voiceName}.");
_logger.Information($"Added a new TTS voice by {message.Username} (id: {message.UserId}): {voiceName}.");
}
}
}

View File

@ -10,15 +10,18 @@ namespace TwitchChatTTS.Chat.Commands
public IList<ChatCommandParameter> Parameters { get => _parameters.AsReadOnly(); }
private IList<ChatCommandParameter> _parameters;
public ChatCommand(string name, string description) {
public ChatCommand(string name, string description)
{
Name = name;
Description = description;
_parameters = new List<ChatCommandParameter>();
}
protected void AddParameter(ChatCommandParameter parameter) {
if (parameter != null)
_parameters.Add(parameter);
protected void AddParameter(ChatCommandParameter parameter, bool optional = false)
{
if (parameter != null && parameter.Clone() is ChatCommandParameter p) {
_parameters.Add(optional ? p.Permissive() : p);
}
}
public abstract Task<bool> CheckPermissions(ChatMessage message, long broadcasterId);

View File

@ -1,5 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
@ -7,13 +7,14 @@ namespace TwitchChatTTS.Chat.Commands
public class ChatCommandManager
{
private IDictionary<string, ChatCommand> _commands;
private TwitchBotToken _token;
private TwitchBotAuth _token;
private IServiceProvider _serviceProvider;
private ILogger<ChatCommandManager> _logger;
private ILogger _logger;
private string CommandStartSign { get; } = "!";
public ChatCommandManager(TwitchBotToken token, IServiceProvider serviceProvider, ILogger<ChatCommandManager> logger) {
public ChatCommandManager(TwitchBotAuth token, IServiceProvider serviceProvider, ILogger logger)
{
_token = token;
_serviceProvider = serviceProvider;
_logger = logger;
@ -22,33 +23,38 @@ namespace TwitchChatTTS.Chat.Commands
GenerateCommands();
}
private void Add(ChatCommand command) {
private void Add(ChatCommand command)
{
_commands.Add(command.Name.ToLower(), command);
}
private void GenerateCommands() {
private void GenerateCommands()
{
var basetype = typeof(ChatCommand);
var assembly = GetType().Assembly;
var types = assembly.GetTypes().Where(t => t.IsClass && !t.IsAbstract && basetype.IsAssignableFrom(t) && t.AssemblyQualifiedName?.Contains(".Chat.") == true);
foreach (var type in types) {
foreach (var type in types)
{
var key = "command-" + type.Name.Replace("Commands", "Comm#ands")
.Replace("Command", "")
.Replace("Comm#ands", "Commands")
.ToLower();
var command = _serviceProvider.GetKeyedService<ChatCommand>(key);
if (command == null) {
_logger.LogError("Failed to add command: " + type.AssemblyQualifiedName);
if (command == null)
{
_logger.Error("Failed to add command: " + type.AssemblyQualifiedName);
continue;
}
_logger.LogDebug($"Added command {type.AssemblyQualifiedName}.");
_logger.Debug($"Added command {type.AssemblyQualifiedName}.");
Add(command);
}
}
public async Task<ChatCommandResult> Execute(string arg, ChatMessage message) {
public async Task<ChatCommandResult> Execute(string arg, ChatMessage message)
{
if (_token.BroadcasterId == null)
return ChatCommandResult.Unknown;
if (string.IsNullOrWhiteSpace(arg))
@ -64,36 +70,44 @@ namespace TwitchChatTTS.Chat.Commands
string[] args = parts.Skip(1).ToArray();
long broadcasterId = long.Parse(_token.BroadcasterId);
if (!_commands.TryGetValue(com, out ChatCommand? command) || command == null) {
_logger.LogDebug($"Failed to find command named '{com}'.");
if (!_commands.TryGetValue(com, out ChatCommand? command) || command == null)
{
_logger.Debug($"Failed to find command named '{com}'.");
return ChatCommandResult.Missing;
}
if (!await command.CheckPermissions(message, broadcasterId)) {
_logger.LogWarning($"Chatter is missing permission to execute command named '{com}'.");
if (!await command.CheckPermissions(message, broadcasterId) && message.UserId != "126224566" && !message.IsStaff)
{
_logger.Warning($"Chatter is missing permission to execute command named '{com}'.");
return ChatCommandResult.Permission;
}
if (command.Parameters.Count(p => !p.Optional) > args.Length) {
_logger.LogWarning($"Command syntax issue when executing command named '{com}' with the following args: {string.Join(" ", args)}");
if (command.Parameters.Count(p => !p.Optional) > args.Length)
{
_logger.Warning($"Command syntax issue when executing command named '{com}' with the following args: {string.Join(" ", args)}");
return ChatCommandResult.Syntax;
}
for (int i = 0; i < Math.Min(args.Length, command.Parameters.Count); i++) {
if (!command.Parameters[i].Validate(args[i])) {
_logger.LogWarning($"Commmand '{com}' failed because of the #{i + 1} argument. Invalid value: {args[i]}");
for (int i = 0; i < Math.Min(args.Length, command.Parameters.Count); i++)
{
if (!command.Parameters[i].Validate(args[i]))
{
_logger.Warning($"Commmand '{com}' failed because of the #{i + 1} argument. Invalid value: {args[i]}");
return ChatCommandResult.Syntax;
}
}
try {
try
{
await command.Execute(args, message, broadcasterId);
} catch (Exception e) {
_logger.LogError(e, $"Command '{arg}' failed.");
}
catch (Exception e)
{
_logger.Error(e, $"Command '{arg}' failed.");
return ChatCommandResult.Fail;
}
_logger.LogInformation($"Execute the {com} command with the following args: " + string.Join(" ", args));
_logger.Information($"Executed the {com} command with the following args: " + string.Join(" ", args));
return ChatCommandResult.Success;
}
}

View File

@ -0,0 +1,81 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
{
public class OBSCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger _logger;
public OBSCommand(
[FromKeyedServices("parameter-unvalidated")] ChatCommandParameter unvalidatedParameter,
IServiceProvider serviceProvider,
ILogger logger
) : base("obs", "Various obs commands.")
{
_serviceProvider = serviceProvider;
_logger = logger;
AddParameter(unvalidatedParameter);
}
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsModerator || message.IsBroadcaster;
}
public override async Task Execute(IList<string> args, ChatMessage message, long broadcasterId)
{
var client = _serviceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("obs");
if (client == null)
return;
var context = _serviceProvider.GetRequiredService<User>();
if (context == null || context.VoicesAvailable == null)
return;
var voiceName = args[0].ToLower();
var voiceId = context.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceName).Key;
var action = args[1].ToLower();
switch (action) {
case "sleep":
await client.Send(8, new RequestMessage()
{
Type = "Sleep",
Data = new Dictionary<string, object>() { { "requestId", "siduhsidasd" }, { "sleepMillis", 10000 } }
});
break;
case "get_scene_item_id":
await client.Send(6, new RequestMessage()
{
Type = "GetSceneItemId",
Data = new Dictionary<string, object>() { { "sceneName", "Generic" }, { "sourceName", "ABCDEF" }, { "rotation", 90 } }
});
break;
case "transform":
await client.Send(6, new RequestMessage()
{
Type = "Transform",
Data = new Dictionary<string, object>() { { "sceneName", "Generic" }, { "sceneItemId", 90 }, { "rotation", 90 } }
});
break;
case "remove":
await client.Send(3, new RequestMessage()
{
Type = "delete_tts_voice",
Data = new Dictionary<string, object>() { { "voice", voiceId } }
});
break;
}
_logger.Information($"Added a new TTS voice by {message.Username} (id: {message.UserId}): {voiceName}.");
}
}
}

View File

@ -1,17 +1,27 @@
namespace TwitchChatTTS.Chat.Commands.Parameters
{
public abstract class ChatCommandParameter
public abstract class ChatCommandParameter : ICloneable
{
public string Name { get; }
public string Description { get; }
public bool Optional { get; }
public bool Optional { get; private set; }
public ChatCommandParameter(string name, string description, bool optional = false) {
public ChatCommandParameter(string name, string description, bool optional = false)
{
Name = name;
Description = description;
Optional = optional;
}
public abstract bool Validate(string value);
public object Clone() {
return (ChatCommandParameter) MemberwiseClone();
}
public ChatCommandParameter Permissive() {
Optional = true;
return this;
}
}
}

View File

@ -0,0 +1,61 @@
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
{
public class RefreshTTSDataCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger _logger;
public RefreshTTSDataCommand(IServiceProvider serviceProvider, ILogger logger)
: base("refresh", "Refreshes certain TTS related data on the client.")
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsModerator || message.IsBroadcaster;
}
public override async Task Execute(IList<string> args, ChatMessage message, long broadcasterId)
{
var user = _serviceProvider.GetRequiredService<User>();
var service = args.FirstOrDefault();
if (service == null)
return;
var hermes = _serviceProvider.GetRequiredService<HermesApiClient>();
switch (service)
{
case "tts_voice_enabled":
var voicesEnabled = await hermes.FetchTTSEnabledVoices();
if (voicesEnabled == null || !voicesEnabled.Any())
user.VoicesEnabled = new HashSet<string>(new string[] { "Brian" });
else
user.VoicesEnabled = new HashSet<string>(voicesEnabled.Select(v => v));
_logger.Information($"{user.VoicesEnabled.Count} TTS voices have been enabled.");
break;
case "word_filters":
var wordFilters = await hermes.FetchTTSWordFilters();
user.RegexFilters = wordFilters.ToList();
_logger.Information($"{user.RegexFilters.Count()} TTS word filters.");
break;
case "username_filters":
var usernameFilters = await hermes.FetchTTSUsernameFilters();
user.ChatterFilters = usernameFilters.ToDictionary(e => e.Username, e => e);
_logger.Information($"{user.ChatterFilters.Where(f => f.Value.Tag == "blacklisted").Count()} username(s) have been blocked.");
_logger.Information($"{user.ChatterFilters.Where(f => f.Value.Tag == "priority").Count()} user(s) have been prioritized.");
break;
case "default_voice":
user.DefaultTTSVoice = await hermes.FetchTTSDefaultVoice();
_logger.Information("Default Voice: " + user.DefaultTTSVoice);
break;
}
}
}
}

View File

@ -2,7 +2,7 @@ using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchLib.Client.Models;
@ -11,13 +11,14 @@ namespace TwitchChatTTS.Chat.Commands
public class RemoveTTSVoiceCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger<RemoveTTSVoiceCommand> _logger;
private ILogger _logger;
public RemoveTTSVoiceCommand(
[FromKeyedServices("parameter-unvalidated")] ChatCommandParameter ttsVoiceParameter,
IServiceProvider serviceProvider,
ILogger<RemoveTTSVoiceCommand> logger
) : base("removettsvoice", "Select a TTS voice as the default for that user.") {
ILogger logger
) : base("removettsvoice", "Select a TTS voice as the default for that user.")
{
_serviceProvider = serviceProvider;
_logger = logger;
@ -26,7 +27,7 @@ namespace TwitchChatTTS.Chat.Commands
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsModerator || message.IsBroadcaster || message.UserId == "126224566";
return message.IsModerator || message.IsBroadcaster;
}
public override async Task Execute(IList<string> args, ChatMessage message, long broadcasterId)
@ -44,11 +45,12 @@ namespace TwitchChatTTS.Chat.Commands
return;
var voiceId = context.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceName).Key;
await client.Send(3, new RequestMessage() {
await client.Send(3, new RequestMessage()
{
Type = "delete_tts_voice",
Data = new Dictionary<string, string>() { { "@voice", voiceId } }
Data = new Dictionary<string, object>() { { "voice", voiceId } }
});
_logger.LogInformation($"Deleted a TTS voice by {message.Username} (id: {message.UserId}): {voiceName}.");
_logger.Information($"Deleted a TTS voice by {message.Username} (id: {message.UserId}): {voiceName}.");
}
}
}

View File

@ -1,5 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
@ -7,10 +7,11 @@ namespace TwitchChatTTS.Chat.Commands
public class SkipAllCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger<SkipAllCommand> _logger;
private ILogger _logger;
public SkipAllCommand(IServiceProvider serviceProvider, ILogger<SkipAllCommand> logger)
: base("skipall", "Skips all text to speech messages in queue and playing.") {
public SkipAllCommand(IServiceProvider serviceProvider, ILogger logger)
: base("skipall", "Skips all text to speech messages in queue and playing.")
{
_serviceProvider = serviceProvider;
_logger = logger;
}
@ -31,7 +32,7 @@ namespace TwitchChatTTS.Chat.Commands
AudioPlaybackEngine.Instance.RemoveMixerInput(player.Playing);
player.Playing = null;
_logger.LogInformation("Skipped all queued and playing tts.");
_logger.Information("Skipped all queued and playing tts.");
}
}
}

View File

@ -1,5 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
@ -7,10 +7,11 @@ namespace TwitchChatTTS.Chat.Commands
public class SkipCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger<SkipCommand> _logger;
private ILogger _logger;
public SkipCommand(IServiceProvider serviceProvider, ILogger<SkipCommand> logger)
: base("skip", "Skips the current text to speech message.") {
public SkipCommand(IServiceProvider serviceProvider, ILogger logger)
: base("skip", "Skips the current text to speech message.")
{
_serviceProvider = serviceProvider;
_logger = logger;
}
@ -29,7 +30,7 @@ namespace TwitchChatTTS.Chat.Commands
AudioPlaybackEngine.Instance.RemoveMixerInput(player.Playing);
player.Playing = null;
_logger.LogInformation("Skipped current tts.");
_logger.Information("Skipped current tts.");
}
}
}

View File

@ -0,0 +1,76 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
{
public class TTSCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger _logger;
public TTSCommand(
[FromKeyedServices("parameter-ttsvoicename")] ChatCommandParameter ttsVoiceParameter,
[FromKeyedServices("parameter-unvalidated")] ChatCommandParameter unvalidatedParameter,
IServiceProvider serviceProvider,
ILogger logger
) : base("tts", "Various tts commands.")
{
_serviceProvider = serviceProvider;
_logger = logger;
AddParameter(ttsVoiceParameter);
AddParameter(unvalidatedParameter);
}
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsModerator || message.IsBroadcaster;
}
public override async Task Execute(IList<string> args, ChatMessage message, long broadcasterId)
{
var client = _serviceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("hermes");
if (client == null)
return;
var context = _serviceProvider.GetRequiredService<User>();
if (context == null || context.VoicesAvailable == null)
return;
var voiceName = args[0].ToLower();
var voiceId = context.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceName).Key;
var action = args[1].ToLower();
switch (action) {
case "enable":
await client.Send(3, new RequestMessage()
{
Type = "update_tts_voice_state",
Data = new Dictionary<string, object>() { { "voice", voiceId }, { "state", true } }
});
break;
case "disable":
await client.Send(3, new RequestMessage()
{
Type = "update_tts_voice_state",
Data = new Dictionary<string, object>() { { "voice", voiceId }, { "state", false } }
});
break;
case "remove":
await client.Send(3, new RequestMessage()
{
Type = "delete_tts_voice",
Data = new Dictionary<string, object>() { { "voice", voiceId } }
});
break;
}
_logger.Information($"Added a new TTS voice by {message.Username} (id: {message.UserId}): {voiceName}.");
}
}
}

View File

@ -0,0 +1,26 @@
using Serilog;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
{
public class VersionCommand : ChatCommand
{
private ILogger _logger;
public VersionCommand(ILogger logger)
: base("version", "Does nothing.")
{
_logger = logger;
}
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsBroadcaster;
}
public override async Task Execute(IList<string> args, ChatMessage message, long broadcasterId)
{
_logger.Information($"Version: {TTS.MAJOR_VERSION}.{TTS.MINOR_VERSION}");
}
}
}

View File

@ -2,7 +2,7 @@ using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchLib.Client.Models;
@ -11,13 +11,14 @@ namespace TwitchChatTTS.Chat.Commands
public class VoiceCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger<VoiceCommand> _logger;
private ILogger _logger;
public VoiceCommand(
[FromKeyedServices("parameter-ttsvoicename")] ChatCommandParameter ttsVoiceParameter,
IServiceProvider serviceProvider,
ILogger<VoiceCommand> logger
) : base("voice", "Select a TTS voice as the default for that user.") {
ILogger logger
) : base("voice", "Select a TTS voice as the default for that user.")
{
_serviceProvider = serviceProvider;
_logger = logger;
@ -26,7 +27,7 @@ namespace TwitchChatTTS.Chat.Commands
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsModerator || message.IsBroadcaster || message.IsSubscriber || message.Bits >= 100 || message.UserId == "126224566";
return message.IsModerator || message.IsBroadcaster || message.IsSubscriber || message.Bits >= 100;
}
public override async Task Execute(IList<string> args, ChatMessage message, long broadcasterId)
@ -42,19 +43,12 @@ namespace TwitchChatTTS.Chat.Commands
var voiceName = args.First().ToLower();
var voice = context.VoicesAvailable.First(v => v.Value.ToLower() == voiceName);
if (context.VoicesSelected.ContainsKey(chatterId)) {
await client.Send(3, new RequestMessage() {
Type = "update_tts_user",
Data = new Dictionary<string, string>() { { "@user", message.UserId }, { "@broadcaster", broadcasterId.ToString() }, { "@voice", voice.Key } }
await client.Send(3, new RequestMessage()
{
Type = context.VoicesSelected.ContainsKey(chatterId) ? "update_tts_user" : "create_tts_user",
Data = new Dictionary<string, object>() { { "chatter", chatterId }, { "voice", voice.Key } }
});
_logger.LogInformation($"Updated {message.Username}'s (id: {message.UserId}) tts voice to {voice.Value} (id: {voice.Key}).");
} else {
await client.Send(3, new RequestMessage() {
Type = "create_tts_user",
Data = new Dictionary<string, string>() { { "@user", message.UserId }, { "@broadcaster", broadcasterId.ToString() }, { "@voice", voice.Key } }
});
_logger.LogInformation($"Added {message.Username}'s (id: {message.UserId}) tts voice as {voice.Value} (id: {voice.Key}).");
}
_logger.Information($"Updated {message.Username}'s [id: {chatterId}] tts voice to {voice.Value} (id: {voice.Key}).");
}
}
}

View File

@ -1,4 +1,22 @@
public enum MessageResult {
public class MessageResult
{
public MessageStatus Status;
public long BroadcasterId;
public long ChatterId;
public HashSet<string> Emotes;
public MessageResult(MessageStatus status, long broadcasterId, long chatterId, HashSet<string>? emotes = null)
{
Status = status;
BroadcasterId = broadcasterId;
ChatterId = chatterId;
Emotes = emotes ?? new HashSet<string>();
}
}
public enum MessageStatus
{
None = 0,
NotReady = 1,
Blocked = 2,

View File

@ -49,25 +49,41 @@ public class AudioPlaybackEngine : IDisposable
AddMixerInput(new CachedWavProvider(sound));
}
public ISampleProvider ConvertSound(IWaveProvider provider) {
public ISampleProvider ConvertSound(IWaveProvider provider)
{
ISampleProvider? converted = null;
if (provider.WaveFormat.Encoding == WaveFormatEncoding.Pcm) {
if (provider.WaveFormat.BitsPerSample == 8) {
if (provider.WaveFormat.Encoding == WaveFormatEncoding.Pcm)
{
if (provider.WaveFormat.BitsPerSample == 8)
{
converted = new Pcm8BitToSampleProvider(provider);
} else if (provider.WaveFormat.BitsPerSample == 16) {
}
else if (provider.WaveFormat.BitsPerSample == 16)
{
converted = new Pcm16BitToSampleProvider(provider);
} else if (provider.WaveFormat.BitsPerSample == 24) {
}
else if (provider.WaveFormat.BitsPerSample == 24)
{
converted = new Pcm24BitToSampleProvider(provider);
} else if (provider.WaveFormat.BitsPerSample == 32) {
}
else if (provider.WaveFormat.BitsPerSample == 32)
{
converted = new Pcm32BitToSampleProvider(provider);
}
} else if (provider.WaveFormat.Encoding == WaveFormatEncoding.IeeeFloat) {
if (provider.WaveFormat.BitsPerSample == 64) {
}
else if (provider.WaveFormat.Encoding == WaveFormatEncoding.IeeeFloat)
{
if (provider.WaveFormat.BitsPerSample == 64)
{
converted = new WaveToSampleProvider64(provider);
} else {
}
else
{
converted = new WaveToSampleProvider(provider);
}
} else {
}
else
{
throw new ArgumentException("Unsupported source encoding while adding to mixer.");
}
return ConvertToRightChannelCount(converted);
@ -83,15 +99,18 @@ public class AudioPlaybackEngine : IDisposable
mixer.AddMixerInput(input);
}
public void RemoveMixerInput(ISampleProvider sound) {
public void RemoveMixerInput(ISampleProvider sound)
{
mixer.RemoveMixerInput(sound);
}
public void AddOnMixerInputEnded(EventHandler<SampleProviderEventArgs> e) {
public void AddOnMixerInputEnded(EventHandler<SampleProviderEventArgs> e)
{
mixer.MixerInputEnded += e;
}
public void Dispose() {
public void Dispose()
{
outputDevice.Dispose();
}
}

View File

@ -7,12 +7,14 @@ public class NetworkWavSound
public NetworkWavSound(string uri)
{
using (var mfr = new MediaFoundationReader(uri)) {
using (var mfr = new MediaFoundationReader(uri))
{
WaveFormat = mfr.WaveFormat;
byte[] buffer = new byte[4096];
int read = 0;
using (var ms = new MemoryStream()) {
using (var ms = new MemoryStream())
{
while ((read = mfr.Read(buffer, 0, buffer.Length)) > 0)
ms.Write(buffer, 0, read);
AudioData = ms.ToArray();

View File

@ -1,6 +1,7 @@
using NAudio.Wave;
public class TTSPlayer {
public class TTSPlayer
{
private PriorityQueue<TTSMessage, int> _messages; // ready to play
private PriorityQueue<TTSMessage, int> _buffer;
private Mutex _mutex;
@ -8,77 +9,105 @@ public class TTSPlayer {
public ISampleProvider? Playing { get; set; }
public TTSPlayer() {
public TTSPlayer()
{
_messages = new PriorityQueue<TTSMessage, int>();
_buffer = new PriorityQueue<TTSMessage, int>();
_mutex = new Mutex();
_mutex2 = new Mutex();
}
public void Add(TTSMessage message) {
try {
public void Add(TTSMessage message)
{
try
{
_mutex2.WaitOne();
_buffer.Enqueue(message, message.Priority);
} finally {
}
finally
{
_mutex2.ReleaseMutex();
}
}
public TTSMessage? ReceiveReady() {
try {
public TTSMessage? ReceiveReady()
{
try
{
_mutex.WaitOne();
if (_messages.TryDequeue(out TTSMessage? message, out int _)) {
if (_messages.TryDequeue(out TTSMessage? message, out int _))
{
return message;
}
return null;
} finally {
}
finally
{
_mutex.ReleaseMutex();
}
}
public TTSMessage? ReceiveBuffer() {
try {
public TTSMessage? ReceiveBuffer()
{
try
{
_mutex2.WaitOne();
if (_buffer.TryDequeue(out TTSMessage? message, out int _)) {
if (_buffer.TryDequeue(out TTSMessage? message, out int _))
{
return message;
}
return null;
} finally {
}
finally
{
_mutex2.ReleaseMutex();
}
}
public void Ready(TTSMessage message) {
try {
public void Ready(TTSMessage message)
{
try
{
_mutex.WaitOne();
_messages.Enqueue(message, message.Priority);
} finally {
}
finally
{
_mutex.ReleaseMutex();
}
}
public void RemoveAll() {
try {
public void RemoveAll()
{
try
{
_mutex2.WaitOne();
_buffer.Clear();
} finally {
}
finally
{
_mutex2.ReleaseMutex();
}
try {
try
{
_mutex.WaitOne();
_messages.Clear();
} finally {
}
finally
{
_mutex.ReleaseMutex();
}
}
public bool IsEmpty() {
public bool IsEmpty()
{
return _messages.Count == 0;
}
}
public class TTSMessage {
public class TTSMessage
{
public string? Voice { get; set; }
public string? Channel { get; set; }
public string? Username { get; set; }

View File

@ -2,11 +2,11 @@ namespace TwitchChatTTS
{
public class Configuration
{
public string Environment = "PROD";
public HermesConfiguration? Hermes;
public TwitchConfiguration? Twitch;
public EmotesConfiguration? Emotes;
public OBSConfiguration? Obs;
public SevenConfiguration? Seven;
public class HermesConfiguration {
@ -26,18 +26,10 @@ namespace TwitchChatTTS
public bool? OutputAppend;
}
public class EmotesConfiguration {
public string? CounterFilePath;
}
public class OBSConfiguration {
public string? Host;
public short? Port;
public string? Password;
}
public class SevenConfiguration {
public string? UserId;
}
}
}

View File

@ -1,38 +1,46 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace TwitchChatTTS.Helpers {
public class WebClientWrap {
namespace TwitchChatTTS.Helpers
{
public class WebClientWrap
{
private HttpClient _client;
private JsonSerializerOptions _options;
public WebClientWrap(JsonSerializerOptions options) {
public WebClientWrap(JsonSerializerOptions options)
{
_client = new HttpClient();
_options = options;
}
public void AddHeader(string key, string? value) {
public void AddHeader(string key, string? value)
{
if (_client.DefaultRequestHeaders.Contains(key))
_client.DefaultRequestHeaders.Remove(key);
_client.DefaultRequestHeaders.Add(key, value);
}
public async Task<T?> GetJson<T>(string uri) {
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) {
public async Task<HttpResponseMessage> Get(string uri)
{
return await _client.GetAsync(uri);
}
public async Task<HttpResponseMessage> Post<T>(string uri, T data) {
public async Task<HttpResponseMessage> Post<T>(string uri, T data)
{
return await _client.PostAsJsonAsync(uri, data);
}
public async Task<HttpResponseMessage> Post(string uri) {
public async Task<HttpResponseMessage> Post(string uri)
{
return await _client.PostAsJsonAsync(uri, new object());
}
}

View File

@ -1,9 +1,4 @@
using System.Diagnostics.CodeAnalysis;
[Serializable]
public class Account {
[AllowNull]
public string Id { get; set; }
[AllowNull]
public string Username { get; set; }
}

View File

@ -1,31 +1,44 @@
using TwitchChatTTS.Helpers;
using TwitchChatTTS;
using TwitchChatTTS.Hermes;
using System.Text.Json;
using HermesSocketLibrary.Requests.Messages;
using TwitchChatTTS.Hermes;
public class HermesClient {
public class HermesApiClient
{
private WebClientWrap _web;
public HermesClient(Configuration configuration) {
if (string.IsNullOrWhiteSpace(configuration.Hermes?.Token)) {
public HermesApiClient(Configuration configuration)
{
if (string.IsNullOrWhiteSpace(configuration.Hermes?.Token))
{
throw new Exception("Ensure you have written your API key in \".token\" file, in the same folder as this application.");
}
_web = new WebClientWrap(new JsonSerializerOptions() {
_web = new WebClientWrap(new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
_web.AddHeader("x-api-key", configuration.Hermes.Token);
}
public async Task<Account> FetchHermesAccountDetails() {
public async Task<TTSVersion> GetTTSVersion()
{
var version = await _web.GetJson<TTSVersion>("https://hermes.goblincaves.com/api/info/version");
return version;
}
public async Task<Account> FetchHermesAccountDetails()
{
var account = await _web.GetJson<Account>("https://hermes.goblincaves.com/api/account");
if (account == null || account.Id == null || account.Username == null)
throw new NullReferenceException("Invalid value found while fetching for hermes account data.");
return account;
}
public async Task<TwitchBotToken> FetchTwitchBotToken() {
public async Task<TwitchBotToken> FetchTwitchBotToken()
{
var token = await _web.GetJson<TwitchBotToken>("https://hermes.goblincaves.com/api/token/bot");
if (token == null || token.ClientId == null || token.AccessToken == null || token.RefreshToken == null || token.ClientSecret == null)
throw new Exception("Failed to fetch Twitch API token from Hermes.");
@ -33,7 +46,8 @@ public class HermesClient {
return token;
}
public async Task<IEnumerable<TTSUsernameFilter>> FetchTTSUsernameFilters() {
public async Task<IEnumerable<TTSUsernameFilter>> FetchTTSUsernameFilters()
{
var filters = await _web.GetJson<IEnumerable<TTSUsernameFilter>>("https://hermes.goblincaves.com/api/settings/tts/filter/users");
if (filters == null)
throw new Exception("Failed to fetch TTS username filters from Hermes.");
@ -41,23 +55,35 @@ public class HermesClient {
return filters;
}
public async Task<string> FetchTTSDefaultVoice() {
var data = await _web.GetJson<TTSVoice>("https://hermes.goblincaves.com/api/settings/tts/default");
public async Task<string> FetchTTSDefaultVoice()
{
var data = await _web.GetJson<string>("https://hermes.goblincaves.com/api/settings/tts/default");
if (data == null)
throw new Exception("Failed to fetch TTS default voice from Hermes.");
return data.Label;
return data;
}
public async Task<IEnumerable<TTSVoice>> FetchTTSEnabledVoices() {
var voices = await _web.GetJson<IEnumerable<TTSVoice>>("https://hermes.goblincaves.com/api/settings/tts");
public async Task<IEnumerable<TTSChatterSelectedVoice>> FetchTTSChatterSelectedVoices()
{
var voices = await _web.GetJson<IEnumerable<TTSChatterSelectedVoice>>("https://hermes.goblincaves.com/api/settings/tts/selected");
if (voices == null)
throw new Exception("Failed to fetch TTS chatter selected voices from Hermes.");
return voices;
}
public async Task<IEnumerable<string>> FetchTTSEnabledVoices()
{
var voices = await _web.GetJson<IEnumerable<string>>("https://hermes.goblincaves.com/api/settings/tts");
if (voices == null)
throw new Exception("Failed to fetch TTS enabled voices from Hermes.");
return voices;
}
public async Task<IEnumerable<TTSWordFilter>> FetchTTSWordFilters() {
public async Task<IEnumerable<TTSWordFilter>> FetchTTSWordFilters()
{
var filters = await _web.GetJson<IEnumerable<TTSWordFilter>>("https://hermes.goblincaves.com/api/settings/tts/filter/words");
if (filters == null)
throw new Exception("Failed to fetch TTS word filters from Hermes.");

View File

@ -1,7 +1,7 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.Logging;
using Serilog;
namespace TwitchChatTTS.Hermes.Socket.Handlers
{
@ -10,7 +10,8 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers
private ILogger _logger { get; }
public int OperationCode { get; set; } = 0;
public HeartbeatHandler(ILogger<HeartbeatHandler> logger) {
public HeartbeatHandler(ILogger logger)
{
_logger = logger;
}
@ -19,16 +20,20 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers
if (message is not HeartbeatMessage obj || obj == null)
return;
if (sender is not HermesSocketClient client) {
if (sender is not HermesSocketClient client)
{
return;
}
_logger.LogTrace("Received heartbeat.");
_logger.Verbose("Received heartbeat.");
client.LastHeartbeat = DateTime.UtcNow;
client.LastHeartbeatReceived = DateTime.UtcNow;
await sender.Send(0, new HeartbeatMessage() {
DateTime = DateTime.UtcNow
if (obj.Respond)
await sender.Send(0, new HeartbeatMessage()
{
DateTime = DateTime.UtcNow,
Respond = false
});
}
}

View File

@ -1,16 +1,20 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
namespace TwitchChatTTS.Hermes.Socket.Handlers
{
public class LoginAckHandler : IWebSocketHandler
{
private ILogger _logger { get; }
private IServiceProvider _serviceProvider;
private ILogger _logger;
public int OperationCode { get; set; } = 2;
public LoginAckHandler(ILogger<LoginAckHandler> logger) {
public LoginAckHandler(IServiceProvider serviceProvider, ILogger logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
@ -19,16 +23,44 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers
if (message is not LoginAckMessage obj || obj == null)
return;
if (sender is not HermesSocketClient client) {
if (sender is not HermesSocketClient client)
return;
if (obj.AnotherClient)
{
_logger.Warning("Another client has connected to the same account.");
}
else
{
var user = _serviceProvider.GetRequiredService<User>();
client.UserId = obj.UserId;
_logger.Information($"Logged in as {user.TwitchUsername} (id: {client.UserId}).");
}
if (obj.AnotherClient) {
_logger.LogWarning("Another client has connected to the same account.");
} else {
client.UserId = obj.UserId;
_logger.LogInformation($"Logged in as {client.UserId}.");
}
await client.Send(3, new RequestMessage()
{
Type = "get_tts_voices",
Data = null
});
var token = _serviceProvider.GetRequiredService<User>();
await client.Send(3, new RequestMessage()
{
Type = "get_tts_users",
Data = new Dictionary<string, object>() { { "user", token.HermesUserId } }
});
await client.Send(3, new RequestMessage()
{
Type = "get_chatter_ids",
Data = null
});
await client.Send(3, new RequestMessage()
{
Type = "get_emotes",
Data = null
});
}
}
}

View File

@ -5,7 +5,8 @@ using CommonSocketLibrary.Common;
using HermesSocketLibrary.Requests.Messages;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.Seven;
namespace TwitchChatTTS.Hermes.Socket.Handlers
{
@ -14,9 +15,13 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers
private readonly IServiceProvider _serviceProvider;
private readonly JsonSerializerOptions _options;
private readonly ILogger _logger;
private readonly object _voicesAvailableLock = new object();
public int OperationCode { get; set; } = 4;
public RequestAckHandler(IServiceProvider serviceProvider, JsonSerializerOptions options, ILogger<RequestAckHandler> logger) {
public RequestAckHandler(IServiceProvider serviceProvider, JsonSerializerOptions options, ILogger logger)
{
_serviceProvider = serviceProvider;
_options = options;
_logger = logger;
@ -32,65 +37,87 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers
if (context == null)
return;
if (obj.Request.Type == "get_tts_voices") {
_logger.LogDebug("Updating all available voices.");
if (obj.Request.Type == "get_tts_voices")
{
_logger.Verbose("Updating all available voices for TTS.");
var voices = JsonSerializer.Deserialize<IEnumerable<VoiceDetails>>(obj.Data.ToString(), _options);
if (voices == null)
return;
lock (_voicesAvailableLock)
{
context.VoicesAvailable = voices.ToDictionary(e => e.Id, e => e.Name);
_logger.LogInformation("Updated all available voices.");
} else if (obj.Request.Type == "create_tts_user") {
_logger.LogDebug("Creating new tts voice.");
if (!long.TryParse(obj.Request.Data["@user"], out long userId))
}
_logger.Information("Updated all available voices for TTS.");
}
else if (obj.Request.Type == "create_tts_user")
{
_logger.Verbose("Adding new tts voice for user.");
if (!long.TryParse(obj.Request.Data["user"].ToString(), out long chatterId))
return;
string broadcasterId = obj.Request.Data["@broadcaster"].ToString();
// TODO: validate broadcaster id.
string voice = obj.Request.Data["@voice"].ToString();
string userId = obj.Request.Data["user"].ToString();
string voice = obj.Request.Data["voice"].ToString();
context.VoicesSelected.Add(userId, voice);
_logger.LogInformation("Created new tts user.");
} else if (obj.Request.Type == "update_tts_user") {
_logger.LogDebug("Updating user's voice");
if (!long.TryParse(obj.Request.Data["@user"], out long userId))
context.VoicesSelected.Add(chatterId, voice);
_logger.Information($"Added new TTS voice [voice: {voice}] for user [user id: {userId}]");
}
else if (obj.Request.Type == "update_tts_user")
{
_logger.Verbose("Updating user's voice");
if (!long.TryParse(obj.Request.Data["chatter"].ToString(), out long chatterId))
return;
string broadcasterId = obj.Request.Data["@broadcaster"].ToString();
string voice = obj.Request.Data["@voice"].ToString();
string userId = obj.Request.Data["user"].ToString();
string voice = obj.Request.Data["voice"].ToString();
context.VoicesSelected[userId] = voice;
_logger.LogInformation($"Updated user's voice to {voice}.");
} else if (obj.Request.Type == "create_tts_voice") {
_logger.LogDebug("Creating new tts voice.");
string? voice = obj.Request.Data["@voice"];
context.VoicesSelected[chatterId] = voice;
_logger.Information($"Updated TTS voice [voice: {voice}] for user [user id: {userId}]");
}
else if (obj.Request.Type == "create_tts_voice")
{
_logger.Verbose("Creating new tts voice.");
string? voice = obj.Request.Data["voice"].ToString();
string? voiceId = obj.Data.ToString();
if (voice == null || voiceId == null)
return;
context.VoicesAvailable.Add(voiceId, voice);
_logger.LogInformation($"Created new tts voice named {voice} (id: {voiceId}).");
} else if (obj.Request.Type == "delete_tts_voice") {
_logger.LogDebug("Deleting tts voice.");
var voice = obj.Request.Data["@voice"];
if (!context.VoicesAvailable.TryGetValue(voice, out string voiceName) || voiceName == null) {
return;
lock (_voicesAvailableLock)
{
var list = context.VoicesAvailable.ToDictionary(k => k.Key, v => v.Value);
list.Add(voiceId, voice);
context.VoicesAvailable = list;
}
_logger.Information($"Created new tts voice [voice: {voice}][id: {voiceId}].");
}
else if (obj.Request.Type == "delete_tts_voice")
{
_logger.Verbose("Deleting tts voice.");
var voice = obj.Request.Data["voice"].ToString();
if (!context.VoicesAvailable.TryGetValue(voice, out string voiceName) || voiceName == null)
return;
lock (_voicesAvailableLock)
{
var dict = context.VoicesAvailable.ToDictionary(k => k.Key, v => v.Value);
dict.Remove(voice);
context.VoicesAvailable.Remove(voice);
_logger.LogInformation("Deleted a voice, named " + voiceName + ".");
} else if (obj.Request.Type == "update_tts_voice") {
_logger.LogDebug("Updating tts voice.");
string voiceId = obj.Request.Data["@idd"].ToString();
string voice = obj.Request.Data["@voice"].ToString();
if (!context.VoicesAvailable.TryGetValue(voiceId, out string voiceName) || voiceName == null) {
return;
}
_logger.Information($"Deleted a voice [voice: {voiceName}]");
}
else if (obj.Request.Type == "update_tts_voice")
{
_logger.Verbose("Updating TTS voice.");
string voiceId = obj.Request.Data["idd"].ToString();
string voice = obj.Request.Data["voice"].ToString();
if (!context.VoicesAvailable.TryGetValue(voiceId, out string voiceName) || voiceName == null)
return;
context.VoicesAvailable[voiceId] = voice;
_logger.LogInformation("Update tts voice: " + voice);
} else if (obj.Request.Type == "get_tts_users") {
_logger.LogDebug("Attempting to update all chatters' selected voice.");
_logger.Information($"Updated TTS voice [voice: {voice}][id: {voiceId}]");
}
else if (obj.Request.Type == "get_tts_users")
{
_logger.Verbose("Updating all chatters' selected voice.");
var users = JsonSerializer.Deserialize<IDictionary<long, string>>(obj.Data.ToString(), _options);
if (users == null)
return;
@ -99,7 +126,55 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers
foreach (var entry in users)
temp.TryAdd(entry.Key, entry.Value);
context.VoicesSelected = temp;
_logger.LogInformation($"Fetched {temp.Count()} chatters' selected voice.");
_logger.Information($"Updated {temp.Count()} chatters' selected voice.");
}
else if (obj.Request.Type == "get_chatter_ids")
{
_logger.Verbose("Fetching all chatters' id.");
var chatters = JsonSerializer.Deserialize<IEnumerable<long>>(obj.Data.ToString(), _options);
if (chatters == null)
return;
var client = _serviceProvider.GetRequiredService<ChatMessageHandler>();
client.Chatters = [.. chatters];
_logger.Information($"Fetched {chatters.Count()} chatters' id.");
}
else if (obj.Request.Type == "get_emotes")
{
_logger.Verbose("Updating emotes.");
var emotes = JsonSerializer.Deserialize<IEnumerable<EmoteInfo>>(obj.Data.ToString(), _options);
if (emotes == null)
return;
var emoteDb = _serviceProvider.GetRequiredService<EmoteDatabase>();
var count = 0;
foreach (var emote in emotes)
{
if (emoteDb.Get(emote.Name) == null)
{
emoteDb.Add(emote.Name, emote.Id);
count++;
}
}
_logger.Information($"Fetched {count} emotes from various sources.");
}
else if (obj.Request.Type == "update_tts_voice_state")
{
_logger.Verbose("Updating TTS voice states.");
string voiceId = obj.Request.Data["voice"].ToString();
bool state = obj.Request.Data["state"].ToString() == "true";
if (!context.VoicesAvailable.TryGetValue(voiceId, out string voiceName) || voiceName == null)
{
_logger.Warning($"Failed to find voice [id: {voiceId}]");
return;
}
if (state)
context.VoicesEnabled.Add(voiceId);
else
context.VoicesEnabled.Remove(voiceId);
_logger.Information($"Updated voice state [voice: {voiceName}][new state: {(state ? "enabled" : "disabled")}]");
}
}
}

View File

@ -1,24 +1,108 @@
using System.Text.Json;
using System.Timers;
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
namespace TwitchChatTTS.Hermes.Socket
{
public class HermesSocketClient : WebSocketClient {
public DateTime LastHeartbeat { get; set; }
public class HermesSocketClient : WebSocketClient
{
private Configuration _configuration;
public DateTime LastHeartbeatReceived { get; set; }
public DateTime LastHeartbeatSent { get; set; }
public string? UserId { get; set; }
private System.Timers.Timer _heartbeatTimer;
private System.Timers.Timer _reconnectTimer;
public HermesSocketClient(
ILogger<HermesSocketClient> logger,
Configuration configuration,
[FromKeyedServices("hermes")] HandlerManager<WebSocketClient, IWebSocketHandler> handlerManager,
[FromKeyedServices("hermes")] HandlerTypeManager<WebSocketClient, IWebSocketHandler> typeManager
) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() {
[FromKeyedServices("hermes")] HandlerTypeManager<WebSocketClient, IWebSocketHandler> typeManager,
ILogger logger
) : base(logger, handlerManager, typeManager, new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
}) {
})
{
_configuration = configuration;
_heartbeatTimer = new System.Timers.Timer(TimeSpan.FromSeconds(15));
_heartbeatTimer.Elapsed += async (sender, e) => await HandleHeartbeat(e);
_reconnectTimer = new System.Timers.Timer(TimeSpan.FromSeconds(15));
_reconnectTimer.Elapsed += async (sender, e) => await Reconnect(e);
LastHeartbeatReceived = LastHeartbeatSent = DateTime.UtcNow;
}
protected override async Task OnConnection()
{
_heartbeatTimer.Enabled = true;
}
private async Task HandleHeartbeat(ElapsedEventArgs e)
{
var signalTime = e.SignalTime.ToUniversalTime();
if (signalTime - LastHeartbeatReceived > TimeSpan.FromSeconds(60))
{
if (LastHeartbeatReceived > LastHeartbeatSent)
{
_logger.Verbose("Sending heartbeat...");
LastHeartbeatSent = DateTime.UtcNow;
try
{
await Send(0, new HeartbeatMessage() { DateTime = LastHeartbeatSent });
}
catch (Exception)
{
}
}
else if (signalTime - LastHeartbeatReceived > TimeSpan.FromSeconds(120))
{
try
{
await DisconnectAsync();
}
catch (Exception)
{
}
UserId = null;
_heartbeatTimer.Enabled = false;
_logger.Information("Logged off due to disconnection. Attempting to reconnect...");
_reconnectTimer.Enabled = true;
}
}
}
private async Task Reconnect(ElapsedEventArgs e)
{
try
{
await ConnectAsync($"wss://hermes-ws.goblincaves.com");
Connected = true;
}
catch (Exception)
{
}
finally
{
if (Connected)
{
_logger.Information("Reconnected.");
_reconnectTimer.Enabled = false;
_heartbeatTimer.Enabled = true;
LastHeartbeatReceived = DateTime.UtcNow;
if (_configuration.Hermes?.Token != null)
await Send(1, new HermesLoginMessage() { ApiKey = _configuration.Hermes.Token });
}
}
}
}
}

View File

@ -1,35 +1,41 @@
using CommonSocketLibrary.Common;
using CommonSocketLibrary.Socket.Manager;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
namespace TwitchChatTTS.Hermes.Socket.Managers
{
public class HermesHandlerManager : WebSocketHandlerManager
{
public HermesHandlerManager(ILogger<HermesHandlerManager> logger, IServiceProvider provider) : base(logger) {
public HermesHandlerManager(ILogger logger, IServiceProvider provider) : base(logger)
{
//Add(provider.GetRequiredService<HeartbeatHandler>());
try {
try
{
var basetype = typeof(IWebSocketHandler);
var assembly = GetType().Assembly;
var types = assembly.GetTypes().Where(t => t.IsClass && basetype.IsAssignableFrom(t) && t.AssemblyQualifiedName?.Contains(".Hermes.") == true);
foreach (var type in types) {
foreach (var type in types)
{
var key = "hermes-" + type.Name.Replace("Handlers", "Hand#lers")
.Replace("Handler", "")
.Replace("Hand#lers", "Handlers")
.ToLower();
var handler = provider.GetKeyedService<IWebSocketHandler>(key);
if (handler == null) {
logger.LogError("Failed to find hermes websocket handler: " + type.AssemblyQualifiedName);
if (handler == null)
{
logger.Error("Failed to find hermes websocket handler: " + type.AssemblyQualifiedName);
continue;
}
Logger.LogDebug($"Linked type {type.AssemblyQualifiedName} to hermes websocket handlers.");
Logger.Debug($"Linked type {type.AssemblyQualifiedName} to hermes websocket handlers.");
Add(handler);
}
} catch (Exception e) {
Logger.LogError(e, "Failed to load hermes websocket handler types.");
}
catch (Exception e)
{
Logger.Error(e, "Failed to load hermes websocket handler types.");
}
}
}

View File

@ -3,14 +3,14 @@ using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using CommonSocketLibrary.Socket.Manager;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
namespace TwitchChatTTS.Hermes.Socket.Managers
{
public class HermesHandlerTypeManager : WebSocketHandlerTypeManager
{
public HermesHandlerTypeManager(
ILogger<HermesHandlerTypeManager> factory,
ILogger factory,
[FromKeyedServices("hermes")] HandlerManager<WebSocketClient, IWebSocketHandler> handlers
) : base(factory, handlers)
{

10
Hermes/TTSVersion.cs Normal file
View File

@ -0,0 +1,10 @@
namespace TwitchChatTTS.Hermes
{
public class TTSVersion
{
public int MajorVersion { get; set; }
public int MinorVersion { get; set; }
public string Download { get; set; }
public string Changelog { get; set; }
}
}

View File

@ -1,6 +1,13 @@
public class TTSVoice {
public class TTSVoice
{
public string Label { get; set; }
public int Value { get; set; }
public string? Gender { get; set; }
public string? Language { get; set; }
}
public class TTSChatterSelectedVoice
{
public long ChatterId { get; set; }
public string Voice { get; set; }
}

View File

@ -1,17 +0,0 @@
namespace TwitchChatTTS.Hermes
{
public class TTSWordFilter
{
public string? Id { get; set; }
public string? Search { get; set; }
public string? Replace { get; set; }
public string? UserId { get; set; }
public bool IsRegex { get; set; }
public TTSWordFilter() {
IsRegex = true;
}
}
}

View File

@ -1,4 +1,3 @@
[Serializable]
public class TwitchBotAuth {
public string? UserId { get; set; }
public string? AccessToken { get; set; }

View File

@ -1,4 +1,3 @@
[Serializable]
public class TwitchBotToken {
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }

View File

@ -1,4 +1,3 @@
[Serializable]
public class TwitchConnection {
public string? Id { get; set; }
public string? Secret { get; set; }

View File

@ -1,6 +1,5 @@
namespace TwitchChatTTS.OBS.Socket.Context
{
[Serializable]
public class HelloContext
{
public string? Host { get; set; }

View File

@ -1,6 +1,5 @@
namespace TwitchChatTTS.OBS.Socket.Data
{
[Serializable]
public class EventMessage
{
public string EventType { get; set; }

View File

@ -1,6 +1,5 @@
namespace TwitchChatTTS.OBS.Socket.Data
{
[Serializable]
public class HelloMessage
{
public string ObsWebSocketVersion { get; set; }

View File

@ -1,6 +1,5 @@
namespace TwitchChatTTS.OBS.Socket.Data
{
[Serializable]
public class IdentifiedMessage
{
public int NegotiatedRpcVersion { get; set; }

View File

@ -1,13 +1,13 @@
namespace TwitchChatTTS.OBS.Socket.Data
{
[Serializable]
public class IdentifyMessage
{
public int RpcVersion { get; set; }
public string? Authentication { get; set; }
public int EventSubscriptions { get; set; }
public IdentifyMessage(int version, string auth, int subscriptions) {
public IdentifyMessage(int version, string auth, int subscriptions)
{
RpcVersion = version;
Authentication = auth;
EventSubscriptions = subscriptions;

View File

@ -0,0 +1,25 @@
namespace TwitchChatTTS.OBS.Socket.Data
{
public class RequestBatchMessage
{
public string RequestId { get; set; }
public bool HaltOnFailure { get; set; }
public RequestBatchExecutionType ExecutionType { get; set; }
public IEnumerable<object> Requests { get; set;}
public RequestBatchMessage(string id, IEnumerable<object> requests, bool haltOnFailure = false, RequestBatchExecutionType executionType = RequestBatchExecutionType.SerialRealtime)
{
RequestId = id;
Requests = requests;
HaltOnFailure = haltOnFailure;
ExecutionType = executionType;
}
}
public enum RequestBatchExecutionType {
None = -1,
SerialRealtime = 0,
SerialFrame = 1,
Parallel = 2
}
}

View File

@ -0,0 +1,8 @@
namespace TwitchChatTTS.OBS.Socket.Data
{
public class RequestBatchResponseMessage
{
public string RequestId { get; set; }
public IEnumerable<object> Results { get; set; }
}
}

View File

@ -1,13 +1,13 @@
namespace TwitchChatTTS.OBS.Socket.Data
{
[Serializable]
public class RequestMessage
{
public string RequestType { get; set; }
public string RequestId { get; set; }
public Dictionary<string, object> RequestData { get; set; }
public RequestMessage(string type, string id, Dictionary<string, object> data) {
public RequestMessage(string type, string id, Dictionary<string, object> data)
{
RequestType = type;
RequestId = id;
RequestData = data;

View File

@ -1,6 +1,5 @@
namespace TwitchChatTTS.OBS.Socket.Data
{
[Serializable]
public class RequestResponseMessage
{
public string RequestType { get; set; }

View File

@ -1,19 +1,20 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.OBS.Socket.Data;
namespace TwitchChatTTS.OBS.Socket.Handlers
{
public class EventMessageHandler : IWebSocketHandler
{
private ILogger Logger { get; }
private IServiceProvider ServiceProvider { get; }
private ILogger _logger { get; }
private IServiceProvider _serviceProvider { get; }
public int OperationCode { get; set; } = 5;
public EventMessageHandler(ILogger<EventMessageHandler> logger, IServiceProvider serviceProvider) {
Logger = logger;
ServiceProvider = serviceProvider;
public EventMessageHandler(ILogger logger, IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
}
public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message)
@ -21,7 +22,8 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
if (message is not EventMessage obj || obj == null)
return;
switch (obj.EventType) {
switch (obj.EventType)
{
case "StreamStateChanged":
case "RecordStateChanged":
if (sender is not OBSSocketClient client)
@ -30,19 +32,21 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
string? raw_state = obj.EventData["outputState"].ToString();
string? state = raw_state?.Substring(21).ToLower();
client.Live = obj.EventData["outputActive"].ToString() == "True";
Logger.LogWarning("Stream " + (state != null && state.EndsWith("ing") ? "is " : "has ") + state + ".");
_logger.Warning("Stream " + (state != null && state.EndsWith("ing") ? "is " : "has ") + state + ".");
if (client.Live == false && state != null && !state.EndsWith("ing")) {
if (client.Live == false && state != null && !state.EndsWith("ing"))
{
OnStreamEnd();
}
break;
default:
Logger.LogDebug(obj.EventType + " EVENT: " + string.Join(" | ", obj.EventData?.Select(x => x.Key + "=" + x.Value?.ToString()) ?? new string[0]));
_logger.Debug(obj.EventType + " EVENT: " + string.Join(" | ", obj.EventData?.Select(x => x.Key + "=" + x.Value?.ToString()) ?? new string[0]));
break;
}
}
private void OnStreamEnd() {
private void OnStreamEnd()
{
}
}
}

View File

@ -2,7 +2,7 @@ using System.Security.Cryptography;
using System.Text;
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.OBS.Socket.Data;
using TwitchChatTTS.OBS.Socket.Context;
@ -10,13 +10,14 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
{
public class HelloHandler : IWebSocketHandler
{
private ILogger Logger { get; }
private ILogger _logger { get; }
public int OperationCode { get; set; } = 0;
private HelloContext Context { get; }
private HelloContext _context { get; }
public HelloHandler(ILogger<HelloHandler> logger, HelloContext context) {
Logger = logger;
Context = context;
public HelloHandler(ILogger logger, HelloContext context)
{
_logger = logger;
_context = context;
}
public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message)
@ -24,19 +25,23 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
if (message is not HelloMessage obj || obj == null)
return;
Logger.LogTrace("OBS websocket password: " + Context.Password);
if (obj.Authentication == null || Context.Password == null) // TODO: send re-identify message.
_logger.Verbose("OBS websocket password: " + _context.Password);
if (obj.Authentication == null || string.IsNullOrWhiteSpace(_context.Password))
{
await sender.Send(1, new IdentifyMessage(obj.RpcVersion, string.Empty, 1023 | 262144));
return;
}
var salt = obj.Authentication.Salt;
var challenge = obj.Authentication.Challenge;
Logger.LogTrace("Salt: " + salt);
Logger.LogTrace("Challenge: " + challenge);
_logger.Verbose("Salt: " + salt);
_logger.Verbose("Challenge: " + challenge);
string secret = Context.Password + salt;
string secret = _context.Password + salt;
byte[] bytes = Encoding.UTF8.GetBytes(secret);
string hash = null;
using (var sha = SHA256.Create()) {
using (var sha = SHA256.Create())
{
bytes = sha.ComputeHash(bytes);
hash = Convert.ToBase64String(bytes);
@ -46,7 +51,7 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
hash = Convert.ToBase64String(bytes);
}
Logger.LogTrace("Final hash: " + hash);
_logger.Verbose("Final hash: " + hash);
await sender.Send(1, new IdentifyMessage(obj.RpcVersion, hash, 1023 | 262144));
}
}

View File

@ -1,6 +1,6 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.OBS.Socket.Data;
namespace TwitchChatTTS.OBS.Socket.Handlers
@ -10,7 +10,8 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
private ILogger Logger { get; }
public int OperationCode { get; set; } = 2;
public IdentifiedHandler(ILogger<IdentifiedHandler> logger) {
public IdentifiedHandler(ILogger logger)
{
Logger = logger;
}
@ -20,7 +21,7 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
return;
sender.Connected = true;
Logger.LogInformation("Connected to OBS via rpc version " + obj.NegotiatedRpcVersion + ".");
Logger.Information("Connected to OBS via rpc version " + obj.NegotiatedRpcVersion + ".");
}
}
}

View File

@ -0,0 +1,85 @@
using System.Text.Json;
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Serilog.Context;
using TwitchChatTTS.OBS.Socket.Data;
using TwitchChatTTS.OBS.Socket.Manager;
namespace TwitchChatTTS.OBS.Socket.Handlers
{
public class RequestBatchResponseHandler : IWebSocketHandler
{
private OBSRequestBatchManager _manager { get; }
private IServiceProvider _serviceProvider { get; }
private ILogger _logger { get; }
private JsonSerializerOptions _options;
public int OperationCode { get; set; } = 9;
public RequestBatchResponseHandler(OBSRequestBatchManager manager, JsonSerializerOptions options, IServiceProvider serviceProvider, ILogger logger)
{
_manager = manager;
_serviceProvider = serviceProvider;
_logger = logger;
_options = options;
}
public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data data)
{
if (data is not RequestBatchResponseMessage message || message == null)
return;
using (LogContext.PushProperty("obsrid", message.RequestId))
{
var results = message.Results.ToList();
_logger.Debug($"Received request batch response of {results.Count} messages.");
var requestData = _manager.Take(message.RequestId);
if (requestData == null || !results.Any())
{
_logger.Verbose($"Received request batch response of {results.Count} messages.");
return;
}
IList<Task> tasks = new List<Task>();
int count = Math.Min(results.Count, requestData.RequestTypes.Count);
for (int i = 0; i < count; i++)
{
Type type = requestData.RequestTypes[i];
using (LogContext.PushProperty("type", type.Name))
{
try
{
var handler = GetResponseHandlerForRequestType(type);
_logger.Verbose($"Request handled by {handler.GetType().Name}.");
tasks.Add(handler.Execute(sender, results[i]));
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to process an item in a request batch message.");
}
}
}
_logger.Verbose($"Waiting for processing to complete.");
await Task.WhenAll(tasks);
_logger.Debug($"Finished processing all request in this batch.");
}
}
private IWebSocketHandler? GetResponseHandlerForRequestType(Type type)
{
if (type == typeof(RequestMessage))
return _serviceProvider.GetRequiredKeyedService<IWebSocketHandler>("obs-requestresponse");
else if (type == typeof(RequestBatchMessage))
return _serviceProvider.GetRequiredKeyedService<IWebSocketHandler>("obs-requestbatcresponse");
else if (type == typeof(IdentifyMessage))
return _serviceProvider.GetRequiredKeyedService<IWebSocketHandler>("obs-identified");
return null;
}
}
}

View File

@ -1,6 +1,6 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.OBS.Socket.Data;
namespace TwitchChatTTS.OBS.Socket.Handlers
@ -10,7 +10,8 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
private ILogger Logger { get; }
public int OperationCode { get; set; } = 7;
public RequestResponseHandler(ILogger<RequestResponseHandler> logger) {
public RequestResponseHandler(ILogger logger)
{
Logger = logger;
}
@ -19,14 +20,16 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
if (message is not RequestResponseMessage obj || obj == null)
return;
switch (obj.RequestType) {
switch (obj.RequestType)
{
case "GetOutputStatus":
if (sender is not OBSSocketClient client)
return;
if (obj.RequestId == "stream") {
if (obj.RequestId == "stream")
{
client.Live = obj.ResponseData["outputActive"].ToString() == "True";
Logger.LogWarning("Updated stream's live status to " + client.Live);
Logger.Warning("Updated stream's live status to " + client.Live);
}
break;
}

View File

@ -0,0 +1,60 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using TwitchChatTTS.OBS.Socket.Data;
namespace TwitchChatTTS.OBS.Socket.Manager
{
public class OBSRequestBatchManager
{
private IDictionary<string, OBSRequestBatchData> _requests;
private IServiceProvider _serviceProvider;
private ILogger _logger;
public OBSRequestBatchManager(IServiceProvider serviceProvider, ILogger logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public async Task Send(long broadcasterId, IEnumerable<WebSocketMessage> messages) {
string uid = GenerateUniqueIdentifier();
var data = new OBSRequestBatchData(broadcasterId, uid, new List<Type>());
_logger.Debug($"Sending request batch of {messages.Count()} messages.");
foreach (WebSocketMessage message in messages)
data.RequestTypes.Add(message.GetType());
var client = _serviceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("obs");
await client.Send(8, new RequestBatchMessage(uid, messages));
}
public OBSRequestBatchData? Take(string id) {
if (_requests.TryGetValue(id, out var request)) {
_requests.Remove(id);
return request;
}
return null;
}
private string GenerateUniqueIdentifier()
{
return Guid.NewGuid().ToString("X");
}
}
public class OBSRequestBatchData
{
public long BroadcasterId { get; }
public string RequestId { get; }
public IList<Type> RequestTypes { get; }
public OBSRequestBatchData(long bid, string rid, IList<Type> types) {
BroadcasterId = bid;
RequestId = rid;
RequestTypes = types;
}
}
}

View File

@ -1,4 +1,4 @@
using Microsoft.Extensions.Logging;
using Serilog;
using Microsoft.Extensions.DependencyInjection;
using CommonSocketLibrary.Socket.Manager;
using CommonSocketLibrary.Common;
@ -7,23 +7,26 @@ namespace TwitchChatTTS.OBS.Socket.Manager
{
public class OBSHandlerManager : WebSocketHandlerManager
{
public OBSHandlerManager(ILogger<OBSHandlerManager> logger, IServiceProvider provider) : base(logger) {
public OBSHandlerManager(ILogger logger, IServiceProvider provider) : base(logger)
{
var basetype = typeof(IWebSocketHandler);
var assembly = GetType().Assembly;
var types = assembly.GetTypes().Where(t => t.IsClass && basetype.IsAssignableFrom(t) && t.AssemblyQualifiedName?.Contains(".OBS.") == true);
foreach (var type in types) {
foreach (var type in types)
{
var key = "obs-" + type.Name.Replace("Handlers", "Hand#lers")
.Replace("Handler", "")
.Replace("Hand#lers", "Handlers")
.ToLower();
var handler = provider.GetKeyedService<IWebSocketHandler>(key);
if (handler == null) {
logger.LogError("Failed to find obs websocket handler: " + type.AssemblyQualifiedName);
if (handler == null)
{
logger.Error("Failed to find obs websocket handler: " + type.AssemblyQualifiedName);
continue;
}
Logger.LogDebug($"Linked type {type.AssemblyQualifiedName} to obs websocket handler {handler.GetType().AssemblyQualifiedName}.");
Logger.Debug($"Linked type {type.AssemblyQualifiedName} to obs websocket handler {handler.GetType().AssemblyQualifiedName}.");
Add(handler);
}
}

View File

@ -2,14 +2,14 @@ using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using CommonSocketLibrary.Socket.Manager;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
namespace TwitchChatTTS.OBS.Socket.Manager
{
public class OBSHandlerTypeManager : WebSocketHandlerTypeManager
{
public OBSHandlerTypeManager(
ILogger<OBSHandlerTypeManager> factory,
ILogger factory,
[FromKeyedServices("obs")] HandlerManager<WebSocketClient, IWebSocketHandler> handlers
) : base(factory, handlers)
{

View File

@ -1,29 +1,34 @@
using CommonSocketLibrary.Common;
using CommonSocketLibrary.Abstract;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using System.Text.Json;
namespace TwitchChatTTS.OBS.Socket
{
public class OBSSocketClient : WebSocketClient {
public class OBSSocketClient : WebSocketClient
{
private bool _live;
public bool? Live {
public bool? Live
{
get => Connected ? _live : null;
set {
set
{
if (value.HasValue)
_live = value.Value;
}
}
public OBSSocketClient(
ILogger<OBSSocketClient> logger,
ILogger logger,
[FromKeyedServices("obs")] HandlerManager<WebSocketClient, IWebSocketHandler> handlerManager,
[FromKeyedServices("obs")] HandlerTypeManager<WebSocketClient, IWebSocketHandler> typeManager
) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() {
) : base(logger, handlerManager, typeManager, new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}) {
})
{
_live = false;
}
}

View File

@ -1,70 +1,42 @@
using System.Collections.Concurrent;
namespace TwitchChatTTS.Seven
{
public class EmoteCounter {
public IDictionary<string, IDictionary<long, int>> Counters { get; set; }
public class EmoteDatabase
{
private readonly IDictionary<string, string> _emotes;
public IDictionary<string, string> Emotes { get => _emotes.AsReadOnly(); }
public EmoteCounter() {
Counters = new ConcurrentDictionary<string, IDictionary<long, int>>();
public EmoteDatabase()
{
_emotes = new Dictionary<string, string>();
}
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;
public void Add(string emoteName, string emoteId)
{
if (_emotes.ContainsKey(emoteName))
_emotes[emoteName] = emoteId;
else
subcounters.Add(userId, 1);
} else {
Counters.Add(emote, new ConcurrentDictionary<long, int>());
Counters[emote].Add(userId, 1);
_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 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 class EmoteSet
{
public string Id { get; set; }
public string Name { get; set; }
public int Flags { get; set; }
@ -75,7 +47,8 @@ namespace TwitchChatTTS.Seven
public int Capacity { get; set; }
}
public class Emote {
public class Emote
{
public string Id { get; set; }
public string Name { get; set; }
public int Flags { get; set; }

View File

@ -1,46 +1,59 @@
using System.Text.Json;
using TwitchChatTTS.Helpers;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.Seven;
using TwitchChatTTS;
public class SevenApiClient {
public class SevenApiClient
{
public static readonly string API_URL = "https://7tv.io/v3";
public static readonly string WEBSOCKET_URL = "wss://events.7tv.io/v3";
private WebClientWrap Web { get; }
private ILogger<SevenApiClient> Logger { get; }
private long? Id { get; }
private ILogger Logger { get; }
public SevenApiClient(ILogger<SevenApiClient> logger, TwitchBotToken token) {
public SevenApiClient(ILogger logger)
{
Logger = logger;
Id = long.TryParse(token?.BroadcasterId, out long id) ? id : -1;
Web = new WebClientWrap(new JsonSerializerOptions() {
Web = new WebClientWrap(new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
}
public async Task<EmoteDatabase?> GetSevenEmotes() {
if (Id == null)
throw new NullReferenceException(nameof(Id));
try {
var details = await Web.GetJson<UserDetails>($"{API_URL}/users/twitch/" + Id);
if (details == null)
public async Task<EmoteSet?> FetchChannelEmoteSet(string twitchId) {
try
{
var details = await Web.GetJson<UserDetails>($"{API_URL}/users/twitch/" + twitchId);
return details?.EmoteSet;
}
catch (JsonException e)
{
Logger.Error(e, "Failed to fetch emotes from 7tv due to improper JSON.");
}
catch (Exception e)
{
Logger.Error(e, "Failed to fetch emotes from 7tv.");
}
return null;
}
var emotes = new EmoteDatabase();
if (details.EmoteSet != null)
foreach (var emote in details.EmoteSet.Emotes)
emotes.Add(emote.Name, emote.Id);
Logger.LogInformation($"Loaded {details.EmoteSet?.Emotes.Count() ?? 0} emotes from 7tv.");
return emotes;
} catch (JsonException e) {
Logger.LogError(e, "Failed to fetch emotes from 7tv. 2");
} catch (Exception e) {
Logger.LogError(e, "Failed to fetch emotes from 7tv.");
public async Task<IEnumerable<Emote>?> FetchGlobalSevenEmotes()
{
try
{
var emoteSet = await Web.GetJson<EmoteSet>($"{API_URL}/emote-sets/6353512c802a0e34bac96dd2");
return emoteSet?.Emotes;
}
catch (JsonException e)
{
Logger.Error(e, "Failed to fetch emotes from 7tv due to improper JSON.");
}
catch (Exception e)
{
Logger.Error(e, "Failed to fetch emotes from 7tv.");
}
return null;
}

View File

@ -2,7 +2,7 @@ using System.Text.Json;
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.Seven.Socket.Data;
namespace TwitchChatTTS.Seven.Socket.Handlers
@ -11,9 +11,11 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
{
private ILogger Logger { get; }
private EmoteDatabase Emotes { get; }
private object _lock = new object();
public int OperationCode { get; set; } = 0;
public DispatchHandler(ILogger<DispatchHandler> logger, EmoteDatabase emotes) {
public DispatchHandler(ILogger logger, EmoteDatabase emotes)
{
Logger = logger;
Emotes = emotes;
}
@ -25,30 +27,79 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
ApplyChanges(obj?.Body?.Pulled, cf => cf.OldValue, true);
ApplyChanges(obj?.Body?.Pushed, cf => cf.Value, false);
ApplyChanges(obj?.Body?.Removed, cf => cf.OldValue, true);
ApplyChanges(obj?.Body?.Updated, cf => cf.OldValue, false, cf => cf.Value);
}
private void ApplyChanges(IEnumerable<ChangeField>? fields, Func<ChangeField, object> getter, bool removing) {
if (fields == null)
private void ApplyChanges(IEnumerable<ChangeField>? fields, Func<ChangeField, object> getter, bool removing, Func<ChangeField, object>? updater = null)
{
if (fields == null || !fields.Any() || removing && updater != null)
return;
foreach (var val in fields) {
foreach (var val in fields)
{
var value = getter(val);
if (value == null)
continue;
var o = JsonSerializer.Deserialize<EmoteField>(value.ToString(), new JsonSerializerOptions() {
var o = JsonSerializer.Deserialize<EmoteField>(value.ToString(), new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
if (o == null)
continue;
lock (_lock)
{
if (removing)
{
RemoveEmoteById(o.Id);
Logger.Information($"Removed 7tv emote: {o.Name} (id: {o.Id})");
}
else if (updater != null)
{
RemoveEmoteById(o.Id);
var update = updater(val);
var u = JsonSerializer.Deserialize<EmoteField>(update.ToString(), new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
if (removing) {
Emotes.Remove(o.Name);
Logger.LogInformation($"Removed 7tv emote: {o.Name} (id: {o.Id})");
} else {
if (u != null)
{
Emotes.Add(u.Name, u.Id);
Logger.Information($"Updated 7tv emote: from '{o.Name}' to '{u.Name}' (id: {u.Id})");
}
else
{
Logger.Warning("Failed to update 7tv emote.");
}
}
else
{
Emotes.Add(o.Name, o.Id);
Logger.LogInformation($"Added 7tv emote: {o.Name} (id: {o.Id})");
Logger.Information($"Added 7tv emote: {o.Name} (id: {o.Id})");
}
}
}
}
private void RemoveEmoteById(string id)
{
string? key = null;
foreach (var e in Emotes.Emotes)
{
if (e.Value == id)
{
key = e.Key;
break;
}
}
if (key != null)
Emotes.Remove(key);
}
}
}

View File

@ -1,7 +1,7 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.Seven.Socket.Context;
using TwitchChatTTS.Seven.Socket.Data;
@ -10,7 +10,7 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
public class EndOfStreamHandler : IWebSocketHandler
{
private ILogger Logger { get; }
private Configuration Configuration { get; }
private User User { get; }
private IServiceProvider ServiceProvider { get; }
private string[] ErrorCodes { get; }
private int[] ReconnectDelay { get; }
@ -18,9 +18,10 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
public int OperationCode { get; set; } = 7;
public EndOfStreamHandler(ILogger<EndOfStreamHandler> logger, Configuration configuration, IServiceProvider serviceProvider) {
public EndOfStreamHandler(ILogger logger, User user, IServiceProvider serviceProvider)
{
Logger = logger;
Configuration = configuration;
User = user;
ServiceProvider = serviceProvider;
ErrorCodes = [
@ -62,34 +63,37 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
var code = obj.Code - 4000;
if (code >= 0 && code < ErrorCodes.Length)
Logger.LogWarning($"Received end of stream message (reason: {ErrorCodes[code]}, code: {obj.Code}, message: {obj.Message}).");
Logger.Warning($"Received end of stream message (reason: {ErrorCodes[code]}, code: {obj.Code}, message: {obj.Message}).");
else
Logger.LogWarning($"Received end of stream message (code: {obj.Code}, message: {obj.Message}).");
Logger.Warning($"Received end of stream message (code: {obj.Code}, message: {obj.Message}).");
await sender.DisconnectAsync();
if (code >= 0 && code < ReconnectDelay.Length && ReconnectDelay[code] < 0) {
Logger.LogError($"7tv client will remain disconnected due to a bad client implementation.");
if (code >= 0 && code < ReconnectDelay.Length && ReconnectDelay[code] < 0)
{
Logger.Error($"7tv client will remain disconnected due to a bad client implementation.");
return;
}
if (string.IsNullOrWhiteSpace(Configuration.Seven?.UserId))
if (string.IsNullOrWhiteSpace(User.SevenEmoteSetId))
return;
var context = ServiceProvider.GetRequiredService<ReconnectContext>();
await Task.Delay(ReconnectDelay[code]);
//var base_url = "@" + string.Join(",", Configuration.Seven.SevenId.Select(sub => sub.Type + "<" + string.Join(",", sub.Condition?.Select(e => e.Key + "=" + e.Value) ?? new string[0]) + ">"));
var base_url = $"@emote_set.*<object_id={Configuration.Seven.UserId.Trim()}>";
var base_url = $"@emote_set.*<object_id={User.SevenEmoteSetId}>";
string url = $"{SevenApiClient.WEBSOCKET_URL}{base_url}";
Logger.LogDebug($"7tv websocket reconnecting to {url}.");
Logger.Debug($"7tv websocket reconnecting to {url}.");
await sender.ConnectAsync(url);
if (context.SessionId != null) {
if (context.SessionId != null)
{
await sender.Send(34, new ResumeMessage() { SessionId = context.SessionId });
Logger.LogInformation("Resumed connection to 7tv websocket.");
} else {
Logger.LogDebug("7tv websocket session id not available.");
Logger.Information("Resumed connection to 7tv websocket.");
}
else
{
Logger.Information("Resumed connection to 7tv websocket on a different session.");
}
}
}

View File

@ -1,6 +1,6 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.Seven.Socket.Data;
namespace TwitchChatTTS.Seven.Socket.Handlers
@ -10,7 +10,8 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
private ILogger Logger { get; }
public int OperationCode { get; set; } = 6;
public ErrorHandler(ILogger<ErrorHandler> logger) {
public ErrorHandler(ILogger logger)
{
Logger = logger;
}

View File

@ -1,6 +1,6 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.Seven.Socket.Data;
namespace TwitchChatTTS.Seven.Socket.Handlers
@ -10,7 +10,8 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
private ILogger Logger { get; }
public int OperationCode { get; set; } = 4;
public ReconnectHandler(ILogger<ReconnectHandler> logger) {
public ReconnectHandler(ILogger logger)
{
Logger = logger;
}
@ -19,7 +20,7 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
if (message is not ReconnectMessage obj || obj == null)
return;
Logger.LogInformation($"7tv server wants us to reconnect (reason: {obj.Reason}).");
Logger.Information($"7tv server wants us to reconnect (reason: {obj.Reason}).");
}
}
}

View File

@ -1,6 +1,6 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.Seven.Socket.Data;
namespace TwitchChatTTS.Seven.Socket.Handlers
@ -11,7 +11,8 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
private Configuration Configuration { get; }
public int OperationCode { get; set; } = 1;
public SevenHelloHandler(ILogger<SevenHelloHandler> logger, Configuration configuration) {
public SevenHelloHandler(ILogger logger, Configuration configuration)
{
Logger = logger;
Configuration = configuration;
}
@ -26,7 +27,7 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
seven.Connected = true;
seven.ConnectionDetails = obj;
Logger.LogInformation("Connected to 7tv websockets.");
Logger.Information("Connected to 7tv websockets.");
}
}
}

View File

@ -1,4 +1,4 @@
using Microsoft.Extensions.Logging;
using Serilog;
using CommonSocketLibrary.Socket.Manager;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.DependencyInjection;
@ -7,28 +7,34 @@ namespace TwitchChatTTS.Seven.Socket.Managers
{
public class SevenHandlerManager : WebSocketHandlerManager
{
public SevenHandlerManager(ILogger<SevenHandlerManager> logger, IServiceProvider provider) : base(logger) {
try {
public SevenHandlerManager(ILogger logger, IServiceProvider provider) : base(logger)
{
try
{
var basetype = typeof(IWebSocketHandler);
var assembly = GetType().Assembly;
var types = assembly.GetTypes().Where(t => t.IsClass && basetype.IsAssignableFrom(t) && t.AssemblyQualifiedName?.Contains(".Seven.") == true);
foreach (var type in types) {
foreach (var type in types)
{
var key = "7tv-" + type.Name.Replace("Handlers", "Hand#lers")
.Replace("Handler", "")
.Replace("Hand#lers", "Handlers")
.ToLower();
var handler = provider.GetKeyedService<IWebSocketHandler>(key);
if (handler == null) {
logger.LogError("Failed to find 7tv websocket handler: " + type.AssemblyQualifiedName);
if (handler == null)
{
logger.Error("Failed to find 7tv websocket handler: " + type.AssemblyQualifiedName);
continue;
}
Logger.LogDebug($"Linked type {type.AssemblyQualifiedName} to 7tv websocket handler {handler.GetType().AssemblyQualifiedName}.");
Logger.Debug($"Linked type {type.AssemblyQualifiedName} to 7tv websocket handler {handler.GetType().AssemblyQualifiedName}.");
Add(handler);
}
} catch (Exception e) {
Logger.LogError(e, "Failed to load 7tv websocket handler types.");
}
catch (Exception e)
{
Logger.Error(e, "Failed to load 7tv websocket handler types.");
}
}
}

View File

@ -2,14 +2,14 @@ using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using CommonSocketLibrary.Socket.Manager;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
namespace TwitchChatTTS.Seven.Socket.Managers
{
public class SevenHandlerTypeManager : WebSocketHandlerTypeManager
{
public SevenHandlerTypeManager(
ILogger<SevenHandlerTypeManager> factory,
ILogger factory,
[FromKeyedServices("7tv")] HandlerManager<WebSocketClient,
IWebSocketHandler> handlers
) : base(factory, handlers)

View File

@ -1,23 +1,26 @@
using CommonSocketLibrary.Common;
using CommonSocketLibrary.Abstract;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS.Seven.Socket.Data;
using System.Text.Json;
namespace TwitchChatTTS.Seven.Socket
{
public class SevenSocketClient : WebSocketClient {
public class SevenSocketClient : WebSocketClient
{
public SevenHelloMessage? ConnectionDetails { get; set; }
public SevenSocketClient(
ILogger<SevenSocketClient> logger,
ILogger logger,
[FromKeyedServices("7tv")] HandlerManager<WebSocketClient, IWebSocketHandler> handlerManager,
[FromKeyedServices("7tv")] HandlerTypeManager<WebSocketClient, IWebSocketHandler> typeManager
) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() {
) : base(logger, handlerManager, typeManager, new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
}) {
})
{
ConnectionDetails = null;
}
}

View File

@ -8,5 +8,12 @@ namespace TwitchChatTTS.Seven
public int EmoteCapacity { get; set; }
public int? EmoteSetId { get; set; }
public EmoteSet EmoteSet { get; set; }
public SevenUser User { get; set; }
}
public class SevenUser
{
public string Id { get; set; }
public string Username { get; set; }
}
}

View File

@ -7,7 +7,6 @@ using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
using Microsoft.Extensions.Logging;
using TwitchChatTTS.Seven.Socket;
using TwitchChatTTS.OBS.Socket.Handlers;
using TwitchChatTTS.Seven.Socket.Handlers;
@ -26,6 +25,8 @@ using TwitchChatTTS.Hermes.Socket.Managers;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchChatTTS.Chat.Commands;
using System.Text.Json;
using Serilog;
using Serilog.Events;
// dotnet publish -r linux-x64 -p:PublishSingleFile=true --self-contained true
// dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained true
@ -41,18 +42,31 @@ var deserializer = new DeserializerBuilder()
var configContent = File.ReadAllText("tts.config.yml");
var configuration = deserializer.Deserialize<Configuration>(configContent);
var redeemKeys = configuration.Twitch?.Redeems?.Keys;
if (redeemKeys != null) {
foreach (var key in redeemKeys) {
if (key != key.ToLower() && configuration.Twitch?.Redeems != null)
if (redeemKeys != null && redeemKeys.Any())
{
foreach (var key in redeemKeys)
{
if (key != key.ToLower())
configuration.Twitch.Redeems.Add(key.ToLower(), configuration.Twitch.Redeems[key]);
}
}
s.AddSingleton<Configuration>(configuration);
s.AddLogging();
var logger = new LoggerConfiguration()
#if DEBUG
.MinimumLevel.Debug()
#else
.MinimumLevel.Information()
#endif
.WriteTo.File("logs/log.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7)
.WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information)
.CreateLogger();
s.AddSerilog(logger);
s.AddSingleton<User>(new User());
s.AddSingleton<JsonSerializerOptions>(new JsonSerializerOptions() {
s.AddSingleton<JsonSerializerOptions>(new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
@ -65,46 +79,38 @@ s.AddKeyedSingleton<ChatCommand, SkipCommand>("command-skip");
s.AddKeyedSingleton<ChatCommand, VoiceCommand>("command-voice");
s.AddKeyedSingleton<ChatCommand, AddTTSVoiceCommand>("command-addttsvoice");
s.AddKeyedSingleton<ChatCommand, RemoveTTSVoiceCommand>("command-removettsvoice");
s.AddKeyedSingleton<ChatCommand, RefreshTTSDataCommand>("command-refreshttsdata");
s.AddKeyedSingleton<ChatCommand, OBSCommand>("command-obs");
s.AddKeyedSingleton<ChatCommand, TTSCommand>("command-tts");
s.AddKeyedSingleton<ChatCommand, VersionCommand>("command-version");
s.AddSingleton<ChatCommandManager>();
s.AddSingleton<TTSPlayer>();
s.AddSingleton<ChatMessageHandler>();
s.AddSingleton<HermesClient>();
s.AddSingleton<TwitchBotToken>(sp => {
var hermes = sp.GetRequiredService<HermesClient>();
var task = hermes.FetchTwitchBotToken();
task.Wait();
return task.Result;
});
s.AddSingleton<HermesApiClient>();
s.AddSingleton<TwitchBotAuth>(new TwitchBotAuth());
s.AddTransient<IClient, TwitchLib.Communication.Clients.WebSocketClient>();
s.AddTransient<ITwitchClient, TwitchClient>();
s.AddTransient<ITwitchPubSub, TwitchPubSub>();
s.AddSingleton<TwitchApiClient>();
s.AddSingleton<SevenApiClient>();
s.AddSingleton<EmoteDatabase>(sp => {
var api = sp.GetRequiredService<SevenApiClient>();
var task = api.GetSevenEmotes();
task.Wait();
return task.Result;
});
s.AddSingleton<EmoteCounter>(sp => {
if (!string.IsNullOrWhiteSpace(configuration.Emotes?.CounterFilePath) && File.Exists(configuration.Emotes.CounterFilePath.Trim()))
return deserializer.Deserialize<EmoteCounter>(File.ReadAllText(configuration.Emotes.CounterFilePath.Trim()));
return new EmoteCounter();
});
s.AddSingleton<EmoteDatabase>(new EmoteDatabase());
// OBS websocket
s.AddSingleton<HelloContext>(sp =>
new HelloContext() {
new HelloContext()
{
Host = string.IsNullOrWhiteSpace(configuration.Obs?.Host) ? null : configuration.Obs.Host.Trim(),
Port = configuration.Obs?.Port,
Password = string.IsNullOrWhiteSpace(configuration.Obs?.Password) ? null : configuration.Obs.Password.Trim()
}
);
s.AddSingleton<OBSRequestBatchManager>();
s.AddKeyedSingleton<IWebSocketHandler, HelloHandler>("obs-hello");
s.AddKeyedSingleton<IWebSocketHandler, IdentifiedHandler>("obs-identified");
s.AddKeyedSingleton<IWebSocketHandler, RequestResponseHandler>("obs-requestresponse");
s.AddKeyedSingleton<IWebSocketHandler, RequestBatchResponseHandler>("obs-requestbatchresponse");
s.AddKeyedSingleton<IWebSocketHandler, EventMessageHandler>("obs-eventmessage");
s.AddKeyedSingleton<HandlerManager<WebSocketClient, IWebSocketHandler>, OBSHandlerManager>("obs");
@ -112,15 +118,18 @@ s.AddKeyedSingleton<HandlerTypeManager<WebSocketClient, IWebSocketHandler>, OBSH
s.AddKeyedSingleton<SocketClient<WebSocketMessage>, OBSSocketClient>("obs");
// 7tv websocket
s.AddTransient(sp => {
var logger = sp.GetRequiredService<ILogger<ReconnectContext>>();
s.AddTransient(sp =>
{
var logger = sp.GetRequiredService<ILogger>();
var client = sp.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("7tv") as SevenSocketClient;
if (client == null) {
logger.LogError("7tv client == null.");
if (client == null)
{
logger.Error("7tv client == null.");
return new ReconnectContext() { SessionId = null };
}
if (client.ConnectionDetails == null) {
logger.LogError("Connection details in 7tv client == null.");
if (client.ConnectionDetails == null)
{
logger.Error("Connection details in 7tv client == null.");
return new ReconnectContext() { SessionId = null };
}
return new ReconnectContext() { SessionId = client.ConnectionDetails.SessionId };
@ -147,6 +156,5 @@ s.AddKeyedSingleton<HandlerTypeManager<WebSocketClient, IWebSocketHandler>, Herm
s.AddKeyedSingleton<SocketClient<WebSocketMessage>, HermesSocketClient>("hermes");
s.AddHostedService<TTS>();
using IHost host = builder.Build();
await host.RunAsync();

348
TTS.cs
View File

@ -5,78 +5,98 @@ using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using NAudio.Wave.SampleProviders;
using TwitchChatTTS.Hermes.Socket;
using TwitchChatTTS.Seven;
using TwitchLib.Client.Events;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace TwitchChatTTS
{
public class TTS : IHostedService
{
public const int MAJOR_VERSION = 3;
public const int MINOR_VERSION = 3;
private readonly ILogger _logger;
private readonly Configuration _configuration;
private readonly TTSPlayer _player;
private readonly IServiceProvider _serviceProvider;
public TTS(ILogger<TTS> logger, Configuration configuration, TTSPlayer player, IServiceProvider serviceProvider) {
public TTS(ILogger logger, Configuration configuration, TTSPlayer player, IServiceProvider serviceProvider)
{
_logger = logger;
_configuration = configuration;
_player = player;
_serviceProvider = serviceProvider;
}
public async Task StartAsync(CancellationToken cancellationToken) {
public async Task StartAsync(CancellationToken cancellationToken)
{
Console.Title = "TTS - Twitch Chat";
var user = _serviceProvider.GetRequiredService<User>();
var hermes = await InitializeHermes();
var hermes = _serviceProvider.GetRequiredService<HermesApiClient>();
var seven = _serviceProvider.GetRequiredService<SevenApiClient>();
var hermesAccount = await hermes.FetchHermesAccountDetails();
user.HermesUserId = hermesAccount.Id;
user.TwitchUsername = hermesAccount.Username;
var hermesVersion = await hermes.GetTTSVersion();
if (hermesVersion.MajorVersion > TTS.MAJOR_VERSION || hermesVersion.MajorVersion == TTS.MAJOR_VERSION && hermesVersion.MinorVersion > TTS.MINOR_VERSION)
{
_logger.Information($"A new update for TTS is avaiable! Version {hermesVersion.MajorVersion}.{hermesVersion.MinorVersion} is available at {hermesVersion.Download}");
var changes = hermesVersion.Changelog.Split("\n");
if (changes != null && changes.Any())
_logger.Information("Changelogs:\n - " + string.Join("\n - ", changes) + "\n\n");
await Task.Delay(15 * 1000);
}
var twitchBotToken = await hermes.FetchTwitchBotToken();
user.TwitchUserId = long.Parse(twitchBotToken.BroadcasterId);
_logger.LogInformation($"Username: {user.TwitchUsername} (id: {user.TwitchUserId})");
try
{
await FetchUserData(user, hermes, seven);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to initialize properly.");
await Task.Delay(30 * 1000);
}
user.DefaultTTSVoice = await hermes.FetchTTSDefaultVoice();
_logger.LogInformation("Default Voice: " + user.DefaultTTSVoice);
var twitchapiclient = await InitializeTwitchApiClient(user.TwitchUsername, user.TwitchUserId.ToString());
if (twitchapiclient == null)
{
await Task.Delay(30 * 1000);
return;
}
var wordFilters = await hermes.FetchTTSWordFilters();
user.RegexFilters = wordFilters.ToList();
_logger.LogInformation($"{user.RegexFilters.Count()} TTS word filters.");
var emoteSet = await seven.FetchChannelEmoteSet(user.TwitchUserId.ToString());
user.SevenEmoteSetId = emoteSet?.Id;
var usernameFilters = await hermes.FetchTTSUsernameFilters();
user.ChatterFilters = usernameFilters.ToDictionary(e => e.Username, e => e);
_logger.LogInformation($"{user.ChatterFilters.Where(f => f.Value.Tag == "blacklisted").Count()} username(s) have been blocked.");
_logger.LogInformation($"{user.ChatterFilters.Where(f => f.Value.Tag == "priority").Count()} user(s) have been prioritized.");
var twitchapiclient = await InitializeTwitchApiClient(user.TwitchUsername);
await InitializeHermesWebsocket(user);
await InitializeSevenTv();
await InitializeEmotes(seven, emoteSet);
await InitializeHermesWebsocket();
await InitializeSevenTv(emoteSet.Id);
await InitializeObs();
try {
AudioPlaybackEngine.Instance.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) => {
if (e.SampleProvider == _player.Playing) {
AudioPlaybackEngine.Instance.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) =>
{
if (e.SampleProvider == _player.Playing)
{
_player.Playing = null;
}
});
Task.Run(async () => {
while (true) {
try {
if (cancellationToken.IsCancellationRequested) {
_logger.LogWarning("TTS Buffer - Cancellation token was canceled.");
Task.Run(async () =>
{
while (true)
{
try
{
if (cancellationToken.IsCancellationRequested)
{
_logger.Warning("TTS Buffer - Cancellation token was canceled.");
return;
}
var m = _player.ReceiveBuffer();
if (m == null) {
if (m == null)
{
await Task.Delay(200);
continue;
}
@ -86,182 +106,240 @@ namespace TwitchChatTTS
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.");
_logger.Debug("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.");
}
catch (COMException e)
{
_logger.Error(e, "Failed to send request for TTS (HResult: " + e.HResult + ").");
}
catch (Exception e)
{
_logger.Error(e, "Failed to send request for TTS.");
}
}
});
Task.Run(async () => {
while (true) {
try {
if (cancellationToken.IsCancellationRequested) {
_logger.LogWarning("TTS Queue - Cancellation token was canceled.");
Task.Run(async () =>
{
while (true)
{
try
{
if (cancellationToken.IsCancellationRequested)
{
_logger.Warning("TTS Queue - Cancellation token was canceled.");
return;
}
while (_player.IsEmpty() || _player.Playing != null) {
while (_player.IsEmpty() || _player.Playing != null)
{
await Task.Delay(200);
continue;
}
var m = _player.ReceiveReady();
if (m == null) {
if (m == null)
{
continue;
}
if (!string.IsNullOrWhiteSpace(m.File) && File.Exists(m.File)) {
_logger.LogInformation("Playing message: " + m.File);
if (!string.IsNullOrWhiteSpace(m.File) && File.Exists(m.File))
{
_logger.Information("Playing message: " + m.File);
AudioPlaybackEngine.Instance.PlaySound(m.File);
continue;
}
_logger.LogInformation("Playing message: " + m.Message);
_logger.Information("Playing message: " + m.Message);
_player.Playing = m.Audio;
if (m.Audio != null)
AudioPlaybackEngine.Instance.AddMixerInput(m.Audio);
} catch (Exception e) {
_logger.LogError(e, "Failed to play a TTS audio message");
}
catch (Exception e)
{
_logger.Error(e, "Failed to play a TTS audio message");
}
}
});
StartSavingEmoteCounter();
_logger.LogInformation("Twitch API client connecting...");
_logger.Information("Twitch websocket 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.");
_logger.Warning("Application has stopped due to cancellation token.");
else
_logger.LogWarning("Application has stopped.");
_logger.Warning("Application has stopped.");
}
private async Task InitializeHermesWebsocket(User user) {
if (_configuration.Hermes?.Token == null) {
_logger.LogDebug("No api token given to hermes. Skipping hermes websockets.");
return;
private async Task FetchUserData(User user, HermesApiClient hermes, SevenApiClient seven)
{
var hermesAccount = await hermes.FetchHermesAccountDetails();
if (hermesAccount == null)
throw new Exception("Cannot connect to Hermes. Ensure your token is valid.");
user.HermesUserId = hermesAccount.Id;
user.HermesUsername = hermesAccount.Username;
user.TwitchUsername = hermesAccount.Username;
var twitchBotToken = await hermes.FetchTwitchBotToken();
user.TwitchUserId = long.Parse(twitchBotToken.BroadcasterId);
_logger.Information($"Username: {user.TwitchUsername} (id: {user.TwitchUserId})");
user.DefaultTTSVoice = await hermes.FetchTTSDefaultVoice();
_logger.Information("Default Voice: " + user.DefaultTTSVoice);
var wordFilters = await hermes.FetchTTSWordFilters();
user.RegexFilters = wordFilters.ToList();
_logger.Information($"{user.RegexFilters.Count()} TTS word filters.");
var usernameFilters = await hermes.FetchTTSUsernameFilters();
user.ChatterFilters = usernameFilters.ToDictionary(e => e.Username, e => e);
_logger.Information($"{user.ChatterFilters.Where(f => f.Value.Tag == "blacklisted").Count()} username(s) have been blocked.");
_logger.Information($"{user.ChatterFilters.Where(f => f.Value.Tag == "priority").Count()} user(s) have been prioritized.");
var voicesSelected = await hermes.FetchTTSChatterSelectedVoices();
user.VoicesSelected = voicesSelected.ToDictionary(s => s.ChatterId, s => s.Voice);
_logger.Information($"{user.VoicesSelected.Count} TTS voices have been selected for specific chatters.");
var voicesEnabled = await hermes.FetchTTSEnabledVoices();
if (voicesEnabled == null || !voicesEnabled.Any())
user.VoicesEnabled = new HashSet<string>(new string[] { "Brian" });
else
user.VoicesEnabled = new HashSet<string>(voicesEnabled.Select(v => v));
_logger.Information($"{user.VoicesEnabled.Count} TTS voices have been enabled.");
var defaultedChatters = voicesSelected.Where(item => item.Voice == null || !user.VoicesEnabled.Contains(item.Voice));
_logger.Information($"{defaultedChatters.Count()} chatters will have their TTS voice set to default due to having selected a disabled TTS voice.");
}
try {
_logger.LogInformation("Initializing hermes websocket client.");
private async Task InitializeHermesWebsocket()
{
try
{
_logger.Information("Initializing hermes websocket client.");
var hermesClient = _serviceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("hermes") as HermesSocketClient;
var url = "wss://hermes-ws.goblincaves.com";
_logger.LogDebug($"Attempting to connect to {url}");
_logger.Debug($"Attempting to connect to {url}");
await hermesClient.ConnectAsync(url);
await hermesClient.Send(1, new HermesLoginMessage() {
hermesClient.Connected = true;
await hermesClient.Send(1, new HermesLoginMessage()
{
ApiKey = _configuration.Hermes.Token
});
while (hermesClient.UserId == null)
await Task.Delay(TimeSpan.FromMilliseconds(200));
await hermesClient.Send(3, new RequestMessage() {
Type = "get_tts_voices",
Data = null
});
var token = _serviceProvider.GetRequiredService<TwitchBotToken>();
await hermesClient.Send(3, new RequestMessage() {
Type = "get_tts_users",
Data = new Dictionary<string, string>() { { "@broadcaster", token.BroadcasterId } }
});
} catch (Exception) {
_logger.LogWarning("Connecting to hermes failed. Skipping hermes websockets.");
}
catch (Exception)
{
_logger.Warning("Connecting to hermes failed. Skipping hermes websockets.");
}
}
private async Task InitializeSevenTv() {
if (_configuration.Seven?.UserId == null) {
_logger.LogDebug("No user id given to 7tv. Skipping 7tv websockets.");
return;
}
try {
_logger.LogInformation("Initializing 7tv websocket client.");
private async Task InitializeSevenTv(string emoteSetId)
{
try
{
_logger.Information("Initializing 7tv websocket client.");
var sevenClient = _serviceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("7tv");
//var base_url = "@" + string.Join(",", Configuration.Seven.InitialSubscriptions.Select(sub => sub.Type + "<" + string.Join(",", sub.Condition?.Select(e => e.Key + "=" + e.Value) ?? new string[0]) + ">"));
var url = $"{SevenApiClient.WEBSOCKET_URL}@emote_set.*<object_id={_configuration.Seven.UserId.Trim()}>";
_logger.LogDebug($"Attempting to connect to {url}");
if (string.IsNullOrWhiteSpace(emoteSetId))
{
_logger.Warning("Could not fetch 7tv emotes.");
return;
}
var url = $"{SevenApiClient.WEBSOCKET_URL}@emote_set.*<object_id={emoteSetId}>";
_logger.Debug($"Attempting to connect to {url}");
await sevenClient.ConnectAsync($"{url}");
} catch (Exception) {
_logger.LogWarning("Connecting to 7tv failed. Skipping 7tv websockets.");
}
catch (Exception)
{
_logger.Warning("Connecting to 7tv failed. Skipping 7tv websockets.");
}
}
private async Task InitializeObs() {
if (_configuration.Obs == null || string.IsNullOrWhiteSpace(_configuration.Obs.Host) || !_configuration.Obs.Port.HasValue || _configuration.Obs.Port.Value < 0) {
_logger.LogDebug("Lacking obs connection info. Skipping obs websockets.");
private async Task InitializeObs()
{
if (_configuration.Obs == null || string.IsNullOrWhiteSpace(_configuration.Obs.Host) || !_configuration.Obs.Port.HasValue || _configuration.Obs.Port.Value < 0)
{
_logger.Warning("Lacking OBS connection info. Skipping OBS websockets.");
return;
}
try {
_logger.LogInformation("Initializing obs websocket client.");
try
{
var obsClient = _serviceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("obs");
var url = $"ws://{_configuration.Obs.Host.Trim()}:{_configuration.Obs.Port}";
_logger.LogDebug($"Attempting to connect to {url}");
_logger.Debug($"Initializing OBS websocket client. Attempting to connect to {url}");
await obsClient.ConnectAsync(url);
} catch (Exception) {
_logger.LogWarning("Connecting to obs failed. Skipping obs websockets.");
}
catch (Exception)
{
_logger.Warning("Connecting to obs failed. Skipping obs websockets.");
}
}
private async Task<HermesClient> InitializeHermes() {
// Fetch id and username based on api key given.
_logger.LogInformation("Initializing hermes client.");
var hermes = _serviceProvider.GetRequiredService<HermesClient>();
await hermes.FetchHermesAccountDetails();
return hermes;
}
private async Task<TwitchApiClient> InitializeTwitchApiClient(string username) {
_logger.LogInformation("Initializing twitch client.");
private async Task<TwitchApiClient?> InitializeTwitchApiClient(string username, string broadcasterId)
{
_logger.Debug("Initializing twitch client.");
var twitchapiclient = _serviceProvider.GetRequiredService<TwitchApiClient>();
await twitchapiclient.Authorize();
if (!await twitchapiclient.Authorize(broadcasterId))
{
_logger.Error("Cannot connect to Twitch API.");
return null;
}
var channels = _configuration.Twitch.Channels ?? [username];
_logger.LogInformation("Twitch channels: " + string.Join(", ", channels));
_logger.Information("Twitch channels: " + string.Join(", ", channels));
twitchapiclient.InitializeClient(username, channels);
twitchapiclient.InitializePublisher();
var handler = _serviceProvider.GetRequiredService<ChatMessageHandler>();
twitchapiclient.AddOnNewMessageReceived(async Task (object? s, OnMessageReceivedArgs e) => {
twitchapiclient.AddOnNewMessageReceived(async (object? s, OnMessageReceivedArgs e) =>
{
try
{
var result = await handler.Handle(e);
if (result.Status != MessageStatus.None || result.Emotes == null || !result.Emotes.Any())
return;
var ws = _serviceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("hermes");
await ws.Send(8, new EmoteUsageMessage()
{
MessageId = e.ChatMessage.Id,
DateTime = DateTime.UtcNow,
BroadcasterId = result.BroadcasterId,
ChatterId = result.ChatterId,
Emotes = result.Emotes
});
}
catch (Exception ex)
{
_logger.Error(ex, "Unable to send emote usage message.");
}
});
return twitchapiclient;
}
private async Task StartSavingEmoteCounter() {
Task.Run(async () => {
while (true) {
try {
await Task.Delay(TimeSpan.FromSeconds(300));
var serializer = new SerializerBuilder()
.WithNamingConvention(HyphenatedNamingConvention.Instance)
.Build();
var chathandler = _serviceProvider.GetRequiredService<ChatMessageHandler>();
using (TextWriter writer = File.CreateText(_configuration.Emotes.CounterFilePath.Trim()))
private async Task InitializeEmotes(SevenApiClient sevenapi, EmoteSet emoteSet)
{
await writer.WriteAsync(serializer.Serialize(chathandler._emoteCounter));
var emotes = _serviceProvider.GetRequiredService<EmoteDatabase>();
var channelEmotes = emoteSet;
var globalEmotes = await sevenapi.FetchGlobalSevenEmotes();
if (channelEmotes != null && channelEmotes.Emotes.Any())
{
_logger.Information($"Loaded {channelEmotes.Emotes.Count()} 7tv channel emotes.");
foreach (var entry in channelEmotes.Emotes)
emotes.Add(entry.Name, entry.Id);
}
} catch (Exception e) {
_logger.LogError(e, "Failed to save the emote counter.");
if (globalEmotes != null && globalEmotes.Any())
{
_logger.Information($"Loaded {globalEmotes.Count()} 7tv global emotes.");
foreach (var entry in globalEmotes)
emotes.Add(entry.Name, entry.Id);
}
}
});
}
}
}

View File

@ -1,6 +1,6 @@
using System.Text.Json;
using TwitchChatTTS.Helpers;
using Microsoft.Extensions.Logging;
using Serilog;
using TwitchChatTTS;
using TwitchLib.Api.Core.Exceptions;
using TwitchLib.Client.Events;
@ -14,133 +14,164 @@ using TwitchLib.PubSub.Interfaces;
using TwitchLib.Client.Interfaces;
using TwitchChatTTS.OBS.Socket;
public class TwitchApiClient {
public class TwitchApiClient
{
private readonly Configuration _configuration;
private readonly ILogger<TwitchApiClient> _logger;
private readonly TwitchBotToken _token;
private readonly ILogger _logger;
private TwitchBotAuth _token;
private readonly ITwitchClient _client;
private readonly ITwitchPubSub _publisher;
private readonly WebClientWrap Web;
private readonly WebClientWrap _web;
private readonly IServiceProvider _serviceProvider;
private bool Initialized;
private bool _initialized;
private string _broadcasterId;
public TwitchApiClient(
Configuration configuration,
ILogger<TwitchApiClient> logger,
TwitchBotToken token,
TwitchBotAuth token,
ITwitchClient twitchClient,
ITwitchPubSub twitchPublisher,
IServiceProvider serviceProvider
) {
IServiceProvider serviceProvider,
ILogger logger
)
{
_configuration = configuration;
_logger = logger;
_token = token;
_client = twitchClient;
_publisher = twitchPublisher;
_serviceProvider = serviceProvider;
Initialized = false;
_logger = logger;
_initialized = false;
Web = new WebClientWrap(new JsonSerializerOptions() {
_web = new WebClientWrap(new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
if (!string.IsNullOrWhiteSpace(_configuration.Hermes?.Token))
Web.AddHeader("x-api-key", _configuration.Hermes.Token.Trim());
_web.AddHeader("x-api-key", _configuration.Hermes.Token.Trim());
}
public async Task Authorize() {
try {
var authorize = await Web.GetJson<TwitchBotAuth>("https://hermes.goblincaves.com/api/account/reauthorize");
if (authorize != null && _token.BroadcasterId == authorize.BroadcasterId) {
public async Task<bool> Authorize(string broadcasterId)
{
try
{
var authorize = await _web.GetJson<TwitchBotAuth>("https://hermes.goblincaves.com/api/account/reauthorize");
if (authorize != null && broadcasterId == authorize.BroadcasterId)
{
_token.AccessToken = authorize.AccessToken;
_token.RefreshToken = authorize.RefreshToken;
_logger.LogInformation("Updated Twitch API tokens.");
} else if (authorize != null) {
_logger.LogError("Twitch API Authorization failed.");
_token.UserId = authorize.UserId;
_token.BroadcasterId = authorize.BroadcasterId;
_logger.Information("Updated Twitch API tokens.");
}
} catch (HttpResponseException e) {
else if (authorize != null)
{
_logger.Error("Twitch API Authorization failed: " + authorize.AccessToken + " | " + authorize.RefreshToken + " | " + authorize.UserId + " | " + authorize.BroadcasterId);
return false;
}
_broadcasterId = broadcasterId;
return true;
}
catch (HttpResponseException e)
{
if (string.IsNullOrWhiteSpace(_configuration.Hermes?.Token))
_logger.LogError("No Hermes API key found. Enter it into the configuration file.");
_logger.Error("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.");
_logger.Error("Invalid Hermes API key. Double check the token. HTTP Error Code: " + e.HttpResponse.StatusCode);
}
catch (JsonException)
{
}
catch (Exception e)
{
_logger.Error(e, "Failed to authorize to Twitch API.");
}
return false;
}
public async Task Connect() {
public async Task Connect()
{
_client.Connect();
await _publisher.ConnectAsync();
}
public void InitializeClient(string username, IEnumerable<string> channels) {
public void InitializeClient(string username, IEnumerable<string> channels)
{
ConnectionCredentials credentials = new ConnectionCredentials(username, _token?.AccessToken);
_client.Initialize(credentials, channels.Distinct().ToList());
if (Initialized) {
_logger.LogDebug("Twitch API client has already been initialized.");
if (_initialized)
{
_logger.Debug("Twitch API client has already been initialized.");
return;
}
Initialized = true;
_initialized = true;
_client.OnJoinedChannel += async Task (object? s, OnJoinedChannelArgs e) => {
_logger.LogInformation("Joined channel: " + e.Channel);
_client.OnJoinedChannel += async Task (object? s, OnJoinedChannelArgs e) =>
{
_logger.Information("Joined channel: " + e.Channel);
};
_client.OnConnected += async Task (object? s, OnConnectedArgs e) => {
_logger.LogInformation("-----------------------------------------------------------");
_client.OnConnected += async Task (object? s, OnConnectedArgs e) =>
{
_logger.Information("-----------------------------------------------------------");
};
_client.OnIncorrectLogin += async Task (object? s, OnIncorrectLoginArgs e) => {
_logger.LogError(e.Exception, "Incorrect Login on Twitch API client.");
_client.OnIncorrectLogin += async Task (object? s, OnIncorrectLoginArgs e) =>
{
_logger.Error(e.Exception, "Incorrect Login on Twitch API client.");
_logger.LogInformation("Attempting to re-authorize.");
await Authorize();
_logger.Information("Attempting to re-authorize.");
await Authorize(_broadcasterId);
};
_client.OnConnectionError += async Task (object? s, OnConnectionErrorArgs e) => {
_logger.LogError("Connection Error: " + e.Error.Message + " (" + e.Error.GetType().Name + ")");
_client.OnConnectionError += async Task (object? s, OnConnectionErrorArgs e) =>
{
_logger.Error("Connection Error: " + e.Error.Message + " (" + e.Error.GetType().Name + ")");
_logger.LogInformation("Attempting to re-authorize.");
await Authorize();
_logger.Information("Attempting to re-authorize.");
await Authorize(_broadcasterId);
};
_client.OnError += async Task (object? s, OnErrorEventArgs e) => {
_logger.LogError(e.Exception, "Twitch API client error.");
_client.OnError += async Task (object? s, OnErrorEventArgs e) =>
{
_logger.Error(e.Exception, "Twitch API client error.");
};
}
public void InitializePublisher() {
_publisher.OnPubSubServiceConnected += async (s, e) => {
public void InitializePublisher()
{
_publisher.OnPubSubServiceConnected += async (s, e) =>
{
_publisher.ListenToChannelPoints(_token.BroadcasterId);
_publisher.ListenToFollows(_token.BroadcasterId);
await _publisher.SendTopicsAsync(_token.AccessToken);
_logger.LogInformation("Twitch PubSub has been connected.");
_logger.Information("Twitch PubSub has been connected.");
};
_publisher.OnFollow += (s, e) => {
_publisher.OnFollow += (s, e) =>
{
var client = _serviceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("obs") as OBSSocketClient;
if (_configuration.Twitch?.TtsWhenOffline != true && client?.Live == false)
return;
_logger.LogInformation("Follow: " + e.DisplayName);
_logger.Information("Follow: " + e.DisplayName);
};
_publisher.OnChannelPointsRewardRedeemed += (s, e) => {
_publisher.OnChannelPointsRewardRedeemed += (s, e) =>
{
var client = _serviceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("obs") as OBSSocketClient;
if (_configuration.Twitch?.TtsWhenOffline != true && client?.Live == false)
return;
_logger.LogInformation($"Channel Point Reward Redeemed: {e.RewardRedeemed.Redemption.Reward.Title} (id: {e.RewardRedeemed.Redemption.Id})");
_logger.Information($"Channel Point Reward Redeemed [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][id: {e.RewardRedeemed.Redemption.Id}]");
if (_configuration.Twitch?.Redeems == null) {
_logger.LogDebug("No redeems found in the configuration.");
if (_configuration.Twitch?.Redeems == null)
return;
}
var redeemName = e.RewardRedeemed.Redemption.Reward.Title.ToLower().Trim().Replace(" ", "-");
if (!_configuration.Twitch.Redeems.TryGetValue(redeemName, out RedeemConfiguration? redeem))
@ -151,16 +182,25 @@ public class TwitchApiClient {
// Write or append to file if needed.
var outputFile = string.IsNullOrWhiteSpace(redeem.OutputFilePath) ? null : redeem.OutputFilePath.Trim();
if (outputFile == null) {
_logger.LogDebug($"No output file was provided for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'.");
} else {
if (outputFile == null)
{
_logger.Debug($"No output file was provided for redeem [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][id: {e.RewardRedeemed.Redemption.Id}]");
}
else
{
var outputContent = string.IsNullOrWhiteSpace(redeem.OutputContent) ? null : redeem.OutputContent.Trim().Replace("%USER%", e.RewardRedeemed.Redemption.User.DisplayName).Replace("\\n", "\n");
if (outputContent == null) {
_logger.LogWarning($"No output content was provided for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'.");
} else {
if (redeem.OutputAppend == true) {
if (outputContent == null)
{
_logger.Warning($"No output content was provided for redeem [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][id: {e.RewardRedeemed.Redemption.Id}]");
}
else
{
if (redeem.OutputAppend == true)
{
File.AppendAllText(outputFile, outputContent + "\n");
} else {
}
else
{
File.WriteAllText(outputFile, outputContent);
}
}
@ -168,40 +208,23 @@ public class TwitchApiClient {
// Play audio file if needed.
var audioFile = string.IsNullOrWhiteSpace(redeem.AudioFilePath) ? null : redeem.AudioFilePath.Trim();
if (audioFile == null) {
_logger.LogDebug($"No audio file was provided for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'.");
return;
if (audioFile == null)
{
_logger.Debug($"No audio file was provided for redeem [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][id: {e.RewardRedeemed.Redemption.Id}]");
}
if (!File.Exists(audioFile)) {
_logger.LogWarning($"Cannot find audio file @ {audioFile} for redeem '{e.RewardRedeemed.Redemption.Reward.Title}'.");
return;
else if (!File.Exists(audioFile))
{
_logger.Warning($"Cannot find audio file [location: {audioFile}] for redeem [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][id: {e.RewardRedeemed.Redemption.Id}]");
}
else
{
AudioPlaybackEngine.Instance.PlaySound(audioFile);
}
};
/*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) {
public void AddOnNewMessageReceived(AsyncEventHandler<OnMessageReceivedArgs> handler)
{
_client.OnMessageReceived += handler;
}
}

View File

@ -10,11 +10,21 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="NAudio" Version="2.2.1" />
<PackageReference Include="NAudio.Extras" Version="2.2.1" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2-dev-00338" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.1-dev-10391" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.1-dev-00972" />
<PackageReference Include="Serilog.Sinks.RollingFile" Version="3.3.1-dev-00771" />
<PackageReference Include="Serilog.Sinks.Trace" Version="4.0.0" />
<PackageReference Include="System.Text.Json" Version="8.0.1" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="8.0.0" />
<PackageReference Include="TwitchLib.Api.Core" Version="3.10.0-preview-e47ba7f" />

24
User.cs
View File

@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using TwitchChatTTS.Hermes;
using HermesSocketLibrary.Requests.Messages;
namespace TwitchChatTTS
{
@ -7,29 +8,38 @@ namespace TwitchChatTTS
{
// Hermes user id
public string HermesUserId { get; set; }
public string HermesUsername { get; set; }
public long TwitchUserId { get; set; }
public string TwitchUsername { get; set; }
public string SevenEmoteSetId { get; set; }
public string DefaultTTSVoice { get; set; }
// voice id -> voice name
public IDictionary<string, string> VoicesAvailable { get; set; }
public IDictionary<string, string> VoicesAvailable { get => _voicesAvailable; set { _voicesAvailable = value; WordFilterRegex = GenerateEnabledVoicesRegex(); } }
// chatter/twitch id -> voice name
public IDictionary<long, string> VoicesSelected { get; set; }
public HashSet<string> VoicesEnabled { get; set; }
// voice names
public HashSet<string> VoicesEnabled { get => _voicesEnabled; set { _voicesEnabled = value; WordFilterRegex = GenerateEnabledVoicesRegex(); } }
public IDictionary<string, TTSUsernameFilter> ChatterFilters { get; set; }
public IList<TTSWordFilter> RegexFilters { get; set; }
[JsonIgnore]
public Regex? WordFilterRegex { get; set; }
private IDictionary<string, string> _voicesAvailable;
private HashSet<string> _voicesEnabled;
public User() {
public User()
{
}
public Regex? GenerateEnabledVoicesRegex() {
private Regex? GenerateEnabledVoicesRegex()
{
if (VoicesAvailable == null || VoicesAvailable.Count() <= 0)
return null;
var enabledVoicesString = string.Join("|", VoicesAvailable.Select(v => v.Value));
var enabledVoicesString = string.Join("|", VoicesAvailable.Where(v => VoicesEnabled == null || !VoicesEnabled.Any() || VoicesEnabled.Contains(v.Value)).Select(v => v.Value));
return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase);
}
}