Fixed command permissions. Moved to using Twitch's EventSub via websockets. Cleaned some code up. Added detection for subscription messages (no TTS), message deletion, full or partial chat clear. Removes messages from TTS queue if applicable. Added command aliases for static parameters. Word filters use compiled regex if possible. Fixed TTS voice deletion.

This commit is contained in:
Tom 2024-08-04 23:46:10 +00:00
parent 472bfcee5d
commit 75fcb8e0f8
61 changed files with 2268 additions and 925 deletions

View File

@ -1,311 +1,310 @@
using System.Text.RegularExpressions; // using System.Text.RegularExpressions;
using TwitchLib.Client.Events; // using TwitchLib.Client.Events;
using Serilog; // using Serilog;
using TwitchChatTTS; // using TwitchChatTTS;
using TwitchChatTTS.Chat.Commands; // using TwitchChatTTS.Chat.Commands;
using TwitchChatTTS.Hermes.Socket; // using TwitchChatTTS.Hermes.Socket;
using TwitchChatTTS.Chat.Groups.Permissions; // using TwitchChatTTS.Chat.Groups.Permissions;
using TwitchChatTTS.Chat.Groups; // using TwitchChatTTS.Chat.Groups;
using TwitchChatTTS.Chat.Emotes; // using TwitchChatTTS.Chat.Emotes;
using Microsoft.Extensions.DependencyInjection; // using Microsoft.Extensions.DependencyInjection;
using CommonSocketLibrary.Common; // using CommonSocketLibrary.Common;
using CommonSocketLibrary.Abstract; // using CommonSocketLibrary.Abstract;
using TwitchChatTTS.OBS.Socket; // using TwitchChatTTS.OBS.Socket;
public class ChatMessageHandler // public class ChatMessageHandler
{ // {
private readonly User _user; // private readonly User _user;
private readonly TTSPlayer _player; // private readonly TTSPlayer _player;
private readonly CommandManager _commands; // private readonly CommandManager _commands;
private readonly IGroupPermissionManager _permissionManager; // private readonly IGroupPermissionManager _permissionManager;
private readonly IChatterGroupManager _chatterGroupManager; // private readonly IChatterGroupManager _chatterGroupManager;
private readonly IEmoteDatabase _emotes; // private readonly IEmoteDatabase _emotes;
private readonly OBSSocketClient _obs; // private readonly OBSSocketClient _obs;
private readonly HermesSocketClient _hermes; // private readonly HermesSocketClient _hermes;
private readonly Configuration _configuration; // private readonly Configuration _configuration;
private readonly ILogger _logger; // private readonly ILogger _logger;
private Regex _sfxRegex; // private Regex _sfxRegex;
private HashSet<long> _chatters; // private HashSet<long> _chatters;
public HashSet<long> Chatters { get => _chatters; set => _chatters = value; } // public HashSet<long> Chatters { get => _chatters; set => _chatters = value; }
public ChatMessageHandler( // public ChatMessageHandler(
User user, // User user,
TTSPlayer player, // TTSPlayer player,
CommandManager commands, // CommandManager commands,
IGroupPermissionManager permissionManager, // IGroupPermissionManager permissionManager,
IChatterGroupManager chatterGroupManager, // IChatterGroupManager chatterGroupManager,
IEmoteDatabase emotes, // IEmoteDatabase emotes,
[FromKeyedServices("hermes")] SocketClient<WebSocketMessage> hermes, // [FromKeyedServices("hermes")] SocketClient<WebSocketMessage> hermes,
[FromKeyedServices("obs")] SocketClient<WebSocketMessage> obs, // [FromKeyedServices("obs")] SocketClient<WebSocketMessage> obs,
Configuration configuration, // Configuration configuration,
ILogger logger // ILogger logger
) // )
{ // {
_user = user; // _user = user;
_player = player; // _player = player;
_commands = commands; // _commands = commands;
_permissionManager = permissionManager; // _permissionManager = permissionManager;
_chatterGroupManager = chatterGroupManager; // _chatterGroupManager = chatterGroupManager;
_emotes = emotes; // _emotes = emotes;
_obs = (obs as OBSSocketClient)!; // _obs = (obs as OBSSocketClient)!;
_hermes = (hermes as HermesSocketClient)!; // _hermes = (hermes as HermesSocketClient)!;
_configuration = configuration; // _configuration = configuration;
_logger = logger; // _logger = logger;
_chatters = new HashSet<long>(); // _chatters = new HashSet<long>();
_sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)"); // _sfxRegex = new Regex(@"\(([A-Za-z0-9_-]+)\)");
} // }
public async Task<MessageResult> Handle(OnMessageReceivedArgs e) // public async Task<MessageResult> Handle(OnMessageReceivedArgs e)
{ // {
var m = e.ChatMessage; // var m = e.ChatMessage;
if (!_hermes.Ready) // if (_hermes.Connected && !_hermes.Ready)
{ // {
_logger.Debug($"TTS is not yet ready. Ignoring chat messages [message id: {m.Id}]"); // _logger.Debug($"TTS is not yet ready. Ignoring chat messages [message id: {m.Id}]");
return new MessageResult(MessageStatus.NotReady, -1, -1); // return new MessageResult(MessageStatus.NotReady, -1, -1);
} // }
if (_configuration.Twitch?.TtsWhenOffline != true && !_obs.Streaming) // if (_configuration.Twitch?.TtsWhenOffline != true && !_obs.Streaming)
{ // {
_logger.Debug($"OBS is not streaming. Ignoring chat messages [message id: {m.Id}]"); // _logger.Debug($"OBS is not streaming. Ignoring chat messages [message id: {m.Id}]");
return new MessageResult(MessageStatus.NotReady, -1, -1); // return new MessageResult(MessageStatus.NotReady, -1, -1);
} // }
var msg = e.ChatMessage.Message; // var msg = e.ChatMessage.Message;
var chatterId = long.Parse(m.UserId); // var chatterId = long.Parse(m.UserId);
var tasks = new List<Task>(); // var tasks = new List<Task>();
var checks = new bool[] { true, m.IsSubscriber, m.IsVip, m.IsModerator, m.IsBroadcaster }; // var checks = new bool[] { true, m.IsSubscriber, m.IsVip, m.IsModerator, m.IsBroadcaster };
var defaultGroups = new string[] { "everyone", "subscribers", "vip", "moderators", "broadcaster" }; // var defaultGroups = new string[] { "everyone", "subscribers", "vip", "moderators", "broadcaster" };
var customGroups = _chatterGroupManager.GetGroupNamesFor(chatterId); // var customGroups = _chatterGroupManager.GetGroupNamesFor(chatterId);
var groups = defaultGroups.Where((e, i) => checks[i]).Union(customGroups); // var groups = defaultGroups.Where((e, i) => checks[i]).Union(customGroups);
try // try
{ // {
var commandResult = await _commands.Execute(msg, m, groups); // var commandResult = await _commands.Execute(msg, m, groups);
if (commandResult != ChatCommandResult.Unknown) // if (commandResult != ChatCommandResult.Unknown)
return new MessageResult(MessageStatus.Command, -1, -1); // return new MessageResult(MessageStatus.Command, -1, -1);
} // }
catch (Exception ex) // catch (Exception ex)
{ // {
_logger.Error(ex, $"Failed executing a chat command [message: {msg}][chatter: {m.Username}][chatter id: {m.UserId}][message id: {m.Id}]"); // _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"; // var permissionPath = "tts.chat.messages.read";
if (!string.IsNullOrWhiteSpace(m.CustomRewardId)) // if (!string.IsNullOrWhiteSpace(m.CustomRewardId))
permissionPath = "tts.chat.redemptions.read"; // permissionPath = "tts.chat.redemptions.read";
var permission = chatterId == _user.OwnerId ? true : _permissionManager.CheckIfAllowed(groups, permissionPath); // var permission = chatterId == _user.OwnerId ? true : _permissionManager.CheckIfAllowed(groups, permissionPath);
if (permission != true) // if (permission != true)
{ // {
_logger.Debug($"Blocked message by {m.Username}: {msg}"); // _logger.Debug($"Blocked message by {m.Username}: {msg}");
return new MessageResult(MessageStatus.Blocked, -1, -1); // return new MessageResult(MessageStatus.Blocked, -1, -1);
} // }
if (_obs.Streaming && !_chatters.Contains(chatterId)) // if (_obs.Streaming && !_chatters.Contains(chatterId))
{ // {
tasks.Add(_hermes.SendChatterDetails(chatterId, m.Username)); // tasks.Add(_hermes.SendChatterDetails(chatterId, m.Username));
_chatters.Add(chatterId); // _chatters.Add(chatterId);
} // }
// Filter highly repetitive words (like emotes) from the message. // // Filter highly repetitive words (like emotes) from the message.
int totalEmoteUsed = 0; // int totalEmoteUsed = 0;
var emotesUsed = new HashSet<string>(); // var emotesUsed = new HashSet<string>();
var words = msg.Split(' '); // var words = msg.Split(' ');
var wordCounter = new Dictionary<string, int>(); // var wordCounter = new Dictionary<string, int>();
string filteredMsg = string.Empty; // string filteredMsg = string.Empty;
var newEmotes = new Dictionary<string, string>(); // var newEmotes = new Dictionary<string, string>();
foreach (var w in words) // foreach (var w in words)
{ // {
if (wordCounter.ContainsKey(w)) // if (wordCounter.ContainsKey(w))
wordCounter[w]++; // wordCounter[w]++;
else // else
wordCounter.Add(w, 1); // wordCounter.Add(w, 1);
var emoteId = _emotes.Get(w); // var emoteId = _emotes.Get(w);
if (emoteId == null) // if (emoteId == null)
{ // {
emoteId = m.EmoteSet.Emotes.FirstOrDefault(e => e.Name == w)?.Id; // emoteId = m.EmoteSet.Emotes.FirstOrDefault(e => e.Name == w)?.Id;
if (emoteId != null) // if (emoteId != null)
{ // {
newEmotes.Add(emoteId, w); // newEmotes.Add(emoteId, w);
_emotes.Add(w, emoteId); // _emotes.Add(w, emoteId);
} // }
} // }
if (emoteId != null) // if (emoteId != null)
{ // {
emotesUsed.Add(emoteId); // emotesUsed.Add(emoteId);
totalEmoteUsed++; // totalEmoteUsed++;
} // }
if (wordCounter[w] <= 4 && (emoteId == null || totalEmoteUsed <= 5)) // if (wordCounter[w] <= 4 && (emoteId == null || totalEmoteUsed <= 5))
filteredMsg += w + " "; // filteredMsg += w + " ";
} // }
if (_obs.Streaming && newEmotes.Any()) // if (_obs.Streaming && newEmotes.Any())
tasks.Add(_hermes.SendEmoteDetails(newEmotes)); // tasks.Add(_hermes.SendEmoteDetails(newEmotes));
msg = filteredMsg; // msg = filteredMsg;
// Replace filtered words. // // Replace filtered words.
if (_user.RegexFilters != null) // if (_user.RegexFilters != null)
{ // {
foreach (var wf in _user.RegexFilters) // foreach (var wf in _user.RegexFilters)
{ // {
if (wf.Search == null || wf.Replace == null) // if (wf.Search == null || wf.Replace == null)
continue; // continue;
if (wf.IsRegex) // if (wf.IsRegex)
{ // {
try // try
{ // {
var regex = new Regex(wf.Search); // var regex = new Regex(wf.Search);
msg = regex.Replace(msg, wf.Replace); // msg = regex.Replace(msg, wf.Replace);
continue; // continue;
} // }
catch (Exception) // catch (Exception)
{ // {
wf.IsRegex = false; // wf.IsRegex = false;
} // }
} // }
msg = msg.Replace(wf.Search, wf.Replace); // msg = msg.Replace(wf.Search, wf.Replace);
} // }
} // }
// Determine the priority of this message // // Determine the priority of this message
int priority = _chatterGroupManager.GetPriorityFor(groups) + m.SubscribedMonthCount * (m.IsSubscriber ? 10 : 5); // int priority = _chatterGroupManager.GetPriorityFor(groups) + m.SubscribedMonthCount * (m.IsSubscriber ? 10 : 5);
// Determine voice selected. // // Determine voice selected.
string voiceSelected = _user.DefaultTTSVoice; // string voiceSelected = _user.DefaultTTSVoice;
if (_user.VoicesSelected?.ContainsKey(chatterId) == true) // if (_user.VoicesSelected?.ContainsKey(chatterId) == true)
{ // {
var voiceId = _user.VoicesSelected[chatterId]; // var voiceId = _user.VoicesSelected[chatterId];
if (_user.VoicesAvailable.TryGetValue(voiceId, out string? voiceName) && voiceName != null) // if (_user.VoicesAvailable.TryGetValue(voiceId, out string? voiceName) && voiceName != null)
{ // {
if (_user.VoicesEnabled.Contains(voiceName) || chatterId == _user.OwnerId || m.IsStaff) // if (_user.VoicesEnabled.Contains(voiceName) || chatterId == _user.OwnerId || m.IsStaff)
{ // {
voiceSelected = voiceName; // voiceSelected = voiceName;
} // }
} // }
} // }
// Determine additional voices used // // Determine additional voices used
var matches = _user.WordFilterRegex?.Matches(msg).ToArray(); // var matches = _user.WordFilterRegex?.Matches(msg).ToArray();
if (matches == null || matches.FirstOrDefault() == null || matches.First().Index < 0) // if (matches == null || matches.FirstOrDefault() == null || matches.First().Index < 0)
{ // {
HandlePartialMessage(priority, voiceSelected, msg.Trim(), e); // HandlePartialMessage(priority, voiceSelected, msg.Trim(), e);
return new MessageResult(MessageStatus.None, _user.TwitchUserId, chatterId, emotesUsed); // return new MessageResult(MessageStatus.None, _user.TwitchUserId, chatterId, emotesUsed);
} // }
HandlePartialMessage(priority, voiceSelected, msg.Substring(0, matches.First().Index).Trim(), e); // HandlePartialMessage(priority, voiceSelected, msg.Substring(0, matches.First().Index).Trim(), e);
foreach (Match match in matches) // foreach (Match match in matches)
{ // {
var message = match.Groups[2].ToString(); // var message = match.Groups[2].ToString();
if (string.IsNullOrWhiteSpace(message)) // if (string.IsNullOrWhiteSpace(message))
continue; // continue;
var voice = match.Groups[1].ToString(); // var voice = match.Groups[1].ToString();
voice = voice[0].ToString().ToUpper() + voice.Substring(1).ToLower(); // voice = voice[0].ToString().ToUpper() + voice.Substring(1).ToLower();
HandlePartialMessage(priority, voice, message.Trim(), e); // HandlePartialMessage(priority, voice, message.Trim(), e);
} // }
if (tasks.Any()) // if (tasks.Any())
await Task.WhenAll(tasks); // 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) // private void HandlePartialMessage(int priority, string voice, string message, OnMessageReceivedArgs e)
{ // {
if (string.IsNullOrWhiteSpace(message)) // if (string.IsNullOrWhiteSpace(message))
return; // return;
var m = e.ChatMessage; // var m = e.ChatMessage;
var parts = _sfxRegex.Split(message); // var parts = _sfxRegex.Split(message);
var badgesString = string.Join(", ", e.ChatMessage.Badges.Select(b => b.Key + " = " + b.Value)); // var badgesString = string.Join(", ", e.ChatMessage.Badges.Select(b => b.Key + " = " + b.Value));
if (parts.Length == 1) // if (parts.Length == 1)
{ // {
_logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {message}; Month: {m.SubscribedMonthCount}; Reward Id: {m.CustomRewardId}; {badgesString}"); // _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() // _player.Add(new TTSMessage()
{ // {
Voice = voice, // Voice = voice,
Message = message, // Message = message,
Moderator = m.IsModerator, // Timestamp = DateTime.UtcNow,
Timestamp = DateTime.UtcNow, // Username = m.Username,
Username = m.Username, // //Bits = m.Bits,
Bits = m.Bits, // Badges = e.Badges,
Badges = m.Badges, // Priority = priority
Priority = priority // });
}); // return;
return; // }
}
var sfxMatches = _sfxRegex.Matches(message); // var sfxMatches = _sfxRegex.Matches(message);
var sfxStart = sfxMatches.FirstOrDefault()?.Index ?? message.Length; // var sfxStart = sfxMatches.FirstOrDefault()?.Index ?? message.Length;
for (var i = 0; i < sfxMatches.Count; i++) // for (var i = 0; i < sfxMatches.Count; i++)
{ // {
var sfxMatch = sfxMatches[i]; // var sfxMatch = sfxMatches[i];
var sfxName = sfxMatch.Groups[1]?.ToString()?.ToLower(); // var sfxName = sfxMatch.Groups[1]?.ToString()?.ToLower();
if (!File.Exists("sfx/" + sfxName + ".mp3")) // if (!File.Exists("sfx/" + sfxName + ".mp3"))
{ // {
parts[i * 2 + 2] = parts[i * 2] + " (" + parts[i * 2 + 1] + ")" + parts[i * 2 + 2]; // parts[i * 2 + 2] = parts[i * 2] + " (" + parts[i * 2 + 1] + ")" + parts[i * 2 + 2];
continue; // continue;
} // }
if (!string.IsNullOrWhiteSpace(parts[i * 2])) // 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}"); // _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() // _player.Add(new TTSMessage()
{ // {
Voice = voice, // Voice = voice,
Message = parts[i * 2], // Message = parts[i * 2],
Moderator = m.IsModerator, // Moderator = m.IsModerator,
Timestamp = DateTime.UtcNow, // Timestamp = DateTime.UtcNow,
Username = m.Username, // Username = m.Username,
Bits = m.Bits, // Bits = m.Bits,
Badges = m.Badges, // Badges = m.Badges,
Priority = priority // Priority = priority
}); // });
} // }
_logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}"); // _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; SFX: {sfxName}; Month: {m.SubscribedMonthCount}; {badgesString}");
_player.Add(new TTSMessage() // _player.Add(new TTSMessage()
{ // {
Voice = voice, // Voice = voice,
Message = sfxName, // Message = sfxName,
File = $"sfx/{sfxName}.mp3", // File = $"sfx/{sfxName}.mp3",
Moderator = m.IsModerator, // Moderator = m.IsModerator,
Timestamp = DateTime.UtcNow, // Timestamp = DateTime.UtcNow,
Username = m.Username, // Username = m.Username,
Bits = m.Bits, // Bits = m.Bits,
Badges = m.Badges, // Badges = m.Badges,
Priority = priority // Priority = priority
}); // });
} // }
if (!string.IsNullOrWhiteSpace(parts.Last())) // 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}"); // _logger.Information($"Username: {m.Username}; User ID: {m.UserId}; Voice: {voice}; Priority: {priority}; Message: {parts.Last()}; Month: {m.SubscribedMonthCount}; {badgesString}");
_player.Add(new TTSMessage() // _player.Add(new TTSMessage()
{ // {
Voice = voice, // Voice = voice,
Message = parts.Last(), // Message = parts.Last(),
Moderator = m.IsModerator, // Moderator = m.IsModerator,
Timestamp = DateTime.UtcNow, // Timestamp = DateTime.UtcNow,
Username = m.Username, // Username = m.Username,
Bits = m.Bits, // Bits = m.Bits,
Badges = m.Badges, // Badges = m.Badges,
Priority = priority // Priority = priority
}); // });
} // }
} // }
} // }

View File

@ -1,17 +1,18 @@
using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Hermes.Socket;
using TwitchLib.Client.Models; using TwitchChatTTS.Twitch.Socket.Messages;
using static TwitchChatTTS.Chat.Commands.TTSCommands; using static TwitchChatTTS.Chat.Commands.TTSCommands;
namespace TwitchChatTTS.Chat.Commands namespace TwitchChatTTS.Chat.Commands
{ {
public interface IChatCommand { public interface IChatCommand
{
string Name { get; } string Name { get; }
void Build(ICommandBuilder builder); void Build(ICommandBuilder builder);
} }
public interface IChatPartialCommand { public interface IChatPartialCommand
{
bool AcceptCustomPermission { get; } bool AcceptCustomPermission { get; }
bool CheckDefaultPermissions(ChatMessage message); Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client);
Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client);
} }
} }

View File

@ -1,5 +1,6 @@
using Serilog; using Serilog;
using TwitchChatTTS.Chat.Commands.Parameters; using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Chat.Commands namespace TwitchChatTTS.Chat.Commands
{ {
@ -8,15 +9,18 @@ namespace TwitchChatTTS.Chat.Commands
public interface ICommandBuilder public interface ICommandBuilder
{ {
ICommandSelector Build(); ICommandSelector Build();
ICommandBuilder AddPermission(string path);
ICommandBuilder AddAlias(string alias, string child);
void Clear(); void Clear();
ICommandBuilder CreateCommandTree(string name, Action<ICommandBuilder> callback); ICommandBuilder CreateCommandTree(string name, Action<ICommandBuilder> callback);
ICommandBuilder CreateCommand(IChatPartialCommand command); ICommandBuilder CreateCommand(IChatPartialCommand command);
ICommandBuilder CreateStaticInputParameter(string value, Action<ICommandBuilder> callback, bool optional = false); ICommandBuilder CreateStaticInputParameter(string value, Action<ICommandBuilder> callback, bool optional = false);
ICommandBuilder CreateMentionParameter(string name, bool enabled, bool optional = false);
ICommandBuilder CreateObsTransformationParameter(string name, bool optional = false); ICommandBuilder CreateObsTransformationParameter(string name, bool optional = false);
ICommandBuilder CreateStateParameter(string name, bool optional = false); ICommandBuilder CreateStateParameter(string name, bool optional = false);
ICommandBuilder CreateUnvalidatedParameter(string name, bool optional = false); ICommandBuilder CreateUnvalidatedParameter(string name, bool optional = false);
ICommandBuilder CreateVoiceNameParameter(string name, bool enabled, bool optional = false); ICommandBuilder CreateVoiceNameParameter(string name, bool enabled, bool optional = false);
} }
public sealed class CommandBuilder : ICommandBuilder 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() public ICommandSelector Build()
{ {
return new CommandSelector(_root); return new CommandSelector(_root);
@ -89,6 +112,19 @@ namespace TwitchChatTTS.Chat.Commands
return this; 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) public ICommandBuilder CreateObsTransformationParameter(string name, bool optional = false)
{ {
if (_root == _current) if (_root == _current)
@ -164,9 +200,8 @@ namespace TwitchChatTTS.Chat.Commands
public interface ICommandSelector public interface ICommandSelector
{ {
CommandSelectorResult GetBestMatch(string[] args); CommandSelectorResult GetBestMatch(string[] args, ChannelChatMessage message);
IDictionary<string, CommandParameter> GetNonStaticArguments(string[] args, string path); IDictionary<string, CommandParameter> GetNonStaticArguments(string[] args, string path);
CommandValidationResult Validate(string[] args, string path);
} }
public sealed class CommandSelector : ICommandSelector public sealed class CommandSelector : ICommandSelector
@ -178,67 +213,36 @@ namespace TwitchChatTTS.Chat.Commands
_root = root; _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<string> args, IChatPartialCommand? match, string path) private CommandSelectorResult GetBestMatch(CommandNode node, ChannelChatMessage message, IEnumerable<string> args, IChatPartialCommand? match, string path, string[]? permissions)
{ {
if (node == null || !args.Any()) if (node == null || !args.Any())
return new CommandSelectorResult(match, path); return new CommandSelectorResult(match, path, permissions);
if (!node.Children.Any()) if (!node.Children.Any())
return new CommandSelectorResult(node.Command ?? match, path); return new CommandSelectorResult(node.Command ?? match, path, permissions);
var argument = args.First(); var argument = args.First();
var argumentLower = argument.ToLower(); var argumentLower = argument.ToLower();
foreach (var child in node.Children) 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.GetType() == typeof(StaticParameter))
{ {
if (child.Parameter.Name.ToLower() == argumentLower) if (child.Parameter.Name.ToLower() == argumentLower)
{ return GetBestMatch(child, message, args.Skip(1), child.Command ?? match, (path.Length == 0 ? string.Empty : path + ".") + child.Parameter.Name.ToLower(), perms);
return GetBestMatch(child, args.Skip(1), child.Command ?? match, (path.Length == 0 ? string.Empty : path + ".") + child.Parameter.Name.ToLower());
}
continue; continue;
} }
if ((!child.Parameter.Optional || child.Parameter.Validate(argument, message)) && child.Command != null)
return GetBestMatch(child, args.Skip(1), child.Command ?? match, (path.Length == 0 ? string.Empty : path + ".") + "*"); 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); return new CommandSelectorResult(match, path, permissions);
}
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);
} }
public IDictionary<string, CommandParameter> GetNonStaticArguments(string[] args, string path) public IDictionary<string, CommandParameter> GetNonStaticArguments(string[] args, string path)
@ -276,11 +280,13 @@ namespace TwitchChatTTS.Chat.Commands
{ {
public IChatPartialCommand? Command { get; set; } public IChatPartialCommand? Command { get; set; }
public string Path { 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; Command = command;
Path = path; Path = path;
Permissions = permissions;
} }
} }
@ -300,6 +306,7 @@ namespace TwitchChatTTS.Chat.Commands
{ {
public IChatPartialCommand? Command { get; private set; } public IChatPartialCommand? Command { get; private set; }
public CommandParameter Parameter { get; } public CommandParameter Parameter { get; }
public string[]? Permissions { get; private set; }
public IList<CommandNode> Children { get => _children.AsReadOnly(); } public IList<CommandNode> Children { get => _children.AsReadOnly(); }
private IList<CommandNode> _children; private IList<CommandNode> _children;
@ -308,9 +315,34 @@ namespace TwitchChatTTS.Chat.Commands
{ {
Parameter = parameter; Parameter = parameter;
_children = new List<CommandNode>(); _children = new List<CommandNode>();
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) public CommandNode CreateCommand(IChatPartialCommand command)
{ {
if (Command != null) if (Command != null)

View File

@ -5,7 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Serilog; using Serilog;
using TwitchChatTTS.Chat.Groups.Permissions; using TwitchChatTTS.Chat.Groups.Permissions;
using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Hermes.Socket;
using TwitchLib.Client.Models; using TwitchChatTTS.Twitch.Socket.Messages;
using static TwitchChatTTS.Chat.Commands.TTSCommands; using static TwitchChatTTS.Chat.Commands.TTSCommands;
namespace TwitchChatTTS.Chat.Commands namespace TwitchChatTTS.Chat.Commands
@ -44,7 +44,7 @@ namespace TwitchChatTTS.Chat.Commands
} }
public async Task<ChatCommandResult> Execute(string arg, ChatMessage message, IEnumerable<string> groups) public async Task<ChatCommandResult> Execute(string arg, ChannelChatMessage message, IEnumerable<string> groups)
{ {
if (string.IsNullOrWhiteSpace(arg)) if (string.IsNullOrWhiteSpace(arg))
return ChatCommandResult.Unknown; return ChatCommandResult.Unknown;
@ -62,7 +62,7 @@ namespace TwitchChatTTS.Chat.Commands
string[] args = parts.ToArray(); string[] args = parts.ToArray();
string com = args.First().ToLower(); string com = args.First().ToLower();
CommandSelectorResult selectorResult = _commandSelector.GetBestMatch(args); CommandSelectorResult selectorResult = _commandSelector.GetBestMatch(args, message);
if (selectorResult.Command == null) if (selectorResult.Command == null)
{ {
_logger.Warning($"Could not match '{arg}' to any command."); _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. // Check if command can be executed by this chatter.
var command = selectorResult.Command; var command = selectorResult.Command;
long chatterId = long.Parse(message.UserId); long chatterId = long.Parse(message.ChatterUserId);
if (chatterId != _user.OwnerId) if (chatterId != _user.OwnerId)
{ {
var executable = command.AcceptCustomPermission ? CanExecute(chatterId, groups, com) : null; bool executable = command.AcceptCustomPermission ? CanExecute(chatterId, groups, $"tts.command.{com}", selectorResult.Permissions) : false;
if (executable == false) if (!executable)
{ {
_logger.Debug($"Denied permission to use command [chatter id: {chatterId}][command: {com}]"); _logger.Debug($"Denied permission to use command [chatter id: {chatterId}][command: {com}]");
return ChatCommandResult.Permission; 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); var arguments = _commandSelector.GetNonStaticArguments(args, selectorResult.Path);
foreach (var entry in arguments) foreach (var entry in arguments)
{ {
var parameter = entry.Value; var parameter = entry.Value;
var argument = entry.Key; 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; return ChatCommandResult.Syntax;
} }
} }
@ -107,18 +103,18 @@ namespace TwitchChatTTS.Chat.Commands
} }
catch (Exception e) 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; 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; return ChatCommandResult.Success;
} }
private bool? CanExecute(long chatterId, IEnumerable<string> groups, string path) private bool CanExecute(long chatterId, IEnumerable<string> groups, string path, string[]? additionalPaths)
{ {
_logger.Debug($"Checking for permission [chatter id: {chatterId}][group: {string.Join(", ", groups)}][path: {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); return _permissionManager.CheckIfAllowed(groups, path) != false && (additionalPaths == null || additionalPaths.All(p => _permissionManager.CheckIfAllowed(groups, p) != false));
} }
} }
} }

View File

@ -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<string, IDictionary<long, Usage>> _usages;
// group + name -> limit
private readonly IDictionary<string, Limit> _limits;
public CommandLimitManager()
{
_usages = new Dictionary<string, IDictionary<long, Usage>>();
_limits = new Dictionary<string, Limit>();
}
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<long, Usage>();
_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; }
}
}
}

View File

@ -5,7 +5,7 @@ using Serilog;
using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Hermes.Socket;
using TwitchChatTTS.OBS.Socket; using TwitchChatTTS.OBS.Socket;
using TwitchChatTTS.OBS.Socket.Data; using TwitchChatTTS.OBS.Socket.Data;
using TwitchLib.Client.Models; using TwitchChatTTS.Twitch.Socket.Messages;
using static TwitchChatTTS.Chat.Commands.TTSCommands; using static TwitchChatTTS.Chat.Commands.TTSCommands;
namespace TwitchChatTTS.Chat.Commands namespace TwitchChatTTS.Chat.Commands
@ -71,12 +71,7 @@ namespace TwitchChatTTS.Chat.Commands
_logger = logger; _logger = logger;
} }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
string sceneName = values["sceneName"]; string sceneName = values["sceneName"];
string sourceName = values["sourceName"]; string sourceName = values["sourceName"];
@ -102,12 +97,7 @@ namespace TwitchChatTTS.Chat.Commands
_logger = logger; _logger = logger;
} }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
string sceneName = values["sceneName"]; string sceneName = values["sceneName"];
string sourceName = values["sourceName"]; string sourceName = values["sourceName"];
@ -143,12 +133,7 @@ namespace TwitchChatTTS.Chat.Commands
_logger = logger; _logger = logger;
} }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
string sceneName = values["sceneName"]; string sceneName = values["sceneName"];
string sourceName = values["sourceName"]; string sourceName = values["sourceName"];

View File

@ -1,6 +1,8 @@
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Chat.Commands.Parameters namespace TwitchChatTTS.Chat.Commands.Parameters
{ {
public abstract class CommandParameter : ICloneable public abstract class CommandParameter
{ {
public string Name { get; } public string Name { get; }
public bool Optional { get; } public bool Optional { get; }
@ -11,10 +13,6 @@ namespace TwitchChatTTS.Chat.Commands.Parameters
Optional = optional; Optional = optional;
} }
public abstract bool Validate(string value); public abstract bool Validate(string value, ChannelChatMessage message);
public object Clone() {
return (CommandParameter) MemberwiseClone();
}
} }
} }

View File

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

View File

@ -1,3 +1,5 @@
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Chat.Commands.Parameters namespace TwitchChatTTS.Chat.Commands.Parameters
{ {
public class OBSTransformationParameter : CommandParameter 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()); return _values.Contains(value.ToLower());
} }

View File

@ -1,3 +1,5 @@
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Chat.Commands.Parameters namespace TwitchChatTTS.Chat.Commands.Parameters
{ {
public class StateParameter : CommandParameter 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()); return _values.Contains(value.ToLower());
} }

View File

@ -1,3 +1,5 @@
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Chat.Commands.Parameters namespace TwitchChatTTS.Chat.Commands.Parameters
{ {
public class StaticParameter : CommandParameter public class StaticParameter : CommandParameter
@ -11,7 +13,7 @@ namespace TwitchChatTTS.Chat.Commands.Parameters
_value = value.ToLower(); _value = value.ToLower();
} }
public override bool Validate(string value) public override bool Validate(string value, ChannelChatMessage message)
{ {
return _value == value.ToLower(); return _value == value.ToLower();
} }

View File

@ -1,3 +1,5 @@
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Chat.Commands.Parameters namespace TwitchChatTTS.Chat.Commands.Parameters
{ {
public class TTSVoiceNameParameter : CommandParameter public class TTSVoiceNameParameter : CommandParameter
@ -11,7 +13,7 @@ namespace TwitchChatTTS.Chat.Commands.Parameters
_user = user; _user = user;
} }
public override bool Validate(string value) public override bool Validate(string value, ChannelChatMessage message)
{ {
if (_user.VoicesAvailable == null) if (_user.VoicesAvailable == null)
return false; return false;

View File

@ -1,3 +1,5 @@
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Chat.Commands.Parameters namespace TwitchChatTTS.Chat.Commands.Parameters
{ {
public class UnvalidatedParameter : CommandParameter 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; return true;
} }

View File

@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Serilog; using Serilog;
using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Hermes.Socket;
using TwitchChatTTS.OBS.Socket; using TwitchChatTTS.OBS.Socket;
using TwitchLib.Client.Models; using TwitchChatTTS.Twitch.Socket.Messages;
using static TwitchChatTTS.Chat.Commands.TTSCommands; using static TwitchChatTTS.Chat.Commands.TTSCommands;
namespace TwitchChatTTS.Chat.Commands namespace TwitchChatTTS.Chat.Commands
@ -44,12 +44,7 @@ namespace TwitchChatTTS.Chat.Commands
{ {
public bool AcceptCustomPermission { get => true; } public bool AcceptCustomPermission { get => true; }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
await client.FetchEnabledTTSVoices(); await client.FetchEnabledTTSVoices();
} }
@ -59,12 +54,7 @@ namespace TwitchChatTTS.Chat.Commands
{ {
public bool AcceptCustomPermission { get => true; } public bool AcceptCustomPermission { get => true; }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
await client.FetchTTSWordFilters(); await client.FetchTTSWordFilters();
} }
@ -74,12 +64,7 @@ namespace TwitchChatTTS.Chat.Commands
{ {
public bool AcceptCustomPermission { get => true; } public bool AcceptCustomPermission { get => true; }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
await client.FetchTTSChatterVoices(); await client.FetchTTSChatterVoices();
} }
@ -89,12 +74,7 @@ namespace TwitchChatTTS.Chat.Commands
{ {
public bool AcceptCustomPermission { get => true; } public bool AcceptCustomPermission { get => true; }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
await client.FetchDefaultTTSVoice(); await client.FetchDefaultTTSVoice();
} }
@ -104,12 +84,7 @@ namespace TwitchChatTTS.Chat.Commands
{ {
public bool AcceptCustomPermission { get => true; } public bool AcceptCustomPermission { get => true; }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
await client.FetchRedemptions(); await client.FetchRedemptions();
} }
@ -127,12 +102,7 @@ namespace TwitchChatTTS.Chat.Commands
_logger = logger; _logger = logger;
} }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
_obsManager.ClearCache(); _obsManager.ClearCache();
_logger.Information("Cleared the cache used for OBS."); _logger.Information("Cleared the cache used for OBS.");
@ -144,20 +114,10 @@ namespace TwitchChatTTS.Chat.Commands
public bool AcceptCustomPermission { get => true; } public bool AcceptCustomPermission { get => true; }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
await client.FetchPermissions(); await client.FetchPermissions();
} }
} }
public bool CheckDefaultPermissions(ChatMessage message)
{
return message.IsModerator || message.IsBroadcaster;
}
} }
} }

View File

@ -1,6 +1,6 @@
using Serilog; using Serilog;
using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Hermes.Socket;
using TwitchLib.Client.Models; using TwitchChatTTS.Twitch.Socket.Messages;
using static TwitchChatTTS.Chat.Commands.TTSCommands; using static TwitchChatTTS.Chat.Commands.TTSCommands;
namespace TwitchChatTTS.Chat.Commands namespace TwitchChatTTS.Chat.Commands
@ -8,11 +8,13 @@ namespace TwitchChatTTS.Chat.Commands
public class SkipCommand : IChatCommand public class SkipCommand : IChatCommand
{ {
private readonly TTSPlayer _player; private readonly TTSPlayer _player;
private readonly AudioPlaybackEngine _playback;
private readonly ILogger _logger; 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; _logger = logger;
} }
@ -24,40 +26,38 @@ namespace TwitchChatTTS.Chat.Commands
{ {
b.CreateStaticInputParameter("all", b => b.CreateStaticInputParameter("all", b =>
{ {
b.CreateCommand(new TTSPlayerSkipAllCommand(_player, _logger)); b.CreateCommand(new TTSPlayerSkipAllCommand(_player, _playback, _logger));
}).CreateCommand(new TTSPlayerSkipCommand(_player, _logger)); }).CreateCommand(new TTSPlayerSkipCommand(_player, _playback, _logger));
}); });
builder.CreateCommandTree("skipall", b => { builder.CreateCommandTree("skipall", b =>
b.CreateCommand(new TTSPlayerSkipAllCommand(_player, _logger)); {
b.CreateCommand(new TTSPlayerSkipAllCommand(_player, _playback, _logger));
}); });
} }
private sealed class TTSPlayerSkipCommand : IChatPartialCommand private sealed class TTSPlayerSkipCommand : IChatPartialCommand
{ {
private readonly TTSPlayer _ttsPlayer; private readonly TTSPlayer _player;
private readonly AudioPlaybackEngine _playback;
private readonly ILogger _logger; private readonly ILogger _logger;
public bool AcceptCustomPermission { get => true; } 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; _logger = logger;
} }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{ {
return message.IsModerator || message.IsVip || message.IsBroadcaster; if (_player.Playing == null)
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{
if (_ttsPlayer.Playing == null)
return; return;
AudioPlaybackEngine.Instance.RemoveMixerInput(_ttsPlayer.Playing); _playback.RemoveMixerInput(_player.Playing.Audio!);
_ttsPlayer.Playing = null; _player.Playing = null;
_logger.Information("Skipped current tts."); _logger.Information("Skipped current tts.");
} }
@ -65,31 +65,28 @@ namespace TwitchChatTTS.Chat.Commands
private sealed class TTSPlayerSkipAllCommand : IChatPartialCommand private sealed class TTSPlayerSkipAllCommand : IChatPartialCommand
{ {
private readonly TTSPlayer _ttsPlayer; private readonly TTSPlayer _player;
private readonly AudioPlaybackEngine _playback;
private readonly ILogger _logger; private readonly ILogger _logger;
public bool AcceptCustomPermission { get => true; } 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; _logger = logger;
} }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{ {
return message.IsModerator || message.IsVip || message.IsBroadcaster; _player.RemoveAll();
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client) if (_player.Playing == null)
{
_ttsPlayer.RemoveAll();
if (_ttsPlayer.Playing == null)
return; return;
AudioPlaybackEngine.Instance.RemoveMixerInput(_ttsPlayer.Playing); _playback.RemoveMixerInput(_player.Playing.Audio!);
_ttsPlayer.Playing = null; _player.Playing = null;
_logger.Information("Skipped all queued and playing tts."); _logger.Information("Skipped all queued and playing tts.");
} }

View File

@ -1,6 +1,6 @@
using Serilog; using Serilog;
using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Hermes.Socket;
using TwitchLib.Client.Models; using TwitchChatTTS.Twitch.Socket.Messages;
using static TwitchChatTTS.Chat.Commands.TTSCommands; using static TwitchChatTTS.Chat.Commands.TTSCommands;
namespace TwitchChatTTS.Chat.Commands namespace TwitchChatTTS.Chat.Commands
@ -28,41 +28,30 @@ namespace TwitchChatTTS.Chat.Commands
b.CreateVoiceNameParameter("voiceName", false) b.CreateVoiceNameParameter("voiceName", false)
.CreateCommand(new AddTTSVoiceCommand(_user, _logger)); .CreateCommand(new AddTTSVoiceCommand(_user, _logger));
}) })
.CreateStaticInputParameter("del", b => .AddAlias("insert", "add")
{
b.CreateVoiceNameParameter("voiceName", true)
.CreateCommand(new DeleteTTSVoiceCommand(_user, _logger));
})
.CreateStaticInputParameter("delete", b => .CreateStaticInputParameter("delete", b =>
{ {
b.CreateVoiceNameParameter("voiceName", true) b.CreateVoiceNameParameter("voiceName", true)
.CreateCommand(new DeleteTTSVoiceCommand(_user, _logger)); .CreateCommand(new DeleteTTSVoiceCommand(_user, _logger));
}) })
.CreateStaticInputParameter("remove", b => .AddAlias("del", "delete")
{ .AddAlias("remove", "delete")
b.CreateVoiceNameParameter("voiceName", true)
.CreateCommand(new DeleteTTSVoiceCommand(_user, _logger));
})
.CreateStaticInputParameter("enable", b => .CreateStaticInputParameter("enable", b =>
{ {
b.CreateVoiceNameParameter("voiceName", false) b.CreateVoiceNameParameter("voiceName", false)
.CreateCommand(new SetTTSVoiceStateCommand(true, _user, _logger)); .CreateCommand(new SetTTSVoiceStateCommand(true, _user, _logger));
}) })
.CreateStaticInputParameter("on", b => .AddAlias("on", "enable")
{ .AddAlias("enabled", "enable")
b.CreateVoiceNameParameter("voiceName", false) .AddAlias("true", "enable")
.CreateCommand(new SetTTSVoiceStateCommand(true, _user, _logger));
})
.CreateStaticInputParameter("disable", b => .CreateStaticInputParameter("disable", b =>
{ {
b.CreateVoiceNameParameter("voiceName", true) b.CreateVoiceNameParameter("voiceName", true)
.CreateCommand(new SetTTSVoiceStateCommand(false, _user, _logger)); .CreateCommand(new SetTTSVoiceStateCommand(false, _user, _logger));
}) })
.CreateStaticInputParameter("off", b => .AddAlias("off", "disable")
{ .AddAlias("disabled", "disable")
b.CreateVoiceNameParameter("voiceName", true) .AddAlias("false", "disable");
.CreateCommand(new SetTTSVoiceStateCommand(false, _user, _logger));
});
}); });
} }
@ -80,12 +69,7 @@ namespace TwitchChatTTS.Chat.Commands
_logger = logger; _logger = logger;
} }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return false;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
if (_user == null || _user.VoicesAvailable == null) if (_user == null || _user.VoicesAvailable == null)
return; return;
@ -95,12 +79,12 @@ namespace TwitchChatTTS.Chat.Commands
var exists = _user.VoicesAvailable.Any(v => v.Value.ToLower() == voiceNameLower); var exists = _user.VoicesAvailable.Any(v => v.Value.ToLower() == voiceNameLower);
if (exists) if (exists)
{ {
_logger.Warning($"Voice already exists [voice: {voiceName}][id: {message.UserId}]"); _logger.Warning($"Voice already exists [voice: {voiceName}][id: {message.ChatterUserId}]");
return; return;
} }
await client.CreateTTSVoice(voiceName); 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; _logger = logger;
} }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return false;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
if (_user == null || _user.VoicesAvailable == null) 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; return;
} }
@ -135,13 +114,18 @@ namespace TwitchChatTTS.Chat.Commands
var exists = _user.VoicesAvailable.Any(v => v.Value.ToLower() == voiceNameLower); var exists = _user.VoicesAvailable.Any(v => v.Value.ToLower() == voiceNameLower);
if (!exists) 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; return;
} }
var voiceId = _user.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceName).Key;
await client.DeleteTTSVoice(voiceId); 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; _logger = logger;
} }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
if (_user == null || _user.VoicesAvailable == null) if (_user == null || _user.VoicesAvailable == null)
return; return;
@ -175,7 +154,7 @@ namespace TwitchChatTTS.Chat.Commands
var voiceId = _user.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceNameLower).Key; var voiceId = _user.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceNameLower).Key;
await client.UpdateTTSVoiceState(voiceId, _state); 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}]");
} }
} }
} }

View File

@ -1,7 +1,7 @@
using HermesSocketLibrary.Socket.Data; using HermesSocketLibrary.Socket.Data;
using Serilog; using Serilog;
using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Hermes.Socket;
using TwitchLib.Client.Models; using TwitchChatTTS.Twitch.Socket.Messages;
using static TwitchChatTTS.Chat.Commands.TTSCommands; using static TwitchChatTTS.Chat.Commands.TTSCommands;
namespace TwitchChatTTS.Chat.Commands namespace TwitchChatTTS.Chat.Commands
@ -37,12 +37,7 @@ namespace TwitchChatTTS.Chat.Commands
_logger = logger; _logger = logger;
} }
public bool CheckDefaultPermissions(ChatMessage message) public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
return message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
_logger.Information($"TTS Version: {TTS.MAJOR_VERSION}.{TTS.MINOR_VERSION}"); _logger.Information($"TTS Version: {TTS.MAJOR_VERSION}.{TTS.MINOR_VERSION}");

View File

@ -1,6 +1,6 @@
using Serilog; using Serilog;
using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Hermes.Socket;
using TwitchLib.Client.Models; using TwitchChatTTS.Twitch.Socket.Messages;
using static TwitchChatTTS.Chat.Commands.TTSCommands; using static TwitchChatTTS.Chat.Commands.TTSCommands;
namespace TwitchChatTTS.Chat.Commands namespace TwitchChatTTS.Chat.Commands
@ -8,6 +8,8 @@ namespace TwitchChatTTS.Chat.Commands
public class VoiceCommand : IChatCommand public class VoiceCommand : IChatCommand
{ {
private readonly User _user; private readonly User _user;
// TODO: get permissions
// TODO: validated parameter for username by including '@' and regex for username
private readonly ILogger _logger; private readonly ILogger _logger;
public VoiceCommand(User user, ILogger logger) public VoiceCommand(User user, ILogger logger)
@ -23,7 +25,10 @@ namespace TwitchChatTTS.Chat.Commands
builder.CreateCommandTree(Name, b => builder.CreateCommandTree(Name, b =>
{ {
b.CreateVoiceNameParameter("voiceName", true) 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; _logger = logger;
} }
public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
public bool CheckDefaultPermissions(ChatMessage message)
{
return message.IsModerator || message.IsBroadcaster || message.IsSubscriber || message.Bits >= 100;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
{ {
if (_user == null || _user.VoicesSelected == null) if (_user == null || _user.VoicesSelected == null)
return; return;
long chatterId = long.Parse(message.UserId); long chatterId = long.Parse(message.ChatterUserId);
var voiceName = values["voiceName"]; var voiceName = values["voiceName"];
var voiceNameLower = voiceName.ToLower(); var voiceNameLower = voiceName.ToLower();
var voice = _user.VoicesAvailable.First(v => v.Value.ToLower() == voiceNameLower); var voice = _user.VoicesAvailable.First(v => v.Value.ToLower() == voiceNameLower);
@ -59,12 +58,56 @@ namespace TwitchChatTTS.Chat.Commands
if (_user.VoicesSelected.ContainsKey(chatterId)) if (_user.VoicesSelected.ContainsKey(chatterId))
{ {
await client.UpdateTTSUser(chatterId, voice.Key); 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 else
{ {
await client.CreateTTSUser(chatterId, voice.Key); 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<string, string> 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]");
} }
} }
} }

View File

@ -23,9 +23,11 @@ namespace TwitchChatTTS.Chat.Groups.Permissions
return res; return res;
} }
public bool? CheckIfAllowed(IEnumerable<string> groups, string path) { public bool? CheckIfAllowed(IEnumerable<string> groups, string path)
{
bool overall = false; bool overall = false;
foreach (var group in groups) { foreach (var group in groups)
{
var result = CheckIfAllowed($"{group}.{path}"); var result = CheckIfAllowed($"{group}.{path}");
if (result == false) if (result == false)
return false; return false;
@ -73,7 +75,7 @@ namespace TwitchChatTTS.Chat.Groups.Permissions
{ {
if (path.Length == 0) if (path.Length == 0)
return node; return node;
var parts = path.Split('.'); var parts = path.Split('.');
var name = parts.First(); var name = parts.First();
var next = node.Children?.FirstOrDefault(n => n.Name == name); 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); return Get(next, string.Join('.', parts.Skip(1)), edit);
} }
}
internal class PermissionNode private sealed class PermissionNode
{
public string Name { get; }
public bool? Allow
{ {
get public string Name { get; }
public bool? Allow
{ {
var current = this; get
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<PermissionNode>? Children { get => _children == null ? null : new ReadOnlyCollection<PermissionNode>(_children); }
private bool? _allow;
private PermissionNode? _parent;
private IList<PermissionNode>? _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<PermissionNode>();
_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); var current = this;
break; while (current._allow == null && current._parent != null)
current = current._parent;
return current._allow;
}
set => _allow = value;
}
internal PermissionNode? Parent { get => _parent; }
public IList<PermissionNode>? Children { get => _children == null ? null : new ReadOnlyCollection<PermissionNode>(_children); }
private bool? _allow;
private PermissionNode? _parent;
private IList<PermissionNode>? _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<PermissionNode>();
_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;
}
} }
} }
} }

View File

@ -2,24 +2,23 @@ using NAudio.Wave;
using NAudio.Extras; using NAudio.Extras;
using NAudio.Wave.SampleProviders; 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; } 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; SampleRate = sampleRate;
outputDevice = new WaveOutEvent(); _outputDevice = new WaveOutEvent();
mixer = new MixingSampleProvider(WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channelCount)); _mixer = new MixingSampleProvider(WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channelCount));
mixer.ReadFully = true; _mixer.ReadFully = true;
outputDevice.Init(mixer); _outputDevice.Init(_mixer);
outputDevice.Play(); _outputDevice.Play();
} }
private ISampleProvider ConvertToRightChannelCount(ISampleProvider? input) private ISampleProvider ConvertToRightChannelCount(ISampleProvider? input)
@ -27,11 +26,11 @@ public class AudioPlaybackEngine : IDisposable
if (input == null) if (input == null)
throw new NullReferenceException(nameof(input)); throw new NullReferenceException(nameof(input));
if (input.WaveFormat.Channels == mixer.WaveFormat.Channels) if (input.WaveFormat.Channels == _mixer.WaveFormat.Channels)
return input; return input;
if (input.WaveFormat.Channels == 1 && mixer.WaveFormat.Channels == 2) if (input.WaveFormat.Channels == 1 && _mixer.WaveFormat.Channels == 2)
return new MonoToStereoSampleProvider(input); 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); return new StereoToMonoSampleProvider(input);
throw new NotImplementedException("Not yet implemented this channel count conversion"); throw new NotImplementedException("Not yet implemented this channel count conversion");
} }
@ -89,26 +88,26 @@ public class AudioPlaybackEngine : IDisposable
public void AddMixerInput(ISampleProvider input) public void AddMixerInput(ISampleProvider input)
{ {
mixer.AddMixerInput(input); _mixer.AddMixerInput(input);
} }
public void AddMixerInput(IWaveProvider input) public void AddMixerInput(IWaveProvider input)
{ {
mixer.AddMixerInput(input); _mixer.AddMixerInput(input);
} }
public void RemoveMixerInput(ISampleProvider sound) public void RemoveMixerInput(ISampleProvider sound)
{ {
mixer.RemoveMixerInput(sound); _mixer.RemoveMixerInput(sound);
} }
public void AddOnMixerInputEnded(EventHandler<SampleProviderEventArgs> e) public void AddOnMixerInputEnded(EventHandler<SampleProviderEventArgs> e)
{ {
mixer.MixerInputEnded += e; _mixer.MixerInputEnded += e;
} }
public void Dispose() public void Dispose()
{ {
outputDevice.Dispose(); _outputDevice.Dispose();
} }
} }

View File

@ -1,4 +1,5 @@
using NAudio.Wave; using NAudio.Wave;
using TwitchChatTTS.Twitch.Socket.Messages;
public class TTSPlayer public class TTSPlayer
{ {
@ -7,7 +8,7 @@ public class TTSPlayer
private readonly Mutex _mutex; private readonly Mutex _mutex;
private readonly Mutex _mutex2; private readonly Mutex _mutex2;
public ISampleProvider? Playing { get; set; } public TTSMessage? Playing { get; set; }
public TTSPlayer() 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() public bool IsEmpty()
{ {
return _messages.Count == 0; return _messages.Count == 0;
} }
private class DescendingOrder : IComparer<int> { private class DescendingOrder : IComparer<int>
{
public int Compare(int x, int y) => y.CompareTo(x); public int Compare(int x, int y) => y.CompareTo(x);
} }
} }
@ -113,15 +182,12 @@ public class TTSPlayer
public class TTSMessage public class TTSMessage
{ {
public string? Voice { get; set; } public string? Voice { get; set; }
public string? Channel { get; set; } public long ChatterId { get; set; }
public string? Username { get; set; } public string MessageId { get; set; }
public string? Message { get; set; } public string? Message { get; set; }
public string? File { get; set; } public string? File { get; set; }
public DateTime Timestamp { get; set; } public DateTime Timestamp { get; set; }
public bool Moderator { get; set; } public IEnumerable<TwitchBadge> Badges { get; set; }
public bool Bot { get; set; }
public IEnumerable<KeyValuePair<string, string>>? Badges { get; set; }
public int Bits { get; set; }
public int Priority { get; set; } public int Priority { get; set; }
public ISampleProvider? Audio { get; set; } public ISampleProvider? Audio { get; set; }
} }

View File

@ -36,12 +36,12 @@ namespace TwitchChatTTS.Helpers
public async Task<HttpResponseMessage> Post<T>(string uri, T data) public async Task<HttpResponseMessage> Post<T>(string uri, T data)
{ {
return await _client.PostAsJsonAsync(uri, data); return await _client.PostAsJsonAsync(uri, data, _options);
} }
public async Task<HttpResponseMessage> Post(string uri) public async Task<HttpResponseMessage> Post(string uri)
{ {
return await _client.PostAsJsonAsync(uri, new object()); return await _client.PostAsJsonAsync(uri, new object(), _options);
} }
} }
} }

View File

@ -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<string, DataInfo> _data;
public CustomDataManager() {
_data = new Dictionary<string, DataInfo>();
}
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; }
}
}

View File

@ -3,29 +3,68 @@ using TwitchChatTTS;
using System.Text.Json; using System.Text.Json;
using HermesSocketLibrary.Requests.Messages; using HermesSocketLibrary.Requests.Messages;
using TwitchChatTTS.Hermes; using TwitchChatTTS.Hermes;
using TwitchChatTTS.Chat.Groups.Permissions; using Serilog;
using TwitchChatTTS.Chat.Groups;
using HermesSocketLibrary.Socket.Data;
public class HermesApiClient public class HermesApiClient
{ {
private readonly TwitchBotAuth _token;
private readonly WebClientWrap _web; private readonly WebClientWrap _web;
private readonly ILogger _logger;
public const string BASE_URL = "tomtospeech.com"; 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)) if (string.IsNullOrWhiteSpace(configuration.Hermes?.Token))
{ {
throw new Exception("Ensure you have written your API key in \".token\" file, in the same folder as this application."); throw new Exception("Ensure you have written your API key in \".token\" file, in the same folder as this application.");
} }
_token = token;
_web = new WebClientWrap(new JsonSerializerOptions() _web = new WebClientWrap(new JsonSerializerOptions()
{ {
PropertyNameCaseInsensitive = false, PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
}); });
_web.AddHeader("x-api-key", configuration.Hermes.Token); _web.AddHeader("x-api-key", configuration.Hermes.Token);
_logger = logger;
}
public async Task<bool> AuthorizeTwitch()
{
try
{
_logger.Debug($"Attempting to authorize Twitch API...");
var authorize = await _web.GetJson<TwitchBotAuth>($"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<TTSVersion?> GetLatestTTSVersion() public async Task<TTSVersion?> GetLatestTTSVersion()

View File

@ -1,5 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common; using CommonSocketLibrary.Common;
using HermesSocketLibrary.Requests.Callbacks; using HermesSocketLibrary.Requests.Callbacks;
@ -160,8 +161,7 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers
if (chatters == null) if (chatters == null)
return; return;
var client = _serviceProvider.GetRequiredService<ChatMessageHandler>(); _user.Chatters = [.. chatters];
client.Chatters = [.. chatters];
_logger.Information($"Fetched {chatters.Count()} chatters' id."); _logger.Information($"Fetched {chatters.Count()} chatters' id.");
} }
else if (message.Request.Type == "get_emotes") else if (message.Request.Type == "get_emotes")
@ -232,7 +232,7 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers
continue; continue;
} }
var path = $"{group.Name}.{permission.Path}"; var path = $"{group.Name}.{permission.Path}";
permissionManager.Set(path, permission.Allow); permissionManager.Set(path, permission.Allow);
_logger.Debug($"Added group permission [id: {permission.Id}][group id: {permission.GroupId}][path: {permission.Path}]"); _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; return;
} }
_user.RegexFilters = wordFilters.ToList(); var filters = wordFilters.Where(f => f.Search != null && f.Replace != null).ToArray();
_logger.Information($"TTS word filters [count: {_user.RegexFilters.Count}] have been refreshed."); 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") else if (message.Request.Type == "update_tts_voice_state")
{ {

View File

@ -383,7 +383,7 @@ namespace TwitchChatTTS.Hermes.Socket
} }
catch (WebSocketException wse) when (wse.Message.Contains("502")) 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) catch (Exception ex)
{ {

View File

@ -1,5 +1,4 @@
using System.Reflection; using System.Reflection;
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common; using CommonSocketLibrary.Common;
using CommonSocketLibrary.Socket.Manager; using CommonSocketLibrary.Socket.Manager;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;

View File

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

View File

@ -23,12 +23,14 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
{ {
if (data is not HelloMessage message || message == null) if (data is not HelloMessage message || message == null)
return; return;
if (sender is not OBSSocketClient client)
return;
string? password = string.IsNullOrWhiteSpace(_configuration.Obs?.Password) ? null : _configuration.Obs.Password.Trim(); string? password = string.IsNullOrWhiteSpace(_configuration.Obs?.Password) ? null : _configuration.Obs.Password.Trim();
_logger.Verbose("OBS websocket password: " + password); _logger.Verbose("OBS websocket password: " + password);
if (message.Authentication == null || string.IsNullOrEmpty(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; return;
} }
@ -52,7 +54,7 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
} }
_logger.Verbose("Final hash: " + hash); _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));
} }
} }
} }

View File

@ -134,7 +134,7 @@ namespace TwitchChatTTS.OBS.Socket
} }
catch (WebSocketException wse) when (wse.Message.Contains("502")) 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) catch (Exception ex)
{ {

View File

@ -94,7 +94,20 @@ namespace TwitchChatTTS.Seven.Socket
} }
_logger.Debug($"7tv client attempting to connect to {URL}"); _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) 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}]"); _logger.Warning($"Received end of stream message for 7tv websocket [reason: {_errorCodes[code]}][code: {code}]");
else else
_logger.Warning($"Received end of stream message for 7tv websocket [code: {code}]"); _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."); _logger.Error($"7tv client will remain disconnected due to a bad client implementation.");
return; return;
} }
else if (_reconnectDelay[code] > 0)
if (_reconnectDelay[code] > 0)
await Task.Delay(_reconnectDelay[code]); await Task.Delay(_reconnectDelay[code]);
} }
else {
if (string.IsNullOrWhiteSpace(_user.SevenEmoteSetId)) _logger.Warning("Unknown 7tv disconnection.");
{ await Task.Delay(TimeSpan.FromSeconds(30));
_logger.Warning("Could not find the 7tv emote set id. Not reconnecting.");
return;
} }
await Connect(); await Connect();

View File

@ -10,16 +10,10 @@ using YamlDotNet.Serialization.NamingConventions;
using TwitchChatTTS.Seven.Socket; using TwitchChatTTS.Seven.Socket;
using TwitchChatTTS.OBS.Socket.Handlers; using TwitchChatTTS.OBS.Socket.Handlers;
using TwitchChatTTS.Seven.Socket.Handlers; using TwitchChatTTS.Seven.Socket.Handlers;
using TwitchLib.Client.Interfaces;
using TwitchLib.Client;
using TwitchLib.PubSub.Interfaces;
using TwitchLib.PubSub;
using TwitchLib.Communication.Interfaces;
using TwitchChatTTS.Seven.Socket.Managers; using TwitchChatTTS.Seven.Socket.Managers;
using TwitchChatTTS.Hermes.Socket.Handlers; using TwitchChatTTS.Hermes.Socket.Handlers;
using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Hermes.Socket;
using TwitchChatTTS.Hermes.Socket.Managers; using TwitchChatTTS.Hermes.Socket.Managers;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchChatTTS.Chat.Commands; using TwitchChatTTS.Chat.Commands;
using System.Text.Json; using System.Text.Json;
using Serilog; using Serilog;
@ -31,6 +25,9 @@ using TwitchChatTTS.Chat.Groups;
using TwitchChatTTS.Chat.Emotes; using TwitchChatTTS.Chat.Emotes;
using HermesSocketLibrary.Requests.Callbacks; using HermesSocketLibrary.Requests.Callbacks;
using static TwitchChatTTS.Chat.Commands.TTSCommands; 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 linux-x64 -p:PublishSingleFile=true --self-contained true
// dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained true // dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained true
@ -61,6 +58,7 @@ var logger = new LoggerConfiguration()
s.AddSerilog(logger); s.AddSerilog(logger);
s.AddSingleton<User>(new User()); s.AddSingleton<User>(new User());
s.AddSingleton<AudioPlaybackEngine>();
s.AddSingleton<ICallbackManager<HermesRequestData>, CallbackManager<HermesRequestData>>(); s.AddSingleton<ICallbackManager<HermesRequestData>, CallbackManager<HermesRequestData>>();
s.AddSingleton<JsonSerializerOptions>(new JsonSerializerOptions() s.AddSingleton<JsonSerializerOptions>(new JsonSerializerOptions()
@ -82,13 +80,9 @@ s.AddSingleton<IGroupPermissionManager, GroupPermissionManager>();
s.AddSingleton<CommandManager>(); s.AddSingleton<CommandManager>();
s.AddSingleton<TTSPlayer>(); s.AddSingleton<TTSPlayer>();
s.AddSingleton<ChatMessageHandler>();
s.AddSingleton<RedemptionManager>(); s.AddSingleton<RedemptionManager>();
s.AddSingleton<HermesApiClient>(); s.AddSingleton<HermesApiClient>();
s.AddSingleton<TwitchBotAuth>(); s.AddSingleton<TwitchBotAuth>();
s.AddTransient<IClient, TwitchLib.Communication.Clients.WebSocketClient>();
s.AddTransient<ITwitchClient, TwitchClient>();
s.AddTransient<ITwitchPubSub, TwitchPubSub>();
s.AddSingleton<TwitchApiClient>(); s.AddSingleton<TwitchApiClient>();
s.AddSingleton<SevenApiClient>(); s.AddSingleton<SevenApiClient>();
@ -114,11 +108,25 @@ s.AddKeyedSingleton<IWebSocketHandler, EndOfStreamHandler>("7tv");
s.AddKeyedSingleton<MessageTypeManager<IWebSocketHandler>, SevenMessageTypeManager>("7tv"); s.AddKeyedSingleton<MessageTypeManager<IWebSocketHandler>, SevenMessageTypeManager>("7tv");
s.AddKeyedSingleton<SocketClient<WebSocketMessage>, SevenSocketClient>("7tv"); s.AddKeyedSingleton<SocketClient<WebSocketMessage>, SevenSocketClient>("7tv");
// twitch websocket
s.AddKeyedSingleton<SocketClient<TwitchWebsocketMessage>, TwitchWebsocketClient>("twitch");
s.AddKeyedSingleton<ITwitchSocketHandler, SessionWelcomeHandler>("twitch");
s.AddKeyedSingleton<ITwitchSocketHandler, SessionReconnectHandler>("twitch");
s.AddKeyedSingleton<ITwitchSocketHandler, NotificationHandler>("twitch");
s.AddKeyedSingleton<ITwitchSocketHandler, ChannelBanHandler>("twitch-notifications");
s.AddKeyedSingleton<ITwitchSocketHandler, ChannelChatMessageHandler>("twitch-notifications");
s.AddKeyedSingleton<ITwitchSocketHandler, ChannelChatClearUserHandler>("twitch-notifications");
s.AddKeyedSingleton<ITwitchSocketHandler, ChannelChatClearHandler>("twitch-notifications");
s.AddKeyedSingleton<ITwitchSocketHandler, ChannelChatDeleteMessageHandler>("twitch-notifications");
s.AddKeyedSingleton<ITwitchSocketHandler, ChannelCustomRedemptionHandler>("twitch-notifications");
s.AddKeyedSingleton<ITwitchSocketHandler, ChannelSubscriptionHandler>("twitch-notifications");
// hermes websocket // hermes websocket
s.AddKeyedSingleton<IWebSocketHandler, HeartbeatHandler>("hermes"); s.AddKeyedSingleton<IWebSocketHandler, HeartbeatHandler>("hermes");
s.AddKeyedSingleton<IWebSocketHandler, LoginAckHandler>("hermes"); s.AddKeyedSingleton<IWebSocketHandler, LoginAckHandler>("hermes");
s.AddKeyedSingleton<IWebSocketHandler, RequestAckHandler>("hermes"); s.AddKeyedSingleton<IWebSocketHandler, RequestAckHandler>("hermes");
//s.AddKeyedSingleton<IWebSocketHandler, HeartbeatHandler>("hermes");
s.AddKeyedSingleton<MessageTypeManager<IWebSocketHandler>, HermesMessageTypeManager>("hermes"); s.AddKeyedSingleton<MessageTypeManager<IWebSocketHandler>, HermesMessageTypeManager>("hermes");
s.AddKeyedSingleton<SocketClient<WebSocketMessage>, HermesSocketClient>("hermes"); s.AddKeyedSingleton<SocketClient<WebSocketMessage>, HermesSocketClient>("hermes");

130
TTS.cs
View File

@ -4,33 +4,34 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Serilog; using Serilog;
using NAudio.Wave.SampleProviders; using NAudio.Wave.SampleProviders;
using TwitchLib.Client.Events;
using org.mariuszgromada.math.mxparser; using org.mariuszgromada.math.mxparser;
using TwitchChatTTS.Hermes.Socket; using TwitchChatTTS.Hermes.Socket;
using TwitchChatTTS.Chat.Groups.Permissions;
using TwitchChatTTS.Chat.Groups;
using TwitchChatTTS.Seven.Socket; using TwitchChatTTS.Seven.Socket;
using TwitchChatTTS.Chat.Emotes; using TwitchChatTTS.Chat.Emotes;
using CommonSocketLibrary.Abstract; using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common; using CommonSocketLibrary.Common;
using TwitchChatTTS.OBS.Socket; using TwitchChatTTS.OBS.Socket;
using TwitchChatTTS.Twitch.Socket.Messages;
using TwitchChatTTS.Twitch.Socket;
namespace TwitchChatTTS namespace TwitchChatTTS
{ {
public class TTS : IHostedService public class TTS : IHostedService
{ {
public const int MAJOR_VERSION = 3; public const int MAJOR_VERSION = 4;
public const int MINOR_VERSION = 10; public const int MINOR_VERSION = 0;
private readonly User _user; private readonly User _user;
private readonly HermesApiClient _hermesApiClient; private readonly HermesApiClient _hermesApiClient;
private readonly SevenApiClient _sevenApiClient; private readonly SevenApiClient _sevenApiClient;
private readonly HermesSocketClient _hermes;
private readonly OBSSocketClient _obs; private readonly OBSSocketClient _obs;
private readonly SevenSocketClient _seven; private readonly SevenSocketClient _seven;
private readonly HermesSocketClient _hermes; private readonly TwitchWebsocketClient _twitch;
private readonly IEmoteDatabase _emotes; private readonly IEmoteDatabase _emotes;
private readonly Configuration _configuration; private readonly Configuration _configuration;
private readonly TTSPlayer _player; private readonly TTSPlayer _player;
private readonly AudioPlaybackEngine _playback;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly ILogger _logger; private readonly ILogger _logger;
@ -41,9 +42,11 @@ namespace TwitchChatTTS
[FromKeyedServices("hermes")] SocketClient<WebSocketMessage> hermes, [FromKeyedServices("hermes")] SocketClient<WebSocketMessage> hermes,
[FromKeyedServices("obs")] SocketClient<WebSocketMessage> obs, [FromKeyedServices("obs")] SocketClient<WebSocketMessage> obs,
[FromKeyedServices("7tv")] SocketClient<WebSocketMessage> seven, [FromKeyedServices("7tv")] SocketClient<WebSocketMessage> seven,
[FromKeyedServices("twitch")] SocketClient<TwitchWebsocketMessage> twitch,
IEmoteDatabase emotes, IEmoteDatabase emotes,
Configuration configuration, Configuration configuration,
TTSPlayer player, TTSPlayer player,
AudioPlaybackEngine playback,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
ILogger logger ILogger logger
) )
@ -54,9 +57,11 @@ namespace TwitchChatTTS
_hermes = (hermes as HermesSocketClient)!; _hermes = (hermes as HermesSocketClient)!;
_obs = (obs as OBSSocketClient)!; _obs = (obs as OBSSocketClient)!;
_seven = (seven as SevenSocketClient)!; _seven = (seven as SevenSocketClient)!;
_twitch = (twitch as TwitchWebsocketClient)!;
_emotes = emotes; _emotes = emotes;
_configuration = configuration; _configuration = configuration;
_player = player; _player = player;
_playback = playback;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_logger = logger; _logger = logger;
} }
@ -89,21 +94,29 @@ namespace TwitchChatTTS
await InitializeHermesWebsocket(); await InitializeHermesWebsocket();
try 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) catch (Exception ex)
{ {
_logger.Error(ex, "Failed to initialize properly. Restart app please."); _logger.Error(ex, "Failed to initialize properly. Restart app please.");
await Task.Delay(30 * 1000); await Task.Delay(30 * 1000);
}
var twitchapiclient = await InitializeTwitchApiClient(_user.TwitchUsername, _user.TwitchUserId.ToString());
if (twitchapiclient == null)
{
await Task.Delay(30 * 1000);
return; 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<TwitchApiClient>();
twitchapiclient2.Initialize(twitchBotToken);
_twitch.Initialize();
await _twitch.Connect();
var emoteSet = await _sevenApiClient.FetchChannelEmoteSet(_user.TwitchUserId.ToString()); var emoteSet = await _sevenApiClient.FetchChannelEmoteSet(_user.TwitchUserId.ToString());
if (emoteSet != null) if (emoteSet != null)
_user.SevenEmoteSetId = emoteSet.Id; _user.SevenEmoteSetId = emoteSet.Id;
@ -112,9 +125,9 @@ namespace TwitchChatTTS
await InitializeSevenTv(); await InitializeSevenTv();
await InitializeObs(); 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; _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)}"; string url = $"https://api.streamelements.com/kappa/v2/speech?voice={m.Voice}&text={HttpUtility.UrlEncode(m.Message)}";
var sound = new NetworkWavSound(url); var sound = new NetworkWavSound(url);
var provider = new CachedWavProvider(sound); var provider = new CachedWavProvider(sound);
var data = AudioPlaybackEngine.Instance.ConvertSound(provider); var data = _playback.ConvertSound(provider);
var resampled = new WdlResamplingSampleProvider(data, AudioPlaybackEngine.Instance.SampleRate); var resampled = new WdlResamplingSampleProvider(data, _playback.SampleRate);
_logger.Verbose("Fetched TTS audio data."); _logger.Verbose("Fetched TTS audio data.");
m.Audio = resampled; m.Audio = resampled;
@ -185,7 +198,7 @@ namespace TwitchChatTTS
if (!string.IsNullOrWhiteSpace(m.File) && File.Exists(m.File)) if (!string.IsNullOrWhiteSpace(m.File) && File.Exists(m.File))
{ {
_logger.Debug("Playing audio file via TTS: " + m.File); _logger.Debug("Playing audio file via TTS: " + m.File);
AudioPlaybackEngine.Instance.PlaySound(m.File); _playback.PlaySound(m.File);
continue; continue;
} }
@ -193,8 +206,8 @@ namespace TwitchChatTTS
if (m.Audio != null) if (m.Audio != null)
{ {
_player.Playing = m.Audio; _player.Playing = m;
AudioPlaybackEngine.Instance.AddMixerInput(m.Audio); _playback.AddMixerInput(m.Audio);
} }
} }
catch (Exception e) catch (Exception e)
@ -203,9 +216,6 @@ namespace TwitchChatTTS
} }
} }
}); });
_logger.Information("Twitch websocket client connecting...");
await twitchapiclient.Connect();
} }
public async Task StopAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken)
@ -216,18 +226,6 @@ namespace TwitchChatTTS
_logger.Warning("Application has stopped."); _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() private async Task InitializeHermesWebsocket()
{ {
try try
@ -267,40 +265,42 @@ namespace TwitchChatTTS
} }
} }
private async Task<TwitchApiClient?> InitializeTwitchApiClient(string username, string broadcasterId) // private async Task<TwitchApiClient?> InitializeTwitchApiClient(string username)
{ // {
_logger.Debug("Initializing twitch client."); // _logger.Debug("Initializing twitch client.");
var twitchapiclient = _serviceProvider.GetRequiredService<TwitchApiClient>();
if (!await twitchapiclient.Authorize(broadcasterId))
{
_logger.Error("Cannot connect to Twitch API.");
return null;
}
var channels = _configuration.Twitch?.Channels ?? [username]; // var hermesapiclient = _serviceProvider.GetRequiredService<HermesApiClient>();
_logger.Information("Twitch channels: " + string.Join(", ", channels)); // if (!await hermesapiclient.AuthorizeTwitch())
twitchapiclient.InitializeClient(username, channels); // {
twitchapiclient.InitializePublisher(); // _logger.Error("Cannot connect to Twitch API.");
// return null;
// }
var handler = _serviceProvider.GetRequiredService<ChatMessageHandler>(); // var twitchapiclient = _serviceProvider.GetRequiredService<TwitchApiClient>();
twitchapiclient.AddOnNewMessageReceived(async (object? s, OnMessageReceivedArgs e) => // var channels = _configuration.Twitch?.Channels ?? [username];
{ // _logger.Information("Twitch channels: " + string.Join(", ", channels));
try // twitchapiclient.InitializeClient(username, channels);
{ // twitchapiclient.InitializePublisher();
var result = await handler.Handle(e);
if (result.Status != MessageStatus.None || result.Emotes == null || !result.Emotes.Any())
return;
await _hermes.SendEmoteUsage(e.ChatMessage.Id, result.ChatterId, result.Emotes); // var handler = _serviceProvider.GetRequiredService<ChatMessageHandler>();
} // twitchapiclient.AddOnNewMessageReceived(async (object? s, OnMessageReceivedArgs e) =>
catch (Exception ex) // {
{ // try
_logger.Error(ex, "Unable to either execute a command or to send emote usage message."); // {
} // 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) private async Task InitializeEmotes(SevenApiClient sevenapi, EmoteSet? channelEmotes)
{ {

View File

@ -17,6 +17,7 @@ namespace TwitchChatTTS.Twitch.Redemptions
private readonly User _user; private readonly User _user;
private readonly OBSSocketClient _obs; private readonly OBSSocketClient _obs;
private readonly HermesSocketClient _hermes; private readonly HermesSocketClient _hermes;
private readonly AudioPlaybackEngine _playback;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Random _random; private readonly Random _random;
private bool _isReady; private bool _isReady;
@ -26,12 +27,14 @@ namespace TwitchChatTTS.Twitch.Redemptions
User user, User user,
[FromKeyedServices("obs")] SocketClient<WebSocketMessage> obs, [FromKeyedServices("obs")] SocketClient<WebSocketMessage> obs,
[FromKeyedServices("hermes")] SocketClient<WebSocketMessage> hermes, [FromKeyedServices("hermes")] SocketClient<WebSocketMessage> hermes,
AudioPlaybackEngine playback,
ILogger logger) ILogger logger)
{ {
_store = new Dictionary<string, IList<RedeemableAction>>(); _store = new Dictionary<string, IList<RedeemableAction>>();
_user = user; _user = user;
_obs = (obs as OBSSocketClient)!; _obs = (obs as OBSSocketClient)!;
_hermes = (hermes as HermesSocketClient)!; _hermes = (hermes as HermesSocketClient)!;
_playback = playback;
_logger = logger; _logger = logger;
_random = new Random(); _random = new Random();
_isReady = false; _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}]"); _logger.Warning($"Cannot find audio file for Twitch channel point redeem [file: {action.Data["file_path"]}][chatter: {senderDisplayName}][chatter id: {senderId}]");
return; 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}]"); _logger.Debug($"Played an audio file for channel point redeem [file: {action.Data["file_path"]}][chatter: {senderDisplayName}][chatter id: {senderId}]");
break; break;
default: default:

View File

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

View File

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

View File

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

View File

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

View File

@ -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<WebSocketMessage> hermes,
[FromKeyedServices("obs")] SocketClient<WebSocketMessage> 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<Task>();
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<string>();
var newEmotes = new Dictionary<string, string>();
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();
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,8 @@
namespace TwitchChatTTS.Twitch.Socket.Handlers
{
public interface ITwitchSocketHandler
{
string Name { get; }
Task Execute(TwitchWebsocketClient sender, object? data);
}
}

View File

@ -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<string, ITwitchSocketHandler> _handlers;
private readonly ILogger _logger;
private IDictionary<string, Type> _messageTypes;
private readonly JsonSerializerOptions _options;
public NotificationHandler(
[FromKeyedServices("twitch-notifications")] IEnumerable<ITwitchSocketHandler> handlers,
ILogger logger
)
{
_handlers = handlers.ToDictionary(h => h.Name, h => h);
_logger = logger;
_options = new JsonSerializerOptions() {
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
_messageTypes = new Dictionary<string, Type>();
_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);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
namespace TwitchChatTTS.Twitch.Socket.Messages
{
public class ChannelChatDeleteMessage : ChannelChatClearUserMessage
{
public string MessageId { get; set; }
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
namespace TwitchChatTTS.Twitch.Socket.Messages
{
public class EventResponse<T>
{
public T[]? Data { get; set; }
public int Total { get; set; }
public int TotalCost { get; set; }
public int MaxTotalCost { get; set; }
}
}

View File

@ -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<string, string> 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<string, string>();
Transport = new EventSubTransport();
}
public EventSubscriptionMessage(string type, string version, string callback, string secret, IDictionary<string, string>? conditions = null)
{
Type = type;
Version = version;
Condition = conditions ?? new Dictionary<string, string>();
Transport = new EventSubTransport("webhook", callback, secret);
}
public EventSubscriptionMessage(string type, string version, string sessionId, IDictionary<string, string>? conditions = null)
{
Type = type;
Version = version;
Condition = conditions ?? new Dictionary<string, string>();
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;
}
}
}
}

View File

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

View File

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

View File

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

View File

@ -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<TwitchWebsocketMessage>
{
public string URL;
private IDictionary<string, ITwitchSocketHandler> _handlers;
private IDictionary<string, Type> _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<ITwitchSocketHandler> 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<string, Type>();
_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<T>(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<T>(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<byte>(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}]");
}
}
}
}

View File

@ -1,226 +1,59 @@
using System.Text.Json; using System.Text.Json;
using TwitchChatTTS.Helpers; using TwitchChatTTS.Helpers;
using Serilog; using Serilog;
using TwitchChatTTS; using TwitchChatTTS.Twitch.Socket.Messages;
using TwitchLib.Api.Core.Exceptions; using System.Net.Http.Json;
using TwitchLib.Client.Events; using System.Net;
using TwitchLib.Client.Models;
using TwitchLib.Communication.Events;
using TwitchLib.PubSub.Interfaces;
using TwitchLib.Client.Interfaces;
using TwitchChatTTS.Twitch.Redemptions;
public class TwitchApiClient 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 ILogger _logger;
private readonly WebClientWrap _web; private readonly WebClientWrap _web;
private bool _initialized;
private string _broadcasterId;
public TwitchApiClient( public TwitchApiClient(
ITwitchClient twitchClient,
ITwitchPubSub twitchPublisher,
RedemptionManager redemptionManager,
HermesApiClient hermesApiClient,
User user,
Configuration configuration,
TwitchBotAuth token,
ILogger logger ILogger logger
) )
{ {
_redemptionManager = redemptionManager;
_hermesApiClient = hermesApiClient;
_client = twitchClient;
_publisher = twitchPublisher;
_user = user;
_configuration = configuration;
_token = token;
_logger = logger; _logger = logger;
_initialized = false;
_broadcasterId = string.Empty;
_web = new WebClientWrap(new JsonSerializerOptions() _web = new WebClientWrap(new JsonSerializerOptions()
{ {
PropertyNameCaseInsensitive = false, PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
}); });
if (!string.IsNullOrWhiteSpace(_configuration.Hermes?.Token))
_web.AddHeader("x-api-key", _configuration.Hermes.Token.Trim());
} }
public async Task<bool> Authorize(string broadcasterId) public async Task<EventResponse<EventSubscriptionMessage>?> CreateEventSubscription(string type, string version, string userId)
{ {
try var conditions = new Dictionary<string, string>() { { "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}]"); _logger.Debug("Twitch API call [type: create event subscription]: " + await response.Content.ReadAsStringAsync());
var authorize = await _web.GetJson<TwitchBotAuth>($"https://{HermesApiClient.BASE_URL}/api/account/reauthorize"); return await response.Content.ReadFromJsonAsync(typeof(EventResponse<EventSubscriptionMessage>)) as EventResponse<EventSubscriptionMessage>;
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;
} }
catch (HttpResponseException e) _logger.Warning("Twitch api failed to create event subscription: " + await response.Content.ReadAsStringAsync());
{ return null;
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;
} }
public async Task Connect() public async Task<EventResponse<EventSubscriptionMessage>?> CreateEventSubscription(string type, string version, string sessionId, string userId)
{ {
_client.Connect(); var conditions = new Dictionary<string, string>() { { "user_id", userId }, { "broadcaster_user_id", userId }, { "moderator_user_id", userId } };
await _publisher.ConnectAsync(); 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)
public void InitializeClient(string username, IEnumerable<string> channels)
{
ConnectionCredentials credentials = new ConnectionCredentials(username, _token!.AccessToken);
_client.Initialize(credentials, channels.Distinct().ToList());
if (_initialized)
{ {
_logger.Debug("Twitch API client has already been initialized."); _logger.Debug("Twitch API call [type: create event subscription]: " + await response.Content.ReadAsStringAsync());
return; return await response.Content.ReadFromJsonAsync(typeof(EventResponse<EventSubscriptionMessage>)) as EventResponse<EventSubscriptionMessage>;
} }
_logger.Error("Twitch api failed to create event subscription: " + await response.Content.ReadAsStringAsync());
_initialized = true; return null;
_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.");
} }
public void InitializePublisher() public void Initialize(TwitchBotToken token) {
{ _web.AddHeader("Authorization", "Bearer " + token.AccessToken);
_publisher.OnPubSubServiceConnected += async (s, e) => _web.AddHeader("Client-Id", token.ClientId);
{
_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<OnMessageReceivedArgs> handler)
{
_client.OnMessageReceived += handler;
} }
} }

View File

@ -14,33 +14,14 @@
<PackageReference Include="NAudio.Extras" Version="2.2.1" /> <PackageReference Include="NAudio.Extras" Version="2.2.1" />
<PackageReference Include="Serilog" Version="4.0.0" /> <PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2-dev-00338" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.2-dev-00338" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.1-dev-10391" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" /> <PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.1" /> <PackageReference Include="Serilog.Settings.Configuration" Version="8.0.2" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" /> <PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.1-dev-00972" /> <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.RollingFile" Version="3.3.1-dev-00771" />
<PackageReference Include="Serilog.Sinks.Trace" Version="4.0.0" />
<PackageReference Include="System.Text.Json" Version="8.0.4" /> <PackageReference Include="System.Text.Json" Version="8.0.4" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="8.0.0" />
<PackageReference Include="TwitchLib.Api.Core" Version="3.10.0-preview-e47ba7f" />
<PackageReference Include="TwitchLib.Api.Core.Enums" Version="3.10.0-preview-e47ba7f" />
<PackageReference Include="TwitchLib.Api.Core.Interfaces" Version="3.10.0-preview-e47ba7f" />
<PackageReference Include="TwitchLib.Api.Helix" Version="3.10.0-preview-e47ba7f" />
<PackageReference Include="TwitchLib.Api.Helix.Models" Version="3.10.0-preview-e47ba7f" />
<PackageReference Include="TwitchLib.Client" Version="4.0.0-preview-fd131763416cb9f1a31705ca609566d7e7e7fac8" />
<PackageReference Include="TwitchLib.Client.Enums" Version="4.0.0-preview-fd131763416cb9f1a31705ca609566d7e7e7fac8" />
<PackageReference Include="TwitchLib.Client.Models" Version="4.0.0-preview-fd131763416cb9f1a31705ca609566d7e7e7fac8" />
<PackageReference Include="TwitchLib.Communication" Version="2.0.1" />
<PackageReference Include="TwitchLib.EventSub.Core" Version="2.5.3-preview-e1a92de" />
<PackageReference Include="TwitchLib.PubSub" Version="4.0.0-preview-f833b1ab1ebef37618dba3fbb1e0a661ff183af5" />
<PackageReference Include="NAudio.Core" Version="2.2.1" /> <PackageReference Include="NAudio.Core" Version="2.2.1" />
<PackageReference Include="TwitchLib.Api" Version="3.10.0-preview-e47ba7f" /> <PackageReference Include="YamlDotNet" Version="16.0.0" />
<PackageReference Include="YamlDotNet" Version="15.1.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

12
User.cs
View File

@ -16,16 +16,16 @@ namespace TwitchChatTTS
public string DefaultTTSVoice { get; set; } public string DefaultTTSVoice { get; set; }
// voice id -> voice name // voice id -> voice name
public IDictionary<string, string> VoicesAvailable { get => _voicesAvailable; set { _voicesAvailable = value; WordFilterRegex = GenerateEnabledVoicesRegex(); } } public IDictionary<string, string> VoicesAvailable { get => _voicesAvailable; set { _voicesAvailable = value; VoiceNameRegex = GenerateEnabledVoicesRegex(); } }
// chatter/twitch id -> voice id // chatter/twitch id -> voice id
public IDictionary<long, string> VoicesSelected { get; set; } public IDictionary<long, string> VoicesSelected { get; set; }
// voice names // voice names
public HashSet<string> VoicesEnabled { get => _voicesEnabled; set { _voicesEnabled = value; WordFilterRegex = GenerateEnabledVoicesRegex(); } } public HashSet<string> VoicesEnabled { get => _voicesEnabled; set { _voicesEnabled = value; VoiceNameRegex = GenerateEnabledVoicesRegex(); } }
public IDictionary<string, TTSUsernameFilter> ChatterFilters { get; set; } public HashSet<long> Chatters { get; set; }
public IList<TTSWordFilter> RegexFilters { get; set; } public TTSWordFilter[] RegexFilters { get; set; }
[JsonIgnore] [JsonIgnore]
public Regex? WordFilterRegex { get; set; } public Regex? VoiceNameRegex { get; set; }
private IDictionary<string, string> _voicesAvailable; private IDictionary<string, string> _voicesAvailable;
private HashSet<string> _voicesEnabled; private HashSet<string> _voicesEnabled;
@ -37,7 +37,7 @@ namespace TwitchChatTTS
return null; return null;
var enabledVoicesString = string.Join("|", VoicesAvailable.Where(v => VoicesEnabled == null || !VoicesEnabled.Any() || VoicesEnabled.Contains(v.Value)).Select(v => v.Value)); var enabledVoicesString = string.Join("|", VoicesAvailable.Where(v => VoicesEnabled == null || !VoicesEnabled.Any() || VoicesEnabled.Contains(v.Value)).Select(v => v.Value));
return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase); return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
} }
} }
} }