Added hermes websocket support. Added chat command support. Added selectable voice command via websocket. Added websocket heartbeat management.

This commit is contained in:
Tom 2024-03-15 12:27:35 +00:00
parent b5cc6b5706
commit d4004d6230
53 changed files with 1227 additions and 461 deletions

View File

@ -4,22 +4,22 @@ using TwitchChatTTS.OBS.Socket;
using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common; using CommonSocketLibrary.Common;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TwitchChatTTS.Twitch;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using TwitchChatTTS; using TwitchChatTTS;
using TwitchChatTTS.Seven; using TwitchChatTTS.Seven;
using TwitchChatTTS.Chat.Commands;
public class ChatMessageHandler { public class ChatMessageHandler {
private ILogger<ChatMessageHandler> Logger { get; } private ILogger<ChatMessageHandler> _logger { get; }
private Configuration Configuration { get; } private Configuration _configuration { get; }
public EmoteCounter EmoteCounter { get; } public EmoteCounter _emoteCounter { get; }
private EmoteDatabase Emotes { get; } private EmoteDatabase _emotes { get; }
private TTSPlayer Player { get; } private TTSPlayer _player { get; }
private OBSSocketClient? Client { get; } private ChatCommandManager _commands { get; }
private TTSContext Context { get; } private OBSSocketClient? _obsClient { get; }
private IServiceProvider _serviceProvider { get; }
private Regex? voicesRegex;
private Regex sfxRegex; private Regex sfxRegex;
@ -29,46 +29,53 @@ public class ChatMessageHandler {
EmoteCounter emoteCounter, EmoteCounter emoteCounter,
EmoteDatabase emotes, EmoteDatabase emotes,
TTSPlayer player, TTSPlayer player,
ChatCommandManager commands,
[FromKeyedServices("obs")] SocketClient<WebSocketMessage> client, [FromKeyedServices("obs")] SocketClient<WebSocketMessage> client,
TTSContext context IServiceProvider serviceProvider
) { ) {
Logger = logger; _logger = logger;
Configuration = configuration; _configuration = configuration;
EmoteCounter = emoteCounter; _emoteCounter = emoteCounter;
Emotes = emotes; _emotes = emotes;
Player = player; _player = player;
Client = client as OBSSocketClient; _commands = commands;
Context = context; _obsClient = client as OBSSocketClient;
_serviceProvider = serviceProvider;
voicesRegex = GenerateEnabledVoicesRegex();
sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)"); sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)");
} }
public MessageResult Handle(OnMessageReceivedArgs e) { public async Task<MessageResult> Handle(OnMessageReceivedArgs e) {
if (Configuration.Twitch?.TtsWhenOffline != true && Client?.Live != true) if (_configuration.Twitch?.TtsWhenOffline != true && _obsClient?.Live == false)
return MessageResult.Blocked; return MessageResult.Blocked;
var user = _serviceProvider.GetRequiredService<User>();
var m = e.ChatMessage; var m = e.ChatMessage;
var msg = e.ChatMessage.Message; var msg = e.ChatMessage.Message;
var chatterId = long.Parse(m.UserId);
// Skip TTS messages
if (m.IsVip || m.IsModerator || m.IsBroadcaster) { var blocked = user.ChatterFilters.TryGetValue(m.Username, out TTSUsernameFilter? filter) && filter.Tag == "blacklisted";
if (msg.ToLower().StartsWith("!skip ") || msg.ToLower() == "!skip")
return MessageResult.Skip; if (!blocked || m.IsBroadcaster) {
try {
if (msg.ToLower().StartsWith("!skipall ") || msg.ToLower() == "!skipall") var commandResult = await _commands.Execute(msg, m);
return MessageResult.SkipAll; if (commandResult != ChatCommandResult.Unknown) {
return MessageResult.Command;
}
} catch (Exception ex) {
_logger.LogError(ex, "Failed at executing command.");
}
} }
if (Context.UsernameFilters.TryGetValue(m.Username, out TTSUsernameFilter? filter) && filter.Tag == "blacklisted") { if (blocked) {
Logger.LogTrace($"Blocked message by {m.Username}: {msg}"); _logger.LogTrace($"Blocked message by {m.Username}: {msg}");
return MessageResult.Blocked; return MessageResult.Blocked;
} }
// Replace filtered words. // Replace filtered words.
if (Context.WordFilters is not null) { if (user.RegexFilters != null) {
foreach (var wf in Context.WordFilters) { foreach (var wf in user.RegexFilters) {
if (wf.Search == null || wf.Replace == null) if (wf.Search == null || wf.Replace == null)
continue; continue;
@ -87,6 +94,7 @@ public class ChatMessageHandler {
} }
// Filter highly repetitive words (like emotes) from the message. // Filter highly repetitive words (like emotes) from the message.
int totalEmoteUsed = 0;
var emotesUsed = new HashSet<string>(); var emotesUsed = new HashSet<string>();
var words = msg.Split(" "); var words = msg.Split(" ");
var wordCounter = new Dictionary<string, int>(); var wordCounter = new Dictionary<string, int>();
@ -98,24 +106,31 @@ public class ChatMessageHandler {
wordCounter.Add(w, 1); wordCounter.Add(w, 1);
} }
var emoteId = Emotes?.Get(w); var emoteId = _emotes?.Get(w);
if (emoteId != null) if (emoteId == null)
emotesUsed.Add("7tv-" + emoteId); emoteId = m.EmoteSet.Emotes.FirstOrDefault(e => e.Name == w)?.Id;
if (emoteId != null) {
emotesUsed.Add(emoteId);
totalEmoteUsed++;
}
if (wordCounter[w] <= 4 && (emoteId == null || emotesUsed.Count <= 4)) if (wordCounter[w] <= 4 && (emoteId == null || totalEmoteUsed <= 5))
filteredMsg += w + " "; filteredMsg += w + " ";
} }
msg = filteredMsg; msg = filteredMsg;
// Adding twitch emotes to the counter. // Adding twitch emotes to the counter.
foreach (var emote in e.ChatMessage.EmoteSet.Emotes) foreach (var emote in e.ChatMessage.EmoteSet.Emotes) {
emotesUsed.Add("twitch-" + emote.Id); _logger.LogTrace("Twitch emote name used: " + emote.Name);
emotesUsed.Add(emote.Id);
}
if (long.TryParse(e.ChatMessage.UserId, out long userId)) if (long.TryParse(e.ChatMessage.UserId, out long userId))
EmoteCounter.Add(userId, emotesUsed); _emoteCounter.Add(userId, emotesUsed);
if (emotesUsed.Any()) if (emotesUsed.Any())
Logger.LogDebug("Emote counters for user #" + userId + ": " + string.Join(" | ", emotesUsed.Select(e => e + "=" + EmoteCounter.Get(userId, e)))); _logger.LogDebug("Emote counters for user #" + userId + ": " + string.Join(" | ", emotesUsed.Select(e => e + "=" + _emoteCounter.Get(userId, e))));
// Determine the priority of this message
int priority = 0; int priority = 0;
if (m.IsStaff) { if (m.IsStaff) {
priority = int.MinValue; priority = int.MinValue;
@ -130,19 +145,30 @@ public class ChatMessageHandler {
} else if (m.IsHighlighted) { } else if (m.IsHighlighted) {
priority = -1; priority = -1;
} }
priority = (int) Math.Round(Math.Min(priority, -m.SubscribedMonthCount * (m.Badges.Any(b => b.Key == "subscriber") ? 1.2 : 1))); priority = Math.Min(priority, -m.SubscribedMonthCount * (m.IsSubscriber ? 2 : 1));
var matches = voicesRegex?.Matches(msg).ToArray() ?? new Match[0]; // Determine voice selected.
int defaultEnd = matches.FirstOrDefault()?.Index ?? msg.Length; string voiceSelected = user.DefaultTTSVoice;
if (defaultEnd > 0) { if (user.VoicesSelected?.ContainsKey(userId) == true) {
HandlePartialMessage(priority, Context.DefaultVoice, msg.Substring(0, defaultEnd).Trim(), e); var voiceId = user.VoicesSelected[userId];
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) {
HandlePartialMessage(priority, voiceSelected, msg.Trim(), e);
return MessageResult.None;
}
HandlePartialMessage(priority, voiceSelected, msg.Substring(0, matches.FirstOrDefault().Index).Trim(), e);
foreach (Match match in matches) { foreach (Match match in matches) {
var message = match.Groups[2].ToString(); var message = match.Groups[2].ToString();
if (string.IsNullOrWhiteSpace(message)) { if (string.IsNullOrWhiteSpace(message))
continue; continue;
}
var voice = match.Groups[1].ToString(); var voice = match.Groups[1].ToString();
voice = voice[0].ToString().ToUpper() + voice.Substring(1).ToLower(); voice = voice[0].ToString().ToUpper() + voice.Substring(1).ToLower();
@ -162,8 +188,8 @@ public class ChatMessageHandler {
var badgesString = string.Join(", ", e.ChatMessage.Badges.Select(b => b.Key + " = " + b.Value)); var badgesString = string.Join(", ", e.ChatMessage.Badges.Select(b => b.Key + " = " + b.Value));
if (parts.Length == 1) { if (parts.Length == 1) {
Logger.LogInformation($"Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; {badgesString}"); _logger.LogInformation($"Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; {badgesString}");
Player.Add(new TTSMessage() { _player.Add(new TTSMessage() {
Voice = voice, Voice = voice,
Message = message, Message = message,
Moderator = m.IsModerator, Moderator = m.IsModerator,
@ -189,8 +215,8 @@ public class ChatMessageHandler {
} }
if (!string.IsNullOrWhiteSpace(parts[i * 2])) { if (!string.IsNullOrWhiteSpace(parts[i * 2])) {
Logger.LogInformation($"Voice: {voice}; Priority: {priority}; Message: {parts[i * 2]}; Month: {m.SubscribedMonthCount}; {badgesString}"); _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() { _player.Add(new TTSMessage() {
Voice = voice, Voice = voice,
Message = parts[i * 2], Message = parts[i * 2],
Moderator = m.IsModerator, Moderator = m.IsModerator,
@ -202,8 +228,8 @@ public class ChatMessageHandler {
}); });
} }
Logger.LogInformation($"Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}"); _logger.LogInformation($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}");
Player.Add(new TTSMessage() { _player.Add(new TTSMessage() {
Voice = voice, Voice = voice,
Message = sfxName, Message = sfxName,
File = $"sfx/{sfxName}.mp3", File = $"sfx/{sfxName}.mp3",
@ -217,8 +243,8 @@ public class ChatMessageHandler {
} }
if (!string.IsNullOrWhiteSpace(parts.Last())) { if (!string.IsNullOrWhiteSpace(parts.Last())) {
Logger.LogInformation($"Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; Month: {m.SubscribedMonthCount}; {badgesString}"); _logger.LogInformation($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; Month: {m.SubscribedMonthCount}; {badgesString}");
Player.Add(new TTSMessage() { _player.Add(new TTSMessage() {
Voice = voice, Voice = voice,
Message = parts.Last(), Message = parts.Last(),
Moderator = m.IsModerator, Moderator = m.IsModerator,
@ -230,13 +256,4 @@ public class ChatMessageHandler {
}); });
} }
} }
private Regex? GenerateEnabledVoicesRegex() {
if (Context.EnabledVoices == null || Context.EnabledVoices.Count() <= 0) {
return null;
}
var enabledVoicesString = string.Join("|", Context.EnabledVoices.Select(v => v.Label));
return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase);
}
} }

View File

@ -0,0 +1,54 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
{
public class AddTTSVoiceCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger<AddTTSVoiceCommand> _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.") {
_serviceProvider = serviceProvider;
_logger = logger;
AddParameter(ttsVoiceParameter);
}
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsModerator || message.IsBroadcaster || message.UserId == "126224566";
}
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.First();
var voiceNameLower = voiceName.ToLower();
var exists = context.VoicesAvailable.Any(v => v.Value.ToLower() == voiceNameLower);
if (exists)
return;
await client.Send(3, new RequestMessage() {
Type = "create_tts_voice",
Data = new Dictionary<string, string>() { { "@voice", voiceName } }
});
_logger.LogInformation($"Added a new TTS voice by {message.Username} (id: {message.UserId}): {voiceName}.");
}
}
}

View File

@ -0,0 +1,27 @@
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
{
public abstract class ChatCommand
{
public string Name { get; }
public string Description { get; }
public IList<ChatCommandParameter> Parameters { get => _parameters.AsReadOnly(); }
private IList<ChatCommandParameter> _parameters;
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);
}
public abstract Task<bool> CheckPermissions(ChatMessage message, long broadcasterId);
public abstract Task Execute(IList<string> args, ChatMessage message, long broadcasterId);
}
}

View File

@ -0,0 +1,100 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
{
public class ChatCommandManager
{
private IDictionary<string, ChatCommand> _commands;
private TwitchBotToken _token;
private IServiceProvider _serviceProvider;
private ILogger<ChatCommandManager> _logger;
private string CommandStartSign { get; } = "!";
public ChatCommandManager(TwitchBotToken token, IServiceProvider serviceProvider, ILogger<ChatCommandManager> logger) {
_token = token;
_serviceProvider = serviceProvider;
_logger = logger;
_commands = new Dictionary<string, ChatCommand>();
GenerateCommands();
}
private void Add(ChatCommand command) {
_commands.Add(command.Name.ToLower(), command);
}
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) {
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);
continue;
}
_logger.LogDebug($"Added command {type.AssemblyQualifiedName}.");
Add(command);
}
}
public async Task<ChatCommandResult> Execute(string arg, ChatMessage message) {
if (_token.BroadcasterId == null)
return ChatCommandResult.Unknown;
if (string.IsNullOrWhiteSpace(arg))
return ChatCommandResult.Unknown;
arg = arg.Trim();
if (!arg.StartsWith(CommandStartSign))
return ChatCommandResult.Unknown;
string[] parts = arg.Split(" ");
string com = parts.First().Substring(CommandStartSign.Length).ToLower();
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}'.");
return ChatCommandResult.Missing;
}
if (!await command.CheckPermissions(message, broadcasterId)) {
_logger.LogWarning($"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)}");
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]}");
return ChatCommandResult.Syntax;
}
}
try {
await command.Execute(args, message, broadcasterId);
} catch (Exception e) {
_logger.LogError(e, $"Command '{arg}' failed.");
return ChatCommandResult.Fail;
}
_logger.LogInformation($"Execute the {com} command with the following args: " + string.Join(" ", args));
return ChatCommandResult.Success;
}
}
}

View File

@ -0,0 +1,12 @@
namespace TwitchChatTTS.Chat.Commands
{
public enum ChatCommandResult
{
Unknown = 0,
Missing = 1,
Success = 2,
Permission = 3,
Syntax = 4,
Fail = 5
}
}

View File

@ -0,0 +1,17 @@
namespace TwitchChatTTS.Chat.Commands.Parameters
{
public abstract class ChatCommandParameter
{
public string Name { get; }
public string Description { get; }
public bool Optional { get; }
public ChatCommandParameter(string name, string description, bool optional = false) {
Name = name;
Description = description;
Optional = optional;
}
public abstract bool Validate(string value);
}
}

View File

@ -0,0 +1,23 @@
using Microsoft.Extensions.DependencyInjection;
namespace TwitchChatTTS.Chat.Commands.Parameters
{
public class TTSVoiceNameParameter : ChatCommandParameter
{
private IServiceProvider _serviceProvider;
public TTSVoiceNameParameter(IServiceProvider serviceProvider, bool optional = false) : base("TTS Voice Name", "Name of a TTS voice", optional)
{
_serviceProvider = serviceProvider;
}
public override bool Validate(string value)
{
var user = _serviceProvider.GetRequiredService<User>();
if (user.VoicesAvailable == null)
return false;
value = value.ToLower();
return user.VoicesAvailable.Any(e => e.Value.ToLower() == value);
}
}
}

View File

@ -0,0 +1,14 @@
namespace TwitchChatTTS.Chat.Commands.Parameters
{
public class UnvalidatedParameter : ChatCommandParameter
{
public UnvalidatedParameter(bool optional = false) : base("TTS Voice Name", "Name of a TTS voice", optional)
{
}
public override bool Validate(string value)
{
return true;
}
}
}

View File

@ -0,0 +1,54 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
{
public class RemoveTTSVoiceCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger<RemoveTTSVoiceCommand> _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.") {
_serviceProvider = serviceProvider;
_logger = logger;
AddParameter(ttsVoiceParameter);
}
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsModerator || message.IsBroadcaster || message.UserId == "126224566";
}
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.First().ToLower();
var exists = context.VoicesAvailable.Any(v => v.Value.ToLower() == voiceName);
if (!exists)
return;
var voiceId = context.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceName).Key;
await client.Send(3, new RequestMessage() {
Type = "delete_tts_voice",
Data = new Dictionary<string, string>() { { "@voice", voiceId } }
});
_logger.LogInformation($"Deleted a TTS voice by {message.Username} (id: {message.UserId}): {voiceName}.");
}
}
}

View File

@ -0,0 +1,37 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
{
public class SkipAllCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger<SkipAllCommand> _logger;
public SkipAllCommand(IServiceProvider serviceProvider, ILogger<SkipAllCommand> logger)
: base("skipall", "Skips all text to speech messages in queue and playing.") {
_serviceProvider = serviceProvider;
_logger = logger;
}
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsModerator || message.IsVip || message.IsBroadcaster;
}
public override async Task Execute(IList<string> args, ChatMessage message, long broadcasterId)
{
var player = _serviceProvider.GetRequiredService<TTSPlayer>();
player.RemoveAll();
if (player.Playing == null)
return;
AudioPlaybackEngine.Instance.RemoveMixerInput(player.Playing);
player.Playing = null;
_logger.LogInformation("Skipped all queued and playing tts.");
}
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
{
public class SkipCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger<SkipCommand> _logger;
public SkipCommand(IServiceProvider serviceProvider, ILogger<SkipCommand> logger)
: base("skip", "Skips the current text to speech message.") {
_serviceProvider = serviceProvider;
_logger = logger;
}
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsModerator || message.IsVip || message.IsBroadcaster;
}
public override async Task Execute(IList<string> args, ChatMessage message, long broadcasterId)
{
var player = _serviceProvider.GetRequiredService<TTSPlayer>();
if (player.Playing == null)
return;
AudioPlaybackEngine.Instance.RemoveMixerInput(player.Playing);
player.Playing = null;
_logger.LogInformation("Skipped current tts.");
}
}
}

View File

@ -0,0 +1,60 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchLib.Client.Models;
namespace TwitchChatTTS.Chat.Commands
{
public class VoiceCommand : ChatCommand
{
private IServiceProvider _serviceProvider;
private ILogger<VoiceCommand> _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.") {
_serviceProvider = serviceProvider;
_logger = logger;
AddParameter(ttsVoiceParameter);
}
public override async Task<bool> CheckPermissions(ChatMessage message, long broadcasterId)
{
return message.IsModerator || message.IsBroadcaster || message.IsSubscriber || message.Bits >= 100 || message.UserId == "126224566";
}
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.VoicesSelected == null || context.VoicesAvailable == null)
return;
long chatterId = long.Parse(message.UserId);
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 } }
});
_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}).");
}
}
}
}

View File

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

View File

@ -24,7 +24,7 @@ public class AudioPlaybackEngine : IDisposable
private ISampleProvider ConvertToRightChannelCount(ISampleProvider? input) private ISampleProvider ConvertToRightChannelCount(ISampleProvider? input)
{ {
if (input is null) if (input == null)
throw new NullReferenceException(nameof(input)); throw new NullReferenceException(nameof(input));
if (input.WaveFormat.Channels == mixer.WaveFormat.Channels) if (input.WaveFormat.Channels == mixer.WaveFormat.Channels)

View File

@ -6,6 +6,8 @@ public class TTSPlayer {
private Mutex _mutex; private Mutex _mutex;
private Mutex _mutex2; private Mutex _mutex2;
public ISampleProvider? Playing { get; set; }
public TTSPlayer() { public TTSPlayer() {
_messages = new PriorityQueue<TTSMessage, int>(); _messages = new PriorityQueue<TTSMessage, int>();
_buffer = new PriorityQueue<TTSMessage, int>(); _buffer = new PriorityQueue<TTSMessage, int>();

View File

@ -1,5 +1,3 @@
using TwitchChatTTS.Seven.Socket.Context;
namespace TwitchChatTTS namespace TwitchChatTTS
{ {
public class Configuration public class Configuration
@ -39,10 +37,7 @@ namespace TwitchChatTTS
} }
public class SevenConfiguration { public class SevenConfiguration {
public string? Protocol; public string? UserId;
public string? Url;
public IEnumerable<SevenSubscriptionConfiguration>? InitialSubscriptions;
} }
} }
} }

View File

@ -4,18 +4,10 @@ using TwitchChatTTS.Hermes;
using System.Text.Json; using System.Text.Json;
public class HermesClient { public class HermesClient {
private Account? account;
private WebClientWrap _web; private WebClientWrap _web;
private Configuration Configuration { get; }
public string? Id { get => account?.Id; }
public string? Username { get => account?.Username; }
public HermesClient(Configuration configuration) { public HermesClient(Configuration configuration) {
Configuration = configuration; if (string.IsNullOrWhiteSpace(configuration.Hermes?.Token)) {
if (string.IsNullOrWhiteSpace(Configuration.Hermes?.Token)) {
throw new Exception("Ensure you have written your API key in \".token\" file, in the same folder as this application."); throw new Exception("Ensure you have written your API key in \".token\" file, in the same folder as this application.");
} }
@ -23,54 +15,52 @@ public class HermesClient {
PropertyNameCaseInsensitive = false, PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
}); });
_web.AddHeader("x-api-key", Configuration.Hermes.Token); _web.AddHeader("x-api-key", configuration.Hermes.Token);
} }
public async Task FetchHermesAccountDetails() { public async Task<Account> FetchHermesAccountDetails() {
account = await _web.GetJson<Account>("https://hermes.goblincaves.com/api/account"); 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"); var token = await _web.GetJson<TwitchBotToken>("https://hermes.goblincaves.com/api/token/bot");
if (token == null) { 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."); throw new Exception("Failed to fetch Twitch API token from Hermes.");
}
return token; 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"); var filters = await _web.GetJson<IEnumerable<TTSUsernameFilter>>("https://hermes.goblincaves.com/api/settings/tts/filter/users");
if (filters == null) { if (filters == null)
throw new Exception("Failed to fetch TTS username filters from Hermes."); throw new Exception("Failed to fetch TTS username filters from Hermes.");
}
return filters; return filters;
} }
public async Task<string?> FetchTTSDefaultVoice() { public async Task<string> FetchTTSDefaultVoice() {
var data = await _web.GetJson<TTSVoice>("https://hermes.goblincaves.com/api/settings/tts/default"); var data = await _web.GetJson<TTSVoice>("https://hermes.goblincaves.com/api/settings/tts/default");
if (data == null) { if (data == null)
throw new Exception("Failed to fetch TTS default voice from Hermes."); throw new Exception("Failed to fetch TTS default voice from Hermes.");
}
return data.Label; return data.Label;
} }
public async Task<IEnumerable<TTSVoice>> FetchTTSEnabledVoices() { public async Task<IEnumerable<TTSVoice>> FetchTTSEnabledVoices() {
var voices = await _web.GetJson<IEnumerable<TTSVoice>>("https://hermes.goblincaves.com/api/settings/tts"); var voices = await _web.GetJson<IEnumerable<TTSVoice>>("https://hermes.goblincaves.com/api/settings/tts");
if (voices == null) { if (voices == null)
throw new Exception("Failed to fetch TTS enabled voices from Hermes."); throw new Exception("Failed to fetch TTS enabled voices from Hermes.");
}
return voices; 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"); var filters = await _web.GetJson<IEnumerable<TTSWordFilter>>("https://hermes.goblincaves.com/api/settings/tts/filter/words");
if (filters == null) { if (filters == null)
throw new Exception("Failed to fetch TTS word filters from Hermes."); throw new Exception("Failed to fetch TTS word filters from Hermes.");
}
return filters; return filters;
} }

View File

@ -0,0 +1,35 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.Logging;
namespace TwitchChatTTS.Hermes.Socket.Handlers
{
public class HeartbeatHandler : IWebSocketHandler
{
private ILogger _logger { get; }
public int OperationCode { get; set; } = 0;
public HeartbeatHandler(ILogger<HeartbeatHandler> logger) {
_logger = logger;
}
public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message)
{
if (message is not HeartbeatMessage obj || obj == null)
return;
if (sender is not HermesSocketClient client) {
return;
}
_logger.LogTrace("Received heartbeat.");
client.LastHeartbeat = DateTime.UtcNow;
await sender.Send(0, new HeartbeatMessage() {
DateTime = DateTime.UtcNow
});
}
}
}

View File

@ -0,0 +1,34 @@
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.Logging;
namespace TwitchChatTTS.Hermes.Socket.Handlers
{
public class LoginAckHandler : IWebSocketHandler
{
private ILogger _logger { get; }
public int OperationCode { get; set; } = 2;
public LoginAckHandler(ILogger<LoginAckHandler> logger) {
_logger = logger;
}
public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message)
{
if (message is not LoginAckMessage obj || obj == null)
return;
if (sender is not HermesSocketClient client) {
return;
}
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}.");
}
}
}
}

View File

@ -0,0 +1,106 @@
using System.Collections.Concurrent;
using System.Text.Json;
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using HermesSocketLibrary.Requests.Messages;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace TwitchChatTTS.Hermes.Socket.Handlers
{
public class RequestAckHandler : IWebSocketHandler
{
private readonly IServiceProvider _serviceProvider;
private readonly JsonSerializerOptions _options;
private readonly ILogger _logger;
public int OperationCode { get; set; } = 4;
public RequestAckHandler(IServiceProvider serviceProvider, JsonSerializerOptions options, ILogger<RequestAckHandler> logger) {
_serviceProvider = serviceProvider;
_options = options;
_logger = logger;
}
public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message)
{
if (message is not RequestAckMessage obj || obj == null)
return;
if (obj.Request == null)
return;
var context = _serviceProvider.GetRequiredService<User>();
if (context == null)
return;
if (obj.Request.Type == "get_tts_voices") {
_logger.LogDebug("Updating all available voices.");
var voices = JsonSerializer.Deserialize<IEnumerable<VoiceDetails>>(obj.Data.ToString(), _options);
if (voices == null)
return;
context.VoicesAvailable = voices.ToDictionary(e => e.Id, e => e.Name);
_logger.LogInformation("Updated all available voices.");
} else if (obj.Request.Type == "create_tts_user") {
_logger.LogDebug("Creating new tts voice.");
if (!long.TryParse(obj.Request.Data["@user"], out long userId))
return;
string broadcasterId = obj.Request.Data["@broadcaster"].ToString();
// TODO: validate broadcaster id.
string voice = obj.Request.Data["@voice"].ToString();
context.VoicesSelected.Add(userId, voice);
_logger.LogInformation("Created new tts user.");
} else if (obj.Request.Type == "update_tts_user") {
_logger.LogDebug("Updating user's voice");
if (!long.TryParse(obj.Request.Data["@user"], out long userId))
return;
string broadcasterId = obj.Request.Data["@broadcaster"].ToString();
string voice = obj.Request.Data["@voice"].ToString();
context.VoicesSelected[userId] = voice;
_logger.LogInformation($"Updated user's voice to {voice}.");
} else if (obj.Request.Type == "create_tts_voice") {
_logger.LogDebug("Creating new tts voice.");
string? voice = obj.Request.Data["@voice"];
string? 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;
}
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;
}
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.");
var users = JsonSerializer.Deserialize<IDictionary<long, string>>(obj.Data.ToString(), _options);
if (users == null)
return;
var temp = new ConcurrentDictionary<long, string>();
foreach (var entry in users)
temp.TryAdd(entry.Key, entry.Value);
context.VoicesSelected = temp;
_logger.LogInformation($"Fetched {temp.Count()} chatters' selected voice.");
}
}
}
}

View File

@ -0,0 +1,24 @@
using System.Text.Json;
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace TwitchChatTTS.Hermes.Socket
{
public class HermesSocketClient : WebSocketClient {
public DateTime LastHeartbeat { get; set; }
public string? UserId { get; set; }
public HermesSocketClient(
ILogger<HermesSocketClient> logger,
[FromKeyedServices("hermes")] HandlerManager<WebSocketClient, IWebSocketHandler> handlerManager,
[FromKeyedServices("hermes")] HandlerTypeManager<WebSocketClient, IWebSocketHandler> typeManager
) : base(logger, handlerManager, typeManager, new JsonSerializerOptions() {
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
}) {
}
}
}

View File

@ -0,0 +1,36 @@
using CommonSocketLibrary.Common;
using CommonSocketLibrary.Socket.Manager;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace TwitchChatTTS.Hermes.Socket.Managers
{
public class HermesHandlerManager : WebSocketHandlerManager
{
public HermesHandlerManager(ILogger<HermesHandlerManager> logger, IServiceProvider provider) : base(logger) {
//Add(provider.GetRequiredService<HeartbeatHandler>());
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) {
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);
continue;
}
Logger.LogDebug($"Linked type {type.AssemblyQualifiedName} to hermes websocket handlers.");
Add(handler);
}
} catch (Exception e) {
Logger.LogError(e, "Failed to load hermes websocket handler types.");
}
}
}
}

View File

@ -0,0 +1,32 @@
using System.Reflection;
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using CommonSocketLibrary.Socket.Manager;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace TwitchChatTTS.Hermes.Socket.Managers
{
public class HermesHandlerTypeManager : WebSocketHandlerTypeManager
{
public HermesHandlerTypeManager(
ILogger<HermesHandlerTypeManager> factory,
[FromKeyedServices("hermes")] HandlerManager<WebSocketClient, IWebSocketHandler> handlers
) : base(factory, handlers)
{
}
protected override Type? FetchMessageType(Type handlerType)
{
if (handlerType == null)
return null;
var name = handlerType.Namespace + "." + handlerType.Name;
name = name.Replace(".Handlers.", ".Data.")
.Replace("Handler", "Message")
.Replace("TwitchChatTTS.Hermes.", "HermesSocketLibrary.");
return Assembly.Load("HermesSocketLibrary").GetType(name);
}
}
}

View File

@ -1,5 +1,5 @@
public class TTSUsernameFilter { public class TTSUsernameFilter {
public string? Username { get; set; } public string Username { get; set; }
public string? Tag { get; set; } public string Tag { get; set; }
public string? UserId { get; set; } public string UserId { get; set; }
} }

View File

@ -1,5 +1,5 @@
public class TTSVoice { public class TTSVoice {
public string? Label { get; set; } public string Label { get; set; }
public int Value { get; set; } public int Value { get; set; }
public string? Gender { get; set; } public string? Gender { get; set; }
public string? Language { get; set; } public string? Language { get; set; }

View File

@ -1,8 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace TwitchChatTTS.Hermes namespace TwitchChatTTS.Hermes
{ {
public class TTSWordFilter public class TTSWordFilter

View File

@ -3,8 +3,8 @@ namespace TwitchChatTTS.OBS.Socket.Data
[Serializable] [Serializable]
public class EventMessage public class EventMessage
{ {
public string eventType { get; set; } public string EventType { get; set; }
public int eventIntent { get; set; } public int EventIntent { get; set; }
public Dictionary<string, object> eventData { get; set; } public Dictionary<string, object> EventData { get; set; }
} }
} }

View File

@ -3,13 +3,13 @@ namespace TwitchChatTTS.OBS.Socket.Data
[Serializable] [Serializable]
public class HelloMessage public class HelloMessage
{ {
public string obsWebSocketVersion { get; set; } public string ObsWebSocketVersion { get; set; }
public int rpcVersion { get; set; } public int RpcVersion { get; set; }
public AuthenticationMessage authentication { get; set; } public AuthenticationMessage Authentication { get; set; }
} }
public class AuthenticationMessage { public class AuthenticationMessage {
public string challenge { get; set; } public string Challenge { get; set; }
public string salt { get; set; } public string Salt { get; set; }
} }
} }

View File

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

View File

@ -3,14 +3,14 @@ namespace TwitchChatTTS.OBS.Socket.Data
[Serializable] [Serializable]
public class IdentifyMessage public class IdentifyMessage
{ {
public int rpcVersion { get; set; } public int RpcVersion { get; set; }
public string? authentication { get; set; } public string? Authentication { get; set; }
public int eventSubscriptions { 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; RpcVersion = version;
authentication = auth; Authentication = auth;
eventSubscriptions = subscriptions; EventSubscriptions = subscriptions;
} }
} }
} }

View File

@ -3,14 +3,14 @@ namespace TwitchChatTTS.OBS.Socket.Data
[Serializable] [Serializable]
public class RequestMessage public class RequestMessage
{ {
public string requestType { get; set; } public string RequestType { get; set; }
public string requestId { get; set; } public string RequestId { get; set; }
public Dictionary<string, object> requestData { 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; RequestType = type;
requestId = id; RequestId = id;
requestData = data; RequestData = data;
} }
} }
} }

View File

@ -3,9 +3,9 @@ namespace TwitchChatTTS.OBS.Socket.Data
[Serializable] [Serializable]
public class RequestResponseMessage public class RequestResponseMessage
{ {
public string requestType { get; set; } public string RequestType { get; set; }
public string requestId { get; set; } public string RequestId { get; set; }
public object requestStatus { get; set; } public object RequestStatus { get; set; }
public Dictionary<string, object> responseData { get; set; } public Dictionary<string, object> ResponseData { get; set; }
} }
} }

View File

@ -21,15 +21,15 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
if (message is not EventMessage obj || obj == null) if (message is not EventMessage obj || obj == null)
return; return;
switch (obj.eventType) { switch (obj.EventType) {
case "StreamStateChanged": case "StreamStateChanged":
case "RecordStateChanged": case "RecordStateChanged":
if (sender is not OBSSocketClient client) if (sender is not OBSSocketClient client)
return; return;
string? raw_state = obj.eventData["outputState"].ToString(); string? raw_state = obj.EventData["outputState"].ToString();
string? state = raw_state?.Substring(21).ToLower(); string? state = raw_state?.Substring(21).ToLower();
client.Live = obj.eventData["outputActive"].ToString() == "True"; client.Live = obj.EventData["outputActive"].ToString() == "True";
Logger.LogWarning("Stream " + (state != null && state.EndsWith("ing") ? "is " : "has ") + state + "."); Logger.LogWarning("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")) {
@ -37,7 +37,7 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
} }
break; break;
default: default:
Logger.LogDebug(obj.eventType + " EVENT: " + string.Join(" | ", obj.eventData?.Select(x => x.Key + "=" + x.Value?.ToString()) ?? new string[0])); Logger.LogDebug(obj.EventType + " EVENT: " + string.Join(" | ", obj.EventData?.Select(x => x.Key + "=" + x.Value?.ToString()) ?? new string[0]));
break; break;
} }
} }

View File

@ -25,15 +25,14 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
return; return;
Logger.LogTrace("OBS websocket password: " + Context.Password); Logger.LogTrace("OBS websocket password: " + Context.Password);
if (obj.authentication is null || Context.Password is null) // TODO: send re-identify message. if (obj.Authentication == null || Context.Password == null) // TODO: send re-identify message.
return; return;
var salt = obj.authentication.salt; var salt = obj.Authentication.Salt;
var challenge = obj.authentication.challenge; var challenge = obj.Authentication.Challenge;
Logger.LogTrace("Salt: " + salt); Logger.LogTrace("Salt: " + salt);
Logger.LogTrace("Challenge: " + challenge); Logger.LogTrace("Challenge: " + challenge);
string secret = Context.Password + salt; string secret = Context.Password + salt;
byte[] bytes = Encoding.UTF8.GetBytes(secret); byte[] bytes = Encoding.UTF8.GetBytes(secret);
string hash = null; string hash = null;
@ -48,8 +47,7 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
} }
Logger.LogTrace("Final hash: " + hash); Logger.LogTrace("Final hash: " + hash);
//await sender.Send(1, new IdentifyMessage(obj.rpcVersion, hash, 1023 | 262144 | 524288)); await sender.Send(1, new IdentifyMessage(obj.RpcVersion, hash, 1023 | 262144));
await sender.Send(1, new IdentifyMessage(obj.rpcVersion, hash, 1023 | 262144));
} }
} }
} }

View File

@ -20,7 +20,7 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
return; return;
sender.Connected = true; sender.Connected = true;
Logger.LogInformation("Connected to OBS via rpc version " + obj.negotiatedRpcVersion + "."); Logger.LogInformation("Connected to OBS via rpc version " + obj.NegotiatedRpcVersion + ".");
} }
} }
} }

View File

@ -19,13 +19,13 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
if (message is not RequestResponseMessage obj || obj == null) if (message is not RequestResponseMessage obj || obj == null)
return; return;
switch (obj.requestType) { switch (obj.RequestType) {
case "GetOutputStatus": case "GetOutputStatus":
if (sender is not OBSSocketClient client) if (sender is not OBSSocketClient client)
return; return;
if (obj.requestId == "stream") { if (obj.RequestId == "stream") {
client.Live = obj.responseData["outputActive"].ToString() == "True"; client.Live = obj.ResponseData["outputActive"].ToString() == "True";
Logger.LogWarning("Updated stream's live status to " + client.Live); Logger.LogWarning("Updated stream's live status to " + client.Live);
} }
break; break;

View File

@ -10,8 +10,7 @@ namespace TwitchChatTTS.OBS.Socket.Manager
{ {
public OBSHandlerTypeManager( public OBSHandlerTypeManager(
ILogger<OBSHandlerTypeManager> factory, ILogger<OBSHandlerTypeManager> factory,
[FromKeyedServices("obs")] HandlerManager<WebSocketClient, [FromKeyedServices("obs")] HandlerManager<WebSocketClient, IWebSocketHandler> handlers
IWebSocketHandler> handlers
) : base(factory, handlers) ) : base(factory, handlers)
{ {
} }

View File

@ -1,4 +1,3 @@
using TwitchChatTTS.OBS.Socket.Manager;
using CommonSocketLibrary.Common; using CommonSocketLibrary.Common;
using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Abstract;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;

View File

@ -73,7 +73,6 @@ namespace TwitchChatTTS.Seven
public IList<Emote> Emotes { get; set; } public IList<Emote> Emotes { get; set; }
public int EmoteCount { get; set; } public int EmoteCount { get; set; }
public int Capacity { get; set; } public int Capacity { get; set; }
} }
public class Emote { public class Emote {

View File

@ -1,18 +1,18 @@
using System.Text.Json; using System.Text.Json;
using TwitchChatTTS.Helpers; using TwitchChatTTS.Helpers;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TwitchChatTTS;
using TwitchChatTTS.Seven; using TwitchChatTTS.Seven;
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 WebClientWrap Web { get; }
private Configuration Configuration { get; }
private ILogger<SevenApiClient> Logger { get; } private ILogger<SevenApiClient> Logger { get; }
private long? Id { get; } private long? Id { get; }
public SevenApiClient(Configuration configuration, ILogger<SevenApiClient> logger, TwitchBotToken token) { public SevenApiClient(ILogger<SevenApiClient> logger, TwitchBotToken token) {
Configuration = configuration;
Logger = logger; Logger = logger;
Id = long.TryParse(token?.BroadcasterId, out long id) ? id : -1; Id = long.TryParse(token?.BroadcasterId, out long id) ? id : -1;
@ -23,16 +23,16 @@ public class SevenApiClient {
} }
public async Task<EmoteDatabase?> GetSevenEmotes() { public async Task<EmoteDatabase?> GetSevenEmotes() {
if (Id is null) if (Id == null)
throw new NullReferenceException(nameof(Id)); throw new NullReferenceException(nameof(Id));
try { try {
var details = await Web.GetJson<UserDetails>("https://7tv.io/v3/users/twitch/" + Id); var details = await Web.GetJson<UserDetails>($"{API_URL}/users/twitch/" + Id);
if (details is null) if (details == null)
return null; return null;
var emotes = new EmoteDatabase(); var emotes = new EmoteDatabase();
if (details.EmoteSet is not null) if (details.EmoteSet != null)
foreach (var emote in details.EmoteSet.Emotes) foreach (var emote in details.EmoteSet.Emotes)
emotes.Add(emote.Name, emote.Id); emotes.Add(emote.Name, emote.Id);
Logger.LogInformation($"Loaded {details.EmoteSet?.Emotes.Count() ?? 0} emotes from 7tv."); Logger.LogInformation($"Loaded {details.EmoteSet?.Emotes.Count() ?? 0} emotes from 7tv.");

View File

@ -2,8 +2,6 @@ namespace TwitchChatTTS.Seven.Socket.Context
{ {
public class ReconnectContext public class ReconnectContext
{ {
public string? Protocol;
public string Url;
public string? SessionId; public string? SessionId;
} }
} }

View File

@ -1,12 +0,0 @@
namespace TwitchChatTTS.Seven.Socket.Context
{
public class SevenHelloContext
{
public IEnumerable<SevenSubscriptionConfiguration>? Subscriptions;
}
public class SevenSubscriptionConfiguration {
public string? Type;
public IDictionary<string, string>? Condition;
}
}

View File

@ -10,12 +10,12 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
public class DispatchHandler : IWebSocketHandler public class DispatchHandler : IWebSocketHandler
{ {
private ILogger Logger { get; } private ILogger Logger { get; }
private IServiceProvider ServiceProvider { get; } private EmoteDatabase Emotes { get; }
public int OperationCode { get; set; } = 0; public int OperationCode { get; set; } = 0;
public DispatchHandler(ILogger<DispatchHandler> logger, IServiceProvider serviceProvider) { public DispatchHandler(ILogger<DispatchHandler> logger, EmoteDatabase emotes) {
Logger = logger; Logger = logger;
ServiceProvider = serviceProvider; Emotes = emotes;
} }
public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message) public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message)
@ -23,23 +23,31 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
if (message is not DispatchMessage obj || obj == null) if (message is not DispatchMessage obj || obj == null)
return; return;
Do(obj?.Body?.Pulled, cf => cf.OldValue); ApplyChanges(obj?.Body?.Pulled, cf => cf.OldValue, true);
Do(obj?.Body?.Pushed, cf => cf.Value); ApplyChanges(obj?.Body?.Pushed, cf => cf.Value, false);
} }
private void Do(IEnumerable<ChangeField>? fields, Func<ChangeField, object> getter) { private void ApplyChanges(IEnumerable<ChangeField>? fields, Func<ChangeField, object> getter, bool removing) {
if (fields is null) if (fields == null)
return; return;
//ServiceProvider.GetRequiredService<EmoteDatabase>()
foreach (var val in fields) { foreach (var val in fields) {
if (getter(val) == null) var value = getter(val);
if (value == null)
continue; continue;
var o = JsonSerializer.Deserialize<EmoteField>(val.OldValue.ToString(), new JsonSerializerOptions() { var o = JsonSerializer.Deserialize<EmoteField>(value.ToString(), new JsonSerializerOptions() {
PropertyNameCaseInsensitive = false, PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
}); });
if (removing) {
Emotes.Remove(o.Name);
Logger.LogInformation($"Removed 7tv emote: {o.Name} (id: {o.Id})");
} else {
Emotes.Add(o.Name, o.Id);
Logger.LogInformation($"Added 7tv emote: {o.Name} (id: {o.Id})");
}
} }
} }
} }

View File

@ -10,6 +10,7 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
public class EndOfStreamHandler : IWebSocketHandler public class EndOfStreamHandler : IWebSocketHandler
{ {
private ILogger Logger { get; } private ILogger Logger { get; }
private Configuration Configuration { get; }
private IServiceProvider ServiceProvider { get; } private IServiceProvider ServiceProvider { get; }
private string[] ErrorCodes { get; } private string[] ErrorCodes { get; }
private int[] ReconnectDelay { get; } private int[] ReconnectDelay { get; }
@ -17,8 +18,9 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
public int OperationCode { get; set; } = 7; public int OperationCode { get; set; } = 7;
public EndOfStreamHandler(ILogger<EndOfStreamHandler> logger, IServiceProvider serviceProvider) { public EndOfStreamHandler(ILogger<EndOfStreamHandler> logger, Configuration configuration, IServiceProvider serviceProvider) {
Logger = logger; Logger = logger;
Configuration = configuration;
ServiceProvider = serviceProvider; ServiceProvider = serviceProvider;
ErrorCodes = [ ErrorCodes = [
@ -71,17 +73,23 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
return; return;
} }
if (string.IsNullOrWhiteSpace(Configuration.Seven?.UserId))
return;
var context = ServiceProvider.GetRequiredService<ReconnectContext>(); var context = ServiceProvider.GetRequiredService<ReconnectContext>();
await Task.Delay(ReconnectDelay[code]); await Task.Delay(ReconnectDelay[code]);
Logger.LogInformation($"7tv client reconnecting."); //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]) + ">"));
await sender.ConnectAsync($"{context.Protocol ?? "wss"}://{context.Url}"); var base_url = $"@emote_set.*<object_id={Configuration.Seven.UserId.Trim()}>";
if (context.SessionId is null) { string url = $"{SevenApiClient.WEBSOCKET_URL}{base_url}";
await sender.Send(33, new object()); Logger.LogDebug($"7tv websocket reconnecting to {url}.");
await sender.ConnectAsync(url);
if (context.SessionId != null) {
await sender.Send(34, new ResumeMessage() { SessionId = context.SessionId });
Logger.LogInformation("Resumed connection to 7tv websocket.");
} else { } else {
await sender.Send(34, new ResumeMessage() { Logger.LogDebug("7tv websocket session id not available.");
SessionId = context.SessionId
});
} }
} }
} }

View File

@ -1,7 +1,6 @@
using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common; using CommonSocketLibrary.Common;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TwitchChatTTS.Seven.Socket.Context;
using TwitchChatTTS.Seven.Socket.Data; using TwitchChatTTS.Seven.Socket.Data;
namespace TwitchChatTTS.Seven.Socket.Handlers namespace TwitchChatTTS.Seven.Socket.Handlers
@ -9,12 +8,12 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
public class SevenHelloHandler : IWebSocketHandler public class SevenHelloHandler : IWebSocketHandler
{ {
private ILogger Logger { get; } private ILogger Logger { get; }
private SevenHelloContext Context { get; } private Configuration Configuration { get; }
public int OperationCode { get; set; } = 1; public int OperationCode { get; set; } = 1;
public SevenHelloHandler(ILogger<SevenHelloHandler> logger, SevenHelloContext context) { public SevenHelloHandler(ILogger<SevenHelloHandler> logger, Configuration configuration) {
Logger = logger; Logger = logger;
Context = context; Configuration = configuration;
} }
public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message) public async Task Execute<Data>(SocketClient<WebSocketMessage> sender, Data message)
@ -27,30 +26,7 @@ namespace TwitchChatTTS.Seven.Socket.Handlers
seven.Connected = true; seven.Connected = true;
seven.ConnectionDetails = obj; seven.ConnectionDetails = obj;
Logger.LogInformation("Connected to 7tv websockets.");
// if (Context.Subscriptions == null || !Context.Subscriptions.Any()) {
// Logger.LogWarning("No subscriptions have been set for the 7tv websocket client.");
// return;
// }
//await Task.Delay(TimeSpan.FromMilliseconds(1000));
//await sender.Send(33, new IdentifyMessage());
//await Task.Delay(TimeSpan.FromMilliseconds(5000));
//await sender.SendRaw("{\"op\":35,\"d\":{\"type\":\"emote_set.*\",\"condition\":{\"object_id\":\"64505914b9fc508169ffe7cc\"}}}");
//await sender.SendRaw(File.ReadAllText("test.txt"));
// foreach (var sub in Context.Subscriptions) {
// if (string.IsNullOrWhiteSpace(sub.Type)) {
// Logger.LogWarning("Non-existent or empty subscription type found on the 7tv websocket client.");
// continue;
// }
// Logger.LogDebug($"Subscription Type: {sub.Type} | Condition: {string.Join(", ", sub.Condition?.Select(e => e.Key + "=" + e.Value) ?? new string[0])}");
// await sender.Send(35, new SubscribeMessage() {
// Type = sub.Type,
// Condition = sub.Condition
// });
// }
} }
} }
} }

View File

@ -3,7 +3,7 @@ using CommonSocketLibrary.Socket.Manager;
using CommonSocketLibrary.Common; using CommonSocketLibrary.Common;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace TwitchChatTTS.Seven.Socket.Manager namespace TwitchChatTTS.Seven.Socket.Managers
{ {
public class SevenHandlerManager : WebSocketHandlerManager public class SevenHandlerManager : WebSocketHandlerManager
{ {

View File

@ -4,7 +4,7 @@ using CommonSocketLibrary.Socket.Manager;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace TwitchChatTTS.Seven.Socket.Manager namespace TwitchChatTTS.Seven.Socket.Managers
{ {
public class SevenHandlerTypeManager : WebSocketHandlerTypeManager public class SevenHandlerTypeManager : WebSocketHandlerTypeManager
{ {

View File

@ -7,38 +7,30 @@ using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common; using CommonSocketLibrary.Common;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions; using YamlDotNet.Serialization.NamingConventions;
using TwitchChatTTS.Twitch;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TwitchChatTTS.Seven.Socket.Manager;
using TwitchChatTTS.Seven.Socket; using TwitchChatTTS.Seven.Socket;
using TwitchChatTTS.OBS.Socket.Handlers; using TwitchChatTTS.OBS.Socket.Handlers;
using TwitchChatTTS.Seven.Socket.Handlers; using TwitchChatTTS.Seven.Socket.Handlers;
using TwitchChatTTS.Seven.Socket.Context; using TwitchChatTTS.Seven.Socket.Context;
using TwitchChatTTS.Seven; using TwitchChatTTS.Seven;
using TwitchChatTTS.OBS.Socket.Context; using TwitchChatTTS.OBS.Socket.Context;
using TwitchLib.Client.Interfaces;
/** using TwitchLib.Client;
Future handshake/connection procedure: using TwitchLib.PubSub.Interfaces;
- GET all tts config data using TwitchLib.PubSub;
- Continuous connection to server to receive commands from tom & send logs/errors (med priority, though tough task) using TwitchLib.Communication.Interfaces;
using TwitchChatTTS.Seven.Socket.Managers;
Ideas: using TwitchChatTTS.Hermes.Socket.Handlers;
- Filter messages by badges. using TwitchChatTTS.Hermes.Socket;
- Speed up TTS based on message queue size? using TwitchChatTTS.Hermes.Socket.Managers;
- Cut TTS off shortly after raid (based on size of raid)? using TwitchChatTTS.Chat.Commands.Parameters;
- Limit duration of TTS using TwitchChatTTS.Chat.Commands;
**/ using System.Text.Json;
// dotnet publish -r linux-x64 -p:PublishSingleFile=true --self-contained true // dotnet publish -r linux-x64 -p:PublishSingleFile=true --self-contained true
// dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained true // dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained true
// SE voices: https://api.streamelements.com/kappa/v2/speech?voice=brian&text=hello // SE voices: https://api.streamelements.com/kappa/v2/speech?voice=brian&text=hello
// TODO:
// Fix OBS/7tv websocket connections when not available.
// Make it possible to do things at end of streams.
// Update emote database with twitch emotes.
// Event Subscription for emote usage?
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
var s = builder.Services; var s = builder.Services;
@ -49,7 +41,7 @@ var deserializer = new DeserializerBuilder()
var configContent = File.ReadAllText("tts.config.yml"); var configContent = File.ReadAllText("tts.config.yml");
var configuration = deserializer.Deserialize<Configuration>(configContent); var configuration = deserializer.Deserialize<Configuration>(configContent);
var redeemKeys = configuration.Twitch?.Redeems?.Keys; var redeemKeys = configuration.Twitch?.Redeems?.Keys;
if (redeemKeys is not null) { if (redeemKeys != null) {
foreach (var key in redeemKeys) { foreach (var key in redeemKeys) {
if (key != key.ToLower() && configuration.Twitch?.Redeems != null) if (key != key.ToLower() && configuration.Twitch?.Redeems != null)
configuration.Twitch.Redeems.Add(key.ToLower(), configuration.Twitch.Redeems[key]); configuration.Twitch.Redeems.Add(key.ToLower(), configuration.Twitch.Redeems[key]);
@ -58,45 +50,35 @@ if (redeemKeys is not null) {
s.AddSingleton<Configuration>(configuration); s.AddSingleton<Configuration>(configuration);
s.AddLogging(); s.AddLogging();
s.AddSingleton<User>(new User());
s.AddSingleton<TTSContext>(sp => { s.AddSingleton<JsonSerializerOptions>(new JsonSerializerOptions() {
var context = new TTSContext(); PropertyNameCaseInsensitive = false,
var logger = sp.GetRequiredService<ILogger<TTSContext>>(); PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
var hermes = sp.GetRequiredService<HermesClient>();
logger.LogInformation("Fetching TTS username filters...");
var usernameFiltersList = hermes.FetchTTSUsernameFilters();
usernameFiltersList.Wait();
context.UsernameFilters = usernameFiltersList.Result.Where(x => x.Username != null).ToDictionary(x => x.Username ?? "", x => x);
logger.LogInformation($"{context.UsernameFilters.Where(f => f.Value.Tag == "blacklisted").Count()} username(s) have been blocked.");
logger.LogInformation($"{context.UsernameFilters.Where(f => f.Value.Tag == "priority").Count()} user(s) have been prioritized.");
var enabledVoices = hermes.FetchTTSEnabledVoices();
enabledVoices.Wait();
context.EnabledVoices = enabledVoices.Result;
logger.LogInformation($"{context.EnabledVoices.Count()} TTS voices enabled.");
var wordFilters = hermes.FetchTTSWordFilters();
wordFilters.Wait();
context.WordFilters = wordFilters.Result;
logger.LogInformation($"{context.WordFilters.Count()} TTS word filters.");
var defaultVoice = hermes.FetchTTSDefaultVoice();
defaultVoice.Wait();
context.DefaultVoice = defaultVoice.Result ?? "Brian";
logger.LogInformation("Default Voice: " + context.DefaultVoice);
return context;
}); });
// Command parameters
s.AddKeyedSingleton<ChatCommandParameter, TTSVoiceNameParameter>("parameter-ttsvoicename");
s.AddKeyedSingleton<ChatCommandParameter, UnvalidatedParameter>("parameter-unvalidated");
s.AddKeyedSingleton<ChatCommand, SkipAllCommand>("command-skipall");
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.AddSingleton<ChatCommandManager>();
s.AddSingleton<TTSPlayer>(); s.AddSingleton<TTSPlayer>();
s.AddSingleton<ChatMessageHandler>(); s.AddSingleton<ChatMessageHandler>();
s.AddSingleton<HermesClient>(); s.AddSingleton<HermesClient>();
s.AddTransient<TwitchBotToken>(sp => { s.AddSingleton<TwitchBotToken>(sp => {
var hermes = sp.GetRequiredService<HermesClient>(); var hermes = sp.GetRequiredService<HermesClient>();
var task = hermes.FetchTwitchBotToken(); var task = hermes.FetchTwitchBotToken();
task.Wait(); task.Wait();
return task.Result; return task.Result;
}); });
s.AddTransient<IClient, TwitchLib.Communication.Clients.WebSocketClient>();
s.AddTransient<ITwitchClient, TwitchClient>();
s.AddTransient<ITwitchPubSub, TwitchPubSub>();
s.AddSingleton<TwitchApiClient>(); s.AddSingleton<TwitchApiClient>();
s.AddSingleton<SevenApiClient>(); s.AddSingleton<SevenApiClient>();
@ -106,14 +88,11 @@ s.AddSingleton<EmoteDatabase>(sp => {
task.Wait(); task.Wait();
return task.Result; return task.Result;
}); });
var emoteCounter = new EmoteCounter(); s.AddSingleton<EmoteCounter>(sp => {
if (!string.IsNullOrWhiteSpace(configuration.Emotes?.CounterFilePath) && File.Exists(configuration.Emotes.CounterFilePath.Trim())) { if (!string.IsNullOrWhiteSpace(configuration.Emotes?.CounterFilePath) && File.Exists(configuration.Emotes.CounterFilePath.Trim()))
var d = new DeserializerBuilder() return deserializer.Deserialize<EmoteCounter>(File.ReadAllText(configuration.Emotes.CounterFilePath.Trim()));
.WithNamingConvention(HyphenatedNamingConvention.Instance) return new EmoteCounter();
.Build(); });
emoteCounter = deserializer.Deserialize<EmoteCounter>(File.ReadAllText(configuration.Emotes.CounterFilePath.Trim()));
}
s.AddSingleton<EmoteCounter>(emoteCounter);
// OBS websocket // OBS websocket
s.AddSingleton<HelloContext>(sp => s.AddSingleton<HelloContext>(sp =>
@ -135,34 +114,16 @@ s.AddKeyedSingleton<SocketClient<WebSocketMessage>, OBSSocketClient>("obs");
// 7tv websocket // 7tv websocket
s.AddTransient(sp => { s.AddTransient(sp => {
var logger = sp.GetRequiredService<ILogger<ReconnectContext>>(); var logger = sp.GetRequiredService<ILogger<ReconnectContext>>();
var configuration = sp.GetRequiredService<Configuration>();
var client = sp.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("7tv") as SevenSocketClient; var client = sp.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("7tv") as SevenSocketClient;
if (client == null) { if (client == null) {
logger.LogError("7tv client is null."); logger.LogError("7tv client == null.");
return new ReconnectContext() { return new ReconnectContext() { SessionId = null };
Protocol = configuration.Seven?.Protocol,
Url = configuration.Seven?.Url,
SessionId = null
};
} }
if (client.ConnectionDetails == null) { if (client.ConnectionDetails == null) {
logger.LogError("Connection details in 7tv client is null."); logger.LogError("Connection details in 7tv client == null.");
return new ReconnectContext() { return new ReconnectContext() { SessionId = null };
Protocol = configuration.Seven?.Protocol,
Url = configuration.Seven?.Url,
SessionId = null
};
} }
return new ReconnectContext() { return new ReconnectContext() { SessionId = client.ConnectionDetails.SessionId };
Protocol = configuration.Seven?.Protocol,
Url = configuration.Seven?.Url,
SessionId = client.ConnectionDetails.SessionId
};
});
s.AddSingleton<SevenHelloContext>(sp => {
return new SevenHelloContext() {
Subscriptions = configuration.Seven?.InitialSubscriptions
};
}); });
s.AddKeyedSingleton<IWebSocketHandler, SevenHelloHandler>("7tv-sevenhello"); s.AddKeyedSingleton<IWebSocketHandler, SevenHelloHandler>("7tv-sevenhello");
s.AddKeyedSingleton<IWebSocketHandler, HelloHandler>("7tv-hello"); s.AddKeyedSingleton<IWebSocketHandler, HelloHandler>("7tv-hello");
@ -175,9 +136,17 @@ s.AddKeyedSingleton<HandlerManager<WebSocketClient, IWebSocketHandler>, SevenHan
s.AddKeyedSingleton<HandlerTypeManager<WebSocketClient, IWebSocketHandler>, SevenHandlerTypeManager>("7tv"); s.AddKeyedSingleton<HandlerTypeManager<WebSocketClient, IWebSocketHandler>, SevenHandlerTypeManager>("7tv");
s.AddKeyedSingleton<SocketClient<WebSocketMessage>, SevenSocketClient>("7tv"); s.AddKeyedSingleton<SocketClient<WebSocketMessage>, SevenSocketClient>("7tv");
// hermes websocket
s.AddKeyedSingleton<IWebSocketHandler, HeartbeatHandler>("hermes-heartbeat");
s.AddKeyedSingleton<IWebSocketHandler, LoginAckHandler>("hermes-loginack");
s.AddKeyedSingleton<IWebSocketHandler, RequestAckHandler>("hermes-requestack");
s.AddKeyedSingleton<IWebSocketHandler, HeartbeatHandler>("hermes-error");
s.AddKeyedSingleton<HandlerManager<WebSocketClient, IWebSocketHandler>, HermesHandlerManager>("hermes");
s.AddKeyedSingleton<HandlerTypeManager<WebSocketClient, IWebSocketHandler>, HermesHandlerTypeManager>("hermes");
s.AddKeyedSingleton<SocketClient<WebSocketMessage>, HermesSocketClient>("hermes");
s.AddHostedService<TTS>(); s.AddHostedService<TTS>();
using IHost host = builder.Build(); using IHost host = builder.Build();
using IServiceScope scope = host.Services.CreateAsyncScope();
IServiceProvider provider = scope.ServiceProvider;
await host.RunAsync(); await host.RunAsync();

210
TTS.cs
View File

@ -2,11 +2,12 @@ using System.Runtime.InteropServices;
using System.Web; using System.Web;
using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common; using CommonSocketLibrary.Common;
using HermesSocketLibrary.Socket.Data;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NAudio.Wave;
using NAudio.Wave.SampleProviders; using NAudio.Wave.SampleProviders;
using TwitchChatTTS.Hermes.Socket;
using TwitchLib.Client.Events; using TwitchLib.Client.Events;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions; using YamlDotNet.Serialization.NamingConventions;
@ -15,32 +16,54 @@ namespace TwitchChatTTS
{ {
public class TTS : IHostedService public class TTS : IHostedService
{ {
private ILogger Logger { get; } private readonly ILogger _logger;
private Configuration Configuration { get; } private readonly Configuration _configuration;
private TTSPlayer Player { get; } private readonly TTSPlayer _player;
private IServiceProvider ServiceProvider { get; } private readonly IServiceProvider _serviceProvider;
private ISampleProvider? Playing { get; set; }
public TTS(ILogger<TTS> logger, Configuration configuration, TTSPlayer player, IServiceProvider serviceProvider) { public TTS(ILogger<TTS> logger, Configuration configuration, TTSPlayer player, IServiceProvider serviceProvider) {
Logger = logger; _logger = logger;
Configuration = configuration; _configuration = configuration;
Player = player; _player = player;
ServiceProvider = serviceProvider; _serviceProvider = serviceProvider;
} }
public async Task StartAsync(CancellationToken cancellationToken) { public async Task StartAsync(CancellationToken cancellationToken) {
Console.Title = "TTS - Twitch Chat"; Console.Title = "TTS - Twitch Chat";
var user = _serviceProvider.GetRequiredService<User>();
var hermes = await InitializeHermes();
var hermesAccount = await hermes.FetchHermesAccountDetails();
user.HermesUserId = hermesAccount.Id;
user.TwitchUsername = hermesAccount.Username;
var twitchBotToken = await hermes.FetchTwitchBotToken();
user.TwitchUserId = long.Parse(twitchBotToken.BroadcasterId);
_logger.LogInformation($"Username: {user.TwitchUsername} (id: {user.TwitchUserId})");
user.DefaultTTSVoice = await hermes.FetchTTSDefaultVoice();
_logger.LogInformation("Default Voice: " + user.DefaultTTSVoice);
var wordFilters = await hermes.FetchTTSWordFilters();
user.RegexFilters = wordFilters.ToList();
_logger.LogInformation($"{user.RegexFilters.Count()} TTS word filters.");
var usernameFilters = await hermes.FetchTTSUsernameFilters();
user.ChatterFilters = usernameFilters.ToDictionary(e => e.Username, e => e);
_logger.LogInformation($"{user.ChatterFilters.Where(f => f.Value.Tag == "blacklisted").Count()} username(s) have been blocked.");
_logger.LogInformation($"{user.ChatterFilters.Where(f => f.Value.Tag == "priority").Count()} user(s) have been prioritized.");
var twitchapiclient = await InitializeTwitchApiClient(user.TwitchUsername);
await InitializeHermesWebsocket(user);
await InitializeSevenTv(); await InitializeSevenTv();
await InitializeObs(); await InitializeObs();
try { try {
var hermes = await InitializeHermes();
var twitchapiclient = await InitializeTwitchApiClient(hermes);
AudioPlaybackEngine.Instance.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) => { AudioPlaybackEngine.Instance.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) => {
if (e.SampleProvider == Playing) { if (e.SampleProvider == _player.Playing) {
Playing = null; _player.Playing = null;
} }
}); });
@ -48,11 +71,11 @@ namespace TwitchChatTTS
while (true) { while (true) {
try { try {
if (cancellationToken.IsCancellationRequested) { if (cancellationToken.IsCancellationRequested) {
Logger.LogWarning("TTS Buffer - Cancellation token was canceled."); _logger.LogWarning("TTS Buffer - Cancellation token was canceled.");
return; return;
} }
var m = Player.ReceiveBuffer(); var m = _player.ReceiveBuffer();
if (m == null) { if (m == null) {
await Task.Delay(200); await Task.Delay(200);
continue; continue;
@ -63,14 +86,14 @@ namespace TwitchChatTTS
var provider = new CachedWavProvider(sound); var provider = new CachedWavProvider(sound);
var data = AudioPlaybackEngine.Instance.ConvertSound(provider); var data = AudioPlaybackEngine.Instance.ConvertSound(provider);
var resampled = new WdlResamplingSampleProvider(data, AudioPlaybackEngine.Instance.SampleRate); var resampled = new WdlResamplingSampleProvider(data, AudioPlaybackEngine.Instance.SampleRate);
Logger.LogDebug("Fetched TTS audio data."); _logger.LogDebug("Fetched TTS audio data.");
m.Audio = resampled; m.Audio = resampled;
Player.Ready(m); _player.Ready(m);
} catch (COMException e) { } catch (COMException e) {
Logger.LogError(e, "Failed to send request for TTS (HResult: " + e.HResult + ")."); _logger.LogError(e, "Failed to send request for TTS (HResult: " + e.HResult + ").");
} catch (Exception e) { } catch (Exception e) {
Logger.LogError(e, "Failed to send request for TTS."); _logger.LogError(e, "Failed to send request for TTS.");
} }
} }
}); });
@ -79,40 +102,40 @@ namespace TwitchChatTTS
while (true) { while (true) {
try { try {
if (cancellationToken.IsCancellationRequested) { if (cancellationToken.IsCancellationRequested) {
Logger.LogWarning("TTS Queue - Cancellation token was canceled."); _logger.LogWarning("TTS Queue - Cancellation token was canceled.");
return; return;
} }
while (Player.IsEmpty() || Playing != null) { while (_player.IsEmpty() || _player.Playing != null) {
await Task.Delay(200); await Task.Delay(200);
continue; continue;
} }
var m = Player.ReceiveReady(); var m = _player.ReceiveReady();
if (m == null) { if (m == null) {
continue; continue;
} }
if (!string.IsNullOrWhiteSpace(m.File) && File.Exists(m.File)) { if (!string.IsNullOrWhiteSpace(m.File) && File.Exists(m.File)) {
Logger.LogInformation("Playing message: " + m.File); _logger.LogInformation("Playing message: " + m.File);
AudioPlaybackEngine.Instance.PlaySound(m.File); AudioPlaybackEngine.Instance.PlaySound(m.File);
continue; continue;
} }
Logger.LogInformation("Playing message: " + m.Message); _logger.LogInformation("Playing message: " + m.Message);
Playing = m.Audio; _player.Playing = m.Audio;
if (m.Audio != null) if (m.Audio != null)
AudioPlaybackEngine.Instance.AddMixerInput(m.Audio); AudioPlaybackEngine.Instance.AddMixerInput(m.Audio);
} catch (Exception e) { } catch (Exception e) {
Logger.LogError(e, "Failed to play a TTS audio message"); _logger.LogError(e, "Failed to play a TTS audio message");
} }
} }
}); });
StartSavingEmoteCounter(); StartSavingEmoteCounter();
Logger.LogInformation("Twitch API client connecting..."); _logger.LogInformation("Twitch API client connecting...");
await twitchapiclient.Connect(); await twitchapiclient.Connect();
} catch (Exception e) { } catch (Exception e) {
Logger.LogError(e, "Failed to initialize."); _logger.LogError(e, "Failed to initialize.");
} }
Console.ReadLine(); Console.ReadLine();
} }
@ -120,75 +143,100 @@ namespace TwitchChatTTS
public async Task StopAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken)
{ {
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
Logger.LogWarning("Application has stopped due to cancellation token."); _logger.LogWarning("Application has stopped due to cancellation token.");
else else
Logger.LogWarning("Application has stopped."); _logger.LogWarning("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;
}
try {
_logger.LogInformation("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}");
await hermesClient.ConnectAsync(url);
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.");
}
} }
private async Task InitializeSevenTv() { private async Task InitializeSevenTv() {
Logger.LogInformation("Initializing 7tv client."); if (_configuration.Seven?.UserId == null) {
var sevenClient = ServiceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("7tv"); _logger.LogDebug("No user id given to 7tv. Skipping 7tv websockets.");
if (Configuration.Seven is not null && !string.IsNullOrWhiteSpace(Configuration.Seven.Url)) { return;
var base_url = "@" + string.Join(",", Configuration.Seven.InitialSubscriptions.Select(sub => sub.Type + "<" + string.Join(",", sub.Condition?.Select(e => e.Key + "=" + e.Value) ?? new string[0]) + ">")); }
Logger.LogDebug($"Attempting to connect to {Configuration.Seven.Protocol?.Trim() ?? "wss"}://{Configuration.Seven.Url.Trim()}{base_url}");
await sevenClient.ConnectAsync($"{Configuration.Seven.Protocol?.Trim() ?? "wss"}://{Configuration.Seven.Url.Trim()}{base_url}"); try {
_logger.LogInformation("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}");
await sevenClient.ConnectAsync($"{url}");
} catch (Exception) {
_logger.LogWarning("Connecting to 7tv failed. Skipping 7tv websockets.");
} }
} }
private async Task InitializeObs() { private async Task InitializeObs() {
Logger.LogInformation("Initializing obs client."); if (_configuration.Obs == null || string.IsNullOrWhiteSpace(_configuration.Obs.Host) || !_configuration.Obs.Port.HasValue || _configuration.Obs.Port.Value < 0) {
var obsClient = ServiceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("obs"); _logger.LogDebug("Lacking obs connection info. Skipping obs websockets.");
if (Configuration.Obs is not null && !string.IsNullOrWhiteSpace(Configuration.Obs.Host) && Configuration.Obs.Port.HasValue && Configuration.Obs.Port.Value >= 0) { return;
Logger.LogDebug($"Attempting to connect to ws://{Configuration.Obs.Host.Trim()}:{Configuration.Obs.Port}"); }
await obsClient.ConnectAsync($"ws://{Configuration.Obs.Host.Trim()}:{Configuration.Obs.Port}");
await Task.Delay(500); try {
_logger.LogInformation("Initializing obs websocket client.");
var obsClient = _serviceProvider.GetRequiredKeyedService<SocketClient<WebSocketMessage>>("obs");
var url = $"ws://{_configuration.Obs.Host.Trim()}:{_configuration.Obs.Port}";
_logger.LogDebug($"Attempting to connect to {url}");
await obsClient.ConnectAsync(url);
} catch (Exception) {
_logger.LogWarning("Connecting to obs failed. Skipping obs websockets.");
} }
} }
private async Task<HermesClient> InitializeHermes() { private async Task<HermesClient> InitializeHermes() {
// Fetch id and username based on api key given. // Fetch id and username based on api key given.
Logger.LogInformation("Initializing hermes client."); _logger.LogInformation("Initializing hermes client.");
var hermes = ServiceProvider.GetRequiredService<HermesClient>(); var hermes = _serviceProvider.GetRequiredService<HermesClient>();
await hermes.FetchHermesAccountDetails(); await hermes.FetchHermesAccountDetails();
if (hermes.Username == null)
throw new Exception("Username fetched from Hermes is invalid.");
Logger.LogInformation("Username: " + hermes.Username);
return hermes; return hermes;
} }
private async Task<TwitchApiClient> InitializeTwitchApiClient(HermesClient hermes) { private async Task<TwitchApiClient> InitializeTwitchApiClient(string username) {
Logger.LogInformation("Initializing twitch client."); _logger.LogInformation("Initializing twitch client.");
var twitchapiclient = ServiceProvider.GetRequiredService<TwitchApiClient>(); var twitchapiclient = _serviceProvider.GetRequiredService<TwitchApiClient>();
await twitchapiclient.Authorize(); await twitchapiclient.Authorize();
var channels = Configuration.Twitch?.Channels ?? [hermes.Username]; var channels = _configuration.Twitch.Channels ?? [username];
Logger.LogInformation("Twitch channels: " + string.Join(", ", channels)); _logger.LogInformation("Twitch channels: " + string.Join(", ", channels));
twitchapiclient.InitializeClient(hermes, channels); twitchapiclient.InitializeClient(username, channels);
twitchapiclient.InitializePublisher(); twitchapiclient.InitializePublisher();
var handler = ServiceProvider.GetRequiredService<ChatMessageHandler>(); var handler = _serviceProvider.GetRequiredService<ChatMessageHandler>();
twitchapiclient.AddOnNewMessageReceived(async Task (object? s, OnMessageReceivedArgs e) => { twitchapiclient.AddOnNewMessageReceived(async Task (object? s, OnMessageReceivedArgs e) => {
var result = handler.Handle(e); var result = await handler.Handle(e);
switch (result) {
case MessageResult.Skip:
if (Playing != null) {
AudioPlaybackEngine.Instance.RemoveMixerInput(Playing);
Playing = null;
}
break;
case MessageResult.SkipAll:
Player.RemoveAll();
if (Playing != null) {
AudioPlaybackEngine.Instance.RemoveMixerInput(Playing);
Playing = null;
}
break;
default:
break;
}
}); });
return twitchapiclient; return twitchapiclient;
@ -204,13 +252,13 @@ namespace TwitchChatTTS
.WithNamingConvention(HyphenatedNamingConvention.Instance) .WithNamingConvention(HyphenatedNamingConvention.Instance)
.Build(); .Build();
var chathandler = ServiceProvider.GetRequiredService<ChatMessageHandler>(); var chathandler = _serviceProvider.GetRequiredService<ChatMessageHandler>();
using (TextWriter writer = File.CreateText(Configuration.Emotes.CounterFilePath.Trim())) using (TextWriter writer = File.CreateText(_configuration.Emotes.CounterFilePath.Trim()))
{ {
await writer.WriteAsync(serializer.Serialize(chathandler.EmoteCounter)); await writer.WriteAsync(serializer.Serialize(chathandler._emoteCounter));
} }
} catch (Exception e) { } catch (Exception e) {
Logger.LogError(e, "Failed to save the emote counter."); _logger.LogError(e, "Failed to save the emote counter.");
} }
} }
}); });

View File

@ -1,12 +1,29 @@
using TwitchChatTTS.Hermes; // using System.Text.RegularExpressions;
// using HermesSocketLibrary.Request.Message;
// using TwitchChatTTS.Hermes;
namespace TwitchChatTTS.Twitch // namespace TwitchChatTTS.Twitch
{ // {
public class TTSContext // public class TTSContext
{ // {
public string DefaultVoice; // public string DefaultVoice;
public IEnumerable<TTSVoice>? EnabledVoices; // public IEnumerable<TTSVoice>? EnabledVoices;
public IDictionary<string, TTSUsernameFilter>? UsernameFilters; // public IDictionary<string, TTSUsernameFilter>? UsernameFilters;
public IEnumerable<TTSWordFilter>? WordFilters; // public IEnumerable<TTSWordFilter>? WordFilters;
} // public IList<VoiceDetails>? AvailableVoices { get => _availableVoices; set { _availableVoices = value; EnabledVoicesRegex = GenerateEnabledVoicesRegex(); } }
} // public IDictionary<long, string>? SelectedVoices;
// public Regex? EnabledVoicesRegex;
// private IList<VoiceDetails>? _availableVoices;
// private Regex? GenerateEnabledVoicesRegex() {
// if (AvailableVoices == null || AvailableVoices.Count() <= 0) {
// return null;
// }
// var enabledVoicesString = string.Join("|", AvailableVoices.Select(v => v.Name));
// return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase);
// }
// }
// }

View File

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

View File

@ -35,5 +35,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\CommonSocketLibrary\CommonSocketLibrary.csproj" /> <ProjectReference Include="..\CommonSocketLibrary\CommonSocketLibrary.csproj" />
<ProjectReference Include="..\HermesSocketLibrary\HermesSocketLibrary.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

36
User.cs Normal file
View File

@ -0,0 +1,36 @@
using System.Text.RegularExpressions;
using TwitchChatTTS.Hermes;
namespace TwitchChatTTS
{
public class User
{
// Hermes user id
public string HermesUserId { get; set; }
public long TwitchUserId { get; set; }
public string TwitchUsername { get; set; }
public string DefaultTTSVoice { get; set; }
// voice id -> voice name
public IDictionary<string, string> VoicesAvailable { get; set; }
// chatter/twitch id -> voice name
public IDictionary<long, string> VoicesSelected { get; set; }
public HashSet<string> VoicesEnabled { get; set; }
public IDictionary<string, TTSUsernameFilter> ChatterFilters { get; set; }
public IList<TTSWordFilter> RegexFilters { get; set; }
public User() {
}
public Regex? GenerateEnabledVoicesRegex() {
if (VoicesAvailable == null || VoicesAvailable.Count() <= 0)
return null;
var enabledVoicesString = string.Join("|", VoicesAvailable.Select(v => v.Value));
return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase);
}
}
}