diff --git a/Chat/ChatMessageHandler.cs b/Chat/ChatMessageHandler.cs index 43a9417..8d2fa89 100644 --- a/Chat/ChatMessageHandler.cs +++ b/Chat/ChatMessageHandler.cs @@ -1,311 +1,310 @@ -using System.Text.RegularExpressions; -using TwitchLib.Client.Events; -using Serilog; -using TwitchChatTTS; -using TwitchChatTTS.Chat.Commands; -using TwitchChatTTS.Hermes.Socket; -using TwitchChatTTS.Chat.Groups.Permissions; -using TwitchChatTTS.Chat.Groups; -using TwitchChatTTS.Chat.Emotes; -using Microsoft.Extensions.DependencyInjection; -using CommonSocketLibrary.Common; -using CommonSocketLibrary.Abstract; -using TwitchChatTTS.OBS.Socket; +// using System.Text.RegularExpressions; +// using TwitchLib.Client.Events; +// using Serilog; +// using TwitchChatTTS; +// using TwitchChatTTS.Chat.Commands; +// using TwitchChatTTS.Hermes.Socket; +// using TwitchChatTTS.Chat.Groups.Permissions; +// using TwitchChatTTS.Chat.Groups; +// using TwitchChatTTS.Chat.Emotes; +// using Microsoft.Extensions.DependencyInjection; +// using CommonSocketLibrary.Common; +// using CommonSocketLibrary.Abstract; +// using TwitchChatTTS.OBS.Socket; -public class ChatMessageHandler -{ - private readonly User _user; - private readonly TTSPlayer _player; - private readonly CommandManager _commands; - private readonly IGroupPermissionManager _permissionManager; - private readonly IChatterGroupManager _chatterGroupManager; - private readonly IEmoteDatabase _emotes; - private readonly OBSSocketClient _obs; - private readonly HermesSocketClient _hermes; - private readonly Configuration _configuration; +// public class ChatMessageHandler +// { +// private readonly User _user; +// private readonly TTSPlayer _player; +// private readonly CommandManager _commands; +// private readonly IGroupPermissionManager _permissionManager; +// private readonly IChatterGroupManager _chatterGroupManager; +// private readonly IEmoteDatabase _emotes; +// private readonly OBSSocketClient _obs; +// private readonly HermesSocketClient _hermes; +// private readonly Configuration _configuration; - private readonly ILogger _logger; +// private readonly ILogger _logger; - private Regex _sfxRegex; - private HashSet _chatters; +// private Regex _sfxRegex; +// private HashSet _chatters; - public HashSet Chatters { get => _chatters; set => _chatters = value; } +// public HashSet Chatters { get => _chatters; set => _chatters = value; } - public ChatMessageHandler( - User user, - TTSPlayer player, - CommandManager commands, - IGroupPermissionManager permissionManager, - IChatterGroupManager chatterGroupManager, - IEmoteDatabase emotes, - [FromKeyedServices("hermes")] SocketClient hermes, - [FromKeyedServices("obs")] SocketClient obs, - Configuration configuration, - ILogger logger - ) - { - _user = user; - _player = player; - _commands = commands; - _permissionManager = permissionManager; - _chatterGroupManager = chatterGroupManager; - _emotes = emotes; - _obs = (obs as OBSSocketClient)!; - _hermes = (hermes as HermesSocketClient)!; - _configuration = configuration; - _logger = logger; +// public ChatMessageHandler( +// User user, +// TTSPlayer player, +// CommandManager commands, +// IGroupPermissionManager permissionManager, +// IChatterGroupManager chatterGroupManager, +// IEmoteDatabase emotes, +// [FromKeyedServices("hermes")] SocketClient hermes, +// [FromKeyedServices("obs")] SocketClient obs, +// Configuration configuration, +// ILogger logger +// ) +// { +// _user = user; +// _player = player; +// _commands = commands; +// _permissionManager = permissionManager; +// _chatterGroupManager = chatterGroupManager; +// _emotes = emotes; +// _obs = (obs as OBSSocketClient)!; +// _hermes = (hermes as HermesSocketClient)!; +// _configuration = configuration; +// _logger = logger; - _chatters = new HashSet(); - _sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)"); - } +// _chatters = new HashSet(); +// _sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)"); +// } - public async Task Handle(OnMessageReceivedArgs e) - { - var m = e.ChatMessage; +// public async Task Handle(OnMessageReceivedArgs e) +// { +// var m = e.ChatMessage; - if (!_hermes.Ready) - { - _logger.Debug($"TTS is not yet ready. Ignoring chat messages [message id: {m.Id}]"); - return new MessageResult(MessageStatus.NotReady, -1, -1); - } - if (_configuration.Twitch?.TtsWhenOffline != true && !_obs.Streaming) - { - _logger.Debug($"OBS is not streaming. Ignoring chat messages [message id: {m.Id}]"); - return new MessageResult(MessageStatus.NotReady, -1, -1); - } +// if (_hermes.Connected && !_hermes.Ready) +// { +// _logger.Debug($"TTS is not yet ready. Ignoring chat messages [message id: {m.Id}]"); +// return new MessageResult(MessageStatus.NotReady, -1, -1); +// } +// if (_configuration.Twitch?.TtsWhenOffline != true && !_obs.Streaming) +// { +// _logger.Debug($"OBS is not streaming. Ignoring chat messages [message id: {m.Id}]"); +// return new MessageResult(MessageStatus.NotReady, -1, -1); +// } - var msg = e.ChatMessage.Message; - var chatterId = long.Parse(m.UserId); - var tasks = new List(); +// var msg = e.ChatMessage.Message; +// var chatterId = long.Parse(m.UserId); +// var tasks = new List(); - var checks = new bool[] { true, m.IsSubscriber, m.IsVip, m.IsModerator, m.IsBroadcaster }; - var defaultGroups = new string[] { "everyone", "subscribers", "vip", "moderators", "broadcaster" }; - var customGroups = _chatterGroupManager.GetGroupNamesFor(chatterId); - var groups = defaultGroups.Where((e, i) => checks[i]).Union(customGroups); +// var checks = new bool[] { true, m.IsSubscriber, m.IsVip, m.IsModerator, m.IsBroadcaster }; +// var defaultGroups = new string[] { "everyone", "subscribers", "vip", "moderators", "broadcaster" }; +// var customGroups = _chatterGroupManager.GetGroupNamesFor(chatterId); +// var groups = defaultGroups.Where((e, i) => checks[i]).Union(customGroups); - try - { - var commandResult = await _commands.Execute(msg, m, groups); - if (commandResult != ChatCommandResult.Unknown) - return new MessageResult(MessageStatus.Command, -1, -1); - } - catch (Exception ex) - { - _logger.Error(ex, $"Failed executing a chat command [message: {msg}][chatter: {m.Username}][chatter id: {m.UserId}][message id: {m.Id}]"); - } +// try +// { +// var commandResult = await _commands.Execute(msg, m, groups); +// if (commandResult != ChatCommandResult.Unknown) +// return new MessageResult(MessageStatus.Command, -1, -1); +// } +// catch (Exception ex) +// { +// _logger.Error(ex, $"Failed executing a chat command [message: {msg}][chatter: {m.Username}][chatter id: {m.UserId}][message id: {m.Id}]"); +// } - var permissionPath = "tts.chat.messages.read"; - if (!string.IsNullOrWhiteSpace(m.CustomRewardId)) - permissionPath = "tts.chat.redemptions.read"; +// var permissionPath = "tts.chat.messages.read"; +// if (!string.IsNullOrWhiteSpace(m.CustomRewardId)) +// permissionPath = "tts.chat.redemptions.read"; - var permission = chatterId == _user.OwnerId ? true : _permissionManager.CheckIfAllowed(groups, permissionPath); - if (permission != true) - { - _logger.Debug($"Blocked message by {m.Username}: {msg}"); - return new MessageResult(MessageStatus.Blocked, -1, -1); - } +// var permission = chatterId == _user.OwnerId ? true : _permissionManager.CheckIfAllowed(groups, permissionPath); +// if (permission != true) +// { +// _logger.Debug($"Blocked message by {m.Username}: {msg}"); +// return new MessageResult(MessageStatus.Blocked, -1, -1); +// } - if (_obs.Streaming && !_chatters.Contains(chatterId)) - { - tasks.Add(_hermes.SendChatterDetails(chatterId, m.Username)); - _chatters.Add(chatterId); - } +// if (_obs.Streaming && !_chatters.Contains(chatterId)) +// { +// tasks.Add(_hermes.SendChatterDetails(chatterId, m.Username)); +// _chatters.Add(chatterId); +// } - // Filter highly repetitive words (like emotes) from the message. - int totalEmoteUsed = 0; - var emotesUsed = new HashSet(); - var words = msg.Split(' '); - var wordCounter = new Dictionary(); - string filteredMsg = string.Empty; - var newEmotes = new Dictionary(); - foreach (var w in words) - { - if (wordCounter.ContainsKey(w)) - wordCounter[w]++; - else - wordCounter.Add(w, 1); +// // Filter highly repetitive words (like emotes) from the message. +// int totalEmoteUsed = 0; +// var emotesUsed = new HashSet(); +// var words = msg.Split(' '); +// var wordCounter = new Dictionary(); +// string filteredMsg = string.Empty; +// var newEmotes = new Dictionary(); +// foreach (var w in words) +// { +// if (wordCounter.ContainsKey(w)) +// wordCounter[w]++; +// else +// wordCounter.Add(w, 1); - var emoteId = _emotes.Get(w); - if (emoteId == null) - { - emoteId = m.EmoteSet.Emotes.FirstOrDefault(e => e.Name == w)?.Id; - if (emoteId != null) - { - newEmotes.Add(emoteId, w); - _emotes.Add(w, emoteId); - } - } - if (emoteId != null) - { - emotesUsed.Add(emoteId); - totalEmoteUsed++; - } +// var emoteId = _emotes.Get(w); +// if (emoteId == null) +// { +// emoteId = m.EmoteSet.Emotes.FirstOrDefault(e => e.Name == w)?.Id; +// if (emoteId != null) +// { +// newEmotes.Add(emoteId, w); +// _emotes.Add(w, emoteId); +// } +// } +// if (emoteId != null) +// { +// emotesUsed.Add(emoteId); +// totalEmoteUsed++; +// } - if (wordCounter[w] <= 4 && (emoteId == null || totalEmoteUsed <= 5)) - filteredMsg += w + " "; - } - if (_obs.Streaming && newEmotes.Any()) - tasks.Add(_hermes.SendEmoteDetails(newEmotes)); - msg = filteredMsg; +// if (wordCounter[w] <= 4 && (emoteId == null || totalEmoteUsed <= 5)) +// filteredMsg += w + " "; +// } +// if (_obs.Streaming && newEmotes.Any()) +// tasks.Add(_hermes.SendEmoteDetails(newEmotes)); +// msg = filteredMsg; - // Replace filtered words. - if (_user.RegexFilters != null) - { - foreach (var wf in _user.RegexFilters) - { - if (wf.Search == null || wf.Replace == null) - continue; +// // Replace filtered words. +// if (_user.RegexFilters != null) +// { +// foreach (var wf in _user.RegexFilters) +// { +// if (wf.Search == null || wf.Replace == null) +// continue; - if (wf.IsRegex) - { - try - { - var regex = new Regex(wf.Search); - msg = regex.Replace(msg, wf.Replace); - continue; - } - catch (Exception) - { - wf.IsRegex = false; - } - } +// if (wf.IsRegex) +// { +// try +// { +// var regex = new Regex(wf.Search); +// msg = regex.Replace(msg, wf.Replace); +// continue; +// } +// catch (Exception) +// { +// wf.IsRegex = false; +// } +// } - msg = msg.Replace(wf.Search, wf.Replace); - } - } +// msg = msg.Replace(wf.Search, wf.Replace); +// } +// } - // Determine the priority of this message - int priority = _chatterGroupManager.GetPriorityFor(groups) + m.SubscribedMonthCount * (m.IsSubscriber ? 10 : 5); +// // Determine the priority of this message +// int priority = _chatterGroupManager.GetPriorityFor(groups) + m.SubscribedMonthCount * (m.IsSubscriber ? 10 : 5); - // Determine voice selected. - string voiceSelected = _user.DefaultTTSVoice; - if (_user.VoicesSelected?.ContainsKey(chatterId) == true) - { - var voiceId = _user.VoicesSelected[chatterId]; - if (_user.VoicesAvailable.TryGetValue(voiceId, out string? voiceName) && voiceName != null) - { - if (_user.VoicesEnabled.Contains(voiceName) || chatterId == _user.OwnerId || m.IsStaff) - { - voiceSelected = voiceName; - } - } - } +// // Determine voice selected. +// string voiceSelected = _user.DefaultTTSVoice; +// if (_user.VoicesSelected?.ContainsKey(chatterId) == true) +// { +// var voiceId = _user.VoicesSelected[chatterId]; +// if (_user.VoicesAvailable.TryGetValue(voiceId, out string? voiceName) && voiceName != null) +// { +// if (_user.VoicesEnabled.Contains(voiceName) || chatterId == _user.OwnerId || m.IsStaff) +// { +// voiceSelected = voiceName; +// } +// } +// } - // Determine additional voices used - var matches = _user.WordFilterRegex?.Matches(msg).ToArray(); - if (matches == null || matches.FirstOrDefault() == null || matches.First().Index < 0) - { - HandlePartialMessage(priority, voiceSelected, msg.Trim(), e); - return new MessageResult(MessageStatus.None, _user.TwitchUserId, chatterId, emotesUsed); - } +// // Determine additional voices used +// var matches = _user.WordFilterRegex?.Matches(msg).ToArray(); +// if (matches == null || matches.FirstOrDefault() == null || matches.First().Index < 0) +// { +// HandlePartialMessage(priority, voiceSelected, msg.Trim(), e); +// return new MessageResult(MessageStatus.None, _user.TwitchUserId, chatterId, emotesUsed); +// } - HandlePartialMessage(priority, voiceSelected, msg.Substring(0, matches.First().Index).Trim(), e); - foreach (Match match in matches) - { - var message = match.Groups[2].ToString(); - if (string.IsNullOrWhiteSpace(message)) - continue; +// HandlePartialMessage(priority, voiceSelected, msg.Substring(0, matches.First().Index).Trim(), e); +// foreach (Match match in matches) +// { +// var message = match.Groups[2].ToString(); +// if (string.IsNullOrWhiteSpace(message)) +// continue; - var voice = match.Groups[1].ToString(); - voice = voice[0].ToString().ToUpper() + voice.Substring(1).ToLower(); - HandlePartialMessage(priority, voice, message.Trim(), e); - } +// var voice = match.Groups[1].ToString(); +// voice = voice[0].ToString().ToUpper() + voice.Substring(1).ToLower(); +// HandlePartialMessage(priority, voice, message.Trim(), e); +// } - if (tasks.Any()) - await Task.WhenAll(tasks); +// if (tasks.Any()) +// await Task.WhenAll(tasks); - return new MessageResult(MessageStatus.None, _user.TwitchUserId, chatterId, emotesUsed); - } +// return new MessageResult(MessageStatus.None, _user.TwitchUserId, chatterId, emotesUsed); +// } - private void HandlePartialMessage(int priority, string voice, string message, OnMessageReceivedArgs e) - { - if (string.IsNullOrWhiteSpace(message)) - return; +// private void HandlePartialMessage(int priority, string voice, string message, OnMessageReceivedArgs e) +// { +// if (string.IsNullOrWhiteSpace(message)) +// return; - var m = e.ChatMessage; - var parts = _sfxRegex.Split(message); - var badgesString = string.Join(", ", e.ChatMessage.Badges.Select(b => b.Key + " = " + b.Value)); +// var m = e.ChatMessage; +// var parts = _sfxRegex.Split(message); +// var badgesString = string.Join(", ", e.ChatMessage.Badges.Select(b => b.Key + " = " + b.Value)); - if (parts.Length == 1) - { - _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; Reward Id: {m.CustomRewardId}; {badgesString}"); - _player.Add(new TTSMessage() - { - Voice = voice, - Message = message, - Moderator = m.IsModerator, - Timestamp = DateTime.UtcNow, - Username = m.Username, - Bits = m.Bits, - Badges = m.Badges, - Priority = priority - }); - return; - } +// if (parts.Length == 1) +// { +// _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; Reward Id: {m.CustomRewardId}; {badgesString}"); +// _player.Add(new TTSMessage() +// { +// Voice = voice, +// Message = message, +// Timestamp = DateTime.UtcNow, +// Username = m.Username, +// //Bits = m.Bits, +// Badges = e.Badges, +// Priority = priority +// }); +// return; +// } - var sfxMatches = _sfxRegex.Matches(message); - var sfxStart = sfxMatches.FirstOrDefault()?.Index ?? message.Length; +// var sfxMatches = _sfxRegex.Matches(message); +// var sfxStart = sfxMatches.FirstOrDefault()?.Index ?? message.Length; - for (var i = 0; i < sfxMatches.Count; i++) - { - var sfxMatch = sfxMatches[i]; - var sfxName = sfxMatch.Groups[1]?.ToString()?.ToLower(); +// for (var i = 0; i < sfxMatches.Count; i++) +// { +// var sfxMatch = sfxMatches[i]; +// var sfxName = sfxMatch.Groups[1]?.ToString()?.ToLower(); - if (!File.Exists("sfx/" + sfxName + ".mp3")) - { - parts[i * 2 + 2] = parts[i * 2] + " (" + parts[i * 2 + 1] + ")" + parts[i * 2 + 2]; - continue; - } +// if (!File.Exists("sfx/" + sfxName + ".mp3")) +// { +// parts[i * 2 + 2] = parts[i * 2] + " (" + parts[i * 2 + 1] + ")" + parts[i * 2 + 2]; +// continue; +// } - if (!string.IsNullOrWhiteSpace(parts[i * 2])) - { - _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts[i * 2]}; Month: {m.SubscribedMonthCount}; {badgesString}"); - _player.Add(new TTSMessage() - { - Voice = voice, - Message = parts[i * 2], - Moderator = m.IsModerator, - Timestamp = DateTime.UtcNow, - Username = m.Username, - Bits = m.Bits, - Badges = m.Badges, - Priority = priority - }); - } +// if (!string.IsNullOrWhiteSpace(parts[i * 2])) +// { +// _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts[i * 2]}; Month: {m.SubscribedMonthCount}; {badgesString}"); +// _player.Add(new TTSMessage() +// { +// Voice = voice, +// Message = parts[i * 2], +// Moderator = m.IsModerator, +// Timestamp = DateTime.UtcNow, +// Username = m.Username, +// Bits = m.Bits, +// Badges = m.Badges, +// Priority = priority +// }); +// } - _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}"); - _player.Add(new TTSMessage() - { - Voice = voice, - Message = sfxName, - File = $"sfx/{sfxName}.mp3", - Moderator = m.IsModerator, - Timestamp = DateTime.UtcNow, - Username = m.Username, - Bits = m.Bits, - Badges = m.Badges, - Priority = priority - }); - } +// _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}"); +// _player.Add(new TTSMessage() +// { +// Voice = voice, +// Message = sfxName, +// File = $"sfx/{sfxName}.mp3", +// Moderator = m.IsModerator, +// Timestamp = DateTime.UtcNow, +// Username = m.Username, +// Bits = m.Bits, +// Badges = m.Badges, +// Priority = priority +// }); +// } - if (!string.IsNullOrWhiteSpace(parts.Last())) - { - _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; Month: {m.SubscribedMonthCount}; {badgesString}"); - _player.Add(new TTSMessage() - { - Voice = voice, - Message = parts.Last(), - Moderator = m.IsModerator, - Timestamp = DateTime.UtcNow, - Username = m.Username, - Bits = m.Bits, - Badges = m.Badges, - Priority = priority - }); - } - } -} \ No newline at end of file +// if (!string.IsNullOrWhiteSpace(parts.Last())) +// { +// _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; Month: {m.SubscribedMonthCount}; {badgesString}"); +// _player.Add(new TTSMessage() +// { +// Voice = voice, +// Message = parts.Last(), +// Moderator = m.IsModerator, +// Timestamp = DateTime.UtcNow, +// Username = m.Username, +// Bits = m.Bits, +// Badges = m.Badges, +// Priority = priority +// }); +// } +// } +// } \ No newline at end of file diff --git a/Chat/Commands/ChatCommand.cs b/Chat/Commands/ChatCommand.cs index cf6046d..faf4722 100644 --- a/Chat/Commands/ChatCommand.cs +++ b/Chat/Commands/ChatCommand.cs @@ -1,17 +1,18 @@ using TwitchChatTTS.Hermes.Socket; -using TwitchLib.Client.Models; +using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; namespace TwitchChatTTS.Chat.Commands { - public interface IChatCommand { + public interface IChatCommand + { string Name { get; } void Build(ICommandBuilder builder); } - public interface IChatPartialCommand { + public interface IChatPartialCommand + { bool AcceptCustomPermission { get; } - bool CheckDefaultPermissions(ChatMessage message); - Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client); + Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client); } } \ No newline at end of file diff --git a/Chat/Commands/CommandBuilder.cs b/Chat/Commands/CommandBuilder.cs index d776bd4..613aee4 100644 --- a/Chat/Commands/CommandBuilder.cs +++ b/Chat/Commands/CommandBuilder.cs @@ -1,5 +1,6 @@ using Serilog; using TwitchChatTTS.Chat.Commands.Parameters; +using TwitchChatTTS.Twitch.Socket.Messages; namespace TwitchChatTTS.Chat.Commands { @@ -8,15 +9,18 @@ namespace TwitchChatTTS.Chat.Commands public interface ICommandBuilder { ICommandSelector Build(); + ICommandBuilder AddPermission(string path); + ICommandBuilder AddAlias(string alias, string child); void Clear(); ICommandBuilder CreateCommandTree(string name, Action callback); ICommandBuilder CreateCommand(IChatPartialCommand command); ICommandBuilder CreateStaticInputParameter(string value, Action callback, bool optional = false); + ICommandBuilder CreateMentionParameter(string name, bool enabled, bool optional = false); ICommandBuilder CreateObsTransformationParameter(string name, bool optional = false); ICommandBuilder CreateStateParameter(string name, bool optional = false); ICommandBuilder CreateUnvalidatedParameter(string name, bool optional = false); ICommandBuilder CreateVoiceNameParameter(string name, bool enabled, bool optional = false); - + } public sealed class CommandBuilder : ICommandBuilder @@ -37,6 +41,25 @@ namespace TwitchChatTTS.Chat.Commands } + public ICommandBuilder AddPermission(string path) + { + if (_current == _root) + throw new Exception("Cannot add permissions without a command name."); + + _current.AddPermission(path); + return this; + } + + public ICommandBuilder AddAlias(string alias, string child) { + if (_current == _root) + throw new Exception("Cannot add aliases without a command name."); + if (_current.Children == null || !_current.Children.Any()) + throw new Exception("Cannot add alias if this has no parameter."); + + _current.AddAlias(alias, child); + return this; + } + public ICommandSelector Build() { return new CommandSelector(_root); @@ -89,6 +112,19 @@ namespace TwitchChatTTS.Chat.Commands return this; } + public ICommandBuilder CreateMentionParameter(string name, bool enabled, bool optional = false) + { + if (_root == _current) + throw new Exception("Cannot create a parameter without a command name."); + if (optional && _current.IsRequired() && _current.Command == null) + throw new Exception("Cannot create a optional parameter without giving the command to the last node with required parameter."); + + var node = _current.CreateUserInput(new MentionParameter(name, optional)); + _logger.Debug($"Creating obs transformation parameter '{name}'"); + _current = node; + return this; + } + public ICommandBuilder CreateObsTransformationParameter(string name, bool optional = false) { if (_root == _current) @@ -164,9 +200,8 @@ namespace TwitchChatTTS.Chat.Commands public interface ICommandSelector { - CommandSelectorResult GetBestMatch(string[] args); + CommandSelectorResult GetBestMatch(string[] args, ChannelChatMessage message); IDictionary GetNonStaticArguments(string[] args, string path); - CommandValidationResult Validate(string[] args, string path); } public sealed class CommandSelector : ICommandSelector @@ -178,67 +213,36 @@ namespace TwitchChatTTS.Chat.Commands _root = root; } - public CommandSelectorResult GetBestMatch(string[] args) + public CommandSelectorResult GetBestMatch(string[] args, ChannelChatMessage message) { - return GetBestMatch(_root, args, null, string.Empty); + return GetBestMatch(_root, message, args, null, string.Empty, null); } - private CommandSelectorResult GetBestMatch(CommandNode node, IEnumerable args, IChatPartialCommand? match, string path) + private CommandSelectorResult GetBestMatch(CommandNode node, ChannelChatMessage message, IEnumerable args, IChatPartialCommand? match, string path, string[]? permissions) { if (node == null || !args.Any()) - return new CommandSelectorResult(match, path); + return new CommandSelectorResult(match, path, permissions); if (!node.Children.Any()) - return new CommandSelectorResult(node.Command ?? match, path); + return new CommandSelectorResult(node.Command ?? match, path, permissions); var argument = args.First(); var argumentLower = argument.ToLower(); foreach (var child in node.Children) { + var perms = child.Permissions != null ? (permissions ?? []).Union(child.Permissions).Distinct().ToArray() : permissions; if (child.Parameter.GetType() == typeof(StaticParameter)) { if (child.Parameter.Name.ToLower() == argumentLower) - { - return GetBestMatch(child, args.Skip(1), child.Command ?? match, (path.Length == 0 ? string.Empty : path + ".") + child.Parameter.Name.ToLower()); - } + return GetBestMatch(child, message, args.Skip(1), child.Command ?? match, (path.Length == 0 ? string.Empty : path + ".") + child.Parameter.Name.ToLower(), perms); continue; } - - return GetBestMatch(child, args.Skip(1), child.Command ?? match, (path.Length == 0 ? string.Empty : path + ".") + "*"); + if ((!child.Parameter.Optional || child.Parameter.Validate(argument, message)) && child.Command != null) + return GetBestMatch(child, message, args.Skip(1), child.Command, (path.Length == 0 ? string.Empty : path + ".") + "*", perms); + if (!child.Parameter.Optional) + return GetBestMatch(child, message, args.Skip(1), match, (path.Length == 0 ? string.Empty : path + ".") + "*", permissions); } - return new CommandSelectorResult(match, path); - } - - public CommandValidationResult Validate(string[] args, string path) - { - CommandNode? current = _root; - var parts = path.Split('.'); - if (args.Length < parts.Length) - throw new Exception($"Command path too long for the number of arguments passed in [path: {path}][parts: {parts.Length}][args count: {args.Length}]"); - - for (var i = 0; i < parts.Length; i++) - { - var part = parts[i]; - if (part == "*") - { - current = current.Children.FirstOrDefault(n => n.Parameter.GetType() != typeof(StaticParameter)); - if (current == null) - throw new Exception($"Cannot find command path [path: {path}][subpath: {part}]"); - - if (!current.Parameter.Validate(args[i])) - { - return new CommandValidationResult(false, args[i]); - } - } - else - { - current = current.Children.FirstOrDefault(n => n.Parameter.GetType() == typeof(StaticParameter) && n.Parameter.Name == part); - if (current == null) - throw new Exception($"Cannot find command path [path: {path}][subpath: {part}]"); - } - } - - return new CommandValidationResult(true, null); + return new CommandSelectorResult(match, path, permissions); } public IDictionary GetNonStaticArguments(string[] args, string path) @@ -276,11 +280,13 @@ namespace TwitchChatTTS.Chat.Commands { public IChatPartialCommand? Command { get; set; } public string Path { get; set; } + public string[]? Permissions { get; set; } - public CommandSelectorResult(IChatPartialCommand? command, string path) + public CommandSelectorResult(IChatPartialCommand? command, string path, string[]? permissions) { Command = command; Path = path; + Permissions = permissions; } } @@ -300,6 +306,7 @@ namespace TwitchChatTTS.Chat.Commands { public IChatPartialCommand? Command { get; private set; } public CommandParameter Parameter { get; } + public string[]? Permissions { get; private set; } public IList Children { get => _children.AsReadOnly(); } private IList _children; @@ -308,9 +315,34 @@ namespace TwitchChatTTS.Chat.Commands { Parameter = parameter; _children = new List(); + Permissions = null; } + public void AddPermission(string path) + { + if (Permissions == null) + Permissions = [path]; + else + Permissions = Permissions.Union([path]).ToArray(); + } + + public CommandNode AddAlias(string alias, string child) { + var target = _children.FirstOrDefault(c => c.Parameter.Name == child); + if (target == null) + throw new Exception($"Cannot find child parameter [parameter: {child}][alias: {alias}]"); + if (target.Parameter.GetType() != typeof(StaticParameter)) + throw new Exception("Command aliases can only be used on static parameters."); + if (Children.FirstOrDefault(n => n.Parameter.Name == alias) != null) + throw new Exception("Failed to create a command alias - name is already in use."); + + var clone = target.MemberwiseClone() as CommandNode; + var node = new CommandNode(new StaticParameter(alias, alias, target.Parameter.Optional)); + node._children = target._children; + _children.Add(node); + return this; + } + public CommandNode CreateCommand(IChatPartialCommand command) { if (Command != null) diff --git a/Chat/Commands/CommandManager.cs b/Chat/Commands/CommandManager.cs index 5ba4cc8..5f9542f 100644 --- a/Chat/Commands/CommandManager.cs +++ b/Chat/Commands/CommandManager.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Serilog; using TwitchChatTTS.Chat.Groups.Permissions; using TwitchChatTTS.Hermes.Socket; -using TwitchLib.Client.Models; +using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; namespace TwitchChatTTS.Chat.Commands @@ -44,7 +44,7 @@ namespace TwitchChatTTS.Chat.Commands } - public async Task Execute(string arg, ChatMessage message, IEnumerable groups) + public async Task Execute(string arg, ChannelChatMessage message, IEnumerable groups) { if (string.IsNullOrWhiteSpace(arg)) return ChatCommandResult.Unknown; @@ -62,7 +62,7 @@ namespace TwitchChatTTS.Chat.Commands string[] args = parts.ToArray(); string com = args.First().ToLower(); - CommandSelectorResult selectorResult = _commandSelector.GetBestMatch(args); + CommandSelectorResult selectorResult = _commandSelector.GetBestMatch(args, message); if (selectorResult.Command == null) { _logger.Warning($"Could not match '{arg}' to any command."); @@ -71,31 +71,27 @@ namespace TwitchChatTTS.Chat.Commands // Check if command can be executed by this chatter. var command = selectorResult.Command; - long chatterId = long.Parse(message.UserId); + long chatterId = long.Parse(message.ChatterUserId); if (chatterId != _user.OwnerId) { - var executable = command.AcceptCustomPermission ? CanExecute(chatterId, groups, com) : null; - if (executable == false) + bool executable = command.AcceptCustomPermission ? CanExecute(chatterId, groups, $"tts.command.{com}", selectorResult.Permissions) : false; + if (!executable) { _logger.Debug($"Denied permission to use command [chatter id: {chatterId}][command: {com}]"); return ChatCommandResult.Permission; } - else if (executable == null && !command.CheckDefaultPermissions(message)) - { - _logger.Debug($"Chatter is missing default permission to execute command named '{com}' [args: {arg}][command type: {command.GetType().Name}][chatter: {message.Username}][chatter id: {message.UserId}]"); - return ChatCommandResult.Permission; - } } - // Check if the arguments are correct. + // Check if the arguments are valid. var arguments = _commandSelector.GetNonStaticArguments(args, selectorResult.Path); foreach (var entry in arguments) { var parameter = entry.Value; var argument = entry.Key; - if (!parameter.Validate(argument)) + // Optional parameters were validated while fetching this command. + if (!parameter.Optional && !parameter.Validate(argument, message)) { - _logger.Warning($"Command failed due to an argument being invalid [argument name: {parameter.Name}][argument value: {argument}][arguments: {arg}][command type: {command.GetType().Name}][chatter: {message.Username}][chatter id: {message.UserId}]"); + _logger.Warning($"Command failed due to an argument being invalid [argument name: {parameter.Name}][argument value: {argument}][arguments: {arg}][command type: {command.GetType().Name}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]"); return ChatCommandResult.Syntax; } } @@ -107,18 +103,18 @@ namespace TwitchChatTTS.Chat.Commands } catch (Exception e) { - _logger.Error(e, $"Command '{arg}' failed [args: {arg}][command type: {command.GetType().Name}][chatter: {message.Username}][chatter id: {message.UserId}]"); + _logger.Error(e, $"Command '{arg}' failed [args: {arg}][command type: {command.GetType().Name}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]"); return ChatCommandResult.Fail; } - _logger.Information($"Executed the {com} command [args: {arg}][command type: {command.GetType().Name}][chatter: {message.Username}][chatter id: {message.UserId}]"); + _logger.Information($"Executed the {com} command [args: {arg}][command type: {command.GetType().Name}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]"); return ChatCommandResult.Success; } - private bool? CanExecute(long chatterId, IEnumerable groups, string path) + private bool CanExecute(long chatterId, IEnumerable groups, string path, string[]? additionalPaths) { - _logger.Debug($"Checking for permission [chatter id: {chatterId}][group: {string.Join(", ", groups)}][path: {path}]"); - return _permissionManager.CheckIfAllowed(groups, path); + _logger.Debug($"Checking for permission [chatter id: {chatterId}][group: {string.Join(", ", groups)}][path: {path}]{(additionalPaths != null ? "[paths: " + string.Join('|', additionalPaths) + "]" : string.Empty)}"); + return _permissionManager.CheckIfAllowed(groups, path) != false && (additionalPaths == null || additionalPaths.All(p => _permissionManager.CheckIfAllowed(groups, p) != false)); } } } \ No newline at end of file diff --git a/Chat/Commands/Limits/CommandLimitManager.cs b/Chat/Commands/Limits/CommandLimitManager.cs new file mode 100644 index 0000000..1e3c5a7 --- /dev/null +++ b/Chat/Commands/Limits/CommandLimitManager.cs @@ -0,0 +1,88 @@ +namespace TwitchChatTTS.Chat.Commands.Limits +{ + public interface ICommandLimitManager + { + + bool HasReachedLimit(long chatterId, string name, string group); + void RemoveUsageLimit(string name, string group); + void SetUsageLimit(int count, TimeSpan span, string name, string group); + bool TryUse(long chatterId, string name, string group); + } + + public class CommandLimitManager : ICommandLimitManager + { + // group + name -> chatter id -> usage + private readonly IDictionary> _usages; + // group + name -> limit + private readonly IDictionary _limits; + + + public CommandLimitManager() + { + _usages = new Dictionary>(); + _limits = new Dictionary(); + } + + + public bool HasReachedLimit(long chatterId, string name, string group) + { + throw new NotImplementedException(); + } + + public void RemoveUsageLimit(string name, string group) + { + throw new NotImplementedException(); + } + + public void SetUsageLimit(int count, TimeSpan span, string name, string group) + { + throw new NotImplementedException(); + } + + public bool TryUse(long chatterId, string name, string group) + { + var path = $"{group}.{name}"; + if (!_limits.TryGetValue(path, out var limit)) + return true; + + if (!_usages.TryGetValue(path, out var groupUsage)) + { + groupUsage = new Dictionary(); + _usages.Add(path, groupUsage); + } + + if (!groupUsage.TryGetValue(chatterId, out var usage)) + { + usage = new Usage() + { + Usages = new long[limit.Count], + Index = 0 + }; + groupUsage.Add(chatterId, usage); + } + + int first = (usage.Index + 1) % limit.Count; + long timestamp = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMillisecond; + if (timestamp - usage.Usages[first] < limit.Span) + { + return false; + } + + usage.Usages[usage.Index] = timestamp; + usage.Index = first; + return true; + } + + private class Usage + { + public long[] Usages { get; set; } + public int Index { get; set; } + } + + private struct Limit + { + public int Count { get; set; } + public int Span { get; set; } + } + } +} \ No newline at end of file diff --git a/Chat/Commands/OBSCommand.cs b/Chat/Commands/OBSCommand.cs index ebb0685..6ef09bd 100644 --- a/Chat/Commands/OBSCommand.cs +++ b/Chat/Commands/OBSCommand.cs @@ -5,7 +5,7 @@ using Serilog; using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.OBS.Socket; using TwitchChatTTS.OBS.Socket.Data; -using TwitchLib.Client.Models; +using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; namespace TwitchChatTTS.Chat.Commands @@ -71,12 +71,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { string sceneName = values["sceneName"]; string sourceName = values["sourceName"]; @@ -102,12 +97,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { string sceneName = values["sceneName"]; string sourceName = values["sourceName"]; @@ -143,12 +133,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { string sceneName = values["sceneName"]; string sourceName = values["sourceName"]; diff --git a/Chat/Commands/Parameters/CommandParameter.cs b/Chat/Commands/Parameters/CommandParameter.cs index edd4383..c75bd24 100644 --- a/Chat/Commands/Parameters/CommandParameter.cs +++ b/Chat/Commands/Parameters/CommandParameter.cs @@ -1,6 +1,8 @@ +using TwitchChatTTS.Twitch.Socket.Messages; + namespace TwitchChatTTS.Chat.Commands.Parameters { - public abstract class CommandParameter : ICloneable + public abstract class CommandParameter { public string Name { get; } public bool Optional { get; } @@ -11,10 +13,6 @@ namespace TwitchChatTTS.Chat.Commands.Parameters Optional = optional; } - public abstract bool Validate(string value); - - public object Clone() { - return (CommandParameter) MemberwiseClone(); - } + public abstract bool Validate(string value, ChannelChatMessage message); } } \ No newline at end of file diff --git a/Chat/Commands/Parameters/MentionParameter.cs b/Chat/Commands/Parameters/MentionParameter.cs new file mode 100644 index 0000000..df3a32b --- /dev/null +++ b/Chat/Commands/Parameters/MentionParameter.cs @@ -0,0 +1,16 @@ +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Chat.Commands.Parameters +{ + public class MentionParameter : CommandParameter + { + public MentionParameter(string name, bool optional = false) : base(name, optional) + { + } + + public override bool Validate(string value, ChannelChatMessage message) + { + return value.StartsWith('@') && message.Message.Fragments.Any(f => f.Text == value && f.Mention != null); + } + } +} \ No newline at end of file diff --git a/Chat/Commands/Parameters/OBSTransformationParameter.cs b/Chat/Commands/Parameters/OBSTransformationParameter.cs index 92d1f0c..ef280a2 100644 --- a/Chat/Commands/Parameters/OBSTransformationParameter.cs +++ b/Chat/Commands/Parameters/OBSTransformationParameter.cs @@ -1,3 +1,5 @@ +using TwitchChatTTS.Twitch.Socket.Messages; + namespace TwitchChatTTS.Chat.Commands.Parameters { public class OBSTransformationParameter : CommandParameter @@ -8,7 +10,7 @@ namespace TwitchChatTTS.Chat.Commands.Parameters { } - public override bool Validate(string value) + public override bool Validate(string value, ChannelChatMessage message) { return _values.Contains(value.ToLower()); } diff --git a/Chat/Commands/Parameters/StateParameter.cs b/Chat/Commands/Parameters/StateParameter.cs index 89dfd2d..b44233a 100644 --- a/Chat/Commands/Parameters/StateParameter.cs +++ b/Chat/Commands/Parameters/StateParameter.cs @@ -1,3 +1,5 @@ +using TwitchChatTTS.Twitch.Socket.Messages; + namespace TwitchChatTTS.Chat.Commands.Parameters { public class StateParameter : CommandParameter @@ -8,7 +10,7 @@ namespace TwitchChatTTS.Chat.Commands.Parameters { } - public override bool Validate(string value) + public override bool Validate(string value, ChannelChatMessage message) { return _values.Contains(value.ToLower()); } diff --git a/Chat/Commands/Parameters/StaticParameter.cs b/Chat/Commands/Parameters/StaticParameter.cs index 1c82468..6077e89 100644 --- a/Chat/Commands/Parameters/StaticParameter.cs +++ b/Chat/Commands/Parameters/StaticParameter.cs @@ -1,3 +1,5 @@ +using TwitchChatTTS.Twitch.Socket.Messages; + namespace TwitchChatTTS.Chat.Commands.Parameters { public class StaticParameter : CommandParameter @@ -11,7 +13,7 @@ namespace TwitchChatTTS.Chat.Commands.Parameters _value = value.ToLower(); } - public override bool Validate(string value) + public override bool Validate(string value, ChannelChatMessage message) { return _value == value.ToLower(); } diff --git a/Chat/Commands/Parameters/TTSVoiceNameParameter.cs b/Chat/Commands/Parameters/TTSVoiceNameParameter.cs index 9d28bd5..95c6143 100644 --- a/Chat/Commands/Parameters/TTSVoiceNameParameter.cs +++ b/Chat/Commands/Parameters/TTSVoiceNameParameter.cs @@ -1,3 +1,5 @@ +using TwitchChatTTS.Twitch.Socket.Messages; + namespace TwitchChatTTS.Chat.Commands.Parameters { public class TTSVoiceNameParameter : CommandParameter @@ -11,7 +13,7 @@ namespace TwitchChatTTS.Chat.Commands.Parameters _user = user; } - public override bool Validate(string value) + public override bool Validate(string value, ChannelChatMessage message) { if (_user.VoicesAvailable == null) return false; diff --git a/Chat/Commands/Parameters/UnvalidatedParameter.cs b/Chat/Commands/Parameters/UnvalidatedParameter.cs index 4a4acd5..b6e5247 100644 --- a/Chat/Commands/Parameters/UnvalidatedParameter.cs +++ b/Chat/Commands/Parameters/UnvalidatedParameter.cs @@ -1,3 +1,5 @@ +using TwitchChatTTS.Twitch.Socket.Messages; + namespace TwitchChatTTS.Chat.Commands.Parameters { public class UnvalidatedParameter : CommandParameter @@ -6,7 +8,7 @@ namespace TwitchChatTTS.Chat.Commands.Parameters { } - public override bool Validate(string value) + public override bool Validate(string value, ChannelChatMessage message) { return true; } diff --git a/Chat/Commands/RefreshCommand.cs b/Chat/Commands/RefreshCommand.cs index fb99528..1e4482f 100644 --- a/Chat/Commands/RefreshCommand.cs +++ b/Chat/Commands/RefreshCommand.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Serilog; using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.OBS.Socket; -using TwitchLib.Client.Models; +using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; namespace TwitchChatTTS.Chat.Commands @@ -44,12 +44,7 @@ namespace TwitchChatTTS.Chat.Commands { public bool AcceptCustomPermission { get => true; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { await client.FetchEnabledTTSVoices(); } @@ -59,12 +54,7 @@ namespace TwitchChatTTS.Chat.Commands { public bool AcceptCustomPermission { get => true; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { await client.FetchTTSWordFilters(); } @@ -74,12 +64,7 @@ namespace TwitchChatTTS.Chat.Commands { public bool AcceptCustomPermission { get => true; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { await client.FetchTTSChatterVoices(); } @@ -89,12 +74,7 @@ namespace TwitchChatTTS.Chat.Commands { public bool AcceptCustomPermission { get => true; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { await client.FetchDefaultTTSVoice(); } @@ -104,12 +84,7 @@ namespace TwitchChatTTS.Chat.Commands { public bool AcceptCustomPermission { get => true; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { await client.FetchRedemptions(); } @@ -127,12 +102,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { _obsManager.ClearCache(); _logger.Information("Cleared the cache used for OBS."); @@ -144,20 +114,10 @@ namespace TwitchChatTTS.Chat.Commands public bool AcceptCustomPermission { get => true; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { await client.FetchPermissions(); } } - - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } } } \ No newline at end of file diff --git a/Chat/Commands/SkipCommand.cs b/Chat/Commands/SkipCommand.cs index 8fff41b..197b65e 100644 --- a/Chat/Commands/SkipCommand.cs +++ b/Chat/Commands/SkipCommand.cs @@ -1,6 +1,6 @@ using Serilog; using TwitchChatTTS.Hermes.Socket; -using TwitchLib.Client.Models; +using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; namespace TwitchChatTTS.Chat.Commands @@ -8,11 +8,13 @@ namespace TwitchChatTTS.Chat.Commands public class SkipCommand : IChatCommand { private readonly TTSPlayer _player; + private readonly AudioPlaybackEngine _playback; private readonly ILogger _logger; - public SkipCommand(TTSPlayer ttsPlayer, ILogger logger) + public SkipCommand(TTSPlayer player, AudioPlaybackEngine playback, ILogger logger) { - _player = ttsPlayer; + _player = player; + _playback = playback; _logger = logger; } @@ -24,40 +26,38 @@ namespace TwitchChatTTS.Chat.Commands { b.CreateStaticInputParameter("all", b => { - b.CreateCommand(new TTSPlayerSkipAllCommand(_player, _logger)); - }).CreateCommand(new TTSPlayerSkipCommand(_player, _logger)); + b.CreateCommand(new TTSPlayerSkipAllCommand(_player, _playback, _logger)); + }).CreateCommand(new TTSPlayerSkipCommand(_player, _playback, _logger)); }); - builder.CreateCommandTree("skipall", b => { - b.CreateCommand(new TTSPlayerSkipAllCommand(_player, _logger)); + builder.CreateCommandTree("skipall", b => + { + b.CreateCommand(new TTSPlayerSkipAllCommand(_player, _playback, _logger)); }); } private sealed class TTSPlayerSkipCommand : IChatPartialCommand { - private readonly TTSPlayer _ttsPlayer; + private readonly TTSPlayer _player; + private readonly AudioPlaybackEngine _playback; private readonly ILogger _logger; public bool AcceptCustomPermission { get => true; } - public TTSPlayerSkipCommand(TTSPlayer ttsPlayer, ILogger logger) + public TTSPlayerSkipCommand(TTSPlayer player, AudioPlaybackEngine playback, ILogger logger) { - _ttsPlayer = ttsPlayer; + _player = player; + _playback = playback; _logger = logger; } - public bool CheckDefaultPermissions(ChatMessage message) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { - return message.IsModerator || message.IsVip || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) - { - if (_ttsPlayer.Playing == null) + if (_player.Playing == null) return; - AudioPlaybackEngine.Instance.RemoveMixerInput(_ttsPlayer.Playing); - _ttsPlayer.Playing = null; + _playback.RemoveMixerInput(_player.Playing.Audio!); + _player.Playing = null; _logger.Information("Skipped current tts."); } @@ -65,31 +65,28 @@ namespace TwitchChatTTS.Chat.Commands private sealed class TTSPlayerSkipAllCommand : IChatPartialCommand { - private readonly TTSPlayer _ttsPlayer; + private readonly TTSPlayer _player; + private readonly AudioPlaybackEngine _playback; private readonly ILogger _logger; public bool AcceptCustomPermission { get => true; } - public TTSPlayerSkipAllCommand(TTSPlayer ttsPlayer, ILogger logger) + public TTSPlayerSkipAllCommand(TTSPlayer player, AudioPlaybackEngine playback, ILogger logger) { - _ttsPlayer = ttsPlayer; + _player = player; + _playback = playback; _logger = logger; } - public bool CheckDefaultPermissions(ChatMessage message) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { - return message.IsModerator || message.IsVip || message.IsBroadcaster; - } + _player.RemoveAll(); - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) - { - _ttsPlayer.RemoveAll(); - - if (_ttsPlayer.Playing == null) + if (_player.Playing == null) return; - AudioPlaybackEngine.Instance.RemoveMixerInput(_ttsPlayer.Playing); - _ttsPlayer.Playing = null; + _playback.RemoveMixerInput(_player.Playing.Audio!); + _player.Playing = null; _logger.Information("Skipped all queued and playing tts."); } diff --git a/Chat/Commands/TTSCommand.cs b/Chat/Commands/TTSCommand.cs index b9214cd..fe5c982 100644 --- a/Chat/Commands/TTSCommand.cs +++ b/Chat/Commands/TTSCommand.cs @@ -1,6 +1,6 @@ using Serilog; using TwitchChatTTS.Hermes.Socket; -using TwitchLib.Client.Models; +using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; namespace TwitchChatTTS.Chat.Commands @@ -28,41 +28,30 @@ namespace TwitchChatTTS.Chat.Commands b.CreateVoiceNameParameter("voiceName", false) .CreateCommand(new AddTTSVoiceCommand(_user, _logger)); }) - .CreateStaticInputParameter("del", b => - { - b.CreateVoiceNameParameter("voiceName", true) - .CreateCommand(new DeleteTTSVoiceCommand(_user, _logger)); - }) + .AddAlias("insert", "add") .CreateStaticInputParameter("delete", b => { b.CreateVoiceNameParameter("voiceName", true) .CreateCommand(new DeleteTTSVoiceCommand(_user, _logger)); }) - .CreateStaticInputParameter("remove", b => - { - b.CreateVoiceNameParameter("voiceName", true) - .CreateCommand(new DeleteTTSVoiceCommand(_user, _logger)); - }) + .AddAlias("del", "delete") + .AddAlias("remove", "delete") .CreateStaticInputParameter("enable", b => { b.CreateVoiceNameParameter("voiceName", false) .CreateCommand(new SetTTSVoiceStateCommand(true, _user, _logger)); }) - .CreateStaticInputParameter("on", b => - { - b.CreateVoiceNameParameter("voiceName", false) - .CreateCommand(new SetTTSVoiceStateCommand(true, _user, _logger)); - }) + .AddAlias("on", "enable") + .AddAlias("enabled", "enable") + .AddAlias("true", "enable") .CreateStaticInputParameter("disable", b => { b.CreateVoiceNameParameter("voiceName", true) .CreateCommand(new SetTTSVoiceStateCommand(false, _user, _logger)); }) - .CreateStaticInputParameter("off", b => - { - b.CreateVoiceNameParameter("voiceName", true) - .CreateCommand(new SetTTSVoiceStateCommand(false, _user, _logger)); - }); + .AddAlias("off", "disable") + .AddAlias("disabled", "disable") + .AddAlias("false", "disable"); }); } @@ -80,12 +69,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return false; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { if (_user == null || _user.VoicesAvailable == null) return; @@ -95,12 +79,12 @@ namespace TwitchChatTTS.Chat.Commands var exists = _user.VoicesAvailable.Any(v => v.Value.ToLower() == voiceNameLower); if (exists) { - _logger.Warning($"Voice already exists [voice: {voiceName}][id: {message.UserId}]"); + _logger.Warning($"Voice already exists [voice: {voiceName}][id: {message.ChatterUserId}]"); return; } await client.CreateTTSVoice(voiceName); - _logger.Information($"Added a new TTS voice by {message.Username} [voice: {voiceName}][id: {message.UserId}]"); + _logger.Information($"Added a new TTS voice [voice: {voiceName}][creator: {message.ChatterUserLogin}][creator id: {message.ChatterUserId}]"); } } @@ -117,16 +101,11 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return false; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { if (_user == null || _user.VoicesAvailable == null) { - _logger.Debug($"Voices available are not loaded [chatter: {message.Username}][chatter id: {message.UserId}]"); + _logger.Warning($"Voices available are not loaded [chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]"); return; } @@ -135,13 +114,18 @@ namespace TwitchChatTTS.Chat.Commands var exists = _user.VoicesAvailable.Any(v => v.Value.ToLower() == voiceNameLower); if (!exists) { - _logger.Debug($"Voice does not exist [voice: {voiceName}][chatter: {message.Username}][chatter id: {message.UserId}]"); + _logger.Warning($"Voice does not exist [voice: {voiceName}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]"); + return; + } + + var voiceId = _user.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceNameLower).Key; + if (voiceId == null) { + _logger.Warning($"Could not find the identifier for the tts voice [voice name: {voiceName}]"); return; } - var voiceId = _user.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceName).Key; await client.DeleteTTSVoice(voiceId); - _logger.Information($"Deleted a TTS voice [voice: {voiceName}][chatter: {message.Username}][chatter id: {message.UserId}]"); + _logger.Information($"Deleted a TTS voice [voice: {voiceName}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]"); } } @@ -160,12 +144,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { if (_user == null || _user.VoicesAvailable == null) return; @@ -175,7 +154,7 @@ namespace TwitchChatTTS.Chat.Commands var voiceId = _user.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceNameLower).Key; await client.UpdateTTSVoiceState(voiceId, _state); - _logger.Information($"Changed state for TTS voice [voice: {voiceName}][state: {_state}][invoker: {message.Username}][id: {message.UserId}]"); + _logger.Information($"Changed state for TTS voice [voice: {voiceName}][state: {_state}][invoker: {message.ChatterUserLogin}][id: {message.ChatterUserId}]"); } } } diff --git a/Chat/Commands/VersionCommand.cs b/Chat/Commands/VersionCommand.cs index f95f5aa..7e04445 100644 --- a/Chat/Commands/VersionCommand.cs +++ b/Chat/Commands/VersionCommand.cs @@ -1,7 +1,7 @@ using HermesSocketLibrary.Socket.Data; using Serilog; using TwitchChatTTS.Hermes.Socket; -using TwitchLib.Client.Models; +using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; namespace TwitchChatTTS.Chat.Commands @@ -37,12 +37,7 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsBroadcaster; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { _logger.Information($"TTS Version: {TTS.MAJOR_VERSION}.{TTS.MINOR_VERSION}"); diff --git a/Chat/Commands/VoiceCommand.cs b/Chat/Commands/VoiceCommand.cs index e40b12e..88a5d7b 100644 --- a/Chat/Commands/VoiceCommand.cs +++ b/Chat/Commands/VoiceCommand.cs @@ -1,6 +1,6 @@ using Serilog; using TwitchChatTTS.Hermes.Socket; -using TwitchLib.Client.Models; +using TwitchChatTTS.Twitch.Socket.Messages; using static TwitchChatTTS.Chat.Commands.TTSCommands; namespace TwitchChatTTS.Chat.Commands @@ -8,6 +8,8 @@ namespace TwitchChatTTS.Chat.Commands public class VoiceCommand : IChatCommand { private readonly User _user; + // TODO: get permissions + // TODO: validated parameter for username by including '@' and regex for username private readonly ILogger _logger; public VoiceCommand(User user, ILogger logger) @@ -23,7 +25,10 @@ namespace TwitchChatTTS.Chat.Commands builder.CreateCommandTree(Name, b => { b.CreateVoiceNameParameter("voiceName", true) - .CreateCommand(new TTSVoiceSelector(_user, _logger)); + .CreateCommand(new TTSVoiceSelector(_user, _logger)) + .CreateUnvalidatedParameter("chatter", optional: true) + .AddPermission("tts.command.voice.admin") + .CreateCommand(new TTSVoiceSelectorAdmin(_user, _logger)); }); } @@ -40,18 +45,12 @@ namespace TwitchChatTTS.Chat.Commands _logger = logger; } - - public bool CheckDefaultPermissions(ChatMessage message) - { - return message.IsModerator || message.IsBroadcaster || message.IsSubscriber || message.Bits >= 100; - } - - public async Task Execute(IDictionary values, ChatMessage message, HermesSocketClient client) + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) { if (_user == null || _user.VoicesSelected == null) return; - long chatterId = long.Parse(message.UserId); + long chatterId = long.Parse(message.ChatterUserId); var voiceName = values["voiceName"]; var voiceNameLower = voiceName.ToLower(); var voice = _user.VoicesAvailable.First(v => v.Value.ToLower() == voiceNameLower); @@ -59,12 +58,56 @@ namespace TwitchChatTTS.Chat.Commands if (_user.VoicesSelected.ContainsKey(chatterId)) { await client.UpdateTTSUser(chatterId, voice.Key); - _logger.Debug($"Sent request to create chat TTS voice [voice: {voice.Value}][username: {message.Username}][reason: command]"); + _logger.Debug($"Sent request to create chat TTS voice [voice: {voice.Value}][username: {message.ChatterUserLogin}][reason: command]"); } else { await client.CreateTTSUser(chatterId, voice.Key); - _logger.Debug($"Sent request to update chat TTS voice [voice: {voice.Value}][username: {message.Username}][reason: command]"); + _logger.Debug($"Sent request to update chat TTS voice [voice: {voice.Value}][username: {message.ChatterUserLogin}][reason: command]"); + } + } + } + + private sealed class TTSVoiceSelectorAdmin : IChatPartialCommand + { + private readonly User _user; + private readonly ILogger _logger; + + public bool AcceptCustomPermission { get => true; } + + public TTSVoiceSelectorAdmin(User user, ILogger logger) + { + _user = user; + _logger = logger; + } + + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient client) + { + if (_user == null || _user.VoicesSelected == null) + return; + + var chatterLogin = values["chatter"].Substring(1); + var mention = message.Message.Fragments.FirstOrDefault(f => f.Mention != null && f.Mention.UserLogin == chatterLogin)?.Mention; + if (mention == null) + { + _logger.Warning("Failed to find the chatter to apply voice command to."); + return; + } + + long chatterId = long.Parse(mention.UserId); + var voiceName = values["voiceName"]; + var voiceNameLower = voiceName.ToLower(); + var voice = _user.VoicesAvailable.First(v => v.Value.ToLower() == voiceNameLower); + + if (_user.VoicesSelected.ContainsKey(chatterId)) + { + await client.UpdateTTSUser(chatterId, voice.Key); + _logger.Debug($"Sent request to create chat TTS voice [voice: {voice.Value}][username: {mention.UserLogin}][reason: command]"); + } + else + { + await client.CreateTTSUser(chatterId, voice.Key); + _logger.Debug($"Sent request to update chat TTS voice [voice: {voice.Value}][username: {mention.UserLogin}][reason: command]"); } } } diff --git a/Chat/Groups/Permissions/GroupPermissionManager.cs b/Chat/Groups/Permissions/GroupPermissionManager.cs index e70ff06..3938a59 100644 --- a/Chat/Groups/Permissions/GroupPermissionManager.cs +++ b/Chat/Groups/Permissions/GroupPermissionManager.cs @@ -23,9 +23,11 @@ namespace TwitchChatTTS.Chat.Groups.Permissions return res; } - public bool? CheckIfAllowed(IEnumerable groups, string path) { + public bool? CheckIfAllowed(IEnumerable groups, string path) + { bool overall = false; - foreach (var group in groups) { + foreach (var group in groups) + { var result = CheckIfAllowed($"{group}.{path}"); if (result == false) return false; @@ -73,7 +75,7 @@ namespace TwitchChatTTS.Chat.Groups.Permissions { if (path.Length == 0) return node; - + var parts = path.Split('.'); var name = parts.First(); var next = node.Children?.FirstOrDefault(n => n.Name == name); @@ -87,61 +89,62 @@ namespace TwitchChatTTS.Chat.Groups.Permissions } return Get(next, string.Join('.', parts.Skip(1)), edit); } - } - internal class PermissionNode - { - public string Name { get; } - public bool? Allow + private sealed class PermissionNode { - get + public string Name { get; } + public bool? Allow { - var current = this; - while (current._allow == null && current._parent != null) - current = current._parent; - return current._allow; - } - set => _allow = value; - } - public int Priority; - internal PermissionNode? Parent { get => _parent; } - public IList? Children { get => _children == null ? null : new ReadOnlyCollection(_children); } - - private bool? _allow; - private PermissionNode? _parent; - private IList? _children; - - - public PermissionNode(string name, PermissionNode? parent, bool? allow) - { - Name = name; - _parent = parent; - _allow = allow; - } - - internal void Add(PermissionNode child) - { - if (_children == null) - _children = new List(); - _children.Add(child); - } - - internal void Clear() { - if (_children != null) - _children.Clear(); - } - - public void Remove(string name) - { - if (_children == null || !_children.Any()) - return; - - for (var i = 0; i < _children.Count; i++) - { - if (_children[i].Name == name) + get { - _children.RemoveAt(i); - break; + var current = this; + while (current._allow == null && current._parent != null) + current = current._parent; + return current._allow; + } + set => _allow = value; + } + + internal PermissionNode? Parent { get => _parent; } + public IList? Children { get => _children == null ? null : new ReadOnlyCollection(_children); } + + private bool? _allow; + private PermissionNode? _parent; + private IList? _children; + + + public PermissionNode(string name, PermissionNode? parent, bool? allow) + { + Name = name; + _parent = parent; + _allow = allow; + } + + internal void Add(PermissionNode child) + { + if (_children == null) + _children = new List(); + _children.Add(child); + } + + internal void Clear() + { + if (_children != null) + _children.Clear(); + } + + public void Remove(string name) + { + if (_children == null || !_children.Any()) + return; + + for (var i = 0; i < _children.Count; i++) + { + if (_children[i].Name == name) + { + _children.RemoveAt(i); + break; + } } } } diff --git a/Chat/Speech/AudioPlaybackEngine.cs b/Chat/Speech/AudioPlaybackEngine.cs index e2cc137..849e8e5 100644 --- a/Chat/Speech/AudioPlaybackEngine.cs +++ b/Chat/Speech/AudioPlaybackEngine.cs @@ -2,24 +2,23 @@ using NAudio.Wave; using NAudio.Extras; using NAudio.Wave.SampleProviders; -public class AudioPlaybackEngine : IDisposable +public sealed class AudioPlaybackEngine : IDisposable { - public static readonly AudioPlaybackEngine Instance = new AudioPlaybackEngine(44100, 2); - - private readonly IWavePlayer outputDevice; - private readonly MixingSampleProvider mixer; public int SampleRate { get; } + + private readonly IWavePlayer _outputDevice; + private readonly MixingSampleProvider _mixer; - private AudioPlaybackEngine(int sampleRate = 44100, int channelCount = 2) + public AudioPlaybackEngine(int sampleRate = 44100, int channelCount = 2) { SampleRate = sampleRate; - outputDevice = new WaveOutEvent(); + _outputDevice = new WaveOutEvent(); - mixer = new MixingSampleProvider(WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channelCount)); - mixer.ReadFully = true; + _mixer = new MixingSampleProvider(WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channelCount)); + _mixer.ReadFully = true; - outputDevice.Init(mixer); - outputDevice.Play(); + _outputDevice.Init(_mixer); + _outputDevice.Play(); } private ISampleProvider ConvertToRightChannelCount(ISampleProvider? input) @@ -27,11 +26,11 @@ public class AudioPlaybackEngine : IDisposable if (input == null) throw new NullReferenceException(nameof(input)); - if (input.WaveFormat.Channels == mixer.WaveFormat.Channels) + if (input.WaveFormat.Channels == _mixer.WaveFormat.Channels) return input; - if (input.WaveFormat.Channels == 1 && mixer.WaveFormat.Channels == 2) + if (input.WaveFormat.Channels == 1 && _mixer.WaveFormat.Channels == 2) return new MonoToStereoSampleProvider(input); - if (input.WaveFormat.Channels == 2 && mixer.WaveFormat.Channels == 1) + if (input.WaveFormat.Channels == 2 && _mixer.WaveFormat.Channels == 1) return new StereoToMonoSampleProvider(input); throw new NotImplementedException("Not yet implemented this channel count conversion"); } @@ -89,26 +88,26 @@ public class AudioPlaybackEngine : IDisposable public void AddMixerInput(ISampleProvider input) { - mixer.AddMixerInput(input); + _mixer.AddMixerInput(input); } public void AddMixerInput(IWaveProvider input) { - mixer.AddMixerInput(input); + _mixer.AddMixerInput(input); } public void RemoveMixerInput(ISampleProvider sound) { - mixer.RemoveMixerInput(sound); + _mixer.RemoveMixerInput(sound); } public void AddOnMixerInputEnded(EventHandler e) { - mixer.MixerInputEnded += e; + _mixer.MixerInputEnded += e; } public void Dispose() { - outputDevice.Dispose(); + _outputDevice.Dispose(); } } \ No newline at end of file diff --git a/Chat/Speech/TTSPlayer.cs b/Chat/Speech/TTSPlayer.cs index 90f75c2..938419d 100644 --- a/Chat/Speech/TTSPlayer.cs +++ b/Chat/Speech/TTSPlayer.cs @@ -1,4 +1,5 @@ using NAudio.Wave; +using TwitchChatTTS.Twitch.Socket.Messages; public class TTSPlayer { @@ -7,7 +8,7 @@ public class TTSPlayer private readonly Mutex _mutex; private readonly Mutex _mutex2; - public ISampleProvider? Playing { get; set; } + public TTSMessage? Playing { get; set; } public TTSPlayer() { @@ -100,12 +101,80 @@ public class TTSPlayer } } + public void RemoveAll(long chatterId) + { + try + { + _mutex2.WaitOne(); + if (_buffer.UnorderedItems.Any(i => i.Element.ChatterId == chatterId)) { + var list = _buffer.UnorderedItems.Where(i => i.Element.ChatterId != chatterId).ToArray(); + _buffer.Clear(); + foreach (var item in list) + _buffer.Enqueue(item.Element, item.Element.Priority); + } + } + finally + { + _mutex2.ReleaseMutex(); + } + + try + { + _mutex.WaitOne(); + if (_messages.UnorderedItems.Any(i => i.Element.ChatterId == chatterId)) { + var list = _messages.UnorderedItems.Where(i => i.Element.ChatterId != chatterId).ToArray(); + _messages.Clear(); + foreach (var item in list) + _messages.Enqueue(item.Element, item.Element.Priority); + } + } + finally + { + _mutex.ReleaseMutex(); + } + } + + public void RemoveMessage(string messageId) + { + try + { + _mutex2.WaitOne(); + if (_buffer.UnorderedItems.Any(i => i.Element.MessageId == messageId)) { + var list = _buffer.UnorderedItems.Where(i => i.Element.MessageId != messageId).ToArray(); + _buffer.Clear(); + foreach (var item in list) + _buffer.Enqueue(item.Element, item.Element.Priority); + return; + } + } + finally + { + _mutex2.ReleaseMutex(); + } + + try + { + _mutex.WaitOne(); + if (_messages.UnorderedItems.Any(i => i.Element.MessageId == messageId)) { + var list = _messages.UnorderedItems.Where(i => i.Element.MessageId != messageId).ToArray(); + _messages.Clear(); + foreach (var item in list) + _messages.Enqueue(item.Element, item.Element.Priority); + } + } + finally + { + _mutex.ReleaseMutex(); + } + } + public bool IsEmpty() { return _messages.Count == 0; } - private class DescendingOrder : IComparer { + private class DescendingOrder : IComparer + { public int Compare(int x, int y) => y.CompareTo(x); } } @@ -113,15 +182,12 @@ public class TTSPlayer public class TTSMessage { public string? Voice { get; set; } - public string? Channel { get; set; } - public string? Username { get; set; } + public long ChatterId { get; set; } + public string MessageId { get; set; } public string? Message { get; set; } public string? File { get; set; } public DateTime Timestamp { get; set; } - public bool Moderator { get; set; } - public bool Bot { get; set; } - public IEnumerable>? Badges { get; set; } - public int Bits { get; set; } + public IEnumerable Badges { get; set; } public int Priority { get; set; } public ISampleProvider? Audio { get; set; } } \ No newline at end of file diff --git a/Helpers/WebClientWrap.cs b/Helpers/WebClientWrap.cs index 9951943..de2f823 100644 --- a/Helpers/WebClientWrap.cs +++ b/Helpers/WebClientWrap.cs @@ -36,12 +36,12 @@ namespace TwitchChatTTS.Helpers public async Task Post(string uri, T data) { - return await _client.PostAsJsonAsync(uri, data); + return await _client.PostAsJsonAsync(uri, data, _options); } public async Task Post(string uri) { - return await _client.PostAsJsonAsync(uri, new object()); + return await _client.PostAsJsonAsync(uri, new object(), _options); } } } \ No newline at end of file diff --git a/Hermes/CustomDataManager.cs b/Hermes/CustomDataManager.cs new file mode 100644 index 0000000..7e4a945 --- /dev/null +++ b/Hermes/CustomDataManager.cs @@ -0,0 +1,46 @@ +namespace TwitchChatTTS.Hermes +{ + public interface ICustomDataManager { + void Add(string key, object value, string type); + void Change(string key, object value); + void Delete(string key); + object? Get(string key); + } + + public class CustomDataManager : ICustomDataManager + { + private IDictionary _data; + + public CustomDataManager() { + _data = new Dictionary(); + } + + + public void Add(string key, object value, string type) + { + throw new NotImplementedException(); + } + + public void Change(string key, object value) + { + throw new NotImplementedException(); + } + + public void Delete(string key) + { + throw new NotImplementedException(); + } + + public object? Get(string key) + { + throw new NotImplementedException(); + } + } + +// type: text (string), whole number (int), number (double), boolean, formula (string, data type of number) + public struct DataInfo { + public string Id { get; set; } + public string Type { get; set; } + public object Value { get; set; } + } +} \ No newline at end of file diff --git a/Hermes/HermesApiClient.cs b/Hermes/HermesApiClient.cs index 11fc898..711df6a 100644 --- a/Hermes/HermesApiClient.cs +++ b/Hermes/HermesApiClient.cs @@ -3,29 +3,68 @@ using TwitchChatTTS; using System.Text.Json; using HermesSocketLibrary.Requests.Messages; using TwitchChatTTS.Hermes; -using TwitchChatTTS.Chat.Groups.Permissions; -using TwitchChatTTS.Chat.Groups; -using HermesSocketLibrary.Socket.Data; +using Serilog; public class HermesApiClient { + private readonly TwitchBotAuth _token; private readonly WebClientWrap _web; + private readonly ILogger _logger; public const string BASE_URL = "tomtospeech.com"; - public HermesApiClient(Configuration configuration) + public HermesApiClient(TwitchBotAuth token, Configuration configuration, ILogger logger) { 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."); } + _token = token; _web = new WebClientWrap(new JsonSerializerOptions() { PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); _web.AddHeader("x-api-key", configuration.Hermes.Token); + _logger = logger; + } + + + public async Task AuthorizeTwitch() + { + try + { + _logger.Debug($"Attempting to authorize Twitch API..."); + var authorize = await _web.GetJson($"https://{HermesApiClient.BASE_URL}/api/account/reauthorize"); + if (authorize != null) + { + _token.AccessToken = authorize.AccessToken; + _token.RefreshToken = authorize.RefreshToken; + _token.UserId = authorize.UserId; + _token.BroadcasterId = authorize.BroadcasterId; + _token.ExpiresIn = authorize.ExpiresIn; + _token.UpdatedAt = DateTime.Now; + _logger.Information("Updated Twitch API tokens."); + _logger.Debug($"Twitch API Auth data [user id: {_token.UserId}][id: {_token.BroadcasterId}][expires in: {_token.ExpiresIn}][expires at: {_token.ExpiresAt.ToShortTimeString()}]"); + } + else if (authorize != null) + { + _logger.Error("Twitch API Authorization failed: " + authorize.AccessToken + " | " + authorize.RefreshToken + " | " + authorize.UserId + " | " + authorize.BroadcasterId); + return false; + } + _logger.Debug($"Authorized Twitch API."); + return true; + } + catch (JsonException) + { + _logger.Debug($"Failed to Authorize Twitch API due to JSON error."); + } + catch (Exception e) + { + _logger.Error(e, "Failed to authorize to Twitch API."); + } + return false; } public async Task GetLatestTTSVersion() diff --git a/Hermes/Socket/Handlers/RequestAckHandler.cs b/Hermes/Socket/Handlers/RequestAckHandler.cs index 35c84ee..74a67eb 100644 --- a/Hermes/Socket/Handlers/RequestAckHandler.cs +++ b/Hermes/Socket/Handlers/RequestAckHandler.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Text.Json; +using System.Text.RegularExpressions; using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using HermesSocketLibrary.Requests.Callbacks; @@ -160,8 +161,7 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers if (chatters == null) return; - var client = _serviceProvider.GetRequiredService(); - client.Chatters = [.. chatters]; + _user.Chatters = [.. chatters]; _logger.Information($"Fetched {chatters.Count()} chatters' id."); } else if (message.Request.Type == "get_emotes") @@ -232,7 +232,7 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers continue; } - + var path = $"{group.Name}.{permission.Path}"; permissionManager.Set(path, permission.Allow); _logger.Debug($"Added group permission [id: {permission.Id}][group id: {permission.GroupId}][path: {permission.Path}]"); @@ -254,8 +254,19 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers return; } - _user.RegexFilters = wordFilters.ToList(); - _logger.Information($"TTS word filters [count: {_user.RegexFilters.Count}] have been refreshed."); + var filters = wordFilters.Where(f => f.Search != null && f.Replace != null).ToArray(); + foreach (var filter in filters) + { + try + { + var re = new Regex(filter.Search!, RegexOptions.Compiled); + re.Match(string.Empty); + filter.Regex = re; + } + catch (Exception e) { } + } + _user.RegexFilters = filters; + _logger.Information($"TTS word filters [count: {_user.RegexFilters.Count()}] have been refreshed."); } else if (message.Request.Type == "update_tts_voice_state") { diff --git a/Hermes/Socket/HermesSocketClient.cs b/Hermes/Socket/HermesSocketClient.cs index 199b8dc..638aed6 100644 --- a/Hermes/Socket/HermesSocketClient.cs +++ b/Hermes/Socket/HermesSocketClient.cs @@ -383,7 +383,7 @@ namespace TwitchChatTTS.Hermes.Socket } catch (WebSocketException wse) when (wse.Message.Contains("502")) { - _logger.Error("Hermes websocket server cannot be found."); + _logger.Error($"Hermes websocket server cannot be found [code: {wse.ErrorCode}]"); } catch (Exception ex) { diff --git a/Hermes/Socket/Managers/HermesHandlerTypeManager.cs b/Hermes/Socket/Managers/HermesHandlerTypeManager.cs index 2a5f2c1..0672f41 100644 --- a/Hermes/Socket/Managers/HermesHandlerTypeManager.cs +++ b/Hermes/Socket/Managers/HermesHandlerTypeManager.cs @@ -1,5 +1,4 @@ using System.Reflection; -using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using CommonSocketLibrary.Socket.Manager; using Microsoft.Extensions.DependencyInjection; diff --git a/Hermes/TTSUsernameFilter.cs b/Hermes/TTSUsernameFilter.cs deleted file mode 100644 index 2141e4e..0000000 --- a/Hermes/TTSUsernameFilter.cs +++ /dev/null @@ -1,5 +0,0 @@ -public class TTSUsernameFilter { - public string Username { get; set; } - public string Tag { get; set; } - public string UserId { get; set; } -} \ No newline at end of file diff --git a/OBS/Socket/Handlers/HelloHandler.cs b/OBS/Socket/Handlers/HelloHandler.cs index 09e5ec5..34bc47f 100644 --- a/OBS/Socket/Handlers/HelloHandler.cs +++ b/OBS/Socket/Handlers/HelloHandler.cs @@ -23,12 +23,14 @@ namespace TwitchChatTTS.OBS.Socket.Handlers { if (data is not HelloMessage message || message == null) return; + if (sender is not OBSSocketClient client) + return; string? password = string.IsNullOrWhiteSpace(_configuration.Obs?.Password) ? null : _configuration.Obs.Password.Trim(); _logger.Verbose("OBS websocket password: " + password); if (message.Authentication == null || string.IsNullOrEmpty(password)) { - await sender.Send(1, new IdentifyMessage(message.RpcVersion, null, 1023 | 262144)); + await client.Send(1, new IdentifyMessage(message.RpcVersion, null, 1023 | 262144)); return; } @@ -52,7 +54,7 @@ namespace TwitchChatTTS.OBS.Socket.Handlers } _logger.Verbose("Final hash: " + hash); - await sender.Send(1, new IdentifyMessage(message.RpcVersion, hash, 1023 | 262144)); + await client.Send(1, new IdentifyMessage(message.RpcVersion, hash, 1023 | 262144)); } } } \ No newline at end of file diff --git a/OBS/Socket/OBSSocketClient.cs b/OBS/Socket/OBSSocketClient.cs index 56e4fe2..8798dd9 100644 --- a/OBS/Socket/OBSSocketClient.cs +++ b/OBS/Socket/OBSSocketClient.cs @@ -134,7 +134,7 @@ namespace TwitchChatTTS.OBS.Socket } catch (WebSocketException wse) when (wse.Message.Contains("502")) { - _logger.Error("OBS websocket server cannot be found. Be sure the server is on by looking at OBS > Tools > Websocket Server Settings."); + _logger.Error($"OBS websocket server cannot be found. Be sure the server is on by looking at OBS > Tools > Websocket Server Settings [code: {wse.ErrorCode}]"); } catch (Exception ex) { diff --git a/Seven/Socket/SevenSocketClient.cs b/Seven/Socket/SevenSocketClient.cs index dd825d2..e5b91d3 100644 --- a/Seven/Socket/SevenSocketClient.cs +++ b/Seven/Socket/SevenSocketClient.cs @@ -94,7 +94,20 @@ namespace TwitchChatTTS.Seven.Socket } _logger.Debug($"7tv client attempting to connect to {URL}"); - await ConnectAsync($"{URL}"); + try + { + await ConnectAsync(URL); + } + catch (Exception ex) + { + _logger.Error(ex, "Could not connect to 7tv websocket."); + } + + if (!Connected) + { + await Task.Delay(30000); + await Connect(); + } } private async void OnDisconnection(object? sender, SocketDisconnectionEventArgs e) @@ -107,21 +120,20 @@ namespace TwitchChatTTS.Seven.Socket _logger.Warning($"Received end of stream message for 7tv websocket [reason: {_errorCodes[code]}][code: {code}]"); else _logger.Warning($"Received end of stream message for 7tv websocket [code: {code}]"); - - if (code >= 0 && code < _reconnectDelay.Length && _reconnectDelay[code] < 0) + + if (code < 0 || code >= _reconnectDelay.Length) + await Task.Delay(TimeSpan.FromSeconds(30)); + else if (_reconnectDelay[code] < 0) { _logger.Error($"7tv client will remain disconnected due to a bad client implementation."); return; } - - if (_reconnectDelay[code] > 0) + else if (_reconnectDelay[code] > 0) await Task.Delay(_reconnectDelay[code]); } - - if (string.IsNullOrWhiteSpace(_user.SevenEmoteSetId)) - { - _logger.Warning("Could not find the 7tv emote set id. Not reconnecting."); - return; + else { + _logger.Warning("Unknown 7tv disconnection."); + await Task.Delay(TimeSpan.FromSeconds(30)); } await Connect(); diff --git a/Startup.cs b/Startup.cs index e46956a..86c6764 100644 --- a/Startup.cs +++ b/Startup.cs @@ -10,16 +10,10 @@ using YamlDotNet.Serialization.NamingConventions; using TwitchChatTTS.Seven.Socket; using TwitchChatTTS.OBS.Socket.Handlers; using TwitchChatTTS.Seven.Socket.Handlers; -using TwitchLib.Client.Interfaces; -using TwitchLib.Client; -using TwitchLib.PubSub.Interfaces; -using TwitchLib.PubSub; -using TwitchLib.Communication.Interfaces; using TwitchChatTTS.Seven.Socket.Managers; using TwitchChatTTS.Hermes.Socket.Handlers; using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Hermes.Socket.Managers; -using TwitchChatTTS.Chat.Commands.Parameters; using TwitchChatTTS.Chat.Commands; using System.Text.Json; using Serilog; @@ -31,6 +25,9 @@ using TwitchChatTTS.Chat.Groups; using TwitchChatTTS.Chat.Emotes; using HermesSocketLibrary.Requests.Callbacks; using static TwitchChatTTS.Chat.Commands.TTSCommands; +using TwitchChatTTS.Twitch.Socket; +using TwitchChatTTS.Twitch.Socket.Messages; +using TwitchChatTTS.Twitch.Socket.Handlers; // dotnet publish -r linux-x64 -p:PublishSingleFile=true --self-contained true // dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained true @@ -61,6 +58,7 @@ var logger = new LoggerConfiguration() s.AddSerilog(logger); s.AddSingleton(new User()); +s.AddSingleton(); s.AddSingleton, CallbackManager>(); s.AddSingleton(new JsonSerializerOptions() @@ -82,13 +80,9 @@ s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); -s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); -s.AddTransient(); -s.AddTransient(); -s.AddTransient(); s.AddSingleton(); s.AddSingleton(); @@ -114,11 +108,25 @@ s.AddKeyedSingleton("7tv"); s.AddKeyedSingleton, SevenMessageTypeManager>("7tv"); s.AddKeyedSingleton, SevenSocketClient>("7tv"); +// twitch websocket +s.AddKeyedSingleton, TwitchWebsocketClient>("twitch"); + +s.AddKeyedSingleton("twitch"); +s.AddKeyedSingleton("twitch"); +s.AddKeyedSingleton("twitch"); + +s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); +s.AddKeyedSingleton("twitch-notifications"); + // hermes websocket s.AddKeyedSingleton("hermes"); s.AddKeyedSingleton("hermes"); s.AddKeyedSingleton("hermes"); -//s.AddKeyedSingleton("hermes"); s.AddKeyedSingleton, HermesMessageTypeManager>("hermes"); s.AddKeyedSingleton, HermesSocketClient>("hermes"); diff --git a/TTS.cs b/TTS.cs index e856038..7001910 100644 --- a/TTS.cs +++ b/TTS.cs @@ -4,33 +4,34 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Serilog; using NAudio.Wave.SampleProviders; -using TwitchLib.Client.Events; using org.mariuszgromada.math.mxparser; using TwitchChatTTS.Hermes.Socket; -using TwitchChatTTS.Chat.Groups.Permissions; -using TwitchChatTTS.Chat.Groups; using TwitchChatTTS.Seven.Socket; using TwitchChatTTS.Chat.Emotes; using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Common; using TwitchChatTTS.OBS.Socket; +using TwitchChatTTS.Twitch.Socket.Messages; +using TwitchChatTTS.Twitch.Socket; namespace TwitchChatTTS { public class TTS : IHostedService { - public const int MAJOR_VERSION = 3; - public const int MINOR_VERSION = 10; + public const int MAJOR_VERSION = 4; + public const int MINOR_VERSION = 0; private readonly User _user; private readonly HermesApiClient _hermesApiClient; private readonly SevenApiClient _sevenApiClient; + private readonly HermesSocketClient _hermes; private readonly OBSSocketClient _obs; private readonly SevenSocketClient _seven; - private readonly HermesSocketClient _hermes; + private readonly TwitchWebsocketClient _twitch; private readonly IEmoteDatabase _emotes; private readonly Configuration _configuration; private readonly TTSPlayer _player; + private readonly AudioPlaybackEngine _playback; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -41,9 +42,11 @@ namespace TwitchChatTTS [FromKeyedServices("hermes")] SocketClient hermes, [FromKeyedServices("obs")] SocketClient obs, [FromKeyedServices("7tv")] SocketClient seven, + [FromKeyedServices("twitch")] SocketClient twitch, IEmoteDatabase emotes, Configuration configuration, TTSPlayer player, + AudioPlaybackEngine playback, IServiceProvider serviceProvider, ILogger logger ) @@ -54,9 +57,11 @@ namespace TwitchChatTTS _hermes = (hermes as HermesSocketClient)!; _obs = (obs as OBSSocketClient)!; _seven = (seven as SevenSocketClient)!; + _twitch = (twitch as TwitchWebsocketClient)!; _emotes = emotes; _configuration = configuration; _player = player; + _playback = playback; _serviceProvider = serviceProvider; _logger = logger; } @@ -89,21 +94,29 @@ namespace TwitchChatTTS await InitializeHermesWebsocket(); try { - await FetchUserData(_user, _hermesApiClient); + var hermesAccount = await _hermesApiClient.FetchHermesAccountDetails(); + _user.HermesUserId = hermesAccount.Id; + _user.HermesUsername = hermesAccount.Username; + _user.TwitchUsername = hermesAccount.Username; } catch (Exception ex) { _logger.Error(ex, "Failed to initialize properly. Restart app please."); await Task.Delay(30 * 1000); - } - - var twitchapiclient = await InitializeTwitchApiClient(_user.TwitchUsername, _user.TwitchUserId.ToString()); - if (twitchapiclient == null) - { - await Task.Delay(30 * 1000); return; } + await _hermesApiClient.AuthorizeTwitch(); + var twitchBotToken = await _hermesApiClient.FetchTwitchBotToken(); + _user.TwitchUserId = long.Parse(twitchBotToken.BroadcasterId!); + _logger.Information($"Username: {_user.TwitchUsername} [id: {_user.TwitchUserId}]"); + + var twitchapiclient2 = _serviceProvider.GetRequiredService(); + twitchapiclient2.Initialize(twitchBotToken); + + _twitch.Initialize(); + await _twitch.Connect(); + var emoteSet = await _sevenApiClient.FetchChannelEmoteSet(_user.TwitchUserId.ToString()); if (emoteSet != null) _user.SevenEmoteSetId = emoteSet.Id; @@ -112,9 +125,9 @@ namespace TwitchChatTTS await InitializeSevenTv(); await InitializeObs(); - AudioPlaybackEngine.Instance.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) => + _playback.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) => { - if (e.SampleProvider == _player.Playing) + if (e.SampleProvider == _player.Playing?.Audio) { _player.Playing = null; } @@ -142,8 +155,8 @@ namespace TwitchChatTTS string url = $"https://api.streamelements.com/kappa/v2/speech?voice={m.Voice}&text={HttpUtility.UrlEncode(m.Message)}"; var sound = new NetworkWavSound(url); var provider = new CachedWavProvider(sound); - var data = AudioPlaybackEngine.Instance.ConvertSound(provider); - var resampled = new WdlResamplingSampleProvider(data, AudioPlaybackEngine.Instance.SampleRate); + var data = _playback.ConvertSound(provider); + var resampled = new WdlResamplingSampleProvider(data, _playback.SampleRate); _logger.Verbose("Fetched TTS audio data."); m.Audio = resampled; @@ -185,7 +198,7 @@ namespace TwitchChatTTS if (!string.IsNullOrWhiteSpace(m.File) && File.Exists(m.File)) { _logger.Debug("Playing audio file via TTS: " + m.File); - AudioPlaybackEngine.Instance.PlaySound(m.File); + _playback.PlaySound(m.File); continue; } @@ -193,8 +206,8 @@ namespace TwitchChatTTS if (m.Audio != null) { - _player.Playing = m.Audio; - AudioPlaybackEngine.Instance.AddMixerInput(m.Audio); + _player.Playing = m; + _playback.AddMixerInput(m.Audio); } } catch (Exception e) @@ -203,9 +216,6 @@ namespace TwitchChatTTS } } }); - - _logger.Information("Twitch websocket client connecting..."); - await twitchapiclient.Connect(); } public async Task StopAsync(CancellationToken cancellationToken) @@ -216,18 +226,6 @@ namespace TwitchChatTTS _logger.Warning("Application has stopped."); } - private async Task FetchUserData(User user, HermesApiClient hermes) - { - var hermesAccount = await hermes.FetchHermesAccountDetails(); - user.HermesUserId = hermesAccount.Id; - user.HermesUsername = hermesAccount.Username; - user.TwitchUsername = hermesAccount.Username; - - var twitchBotToken = await hermes.FetchTwitchBotToken(); - user.TwitchUserId = long.Parse(twitchBotToken.BroadcasterId!); - _logger.Information($"Username: {user.TwitchUsername} [id: {user.TwitchUserId}]"); - } - private async Task InitializeHermesWebsocket() { try @@ -267,40 +265,42 @@ namespace TwitchChatTTS } } - private async Task InitializeTwitchApiClient(string username, string broadcasterId) - { - _logger.Debug("Initializing twitch client."); - var twitchapiclient = _serviceProvider.GetRequiredService(); - if (!await twitchapiclient.Authorize(broadcasterId)) - { - _logger.Error("Cannot connect to Twitch API."); - return null; - } + // private async Task InitializeTwitchApiClient(string username) + // { + // _logger.Debug("Initializing twitch client."); - var channels = _configuration.Twitch?.Channels ?? [username]; - _logger.Information("Twitch channels: " + string.Join(", ", channels)); - twitchapiclient.InitializeClient(username, channels); - twitchapiclient.InitializePublisher(); + // var hermesapiclient = _serviceProvider.GetRequiredService(); + // if (!await hermesapiclient.AuthorizeTwitch()) + // { + // _logger.Error("Cannot connect to Twitch API."); + // return null; + // } - var handler = _serviceProvider.GetRequiredService(); - twitchapiclient.AddOnNewMessageReceived(async (object? s, OnMessageReceivedArgs e) => - { - try - { - var result = await handler.Handle(e); - if (result.Status != MessageStatus.None || result.Emotes == null || !result.Emotes.Any()) - return; + // var twitchapiclient = _serviceProvider.GetRequiredService(); + // var channels = _configuration.Twitch?.Channels ?? [username]; + // _logger.Information("Twitch channels: " + string.Join(", ", channels)); + // twitchapiclient.InitializeClient(username, channels); + // twitchapiclient.InitializePublisher(); - await _hermes.SendEmoteUsage(e.ChatMessage.Id, result.ChatterId, result.Emotes); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to either execute a command or to send emote usage message."); - } - }); + // var handler = _serviceProvider.GetRequiredService(); + // twitchapiclient.AddOnNewMessageReceived(async (object? s, OnMessageReceivedArgs e) => + // { + // try + // { + // var result = await handler.Handle(e); + // if (result.Status != MessageStatus.None || result.Emotes == null || !result.Emotes.Any()) + // return; - return twitchapiclient; - } + // await _hermes.SendEmoteUsage(e.ChatMessage.Id, result.ChatterId, result.Emotes); + // } + // catch (Exception ex) + // { + // _logger.Error(ex, "Unable to either execute a command or to send emote usage message."); + // } + // }); + + // return twitchapiclient; + // } private async Task InitializeEmotes(SevenApiClient sevenapi, EmoteSet? channelEmotes) { diff --git a/Twitch/Redemptions/RedemptionManager.cs b/Twitch/Redemptions/RedemptionManager.cs index f1f2158..8c6c212 100644 --- a/Twitch/Redemptions/RedemptionManager.cs +++ b/Twitch/Redemptions/RedemptionManager.cs @@ -17,6 +17,7 @@ namespace TwitchChatTTS.Twitch.Redemptions private readonly User _user; private readonly OBSSocketClient _obs; private readonly HermesSocketClient _hermes; + private readonly AudioPlaybackEngine _playback; private readonly ILogger _logger; private readonly Random _random; private bool _isReady; @@ -26,12 +27,14 @@ namespace TwitchChatTTS.Twitch.Redemptions User user, [FromKeyedServices("obs")] SocketClient obs, [FromKeyedServices("hermes")] SocketClient hermes, + AudioPlaybackEngine playback, ILogger logger) { _store = new Dictionary>(); _user = user; _obs = (obs as OBSSocketClient)!; _hermes = (hermes as HermesSocketClient)!; + _playback = playback; _logger = logger; _random = new Random(); _isReady = false; @@ -185,7 +188,7 @@ namespace TwitchChatTTS.Twitch.Redemptions _logger.Warning($"Cannot find audio file for Twitch channel point redeem [file: {action.Data["file_path"]}][chatter: {senderDisplayName}][chatter id: {senderId}]"); return; } - AudioPlaybackEngine.Instance.PlaySound(action.Data["file_path"]); + _playback.PlaySound(action.Data["file_path"]); _logger.Debug($"Played an audio file for channel point redeem [file: {action.Data["file_path"]}][chatter: {senderDisplayName}][chatter id: {senderId}]"); break; default: diff --git a/Twitch/Socket/Handlers/ChannelBanHandler.cs b/Twitch/Socket/Handlers/ChannelBanHandler.cs new file mode 100644 index 0000000..0eee517 --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelBanHandler.cs @@ -0,0 +1,26 @@ +using Serilog; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class ChannelBanHandler : ITwitchSocketHandler + { + public string Name => "channel.ban"; + + private readonly ILogger _logger; + + public ChannelBanHandler(ILogger logger) + { + _logger = logger; + } + + public Task Execute(TwitchWebsocketClient sender, object? data) + { + if (data is not ChannelBanMessage message) + return Task.CompletedTask; + + _logger.Warning($"Chatter banned [chatter: {message.UserLogin}][chatter id: {message.UserId}][End: {(message.IsPermanent ? "Permanent" : message.EndsAt.ToString())}]"); + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ChannelChatClearHandler.cs b/Twitch/Socket/Handlers/ChannelChatClearHandler.cs new file mode 100644 index 0000000..2f2f9b5 --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelChatClearHandler.cs @@ -0,0 +1,37 @@ +using Serilog; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class ChannelChatClearHandler : ITwitchSocketHandler + { + public string Name => "channel.chat.clear"; + + private readonly TTSPlayer _player; + private readonly AudioPlaybackEngine _playback; + private readonly ILogger _logger; + + public ChannelChatClearHandler(TTSPlayer player, AudioPlaybackEngine playback, ILogger logger) + { + _player = player; + _playback = playback; + _logger = logger; + } + + public Task Execute(TwitchWebsocketClient sender, object? data) + { + if (data is not ChannelChatClearMessage message) + return Task.CompletedTask; + + _player.RemoveAll(); + if (_player.Playing != null) + { + _playback.RemoveMixerInput(_player.Playing.Audio!); + _player.Playing = null; + } + + _logger.Information($"Chat cleared [broadcaster: {message.BroadcasterUserLogin}][broadcaster id: {message.BroadcasterUserId}]"); + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ChannelChatClearUserHandler.cs b/Twitch/Socket/Handlers/ChannelChatClearUserHandler.cs new file mode 100644 index 0000000..717ff94 --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelChatClearUserHandler.cs @@ -0,0 +1,37 @@ +using Serilog; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class ChannelChatClearUserHandler : ITwitchSocketHandler + { + public string Name => "channel.chat.clear_user_messages"; + + private readonly TTSPlayer _player; + private readonly AudioPlaybackEngine _playback; + private readonly ILogger _logger; + + public ChannelChatClearUserHandler(TTSPlayer player, AudioPlaybackEngine playback, ILogger logger) + { + _player = player; + _playback = playback; + _logger = logger; + } + + public Task Execute(TwitchWebsocketClient sender, object? data) + { + if (data is not ChannelChatClearUserMessage message) + return Task.CompletedTask; + + long chatterId = long.Parse(message.TargetUserId); + _player.RemoveAll(chatterId); + if (_player.Playing?.ChatterId == chatterId) { + _playback.RemoveMixerInput(_player.Playing.Audio!); + _player.Playing = null; + } + + _logger.Information($"Cleared all messages by user [target chatter: {message.TargetUserLogin}][target chatter id: {chatterId}][broadcaster: {message.BroadcasterUserLogin}][broadcaster id: {message.BroadcasterUserId}]"); + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ChannelChatDeleteMessageHandler.cs b/Twitch/Socket/Handlers/ChannelChatDeleteMessageHandler.cs new file mode 100644 index 0000000..b38f7d1 --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelChatDeleteMessageHandler.cs @@ -0,0 +1,39 @@ +using Serilog; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class ChannelChatDeleteMessageHandler : ITwitchSocketHandler + { + public string Name => "channel.chat.message_delete"; + + private readonly TTSPlayer _player; + private readonly AudioPlaybackEngine _playback; + private readonly ILogger _logger; + + public ChannelChatDeleteMessageHandler(TTSPlayer player, AudioPlaybackEngine playback, ILogger logger) + { + _player = player; + _playback = playback; + _logger = logger; + } + + public Task Execute(TwitchWebsocketClient sender, object? data) + { + if (data is not ChannelChatDeleteMessage message) + return Task.CompletedTask; + + if (_player.Playing?.MessageId == message.MessageId) + { + _playback.RemoveMixerInput(_player.Playing.Audio!); + _player.Playing = null; + } + else + _player.RemoveMessage(message.MessageId); + + + _logger.Information($"Deleted chat message [message id: {message.MessageId}][target chatter: {message.TargetUserLogin}][target chatter id: {message.TargetUserId}][broadcaster: {message.BroadcasterUserLogin}][broadcaster id: {message.BroadcasterUserId}]"); + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ChannelChatMessageHandler.cs b/Twitch/Socket/Handlers/ChannelChatMessageHandler.cs new file mode 100644 index 0000000..d2f8c37 --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelChatMessageHandler.cs @@ -0,0 +1,319 @@ +using System.Text.RegularExpressions; +using CommonSocketLibrary.Abstract; +using CommonSocketLibrary.Common; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using TwitchChatTTS.Chat.Commands; +using TwitchChatTTS.Chat.Emotes; +using TwitchChatTTS.Chat.Groups; +using TwitchChatTTS.Chat.Groups.Permissions; +using TwitchChatTTS.Hermes.Socket; +using TwitchChatTTS.OBS.Socket; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class ChannelChatMessageHandler : ITwitchSocketHandler + { + public string Name => "channel.chat.message"; + + private readonly User _user; + private readonly TTSPlayer _player; + private readonly CommandManager _commands; + private readonly IGroupPermissionManager _permissionManager; + private readonly IChatterGroupManager _chatterGroupManager; + private readonly IEmoteDatabase _emotes; + private readonly OBSSocketClient _obs; + private readonly HermesSocketClient _hermes; + private readonly Configuration _configuration; + private readonly ILogger _logger; + + private readonly Regex _sfxRegex; + + + public ChannelChatMessageHandler( + User user, + TTSPlayer player, + CommandManager commands, + IGroupPermissionManager permissionManager, + IChatterGroupManager chatterGroupManager, + IEmoteDatabase emotes, + [FromKeyedServices("hermes")] SocketClient hermes, + [FromKeyedServices("obs")] SocketClient obs, + Configuration configuration, + ILogger logger + ) + { + _user = user; + _player = player; + _commands = commands; + _permissionManager = permissionManager; + _chatterGroupManager = chatterGroupManager; + _emotes = emotes; + _obs = (obs as OBSSocketClient)!; + _hermes = (hermes as HermesSocketClient)!; + _configuration = configuration; + _logger = logger; + + _sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)", RegexOptions.Compiled); + _logger = logger; + } + + public async Task Execute(TwitchWebsocketClient sender, object? data) + { + if (sender == null) + return; + if (data == null) + { + _logger.Warning("Twitch websocket message data is null."); + return; + } + if (data is not ChannelChatMessage message) + return; + + if (_hermes.Connected && !_hermes.Ready) + { + _logger.Debug($"TTS is not yet ready. Ignoring chat messages [message id: {message.MessageId}]"); + return; // new MessageResult(MessageStatus.NotReady, -1, -1); + } + if (_configuration.Twitch?.TtsWhenOffline != true && !_obs.Streaming) + { + _logger.Debug($"OBS is not streaming. Ignoring chat messages [message id: {message.MessageId}]"); + return; // new MessageResult(MessageStatus.NotReady, -1, -1); + } + + var msg = message.Message.Text; + var chatterId = long.Parse(message.ChatterUserId); + var tasks = new List(); + + var defaultGroups = new string[] { "everyone" }; + var badgesGroups = message.Badges.Select(b => b.SetId).Select(GetGroupNameByBadgeName); + var customGroups = _chatterGroupManager.GetGroupNamesFor(chatterId); + var groups = defaultGroups.Union(badgesGroups).Union(customGroups); + + try + { + var commandResult = await _commands.Execute(msg, message, groups); + if (commandResult != ChatCommandResult.Unknown) + return; // new MessageResult(MessageStatus.Command, -1, -1); + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed executing a chat command [message: {msg}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}][message id: {message.MessageId}]"); + return; + } + + if (message.Reply != null) + msg = msg.Substring(message.Reply.ParentUserLogin.Length + 2); + + var permissionPath = "tts.chat.messages.read"; + if (!string.IsNullOrWhiteSpace(message.ChannelPointsCustomRewardId)) + permissionPath = "tts.chat.redemptions.read"; + + var permission = chatterId == _user.OwnerId ? true : _permissionManager.CheckIfAllowed(groups, permissionPath); + if (permission != true) + { + _logger.Debug($"Blocked message by {message.ChatterUserLogin}: {msg}"); + return; // new MessageResult(MessageStatus.Blocked, -1, -1); + } + + // Keep track of emotes usage + var emotesUsed = new HashSet(); + var newEmotes = new Dictionary(); + foreach (var fragment in message.Message.Fragments) + { + if (fragment.Emote != null) + { + if (_emotes.Get(fragment.Text) == null) + { + newEmotes.Add(fragment.Text, fragment.Emote.Id); + _emotes.Add(fragment.Text, fragment.Emote.Id); + } + emotesUsed.Add(fragment.Emote.Id); + continue; + } + + if (fragment.Mention != null) + continue; + + var text = fragment.Text.Trim(); + var textFragments = text.Split(' '); + foreach (var f in textFragments) + { + var emoteId = _emotes.Get(f); + if (emoteId != null) + { + emotesUsed.Add(emoteId); + } + } + } + if (_obs.Streaming) + { + if (newEmotes.Any()) + tasks.Add(_hermes.SendEmoteDetails(newEmotes)); + if (emotesUsed.Any()) + tasks.Add(_hermes.SendEmoteUsage(message.MessageId, chatterId, emotesUsed)); + if (!_user.Chatters.Contains(chatterId)) + { + tasks.Add(_hermes.SendChatterDetails(chatterId, message.ChatterUserLogin)); + _user.Chatters.Add(chatterId); + } + } + + // Replace filtered words. + if (_user.RegexFilters != null) + { + foreach (var wf in _user.RegexFilters) + { + if (wf.Search == null || wf.Replace == null) + continue; + + if (wf.Regex != null) + { + try + { + msg = wf.Regex.Replace(msg, wf.Replace); + continue; + } + catch (Exception) + { + wf.Regex = null; + } + } + + msg = msg.Replace(wf.Search, wf.Replace); + } + } + + // Determine the priority of this message + int priority = _chatterGroupManager.GetPriorityFor(groups); // + m.SubscribedMonthCount * (m.IsSubscriber ? 10 : 5); + + // Determine voice selected. + string voiceSelected = _user.DefaultTTSVoice; + if (_user.VoicesSelected?.ContainsKey(chatterId) == true) + { + var voiceId = _user.VoicesSelected[chatterId]; + if (_user.VoicesAvailable.TryGetValue(voiceId, out string? voiceName) && voiceName != null) + { + if (_user.VoicesEnabled.Contains(voiceName) || chatterId == _user.OwnerId) + voiceSelected = voiceName; + } + } + + // Determine additional voices used + var matches = _user.VoiceNameRegex?.Matches(msg).ToArray(); + if (matches == null || matches.FirstOrDefault() == null || matches.First().Index < 0) + { + HandlePartialMessage(priority, voiceSelected, msg.Trim(), message); + return; // new MessageResult(MessageStatus.None, _user.TwitchUserId, chatterId, emotesUsed); + } + + HandlePartialMessage(priority, voiceSelected, msg.Substring(0, matches.First().Index).Trim(), message); + foreach (Match match in matches) + { + var m = match.Groups[2].ToString(); + if (string.IsNullOrWhiteSpace(m)) + continue; + + var voice = match.Groups[1].ToString(); + voice = voice[0].ToString().ToUpper() + voice.Substring(1).ToLower(); + HandlePartialMessage(priority, voice, m.Trim(), message); + } + + if (tasks.Any()) + await Task.WhenAll(tasks); + } + + private void HandlePartialMessage(int priority, string voice, string message, ChannelChatMessage e) + { + if (string.IsNullOrWhiteSpace(message)) + return; + + var parts = _sfxRegex.Split(message); + var chatterId = long.Parse(e.ChatterUserId); + var badgesString = string.Join(", ", e.Badges.Select(b => b.SetId + '|' + b.Id + '=' + b.Info)); + + if (parts.Length == 1) + { + _logger.Information($"Username: {e.ChatterUserLogin}; User ID: {e.ChatterUserId}; Voice: {voice}; Priority: {priority}; Message: {message}; Reward Id: {e.ChannelPointsCustomRewardId}; {badgesString}"); + _player.Add(new TTSMessage() + { + Voice = voice, + Message = message, + Timestamp = DateTime.UtcNow, + ChatterId = chatterId, + MessageId = e.MessageId, + Badges = e.Badges, + Priority = priority + }); + return; + } + + var sfxMatches = _sfxRegex.Matches(message); + var sfxStart = sfxMatches.FirstOrDefault()?.Index ?? message.Length; + + for (var i = 0; i < sfxMatches.Count; i++) + { + var sfxMatch = sfxMatches[i]; + var sfxName = sfxMatch.Groups[1]?.ToString()?.ToLower(); + + if (!File.Exists("sfx/" + sfxName + ".mp3")) + { + parts[i * 2 + 2] = parts[i * 2] + " (" + parts[i * 2 + 1] + ")" + parts[i * 2 + 2]; + continue; + } + + if (!string.IsNullOrWhiteSpace(parts[i * 2])) + { + _logger.Information($"Username: {e.ChatterUserLogin}; User ID: {e.ChatterUserId}; Voice: {voice}; Priority: {priority}; Message: {parts[i * 2]}; {badgesString}"); + _player.Add(new TTSMessage() + { + Voice = voice, + Message = parts[i * 2], + Timestamp = DateTime.UtcNow, + ChatterId = chatterId, + MessageId = e.MessageId, + Badges = e.Badges, + Priority = priority + }); + } + + _logger.Information($"Username: {e.ChatterUserLogin}; User ID: {e.ChatterUserId}; Voice: {voice}; Priority: {priority}; SFX: {sfxName}; {badgesString}"); + _player.Add(new TTSMessage() + { + Voice = voice, + File = $"sfx/{sfxName}.mp3", + Timestamp = DateTime.UtcNow, + ChatterId = chatterId, + MessageId = e.MessageId, + Badges = e.Badges, + Priority = priority + }); + } + + if (!string.IsNullOrWhiteSpace(parts.Last())) + { + _logger.Information($"Username: {e.ChatterUserLogin}; User ID: {e.ChatterUserId}; Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; {badgesString}"); + _player.Add(new TTSMessage() + { + Voice = voice, + Message = parts.Last(), + Timestamp = DateTime.UtcNow, + ChatterId = chatterId, + MessageId = e.MessageId, + Badges = e.Badges, + Priority = priority + }); + } + } + + private string GetGroupNameByBadgeName(string badgeName) + { + if (badgeName == "subscriber") + return "subscribers"; + if (badgeName == "moderator") + return "moderators"; + return badgeName.ToLower(); + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ChannelCustomRedemptionHandler.cs b/Twitch/Socket/Handlers/ChannelCustomRedemptionHandler.cs new file mode 100644 index 0000000..b3f6538 --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelCustomRedemptionHandler.cs @@ -0,0 +1,56 @@ +using Serilog; +using TwitchChatTTS.Twitch.Redemptions; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class ChannelCustomRedemptionHandler : ITwitchSocketHandler + { + public string Name => "channel.channel_points_custom_reward_redemption.add"; + + private readonly RedemptionManager _redemptionManager; + private readonly ILogger _logger; + + public ChannelCustomRedemptionHandler( + RedemptionManager redemptionManager, + ILogger logger + ) + { + _redemptionManager = redemptionManager; + _logger = logger; + } + + public async Task Execute(TwitchWebsocketClient sender, object? data) + { + if (data is not ChannelCustomRedemptionMessage message) + return; + + _logger.Information($"Channel Point Reward Redeemed [redeem: {message.Reward.Title}][redeem id: {message.Reward.Id}][transaction: {message.Id}]"); + + try + { + var actions = _redemptionManager.Get(message.Reward.Id); + if (!actions.Any()) + { + _logger.Debug($"No redemable actions for this redeem was found [redeem: {message.Reward.Title}][redeem id: {message.Reward.Id}][transaction: {message.Id}]"); + return; + } + _logger.Debug($"Found {actions.Count} actions for this Twitch channel point redemption [redeem: {message.Reward.Title}][redeem id: {message.Reward.Id}][transaction: {message.Id}]"); + + foreach (var action in actions) + try + { + await _redemptionManager.Execute(action, message.UserName, long.Parse(message.UserId)); + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to execute redeeemable action [action: {action.Name}][action type: {action.Type}][redeem: {message.Reward.Title}][redeem id: {message.Reward.Id}][transaction: {message.Id}]"); + } + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to fetch the redeemable actions for a redemption [redeem: {message.Reward.Title}][redeem id: {message.Reward.Id}][transaction: {message.Id}]"); + } + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ChannelSubscriptionHandler.cs b/Twitch/Socket/Handlers/ChannelSubscriptionHandler.cs new file mode 100644 index 0000000..7bf1abe --- /dev/null +++ b/Twitch/Socket/Handlers/ChannelSubscriptionHandler.cs @@ -0,0 +1,33 @@ +using Serilog; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class ChannelSubscriptionHandler : ITwitchSocketHandler + { + public string Name => "channel.subscription.message"; + + private readonly TTSPlayer _player; + private readonly ILogger _logger; + + public ChannelSubscriptionHandler(TTSPlayer player, ILogger logger) { + _player = player; + _logger = logger; + } + + public async Task Execute(TwitchWebsocketClient sender, object? data) + { + if (sender == null) + return; + if (data == null) + { + _logger.Warning("Twitch websocket message data is null."); + return; + } + if (data is not ChannelSubscriptionMessage message) + return; + + _logger.Debug("Subscription occured."); + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/ITwitchSocketHandler.cs b/Twitch/Socket/Handlers/ITwitchSocketHandler.cs new file mode 100644 index 0000000..d5c2a38 --- /dev/null +++ b/Twitch/Socket/Handlers/ITwitchSocketHandler.cs @@ -0,0 +1,8 @@ +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public interface ITwitchSocketHandler + { + string Name { get; } + Task Execute(TwitchWebsocketClient sender, object? data); + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/NotificationHandler.cs b/Twitch/Socket/Handlers/NotificationHandler.cs new file mode 100644 index 0000000..a0e311f --- /dev/null +++ b/Twitch/Socket/Handlers/NotificationHandler.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public sealed class NotificationHandler : ITwitchSocketHandler + { + public string Name => "notification"; + + private IDictionary _handlers; + private readonly ILogger _logger; + + private IDictionary _messageTypes; + private readonly JsonSerializerOptions _options; + + public NotificationHandler( + [FromKeyedServices("twitch-notifications")] IEnumerable handlers, + ILogger logger + ) + { + _handlers = handlers.ToDictionary(h => h.Name, h => h); + _logger = logger; + + _options = new JsonSerializerOptions() { + PropertyNameCaseInsensitive = false, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + _messageTypes = new Dictionary(); + _messageTypes.Add("channel.ban", typeof(ChannelBanMessage)); + _messageTypes.Add("channel.chat.message", typeof(ChannelChatMessage)); + _messageTypes.Add("channel.chat.clear_user_messages", typeof(ChannelChatClearUserMessage)); + _messageTypes.Add("channel.chat.clear", typeof(ChannelChatClearMessage)); + _messageTypes.Add("channel.chat.message_delete", typeof(ChannelChatDeleteMessage)); + _messageTypes.Add("channel.channel_points_custom_reward_redemption.add", typeof(ChannelCustomRedemptionMessage)); + _messageTypes.Add("channel.subscription.message", typeof(ChannelSubscriptionMessage)); + } + + public async Task Execute(TwitchWebsocketClient sender, object? data) + { + if (sender == null) + return; + if (data == null) + { + _logger.Warning("Twitch websocket message data is null."); + return; + } + if (data is not NotificationMessage message) + return; + + if (!_messageTypes.TryGetValue(message.Subscription.Type, out var type) || type == null) + { + _logger.Warning($"Could not find Twitch notification type [message type: {message.Subscription.Type}]"); + return; + } + + if (!_handlers.TryGetValue(message.Subscription.Type, out ITwitchSocketHandler? handler) || handler == null) + { + _logger.Warning($"Could not find Twitch notification handler [message type: {message.Subscription.Type}]"); + return; + } + + var d = JsonSerializer.Deserialize(message.Event.ToString()!, type, _options); + await handler.Execute(sender, d); + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/SessionReconnectHandler.cs b/Twitch/Socket/Handlers/SessionReconnectHandler.cs new file mode 100644 index 0000000..f243451 --- /dev/null +++ b/Twitch/Socket/Handlers/SessionReconnectHandler.cs @@ -0,0 +1,47 @@ +using CommonSocketLibrary.Abstract; +using Serilog; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class SessionReconnectHandler : ITwitchSocketHandler + { + public string Name => "session_reconnect"; + + private readonly TwitchApiClient _api; + private readonly ILogger _logger; + + public SessionReconnectHandler(TwitchApiClient api, ILogger logger) + { + _api = api; + _logger = logger; + } + + public async Task Execute(TwitchWebsocketClient sender, object? data) + { + if (sender == null) + return; + if (data == null) + { + _logger.Warning("Twitch websocket message data is null."); + return; + } + if (data is not SessionWelcomeMessage message) + return; + if (_api == null) + return; + + if (string.IsNullOrEmpty(message.Session.Id)) + { + _logger.Warning($"No session info provided by Twitch [status: {message.Session.Status}]"); + return; + } + + // TODO: Be able to handle multiple websocket connections. + sender.URL = message.Session.ReconnectUrl; + await Task.Delay(TimeSpan.FromSeconds(29)); + await sender.DisconnectAsync(new SocketDisconnectionEventArgs("Close", "Twitch asking to reconnect.")); + await sender.Connect(); + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Handlers/SessionWelcomeHandler.cs b/Twitch/Socket/Handlers/SessionWelcomeHandler.cs new file mode 100644 index 0000000..ed3d0e0 --- /dev/null +++ b/Twitch/Socket/Handlers/SessionWelcomeHandler.cs @@ -0,0 +1,94 @@ +using CommonSocketLibrary.Abstract; +using Serilog; +using TwitchChatTTS.Twitch.Socket.Messages; + +namespace TwitchChatTTS.Twitch.Socket.Handlers +{ + public class SessionWelcomeHandler : ITwitchSocketHandler + { + public string Name => "session_welcome"; + + private readonly TwitchApiClient _api; + private readonly User _user; + private readonly ILogger _logger; + + public SessionWelcomeHandler(TwitchApiClient api, User user, ILogger logger) + { + _api = api; + _user = user; + _logger = logger; + } + + public async Task Execute(TwitchWebsocketClient sender, object? data) + { + if (sender == null) + return; + if (data == null) + { + _logger.Warning("Twitch websocket message data is null."); + return; + } + if (data is not SessionWelcomeMessage message) + return; + if (_api == null) + return; + + if (string.IsNullOrEmpty(message.Session.Id)) + { + _logger.Warning($"No session info provided by Twitch [status: {message.Session.Status}]"); + return; + } + + string[] subscriptionsv1 = [ + "channel.chat.message", + "channel.chat.message_delete", + "channel.chat.notification", + "channel.chat.clear", + "channel.chat.clear_user_messages", + "channel.ad_break.begin", + "channel.subscription.message", + "channel.ban", + "channel.channel_points_custom_reward_redemption.add" + ]; + string[] subscriptionsv2 = [ + "channel.follow", + ]; + string broadcasterId = _user.TwitchUserId.ToString(); + foreach (var subscription in subscriptionsv1) + await Subscribe(subscription, message.Session.Id, broadcasterId, "1"); + foreach (var subscription in subscriptionsv2) + await Subscribe(subscription, message.Session.Id, broadcasterId, "2"); + + sender.SessionId = message.Session.Id; + sender.Identified = sender.SessionId != null; + } + + private async Task Subscribe(string subscriptionName, string sessionId, string broadcasterId, string version) + { + try + { + var response = await _api.CreateEventSubscription(subscriptionName, version, sessionId, broadcasterId); + if (response == null) + { + _logger.Error($"Failed to create an event subscription [subscription type: {subscriptionName}][reason: response is null]"); + return; + } + if (response.Data == null) + { + _logger.Error($"Failed to create an event subscription [subscription type: {subscriptionName}][reason: data is null]"); + return; + } + if (!response.Data.Any()) + { + _logger.Error($"Failed to create an event subscription [subscription type: {subscriptionName}][reason: data is empty]"); + return; + } + _logger.Information($"Sucessfully added subscription to Twitch websockets [subscription type: {subscriptionName}]"); + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to create an event subscription [subscription type: {subscriptionName}][reason: exception]"); + } + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/ChannelBanMessage.cs b/Twitch/Socket/Messages/ChannelBanMessage.cs new file mode 100644 index 0000000..236f26e --- /dev/null +++ b/Twitch/Socket/Messages/ChannelBanMessage.cs @@ -0,0 +1,19 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class ChannelBanMessage + { + public string UserId { get; set; } + public string UserLogin { get; set; } + public string UserName { get; set; } + public string BroadcasterUserId { get; set; } + public string BroadcasterUserLogin { get; set; } + public string BroadcasterUserName { get; set; } + public string ModeratorUserId { get; set; } + public string ModeratorUserLogin { get; set; } + public string ModeratorUserName { get; set; } + public string Reason { get; set; } + public DateTime BannedAt { get; set; } + public DateTime? EndsAt { get; set; } + public bool IsPermanent { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/ChannelChatClearMessage.cs b/Twitch/Socket/Messages/ChannelChatClearMessage.cs new file mode 100644 index 0000000..2aeaf3e --- /dev/null +++ b/Twitch/Socket/Messages/ChannelChatClearMessage.cs @@ -0,0 +1,9 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class ChannelChatClearMessage + { + public string BroadcasterUserId { get; set; } + public string BroadcasterUserLogin { get; set; } + public string BroadcasterUserName { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/ChannelChatClearUserMessage.cs b/Twitch/Socket/Messages/ChannelChatClearUserMessage.cs new file mode 100644 index 0000000..c2b67a5 --- /dev/null +++ b/Twitch/Socket/Messages/ChannelChatClearUserMessage.cs @@ -0,0 +1,9 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class ChannelChatClearUserMessage : ChannelChatClearMessage + { + public string TargetUserId { get; set; } + public string TargetUserLogin { get; set; } + public string TargetUserName { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/ChannelChatDeleteMessage.cs b/Twitch/Socket/Messages/ChannelChatDeleteMessage.cs new file mode 100644 index 0000000..41f16cc --- /dev/null +++ b/Twitch/Socket/Messages/ChannelChatDeleteMessage.cs @@ -0,0 +1,7 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class ChannelChatDeleteMessage : ChannelChatClearUserMessage + { + public string MessageId { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/ChannelChatMessage.cs b/Twitch/Socket/Messages/ChannelChatMessage.cs new file mode 100644 index 0000000..27b42e1 --- /dev/null +++ b/Twitch/Socket/Messages/ChannelChatMessage.cs @@ -0,0 +1,75 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class ChannelChatMessage + { + public string BroadcasterUserId { get; set; } + public string BroadcasterUserLogin { get; set; } + public string BroadcasterUserName { get; set; } + public string ChatterUserId { get; set; } + public string ChatterUserLogin { get; set; } + public string ChatterUserName { get; set; } + public string MessageId { get; set; } + public TwitchChatMessageInfo Message { get; set; } + public string MessageType { get; set; } + public TwitchBadge[] Badges { get; set; } + public TwitchReplyInfo? Reply { get; set; } + public string? ChannelPointsCustomRewardId { get; set; } + public string? ChannelPointsAnimationId { get; set; } + } + + public class TwitchChatMessageInfo + { + public string Text { get; set; } + public TwitchChatFragment[] Fragments { get; set; } + } + + public class TwitchChatFragment + { + public string Type { get; set; } + public string Text { get; set; } + public TwitchCheerInfo? Cheermote { get; set; } + public TwitchEmoteInfo? Emote { get; set; } + public TwitchMentionInfo? Mention { get; set; } + } + + public class TwitchCheerInfo + { + public string Prefix { get; set; } + public int Bits { get; set; } + public int Tier { get; set; } + } + + public class TwitchEmoteInfo + { + public string Id { get; set; } + public string EmoteSetId { get; set; } + public string OwnerId { get; set; } + public string[] Format { get; set; } + } + + public class TwitchMentionInfo + { + public string UserId { get; set; } + public string UserName { get; set; } + public string UserLogin { get; set; } + } + + public class TwitchBadge + { + public string SetId { get; set; } + public string Id { get; set; } + public string Info { get; set; } + } + + public class TwitchReplyInfo + { + public string ParentMessageId { get; set; } + public string ParentMessageBody { get; set; } + public string ParentUserId { get; set; } + public string ParentUserName { get; set; } + public string ParentUserLogin { get; set; } + public string ThreadMessageId { get; set; } + public string ThreadUserName { get; set; } + public string ThreadUserLogin { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/ChannelCustomRedemptionMessage.cs b/Twitch/Socket/Messages/ChannelCustomRedemptionMessage.cs new file mode 100644 index 0000000..2bf1bed --- /dev/null +++ b/Twitch/Socket/Messages/ChannelCustomRedemptionMessage.cs @@ -0,0 +1,24 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class ChannelCustomRedemptionMessage + { + public string BroadcasterUserId { get; set; } + public string BroadcasterUserLogin { get; set; } + public string BroadcasterUserName { get; set; } + public string Id { get; set; } + public string UserId { get; set; } + public string UserLogin { get; set; } + public string UserName { get; set; } + public string Status { get; set; } + public DateTime RedeemedAt { get; set; } + public RedemptionReward Reward { get; set; } + } + + public class RedemptionReward + { + public string Id { get; set; } + public string Title { get; set; } + public string Prompt { get; set; } + public int Cost { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/ChannelSubscriptionMessage.cs b/Twitch/Socket/Messages/ChannelSubscriptionMessage.cs new file mode 100644 index 0000000..769d103 --- /dev/null +++ b/Twitch/Socket/Messages/ChannelSubscriptionMessage.cs @@ -0,0 +1,17 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class ChannelSubscriptionMessage + { + public string BroadcasterUserId { get; set; } + public string BroadcasterUserLogin { get; set; } + public string BroadcasterUserName { get; set; } + public string ChatterUserId { get; set; } + public string ChatterUserLogin { get; set; } + public string ChatterUserName { get; set; } + public string Tier { get; set; } + public TwitchChatMessageInfo Message { get; set; } + public int CumulativeMonths { get; set; } + public int StreakMonths { get; set; } + public int DurationMonths { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/EventResponse.cs b/Twitch/Socket/Messages/EventResponse.cs new file mode 100644 index 0000000..aaa6549 --- /dev/null +++ b/Twitch/Socket/Messages/EventResponse.cs @@ -0,0 +1,10 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class EventResponse + { + public T[]? Data { get; set; } + public int Total { get; set; } + public int TotalCost { get; set; } + public int MaxTotalCost { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/EventSubscriptionMessage.cs b/Twitch/Socket/Messages/EventSubscriptionMessage.cs new file mode 100644 index 0000000..d9a6d7d --- /dev/null +++ b/Twitch/Socket/Messages/EventSubscriptionMessage.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Serialization; + +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class EventSubscriptionMessage : IVersionedMessage + { + public string Type { get; set; } + public string Version { get; set; } + public IDictionary Condition { get; set; } + public EventSubTransport Transport { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Cost { get; set; } + + public EventSubscriptionMessage() { + Type = string.Empty; + Version = string.Empty; + Condition = new Dictionary(); + Transport = new EventSubTransport(); + } + + public EventSubscriptionMessage(string type, string version, string callback, string secret, IDictionary? conditions = null) + { + Type = type; + Version = version; + Condition = conditions ?? new Dictionary(); + Transport = new EventSubTransport("webhook", callback, secret); + } + + public EventSubscriptionMessage(string type, string version, string sessionId, IDictionary? conditions = null) + { + Type = type; + Version = version; + Condition = conditions ?? new Dictionary(); + Transport = new EventSubTransport("websocket", sessionId); + } + + + public class EventSubTransport + { + public string Method { get; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Callback { get; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Secret { get; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SessionId { get; } + + public EventSubTransport() { + Method = string.Empty; + } + + public EventSubTransport(string method, string callback, string secret) + { + Method = method; + Callback = callback; + Secret = secret; + } + + public EventSubTransport(string method, string sessionId) + { + Method = method; + SessionId = sessionId; + } + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/NotificationMessage.cs b/Twitch/Socket/Messages/NotificationMessage.cs new file mode 100644 index 0000000..7300baf --- /dev/null +++ b/Twitch/Socket/Messages/NotificationMessage.cs @@ -0,0 +1,16 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class NotificationMessage + { + public NotificationInfo Subscription { get; set; } + public object Event { get; set; } + } + + public class NotificationInfo : EventSubscriptionMessage + { + public string Id { get; set; } + public string Status { get; set; } + public DateTime CreatedAt { get; set; } + public object Event { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/SessionWelcomeMessage.cs b/Twitch/Socket/Messages/SessionWelcomeMessage.cs new file mode 100644 index 0000000..4562a51 --- /dev/null +++ b/Twitch/Socket/Messages/SessionWelcomeMessage.cs @@ -0,0 +1,16 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class SessionWelcomeMessage + { + public TwitchSocketSession Session { get; set; } + + public class TwitchSocketSession { + public string Id { get; set; } + public string Status { get; set; } + public DateTime ConnectedAt { get; set; } + public int KeepaliveTimeoutSeconds { get; set; } + public string? ReconnectUrl { get; set; } + public string? RecoveryUrl { get; set; } + } + } +} \ No newline at end of file diff --git a/Twitch/Socket/Messages/TwitchWebsocketMessage.cs b/Twitch/Socket/Messages/TwitchWebsocketMessage.cs new file mode 100644 index 0000000..4fde712 --- /dev/null +++ b/Twitch/Socket/Messages/TwitchWebsocketMessage.cs @@ -0,0 +1,18 @@ +namespace TwitchChatTTS.Twitch.Socket.Messages +{ + public class TwitchWebsocketMessage + { + public TwitchMessageMetadata Metadata { get; set; } + public object? Payload { get; set; } + } + + public class TwitchMessageMetadata { + public string MessageId { get; set; } + public string MessageType { get; set; } + public DateTime MessageTimestamp { get; set; } + } + + public interface IVersionedMessage { + string Version { get; set; } + } +} \ No newline at end of file diff --git a/Twitch/Socket/TwitchWebsocketClient.cs b/Twitch/Socket/TwitchWebsocketClient.cs new file mode 100644 index 0000000..f7f407b --- /dev/null +++ b/Twitch/Socket/TwitchWebsocketClient.cs @@ -0,0 +1,196 @@ +using CommonSocketLibrary.Abstract; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using System.Text.Json; +using System.Net.WebSockets; +using TwitchChatTTS.Twitch.Socket.Messages; +using System.Text; +using TwitchChatTTS.Twitch.Socket.Handlers; + +namespace TwitchChatTTS.Twitch.Socket +{ + public class TwitchWebsocketClient : SocketClient + { + public string URL; + + private IDictionary _handlers; + private IDictionary _messageTypes; + private readonly Configuration _configuration; + private System.Timers.Timer _reconnectTimer; + + public bool Connected { get; set; } + public bool Identified { get; set; } + public string SessionId { get; set; } + + + public TwitchWebsocketClient( + Configuration configuration, + [FromKeyedServices("twitch")] IEnumerable handlers, + ILogger logger + ) : base(logger, new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = false, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }) + { + _handlers = handlers.ToDictionary(h => h.Name, h => h); + _configuration = configuration; + + _reconnectTimer = new System.Timers.Timer(TimeSpan.FromSeconds(30)); + _reconnectTimer.AutoReset = false; + _reconnectTimer.Elapsed += async (sender, e) => await Reconnect(); + _reconnectTimer.Enabled = false; + + _messageTypes = new Dictionary(); + _messageTypes.Add("session_welcome", typeof(SessionWelcomeMessage)); + _messageTypes.Add("session_reconnect", typeof(SessionWelcomeMessage)); + _messageTypes.Add("notification", typeof(NotificationMessage)); + + URL = "wss://eventsub.wss.twitch.tv/ws"; + } + + + public void Initialize() + { + _logger.Information($"Initializing OBS websocket client."); + OnConnected += (sender, e) => + { + Connected = true; + _reconnectTimer.Enabled = false; + _logger.Information("Twitch websocket client connected."); + }; + + OnDisconnected += (sender, e) => + { + _reconnectTimer.Enabled = Identified; + _logger.Information($"Twitch websocket client disconnected [status: {e.Status}][reason: {e.Reason}] " + (Identified ? "Will be attempting to reconnect every 30 seconds." : "Will not be attempting to reconnect.")); + + Connected = false; + Identified = false; + }; + } + + public async Task Connect() + { + if (string.IsNullOrWhiteSpace(URL)) + { + _logger.Warning("Lacking connection info for Twitch websockets. Not connecting to Twitch."); + return; + } + + _logger.Debug($"Twitch websocket client attempting to connect to {URL}"); + try + { + await ConnectAsync(URL); + } + catch (Exception) + { + _logger.Warning("Connecting to twitch failed. Skipping Twitch websockets."); + } + } + + private async Task Reconnect() + { + if (Connected) + { + try + { + await DisconnectAsync(new SocketDisconnectionEventArgs(WebSocketCloseStatus.Empty.ToString(), "")); + } + catch (Exception) + { + _logger.Error("Failed to disconnect from Twitch websocket server."); + } + } + + try + { + await Connect(); + } + catch (WebSocketException wse) when (wse.Message.Contains("502")) + { + _logger.Error("Twitch websocket server cannot be found."); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to reconnect to Twitch websocket server."); + } + } + + protected TwitchWebsocketMessage GenerateMessage(string messageType, T data) + { + var metadata = new TwitchMessageMetadata() + { + MessageId = Guid.NewGuid().ToString(), + MessageType = messageType, + MessageTimestamp = DateTime.UtcNow + }; + return new TwitchWebsocketMessage() + { + Metadata = metadata, + Payload = data + }; + } + + protected override async Task OnResponseReceived(TwitchWebsocketMessage? message) + { + if (message == null || message.Metadata == null) { + _logger.Information("Twitch message is null"); + return; + } + + string content = message.Payload?.ToString() ?? string.Empty; + if (message.Metadata.MessageType != "session_keepalive") + _logger.Information("Twitch RX #" + message.Metadata.MessageType + ": " + content); + + if (!_messageTypes.TryGetValue(message.Metadata.MessageType, out var type) || type == null) + { + _logger.Debug($"Could not find Twitch message type [message type: {message.Metadata.MessageType}]"); + return; + } + + if (!_handlers.TryGetValue(message.Metadata.MessageType, out ITwitchSocketHandler? handler) || handler == null) + { + _logger.Debug($"Could not find Twitch handler [message type: {message.Metadata.MessageType}]"); + return; + } + + var data = JsonSerializer.Deserialize(content, type, _options); + await handler.Execute(this, data); + } + + public async Task Send(string type, T data) + { + if (_socket == null || type == null || data == null) + return; + + try + { + var message = GenerateMessage(type, data); + var content = JsonSerializer.Serialize(message, _options); + + var bytes = Encoding.UTF8.GetBytes(content); + var array = new ArraySegment(bytes); + var total = bytes.Length; + var current = 0; + + while (current < total) + { + var size = Encoding.UTF8.GetBytes(content.Substring(current), array); + await _socket!.SendAsync(array, WebSocketMessageType.Text, current + size >= total, _cts!.Token); + current += size; + } + _logger.Information("TX #" + type + ": " + content); + } + catch (Exception e) + { + if (_socket.State.ToString().Contains("Close") || _socket.State == WebSocketState.Aborted) + { + await DisconnectAsync(new SocketDisconnectionEventArgs(_socket.CloseStatus.ToString()!, _socket.CloseStatusDescription ?? string.Empty)); + _logger.Warning($"Socket state on closing = {_socket.State} | {_socket.CloseStatus?.ToString()} | {_socket.CloseStatusDescription}"); + } + _logger.Error(e, $"Failed to send a websocket message [message type: {type}]"); + } + } + } +} \ No newline at end of file diff --git a/Twitch/TwitchApiClient.cs b/Twitch/TwitchApiClient.cs index 78a0dbb..a24e066 100644 --- a/Twitch/TwitchApiClient.cs +++ b/Twitch/TwitchApiClient.cs @@ -1,226 +1,59 @@ using System.Text.Json; using TwitchChatTTS.Helpers; using Serilog; -using TwitchChatTTS; -using TwitchLib.Api.Core.Exceptions; -using TwitchLib.Client.Events; -using TwitchLib.Client.Models; -using TwitchLib.Communication.Events; -using TwitchLib.PubSub.Interfaces; -using TwitchLib.Client.Interfaces; -using TwitchChatTTS.Twitch.Redemptions; +using TwitchChatTTS.Twitch.Socket.Messages; +using System.Net.Http.Json; +using System.Net; public class TwitchApiClient { - private readonly RedemptionManager _redemptionManager; - private readonly HermesApiClient _hermesApiClient; - private readonly ITwitchClient _client; - private readonly ITwitchPubSub _publisher; - private readonly User _user; - private readonly Configuration _configuration; - private readonly TwitchBotAuth _token; private readonly ILogger _logger; private readonly WebClientWrap _web; - private bool _initialized; - private string _broadcasterId; public TwitchApiClient( - ITwitchClient twitchClient, - ITwitchPubSub twitchPublisher, - RedemptionManager redemptionManager, - HermesApiClient hermesApiClient, - User user, - Configuration configuration, - TwitchBotAuth token, ILogger logger ) { - _redemptionManager = redemptionManager; - _hermesApiClient = hermesApiClient; - _client = twitchClient; - _publisher = twitchPublisher; - _user = user; - _configuration = configuration; - _token = token; _logger = logger; - _initialized = false; - _broadcasterId = string.Empty; _web = new WebClientWrap(new JsonSerializerOptions() { PropertyNameCaseInsensitive = false, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); - if (!string.IsNullOrWhiteSpace(_configuration.Hermes?.Token)) - _web.AddHeader("x-api-key", _configuration.Hermes.Token.Trim()); } - public async Task Authorize(string broadcasterId) + public async Task?> CreateEventSubscription(string type, string version, string userId) { - try + var conditions = new Dictionary() { { "user_id", userId }, { "broadcaster_user_id", userId }, { "moderator_user_id", userId } }; + var subscriptionData = new EventSubscriptionMessage(type, version, "https://hermes.goblincaves.com/api/account/authorize", "isdnmjfopsdfmsf4390", conditions); + var response = await _web.Post("https://api.twitch.tv/helix/eventsub/subscriptions", subscriptionData); + if (response.StatusCode == HttpStatusCode.Accepted) { - _logger.Debug($"Attempting to authorize Twitch API [id: {broadcasterId}]"); - var authorize = await _web.GetJson($"https://{HermesApiClient.BASE_URL}/api/account/reauthorize"); - if (authorize != null && broadcasterId == authorize.BroadcasterId) - { - _token.AccessToken = authorize.AccessToken; - _token.RefreshToken = authorize.RefreshToken; - _token.UserId = authorize.UserId; - _token.BroadcasterId = authorize.BroadcasterId; - _token.ExpiresIn = authorize.ExpiresIn; - _token.UpdatedAt = DateTime.Now; - _logger.Information("Updated Twitch API tokens."); - _logger.Debug($"Twitch API Auth data [user id: {_token.UserId}][id: {_token.BroadcasterId}][expires in: {_token.ExpiresIn}][expires at: {_token.ExpiresAt.ToShortTimeString()}]"); - } - else if (authorize != null) - { - _logger.Error("Twitch API Authorization failed: " + authorize.AccessToken + " | " + authorize.RefreshToken + " | " + authorize.UserId + " | " + authorize.BroadcasterId); - return false; - } - _broadcasterId = broadcasterId; - _logger.Debug($"Authorized Twitch API [id: {broadcasterId}]"); - return true; + _logger.Debug("Twitch API call [type: create event subscription]: " + await response.Content.ReadAsStringAsync()); + return await response.Content.ReadFromJsonAsync(typeof(EventResponse)) as EventResponse; } - catch (HttpResponseException e) - { - if (string.IsNullOrWhiteSpace(_configuration.Hermes!.Token)) - _logger.Error("No Hermes API key found. Enter it into the configuration file."); - else - _logger.Error("Invalid Hermes API key. Double check the token. HTTP Error Code: " + e.HttpResponse.StatusCode); - } - catch (JsonException) - { - _logger.Debug($"Failed to Authorize Twitch API due to JSON error [id: {broadcasterId}]"); - } - catch (Exception e) - { - _logger.Error(e, "Failed to authorize to Twitch API."); - } - return false; + _logger.Warning("Twitch api failed to create event subscription: " + await response.Content.ReadAsStringAsync()); + return null; } - public async Task Connect() + public async Task?> CreateEventSubscription(string type, string version, string sessionId, string userId) { - _client.Connect(); - await _publisher.ConnectAsync(); - } - - public void InitializeClient(string username, IEnumerable channels) - { - ConnectionCredentials credentials = new ConnectionCredentials(username, _token!.AccessToken); - _client.Initialize(credentials, channels.Distinct().ToList()); - - if (_initialized) + var conditions = new Dictionary() { { "user_id", userId }, { "broadcaster_user_id", userId }, { "moderator_user_id", userId } }; + var subscriptionData = new EventSubscriptionMessage(type, version, sessionId, conditions); + var response = await _web.Post("https://api.twitch.tv/helix/eventsub/subscriptions", subscriptionData); + if (response.StatusCode == HttpStatusCode.Accepted) { - _logger.Debug("Twitch API client has already been initialized."); - return; + _logger.Debug("Twitch API call [type: create event subscription]: " + await response.Content.ReadAsStringAsync()); + return await response.Content.ReadFromJsonAsync(typeof(EventResponse)) as EventResponse; } - - _initialized = true; - - _client.OnJoinedChannel += async Task (object? s, OnJoinedChannelArgs e) => - { - _logger.Information("Joined channel: " + e.Channel); - }; - - _client.OnConnected += async Task (object? s, OnConnectedArgs e) => - { - _logger.Information("Twitch API client connected."); - }; - - _client.OnIncorrectLogin += async Task (object? s, OnIncorrectLoginArgs e) => - { - _logger.Error(e.Exception, "Incorrect Login on Twitch API client."); - - _logger.Information("Attempting to re-authorize."); - await Authorize(_broadcasterId); - _client.SetConnectionCredentials(new ConnectionCredentials(_user.TwitchUsername, _token!.AccessToken)); - - await Task.Delay(TimeSpan.FromSeconds(3)); - await _client.ReconnectAsync(); - }; - - _client.OnConnectionError += async Task (object? s, OnConnectionErrorArgs e) => - { - _logger.Error("Connection Error: " + e.Error.Message + " (" + e.Error.GetType().Name + ")"); - - _logger.Information("Attempting to re-authorize."); - await Authorize(_broadcasterId); - }; - - _client.OnError += async Task (object? s, OnErrorEventArgs e) => - { - _logger.Error(e.Exception, "Twitch API client error."); - }; - - _client.OnDisconnected += async Task (s, e) => _logger.Warning("Twitch API client disconnected."); + _logger.Error("Twitch api failed to create event subscription: " + await response.Content.ReadAsStringAsync()); + return null; } - public void InitializePublisher() - { - _publisher.OnPubSubServiceConnected += async (s, e) => - { - _publisher.ListenToChannelPoints(_token.BroadcasterId); - _publisher.ListenToFollows(_token.BroadcasterId); - - await _publisher.SendTopicsAsync(_token.AccessToken); - _logger.Information("Twitch PubSub has been connected."); - }; - - _publisher.OnFollow += (s, e) => - { - _logger.Information($"New Follower [name: {e.DisplayName}][username: {e.Username}]"); - }; - - _publisher.OnChannelPointsRewardRedeemed += async (s, e) => - { - _logger.Information($"Channel Point Reward Redeemed [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][redeem id: {e.RewardRedeemed.Redemption.Reward.Id}][transaction: {e.RewardRedeemed.Redemption.Id}]"); - - try - { - var actions = _redemptionManager.Get(e.RewardRedeemed.Redemption.Reward.Id); - if (!actions.Any()) - { - _logger.Debug($"No redemable actions for this redeem was found [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][redeem id: {e.RewardRedeemed.Redemption.Reward.Id}][transaction: {e.RewardRedeemed.Redemption.Id}]"); - return; - } - _logger.Debug($"Found {actions.Count} actions for this Twitch channel point redemption [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][redeem id: {e.RewardRedeemed.Redemption.Reward.Id}][transaction: {e.RewardRedeemed.Redemption.Id}]"); - - foreach (var action in actions) - try - { - await _redemptionManager.Execute(action, e.RewardRedeemed.Redemption.User.DisplayName, long.Parse(e.RewardRedeemed.Redemption.User.Id)); - } - catch (Exception ex) - { - _logger.Error(ex, $"Failed to execute redeeemable action [action: {action.Name}][action type: {action.Type}][redeem: {e.RewardRedeemed.Redemption.Reward.Title}][redeem id: {e.RewardRedeemed.Redemption.Reward.Id}][transaction: {e.RewardRedeemed.Redemption.Id}]"); - } - } - catch (Exception ex) - { - _logger.Error(ex, $"Failed to fetch the redeemable actions for a redemption [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][redeem id: {e.RewardRedeemed.Redemption.Reward.Id}][transaction: {e.RewardRedeemed.Redemption.Id}]"); - } - }; - - _publisher.OnPubSubServiceClosed += async (s, e) => - { - _logger.Warning("Twitch PubSub ran into a service close. Attempting to connect again."); - //await Task.Delay(Math.Min(3000 + (1 << psConnectionFailures), 120000)); - var authorized = await Authorize(_broadcasterId); - - var twitchBotData = await _hermesApiClient.FetchTwitchBotToken(); - if (twitchBotData == null) - { - Console.WriteLine("The API is down. Contact the owner."); - return; - } - await _publisher.ConnectAsync(); - }; - } - - public void AddOnNewMessageReceived(AsyncEventHandler handler) - { - _client.OnMessageReceived += handler; + public void Initialize(TwitchBotToken token) { + _web.AddHeader("Authorization", "Bearer " + token.AccessToken); + _web.AddHeader("Client-Id", token.ClientId); } } \ No newline at end of file diff --git a/TwitchChatTTS.csproj b/TwitchChatTTS.csproj index 7c11483..bab5589 100644 --- a/TwitchChatTTS.csproj +++ b/TwitchChatTTS.csproj @@ -14,33 +14,14 @@ - - - - - + - - - + - - - - - - - - - - - - - - + diff --git a/User.cs b/User.cs index b1ff599..66f6501 100644 --- a/User.cs +++ b/User.cs @@ -16,16 +16,16 @@ namespace TwitchChatTTS public string DefaultTTSVoice { get; set; } // voice id -> voice name - public IDictionary VoicesAvailable { get => _voicesAvailable; set { _voicesAvailable = value; WordFilterRegex = GenerateEnabledVoicesRegex(); } } + public IDictionary VoicesAvailable { get => _voicesAvailable; set { _voicesAvailable = value; VoiceNameRegex = GenerateEnabledVoicesRegex(); } } // chatter/twitch id -> voice id public IDictionary VoicesSelected { get; set; } // voice names - public HashSet VoicesEnabled { get => _voicesEnabled; set { _voicesEnabled = value; WordFilterRegex = GenerateEnabledVoicesRegex(); } } + public HashSet VoicesEnabled { get => _voicesEnabled; set { _voicesEnabled = value; VoiceNameRegex = GenerateEnabledVoicesRegex(); } } - public IDictionary ChatterFilters { get; set; } - public IList RegexFilters { get; set; } + public HashSet Chatters { get; set; } + public TTSWordFilter[] RegexFilters { get; set; } [JsonIgnore] - public Regex? WordFilterRegex { get; set; } + public Regex? VoiceNameRegex { get; set; } private IDictionary _voicesAvailable; private HashSet _voicesEnabled; @@ -37,7 +37,7 @@ namespace TwitchChatTTS return null; var enabledVoicesString = string.Join("|", VoicesAvailable.Where(v => VoicesEnabled == null || !VoicesEnabled.Any() || VoicesEnabled.Contains(v.Value)).Select(v => v.Value)); - return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase); + return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase | RegexOptions.Compiled); } } } \ No newline at end of file