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

View File

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

View File

@ -1,5 +1,6 @@
using Serilog;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Chat.Commands
{
@ -8,15 +9,18 @@ namespace TwitchChatTTS.Chat.Commands
public interface ICommandBuilder
{
ICommandSelector Build();
ICommandBuilder AddPermission(string path);
ICommandBuilder AddAlias(string alias, string child);
void Clear();
ICommandBuilder CreateCommandTree(string name, Action<ICommandBuilder> callback);
ICommandBuilder CreateCommand(IChatPartialCommand command);
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 CreateStateParameter(string name, bool optional = false);
ICommandBuilder CreateUnvalidatedParameter(string name, bool optional = false);
ICommandBuilder CreateVoiceNameParameter(string name, bool enabled, bool optional = false);
}
public sealed class CommandBuilder : ICommandBuilder
@ -37,6 +41,25 @@ namespace TwitchChatTTS.Chat.Commands
}
public ICommandBuilder AddPermission(string path)
{
if (_current == _root)
throw new Exception("Cannot add permissions without a command name.");
_current.AddPermission(path);
return this;
}
public ICommandBuilder AddAlias(string alias, string child) {
if (_current == _root)
throw new Exception("Cannot add aliases without a command name.");
if (_current.Children == null || !_current.Children.Any())
throw new Exception("Cannot add alias if this has no parameter.");
_current.AddAlias(alias, child);
return this;
}
public ICommandSelector Build()
{
return new CommandSelector(_root);
@ -89,6 +112,19 @@ namespace TwitchChatTTS.Chat.Commands
return this;
}
public ICommandBuilder CreateMentionParameter(string name, bool enabled, bool optional = false)
{
if (_root == _current)
throw new Exception("Cannot create a parameter without a command name.");
if (optional && _current.IsRequired() && _current.Command == null)
throw new Exception("Cannot create a optional parameter without giving the command to the last node with required parameter.");
var node = _current.CreateUserInput(new MentionParameter(name, optional));
_logger.Debug($"Creating obs transformation parameter '{name}'");
_current = node;
return this;
}
public ICommandBuilder CreateObsTransformationParameter(string name, bool optional = false)
{
if (_root == _current)
@ -164,9 +200,8 @@ namespace TwitchChatTTS.Chat.Commands
public interface ICommandSelector
{
CommandSelectorResult GetBestMatch(string[] args);
CommandSelectorResult GetBestMatch(string[] args, ChannelChatMessage message);
IDictionary<string, CommandParameter> GetNonStaticArguments(string[] args, string path);
CommandValidationResult Validate(string[] args, string path);
}
public sealed class CommandSelector : ICommandSelector
@ -178,67 +213,36 @@ namespace TwitchChatTTS.Chat.Commands
_root = root;
}
public CommandSelectorResult GetBestMatch(string[] args)
public CommandSelectorResult GetBestMatch(string[] args, ChannelChatMessage message)
{
return GetBestMatch(_root, args, null, string.Empty);
return GetBestMatch(_root, message, args, null, string.Empty, null);
}
private CommandSelectorResult GetBestMatch(CommandNode node, IEnumerable<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())
return new CommandSelectorResult(match, path);
return new CommandSelectorResult(match, path, permissions);
if (!node.Children.Any())
return new CommandSelectorResult(node.Command ?? match, path);
return new CommandSelectorResult(node.Command ?? match, path, permissions);
var argument = args.First();
var argumentLower = argument.ToLower();
foreach (var child in node.Children)
{
var perms = child.Permissions != null ? (permissions ?? []).Union(child.Permissions).Distinct().ToArray() : permissions;
if (child.Parameter.GetType() == typeof(StaticParameter))
{
if (child.Parameter.Name.ToLower() == argumentLower)
{
return GetBestMatch(child, args.Skip(1), child.Command ?? match, (path.Length == 0 ? string.Empty : path + ".") + child.Parameter.Name.ToLower());
}
return GetBestMatch(child, message, args.Skip(1), child.Command ?? match, (path.Length == 0 ? string.Empty : path + ".") + child.Parameter.Name.ToLower(), perms);
continue;
}
return GetBestMatch(child, args.Skip(1), child.Command ?? match, (path.Length == 0 ? string.Empty : path + ".") + "*");
if ((!child.Parameter.Optional || child.Parameter.Validate(argument, message)) && child.Command != null)
return GetBestMatch(child, message, args.Skip(1), child.Command, (path.Length == 0 ? string.Empty : path + ".") + "*", perms);
if (!child.Parameter.Optional)
return GetBestMatch(child, message, args.Skip(1), match, (path.Length == 0 ? string.Empty : path + ".") + "*", permissions);
}
return new CommandSelectorResult(match, path);
}
public CommandValidationResult Validate(string[] args, string path)
{
CommandNode? current = _root;
var parts = path.Split('.');
if (args.Length < parts.Length)
throw new Exception($"Command path too long for the number of arguments passed in [path: {path}][parts: {parts.Length}][args count: {args.Length}]");
for (var i = 0; i < parts.Length; i++)
{
var part = parts[i];
if (part == "*")
{
current = current.Children.FirstOrDefault(n => n.Parameter.GetType() != typeof(StaticParameter));
if (current == null)
throw new Exception($"Cannot find command path [path: {path}][subpath: {part}]");
if (!current.Parameter.Validate(args[i]))
{
return new CommandValidationResult(false, args[i]);
}
}
else
{
current = current.Children.FirstOrDefault(n => n.Parameter.GetType() == typeof(StaticParameter) && n.Parameter.Name == part);
if (current == null)
throw new Exception($"Cannot find command path [path: {path}][subpath: {part}]");
}
}
return new CommandValidationResult(true, null);
return new CommandSelectorResult(match, path, permissions);
}
public IDictionary<string, CommandParameter> GetNonStaticArguments(string[] args, string path)
@ -276,11 +280,13 @@ namespace TwitchChatTTS.Chat.Commands
{
public IChatPartialCommand? Command { get; set; }
public string Path { get; set; }
public string[]? Permissions { get; set; }
public CommandSelectorResult(IChatPartialCommand? command, string path)
public CommandSelectorResult(IChatPartialCommand? command, string path, string[]? permissions)
{
Command = command;
Path = path;
Permissions = permissions;
}
}
@ -300,6 +306,7 @@ namespace TwitchChatTTS.Chat.Commands
{
public IChatPartialCommand? Command { get; private set; }
public CommandParameter Parameter { get; }
public string[]? Permissions { get; private set; }
public IList<CommandNode> Children { get => _children.AsReadOnly(); }
private IList<CommandNode> _children;
@ -308,9 +315,34 @@ namespace TwitchChatTTS.Chat.Commands
{
Parameter = parameter;
_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)
{
if (Command != null)

View File

@ -5,7 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Serilog;
using TwitchChatTTS.Chat.Groups.Permissions;
using TwitchChatTTS.Hermes.Socket;
using TwitchLib.Client.Models;
using TwitchChatTTS.Twitch.Socket.Messages;
using static TwitchChatTTS.Chat.Commands.TTSCommands;
namespace TwitchChatTTS.Chat.Commands
@ -44,7 +44,7 @@ namespace TwitchChatTTS.Chat.Commands
}
public async Task<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))
return ChatCommandResult.Unknown;
@ -62,7 +62,7 @@ namespace TwitchChatTTS.Chat.Commands
string[] args = parts.ToArray();
string com = args.First().ToLower();
CommandSelectorResult selectorResult = _commandSelector.GetBestMatch(args);
CommandSelectorResult selectorResult = _commandSelector.GetBestMatch(args, message);
if (selectorResult.Command == null)
{
_logger.Warning($"Could not match '{arg}' to any command.");
@ -71,31 +71,27 @@ namespace TwitchChatTTS.Chat.Commands
// Check if command can be executed by this chatter.
var command = selectorResult.Command;
long chatterId = long.Parse(message.UserId);
long chatterId = long.Parse(message.ChatterUserId);
if (chatterId != _user.OwnerId)
{
var executable = command.AcceptCustomPermission ? CanExecute(chatterId, groups, com) : null;
if (executable == false)
bool executable = command.AcceptCustomPermission ? CanExecute(chatterId, groups, $"tts.command.{com}", selectorResult.Permissions) : false;
if (!executable)
{
_logger.Debug($"Denied permission to use command [chatter id: {chatterId}][command: {com}]");
return ChatCommandResult.Permission;
}
else if (executable == null && !command.CheckDefaultPermissions(message))
{
_logger.Debug($"Chatter is missing default permission to execute command named '{com}' [args: {arg}][command type: {command.GetType().Name}][chatter: {message.Username}][chatter id: {message.UserId}]");
return ChatCommandResult.Permission;
}
}
// Check if the arguments are correct.
// Check if the arguments are valid.
var arguments = _commandSelector.GetNonStaticArguments(args, selectorResult.Path);
foreach (var entry in arguments)
{
var parameter = entry.Value;
var argument = entry.Key;
if (!parameter.Validate(argument))
// Optional parameters were validated while fetching this command.
if (!parameter.Optional && !parameter.Validate(argument, message))
{
_logger.Warning($"Command failed due to an argument being invalid [argument name: {parameter.Name}][argument value: {argument}][arguments: {arg}][command type: {command.GetType().Name}][chatter: {message.Username}][chatter id: {message.UserId}]");
_logger.Warning($"Command failed due to an argument being invalid [argument name: {parameter.Name}][argument value: {argument}][arguments: {arg}][command type: {command.GetType().Name}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]");
return ChatCommandResult.Syntax;
}
}
@ -107,18 +103,18 @@ namespace TwitchChatTTS.Chat.Commands
}
catch (Exception e)
{
_logger.Error(e, $"Command '{arg}' failed [args: {arg}][command type: {command.GetType().Name}][chatter: {message.Username}][chatter id: {message.UserId}]");
_logger.Error(e, $"Command '{arg}' failed [args: {arg}][command type: {command.GetType().Name}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]");
return ChatCommandResult.Fail;
}
_logger.Information($"Executed the {com} command [args: {arg}][command type: {command.GetType().Name}][chatter: {message.Username}][chatter id: {message.UserId}]");
_logger.Information($"Executed the {com} command [args: {arg}][command type: {command.GetType().Name}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]");
return ChatCommandResult.Success;
}
private bool? CanExecute(long chatterId, IEnumerable<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}]");
return _permissionManager.CheckIfAllowed(groups, path);
_logger.Debug($"Checking for permission [chatter id: {chatterId}][group: {string.Join(", ", groups)}][path: {path}]{(additionalPaths != null ? "[paths: " + string.Join('|', additionalPaths) + "]" : string.Empty)}");
return _permissionManager.CheckIfAllowed(groups, path) != false && (additionalPaths == null || additionalPaths.All(p => _permissionManager.CheckIfAllowed(groups, p) != false));
}
}
}

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

View File

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

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

View File

@ -1,3 +1,5 @@
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Chat.Commands.Parameters
{
public class StateParameter : CommandParameter
@ -8,7 +10,7 @@ namespace TwitchChatTTS.Chat.Commands.Parameters
{
}
public override bool Validate(string value)
public override bool Validate(string value, ChannelChatMessage message)
{
return _values.Contains(value.ToLower());
}

View File

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

View File

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

View File

@ -1,3 +1,5 @@
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Chat.Commands.Parameters
{
public class UnvalidatedParameter : CommandParameter
@ -6,7 +8,7 @@ namespace TwitchChatTTS.Chat.Commands.Parameters
{
}
public override bool Validate(string value)
public override bool Validate(string value, ChannelChatMessage message)
{
return true;
}

View File

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

View File

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

View File

@ -1,6 +1,6 @@
using Serilog;
using TwitchChatTTS.Hermes.Socket;
using TwitchLib.Client.Models;
using TwitchChatTTS.Twitch.Socket.Messages;
using static TwitchChatTTS.Chat.Commands.TTSCommands;
namespace TwitchChatTTS.Chat.Commands
@ -28,41 +28,30 @@ namespace TwitchChatTTS.Chat.Commands
b.CreateVoiceNameParameter("voiceName", false)
.CreateCommand(new AddTTSVoiceCommand(_user, _logger));
})
.CreateStaticInputParameter("del", b =>
{
b.CreateVoiceNameParameter("voiceName", true)
.CreateCommand(new DeleteTTSVoiceCommand(_user, _logger));
})
.AddAlias("insert", "add")
.CreateStaticInputParameter("delete", b =>
{
b.CreateVoiceNameParameter("voiceName", true)
.CreateCommand(new DeleteTTSVoiceCommand(_user, _logger));
})
.CreateStaticInputParameter("remove", b =>
{
b.CreateVoiceNameParameter("voiceName", true)
.CreateCommand(new DeleteTTSVoiceCommand(_user, _logger));
})
.AddAlias("del", "delete")
.AddAlias("remove", "delete")
.CreateStaticInputParameter("enable", b =>
{
b.CreateVoiceNameParameter("voiceName", false)
.CreateCommand(new SetTTSVoiceStateCommand(true, _user, _logger));
})
.CreateStaticInputParameter("on", b =>
{
b.CreateVoiceNameParameter("voiceName", false)
.CreateCommand(new SetTTSVoiceStateCommand(true, _user, _logger));
})
.AddAlias("on", "enable")
.AddAlias("enabled", "enable")
.AddAlias("true", "enable")
.CreateStaticInputParameter("disable", b =>
{
b.CreateVoiceNameParameter("voiceName", true)
.CreateCommand(new SetTTSVoiceStateCommand(false, _user, _logger));
})
.CreateStaticInputParameter("off", b =>
{
b.CreateVoiceNameParameter("voiceName", true)
.CreateCommand(new SetTTSVoiceStateCommand(false, _user, _logger));
});
.AddAlias("off", "disable")
.AddAlias("disabled", "disable")
.AddAlias("false", "disable");
});
}
@ -80,12 +69,7 @@ namespace TwitchChatTTS.Chat.Commands
_logger = logger;
}
public bool CheckDefaultPermissions(ChatMessage message)
{
return false;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
if (_user == null || _user.VoicesAvailable == null)
return;
@ -95,12 +79,12 @@ namespace TwitchChatTTS.Chat.Commands
var exists = _user.VoicesAvailable.Any(v => v.Value.ToLower() == voiceNameLower);
if (exists)
{
_logger.Warning($"Voice already exists [voice: {voiceName}][id: {message.UserId}]");
_logger.Warning($"Voice already exists [voice: {voiceName}][id: {message.ChatterUserId}]");
return;
}
await client.CreateTTSVoice(voiceName);
_logger.Information($"Added a new TTS voice by {message.Username} [voice: {voiceName}][id: {message.UserId}]");
_logger.Information($"Added a new TTS voice [voice: {voiceName}][creator: {message.ChatterUserLogin}][creator id: {message.ChatterUserId}]");
}
}
@ -117,16 +101,11 @@ namespace TwitchChatTTS.Chat.Commands
_logger = logger;
}
public bool CheckDefaultPermissions(ChatMessage message)
{
return false;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
if (_user == null || _user.VoicesAvailable == null)
{
_logger.Debug($"Voices available are not loaded [chatter: {message.Username}][chatter id: {message.UserId}]");
_logger.Warning($"Voices available are not loaded [chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]");
return;
}
@ -135,13 +114,18 @@ namespace TwitchChatTTS.Chat.Commands
var exists = _user.VoicesAvailable.Any(v => v.Value.ToLower() == voiceNameLower);
if (!exists)
{
_logger.Debug($"Voice does not exist [voice: {voiceName}][chatter: {message.Username}][chatter id: {message.UserId}]");
_logger.Warning($"Voice does not exist [voice: {voiceName}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]");
return;
}
var voiceId = _user.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceNameLower).Key;
if (voiceId == null) {
_logger.Warning($"Could not find the identifier for the tts voice [voice name: {voiceName}]");
return;
}
var voiceId = _user.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceName).Key;
await client.DeleteTTSVoice(voiceId);
_logger.Information($"Deleted a TTS voice [voice: {voiceName}][chatter: {message.Username}][chatter id: {message.UserId}]");
_logger.Information($"Deleted a TTS voice [voice: {voiceName}][chatter: {message.ChatterUserLogin}][chatter id: {message.ChatterUserId}]");
}
}
@ -160,12 +144,7 @@ namespace TwitchChatTTS.Chat.Commands
_logger = logger;
}
public bool CheckDefaultPermissions(ChatMessage message)
{
return message.IsModerator || message.IsBroadcaster;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
if (_user == null || _user.VoicesAvailable == null)
return;
@ -175,7 +154,7 @@ namespace TwitchChatTTS.Chat.Commands
var voiceId = _user.VoicesAvailable.FirstOrDefault(v => v.Value.ToLower() == voiceNameLower).Key;
await client.UpdateTTSVoiceState(voiceId, _state);
_logger.Information($"Changed state for TTS voice [voice: {voiceName}][state: {_state}][invoker: {message.Username}][id: {message.UserId}]");
_logger.Information($"Changed state for TTS voice [voice: {voiceName}][state: {_state}][invoker: {message.ChatterUserLogin}][id: {message.ChatterUserId}]");
}
}
}

View File

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

View File

@ -1,6 +1,6 @@
using Serilog;
using TwitchChatTTS.Hermes.Socket;
using TwitchLib.Client.Models;
using TwitchChatTTS.Twitch.Socket.Messages;
using static TwitchChatTTS.Chat.Commands.TTSCommands;
namespace TwitchChatTTS.Chat.Commands
@ -8,6 +8,8 @@ namespace TwitchChatTTS.Chat.Commands
public class VoiceCommand : IChatCommand
{
private readonly User _user;
// TODO: get permissions
// TODO: validated parameter for username by including '@' and regex for username
private readonly ILogger _logger;
public VoiceCommand(User user, ILogger logger)
@ -23,7 +25,10 @@ namespace TwitchChatTTS.Chat.Commands
builder.CreateCommandTree(Name, b =>
{
b.CreateVoiceNameParameter("voiceName", true)
.CreateCommand(new TTSVoiceSelector(_user, _logger));
.CreateCommand(new TTSVoiceSelector(_user, _logger))
.CreateUnvalidatedParameter("chatter", optional: true)
.AddPermission("tts.command.voice.admin")
.CreateCommand(new TTSVoiceSelectorAdmin(_user, _logger));
});
}
@ -40,18 +45,12 @@ namespace TwitchChatTTS.Chat.Commands
_logger = logger;
}
public bool CheckDefaultPermissions(ChatMessage message)
{
return message.IsModerator || message.IsBroadcaster || message.IsSubscriber || message.Bits >= 100;
}
public async Task Execute(IDictionary<string, string> values, ChatMessage message, HermesSocketClient client)
public async Task Execute(IDictionary<string, string> values, ChannelChatMessage message, HermesSocketClient client)
{
if (_user == null || _user.VoicesSelected == null)
return;
long chatterId = long.Parse(message.UserId);
long chatterId = long.Parse(message.ChatterUserId);
var voiceName = values["voiceName"];
var voiceNameLower = voiceName.ToLower();
var voice = _user.VoicesAvailable.First(v => v.Value.ToLower() == voiceNameLower);
@ -59,12 +58,56 @@ namespace TwitchChatTTS.Chat.Commands
if (_user.VoicesSelected.ContainsKey(chatterId))
{
await client.UpdateTTSUser(chatterId, voice.Key);
_logger.Debug($"Sent request to create chat TTS voice [voice: {voice.Value}][username: {message.Username}][reason: command]");
_logger.Debug($"Sent request to create chat TTS voice [voice: {voice.Value}][username: {message.ChatterUserLogin}][reason: command]");
}
else
{
await client.CreateTTSUser(chatterId, voice.Key);
_logger.Debug($"Sent request to update chat TTS voice [voice: {voice.Value}][username: {message.Username}][reason: command]");
_logger.Debug($"Sent request to update chat TTS voice [voice: {voice.Value}][username: {message.ChatterUserLogin}][reason: command]");
}
}
}
private sealed class TTSVoiceSelectorAdmin : IChatPartialCommand
{
private readonly User _user;
private readonly ILogger _logger;
public bool AcceptCustomPermission { get => true; }
public TTSVoiceSelectorAdmin(User user, ILogger logger)
{
_user = user;
_logger = logger;
}
public async Task Execute(IDictionary<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;
}
public bool? CheckIfAllowed(IEnumerable<string> groups, string path) {
public bool? CheckIfAllowed(IEnumerable<string> groups, string path)
{
bool overall = false;
foreach (var group in groups) {
foreach (var group in groups)
{
var result = CheckIfAllowed($"{group}.{path}");
if (result == false)
return false;
@ -73,7 +75,7 @@ namespace TwitchChatTTS.Chat.Groups.Permissions
{
if (path.Length == 0)
return node;
var parts = path.Split('.');
var name = parts.First();
var next = node.Children?.FirstOrDefault(n => n.Name == name);
@ -87,61 +89,62 @@ namespace TwitchChatTTS.Chat.Groups.Permissions
}
return Get(next, string.Join('.', parts.Skip(1)), edit);
}
}
internal class PermissionNode
{
public string Name { get; }
public bool? Allow
private sealed class PermissionNode
{
get
public string Name { get; }
public bool? Allow
{
var current = this;
while (current._allow == null && current._parent != null)
current = current._parent;
return current._allow;
}
set => _allow = value;
}
public int Priority;
internal PermissionNode? Parent { get => _parent; }
public IList<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)
get
{
_children.RemoveAt(i);
break;
var current = this;
while (current._allow == null && current._parent != null)
current = current._parent;
return current._allow;
}
set => _allow = value;
}
internal PermissionNode? Parent { get => _parent; }
public IList<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.Wave.SampleProviders;
public class AudioPlaybackEngine : IDisposable
public sealed class AudioPlaybackEngine : IDisposable
{
public static readonly AudioPlaybackEngine Instance = new AudioPlaybackEngine(44100, 2);
private readonly IWavePlayer outputDevice;
private readonly MixingSampleProvider mixer;
public int SampleRate { get; }
private readonly IWavePlayer _outputDevice;
private readonly MixingSampleProvider _mixer;
private AudioPlaybackEngine(int sampleRate = 44100, int channelCount = 2)
public AudioPlaybackEngine(int sampleRate = 44100, int channelCount = 2)
{
SampleRate = sampleRate;
outputDevice = new WaveOutEvent();
_outputDevice = new WaveOutEvent();
mixer = new MixingSampleProvider(WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channelCount));
mixer.ReadFully = true;
_mixer = new MixingSampleProvider(WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, channelCount));
_mixer.ReadFully = true;
outputDevice.Init(mixer);
outputDevice.Play();
_outputDevice.Init(_mixer);
_outputDevice.Play();
}
private ISampleProvider ConvertToRightChannelCount(ISampleProvider? input)
@ -27,11 +26,11 @@ public class AudioPlaybackEngine : IDisposable
if (input == null)
throw new NullReferenceException(nameof(input));
if (input.WaveFormat.Channels == mixer.WaveFormat.Channels)
if (input.WaveFormat.Channels == _mixer.WaveFormat.Channels)
return input;
if (input.WaveFormat.Channels == 1 && mixer.WaveFormat.Channels == 2)
if (input.WaveFormat.Channels == 1 && _mixer.WaveFormat.Channels == 2)
return new MonoToStereoSampleProvider(input);
if (input.WaveFormat.Channels == 2 && mixer.WaveFormat.Channels == 1)
if (input.WaveFormat.Channels == 2 && _mixer.WaveFormat.Channels == 1)
return new StereoToMonoSampleProvider(input);
throw new NotImplementedException("Not yet implemented this channel count conversion");
}
@ -89,26 +88,26 @@ public class AudioPlaybackEngine : IDisposable
public void AddMixerInput(ISampleProvider input)
{
mixer.AddMixerInput(input);
_mixer.AddMixerInput(input);
}
public void AddMixerInput(IWaveProvider input)
{
mixer.AddMixerInput(input);
_mixer.AddMixerInput(input);
}
public void RemoveMixerInput(ISampleProvider sound)
{
mixer.RemoveMixerInput(sound);
_mixer.RemoveMixerInput(sound);
}
public void AddOnMixerInputEnded(EventHandler<SampleProviderEventArgs> e)
{
mixer.MixerInputEnded += e;
_mixer.MixerInputEnded += e;
}
public void Dispose()
{
outputDevice.Dispose();
_outputDevice.Dispose();
}
}

View File

@ -1,4 +1,5 @@
using NAudio.Wave;
using TwitchChatTTS.Twitch.Socket.Messages;
public class TTSPlayer
{
@ -7,7 +8,7 @@ public class TTSPlayer
private readonly Mutex _mutex;
private readonly Mutex _mutex2;
public ISampleProvider? Playing { get; set; }
public TTSMessage? Playing { get; set; }
public TTSPlayer()
{
@ -100,12 +101,80 @@ public class TTSPlayer
}
}
public void RemoveAll(long chatterId)
{
try
{
_mutex2.WaitOne();
if (_buffer.UnorderedItems.Any(i => i.Element.ChatterId == chatterId)) {
var list = _buffer.UnorderedItems.Where(i => i.Element.ChatterId != chatterId).ToArray();
_buffer.Clear();
foreach (var item in list)
_buffer.Enqueue(item.Element, item.Element.Priority);
}
}
finally
{
_mutex2.ReleaseMutex();
}
try
{
_mutex.WaitOne();
if (_messages.UnorderedItems.Any(i => i.Element.ChatterId == chatterId)) {
var list = _messages.UnorderedItems.Where(i => i.Element.ChatterId != chatterId).ToArray();
_messages.Clear();
foreach (var item in list)
_messages.Enqueue(item.Element, item.Element.Priority);
}
}
finally
{
_mutex.ReleaseMutex();
}
}
public void RemoveMessage(string messageId)
{
try
{
_mutex2.WaitOne();
if (_buffer.UnorderedItems.Any(i => i.Element.MessageId == messageId)) {
var list = _buffer.UnorderedItems.Where(i => i.Element.MessageId != messageId).ToArray();
_buffer.Clear();
foreach (var item in list)
_buffer.Enqueue(item.Element, item.Element.Priority);
return;
}
}
finally
{
_mutex2.ReleaseMutex();
}
try
{
_mutex.WaitOne();
if (_messages.UnorderedItems.Any(i => i.Element.MessageId == messageId)) {
var list = _messages.UnorderedItems.Where(i => i.Element.MessageId != messageId).ToArray();
_messages.Clear();
foreach (var item in list)
_messages.Enqueue(item.Element, item.Element.Priority);
}
}
finally
{
_mutex.ReleaseMutex();
}
}
public bool IsEmpty()
{
return _messages.Count == 0;
}
private class DescendingOrder : IComparer<int> {
private class DescendingOrder : IComparer<int>
{
public int Compare(int x, int y) => y.CompareTo(x);
}
}
@ -113,15 +182,12 @@ public class TTSPlayer
public class TTSMessage
{
public string? Voice { get; set; }
public string? Channel { get; set; }
public string? Username { get; set; }
public long ChatterId { get; set; }
public string MessageId { get; set; }
public string? Message { get; set; }
public string? File { get; set; }
public DateTime Timestamp { get; set; }
public bool Moderator { get; set; }
public bool Bot { get; set; }
public IEnumerable<KeyValuePair<string, string>>? Badges { get; set; }
public int Bits { get; set; }
public IEnumerable<TwitchBadge> Badges { get; set; }
public int Priority { 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)
{
return await _client.PostAsJsonAsync(uri, data);
return await _client.PostAsJsonAsync(uri, data, _options);
}
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 HermesSocketLibrary.Requests.Messages;
using TwitchChatTTS.Hermes;
using TwitchChatTTS.Chat.Groups.Permissions;
using TwitchChatTTS.Chat.Groups;
using HermesSocketLibrary.Socket.Data;
using Serilog;
public class HermesApiClient
{
private readonly TwitchBotAuth _token;
private readonly WebClientWrap _web;
private readonly ILogger _logger;
public const string BASE_URL = "tomtospeech.com";
public HermesApiClient(Configuration configuration)
public HermesApiClient(TwitchBotAuth token, Configuration configuration, ILogger logger)
{
if (string.IsNullOrWhiteSpace(configuration.Hermes?.Token))
{
throw new Exception("Ensure you have written your API key in \".token\" file, in the same folder as this application.");
}
_token = token;
_web = new WebClientWrap(new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
_web.AddHeader("x-api-key", configuration.Hermes.Token);
_logger = logger;
}
public async Task<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()

View File

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

View File

@ -383,7 +383,7 @@ namespace TwitchChatTTS.Hermes.Socket
}
catch (WebSocketException wse) when (wse.Message.Contains("502"))
{
_logger.Error("Hermes websocket server cannot be found.");
_logger.Error($"Hermes websocket server cannot be found [code: {wse.ErrorCode}]");
}
catch (Exception ex)
{

View File

@ -1,5 +1,4 @@
using System.Reflection;
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using CommonSocketLibrary.Socket.Manager;
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)
return;
if (sender is not OBSSocketClient client)
return;
string? password = string.IsNullOrWhiteSpace(_configuration.Obs?.Password) ? null : _configuration.Obs.Password.Trim();
_logger.Verbose("OBS websocket password: " + password);
if (message.Authentication == null || string.IsNullOrEmpty(password))
{
await sender.Send(1, new IdentifyMessage(message.RpcVersion, null, 1023 | 262144));
await client.Send(1, new IdentifyMessage(message.RpcVersion, null, 1023 | 262144));
return;
}
@ -52,7 +54,7 @@ namespace TwitchChatTTS.OBS.Socket.Handlers
}
_logger.Verbose("Final hash: " + hash);
await sender.Send(1, new IdentifyMessage(message.RpcVersion, hash, 1023 | 262144));
await client.Send(1, new IdentifyMessage(message.RpcVersion, hash, 1023 | 262144));
}
}
}

View File

@ -134,7 +134,7 @@ namespace TwitchChatTTS.OBS.Socket
}
catch (WebSocketException wse) when (wse.Message.Contains("502"))
{
_logger.Error("OBS websocket server cannot be found. Be sure the server is on by looking at OBS > Tools > Websocket Server Settings.");
_logger.Error($"OBS websocket server cannot be found. Be sure the server is on by looking at OBS > Tools > Websocket Server Settings [code: {wse.ErrorCode}]");
}
catch (Exception ex)
{

View File

@ -94,7 +94,20 @@ namespace TwitchChatTTS.Seven.Socket
}
_logger.Debug($"7tv client attempting to connect to {URL}");
await ConnectAsync($"{URL}");
try
{
await ConnectAsync(URL);
}
catch (Exception ex)
{
_logger.Error(ex, "Could not connect to 7tv websocket.");
}
if (!Connected)
{
await Task.Delay(30000);
await Connect();
}
}
private async void OnDisconnection(object? sender, SocketDisconnectionEventArgs e)
@ -107,21 +120,20 @@ namespace TwitchChatTTS.Seven.Socket
_logger.Warning($"Received end of stream message for 7tv websocket [reason: {_errorCodes[code]}][code: {code}]");
else
_logger.Warning($"Received end of stream message for 7tv websocket [code: {code}]");
if (code >= 0 && code < _reconnectDelay.Length && _reconnectDelay[code] < 0)
if (code < 0 || code >= _reconnectDelay.Length)
await Task.Delay(TimeSpan.FromSeconds(30));
else if (_reconnectDelay[code] < 0)
{
_logger.Error($"7tv client will remain disconnected due to a bad client implementation.");
return;
}
if (_reconnectDelay[code] > 0)
else if (_reconnectDelay[code] > 0)
await Task.Delay(_reconnectDelay[code]);
}
if (string.IsNullOrWhiteSpace(_user.SevenEmoteSetId))
{
_logger.Warning("Could not find the 7tv emote set id. Not reconnecting.");
return;
else {
_logger.Warning("Unknown 7tv disconnection.");
await Task.Delay(TimeSpan.FromSeconds(30));
}
await Connect();

View File

@ -10,16 +10,10 @@ using YamlDotNet.Serialization.NamingConventions;
using TwitchChatTTS.Seven.Socket;
using TwitchChatTTS.OBS.Socket.Handlers;
using TwitchChatTTS.Seven.Socket.Handlers;
using TwitchLib.Client.Interfaces;
using TwitchLib.Client;
using TwitchLib.PubSub.Interfaces;
using TwitchLib.PubSub;
using TwitchLib.Communication.Interfaces;
using TwitchChatTTS.Seven.Socket.Managers;
using TwitchChatTTS.Hermes.Socket.Handlers;
using TwitchChatTTS.Hermes.Socket;
using TwitchChatTTS.Hermes.Socket.Managers;
using TwitchChatTTS.Chat.Commands.Parameters;
using TwitchChatTTS.Chat.Commands;
using System.Text.Json;
using Serilog;
@ -31,6 +25,9 @@ using TwitchChatTTS.Chat.Groups;
using TwitchChatTTS.Chat.Emotes;
using HermesSocketLibrary.Requests.Callbacks;
using static TwitchChatTTS.Chat.Commands.TTSCommands;
using TwitchChatTTS.Twitch.Socket;
using TwitchChatTTS.Twitch.Socket.Messages;
using TwitchChatTTS.Twitch.Socket.Handlers;
// dotnet publish -r linux-x64 -p:PublishSingleFile=true --self-contained true
// dotnet publish -r win-x64 -p:PublishSingleFile=true --self-contained true
@ -61,6 +58,7 @@ var logger = new LoggerConfiguration()
s.AddSerilog(logger);
s.AddSingleton<User>(new User());
s.AddSingleton<AudioPlaybackEngine>();
s.AddSingleton<ICallbackManager<HermesRequestData>, CallbackManager<HermesRequestData>>();
s.AddSingleton<JsonSerializerOptions>(new JsonSerializerOptions()
@ -82,13 +80,9 @@ s.AddSingleton<IGroupPermissionManager, GroupPermissionManager>();
s.AddSingleton<CommandManager>();
s.AddSingleton<TTSPlayer>();
s.AddSingleton<ChatMessageHandler>();
s.AddSingleton<RedemptionManager>();
s.AddSingleton<HermesApiClient>();
s.AddSingleton<TwitchBotAuth>();
s.AddTransient<IClient, TwitchLib.Communication.Clients.WebSocketClient>();
s.AddTransient<ITwitchClient, TwitchClient>();
s.AddTransient<ITwitchPubSub, TwitchPubSub>();
s.AddSingleton<TwitchApiClient>();
s.AddSingleton<SevenApiClient>();
@ -114,11 +108,25 @@ s.AddKeyedSingleton<IWebSocketHandler, EndOfStreamHandler>("7tv");
s.AddKeyedSingleton<MessageTypeManager<IWebSocketHandler>, SevenMessageTypeManager>("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
s.AddKeyedSingleton<IWebSocketHandler, HeartbeatHandler>("hermes");
s.AddKeyedSingleton<IWebSocketHandler, LoginAckHandler>("hermes");
s.AddKeyedSingleton<IWebSocketHandler, RequestAckHandler>("hermes");
//s.AddKeyedSingleton<IWebSocketHandler, HeartbeatHandler>("hermes");
s.AddKeyedSingleton<MessageTypeManager<IWebSocketHandler>, HermesMessageTypeManager>("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 Serilog;
using NAudio.Wave.SampleProviders;
using TwitchLib.Client.Events;
using org.mariuszgromada.math.mxparser;
using TwitchChatTTS.Hermes.Socket;
using TwitchChatTTS.Chat.Groups.Permissions;
using TwitchChatTTS.Chat.Groups;
using TwitchChatTTS.Seven.Socket;
using TwitchChatTTS.Chat.Emotes;
using CommonSocketLibrary.Abstract;
using CommonSocketLibrary.Common;
using TwitchChatTTS.OBS.Socket;
using TwitchChatTTS.Twitch.Socket.Messages;
using TwitchChatTTS.Twitch.Socket;
namespace TwitchChatTTS
{
public class TTS : IHostedService
{
public const int MAJOR_VERSION = 3;
public const int MINOR_VERSION = 10;
public const int MAJOR_VERSION = 4;
public const int MINOR_VERSION = 0;
private readonly User _user;
private readonly HermesApiClient _hermesApiClient;
private readonly SevenApiClient _sevenApiClient;
private readonly HermesSocketClient _hermes;
private readonly OBSSocketClient _obs;
private readonly SevenSocketClient _seven;
private readonly HermesSocketClient _hermes;
private readonly TwitchWebsocketClient _twitch;
private readonly IEmoteDatabase _emotes;
private readonly Configuration _configuration;
private readonly TTSPlayer _player;
private readonly AudioPlaybackEngine _playback;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger _logger;
@ -41,9 +42,11 @@ namespace TwitchChatTTS
[FromKeyedServices("hermes")] SocketClient<WebSocketMessage> hermes,
[FromKeyedServices("obs")] SocketClient<WebSocketMessage> obs,
[FromKeyedServices("7tv")] SocketClient<WebSocketMessage> seven,
[FromKeyedServices("twitch")] SocketClient<TwitchWebsocketMessage> twitch,
IEmoteDatabase emotes,
Configuration configuration,
TTSPlayer player,
AudioPlaybackEngine playback,
IServiceProvider serviceProvider,
ILogger logger
)
@ -54,9 +57,11 @@ namespace TwitchChatTTS
_hermes = (hermes as HermesSocketClient)!;
_obs = (obs as OBSSocketClient)!;
_seven = (seven as SevenSocketClient)!;
_twitch = (twitch as TwitchWebsocketClient)!;
_emotes = emotes;
_configuration = configuration;
_player = player;
_playback = playback;
_serviceProvider = serviceProvider;
_logger = logger;
}
@ -89,21 +94,29 @@ namespace TwitchChatTTS
await InitializeHermesWebsocket();
try
{
await FetchUserData(_user, _hermesApiClient);
var hermesAccount = await _hermesApiClient.FetchHermesAccountDetails();
_user.HermesUserId = hermesAccount.Id;
_user.HermesUsername = hermesAccount.Username;
_user.TwitchUsername = hermesAccount.Username;
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to initialize properly. Restart app please.");
await Task.Delay(30 * 1000);
}
var twitchapiclient = await InitializeTwitchApiClient(_user.TwitchUsername, _user.TwitchUserId.ToString());
if (twitchapiclient == null)
{
await Task.Delay(30 * 1000);
return;
}
await _hermesApiClient.AuthorizeTwitch();
var twitchBotToken = await _hermesApiClient.FetchTwitchBotToken();
_user.TwitchUserId = long.Parse(twitchBotToken.BroadcasterId!);
_logger.Information($"Username: {_user.TwitchUsername} [id: {_user.TwitchUserId}]");
var twitchapiclient2 = _serviceProvider.GetRequiredService<TwitchApiClient>();
twitchapiclient2.Initialize(twitchBotToken);
_twitch.Initialize();
await _twitch.Connect();
var emoteSet = await _sevenApiClient.FetchChannelEmoteSet(_user.TwitchUserId.ToString());
if (emoteSet != null)
_user.SevenEmoteSetId = emoteSet.Id;
@ -112,9 +125,9 @@ namespace TwitchChatTTS
await InitializeSevenTv();
await InitializeObs();
AudioPlaybackEngine.Instance.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) =>
_playback.AddOnMixerInputEnded((object? s, SampleProviderEventArgs e) =>
{
if (e.SampleProvider == _player.Playing)
if (e.SampleProvider == _player.Playing?.Audio)
{
_player.Playing = null;
}
@ -142,8 +155,8 @@ namespace TwitchChatTTS
string url = $"https://api.streamelements.com/kappa/v2/speech?voice={m.Voice}&text={HttpUtility.UrlEncode(m.Message)}";
var sound = new NetworkWavSound(url);
var provider = new CachedWavProvider(sound);
var data = AudioPlaybackEngine.Instance.ConvertSound(provider);
var resampled = new WdlResamplingSampleProvider(data, AudioPlaybackEngine.Instance.SampleRate);
var data = _playback.ConvertSound(provider);
var resampled = new WdlResamplingSampleProvider(data, _playback.SampleRate);
_logger.Verbose("Fetched TTS audio data.");
m.Audio = resampled;
@ -185,7 +198,7 @@ namespace TwitchChatTTS
if (!string.IsNullOrWhiteSpace(m.File) && File.Exists(m.File))
{
_logger.Debug("Playing audio file via TTS: " + m.File);
AudioPlaybackEngine.Instance.PlaySound(m.File);
_playback.PlaySound(m.File);
continue;
}
@ -193,8 +206,8 @@ namespace TwitchChatTTS
if (m.Audio != null)
{
_player.Playing = m.Audio;
AudioPlaybackEngine.Instance.AddMixerInput(m.Audio);
_player.Playing = m;
_playback.AddMixerInput(m.Audio);
}
}
catch (Exception e)
@ -203,9 +216,6 @@ namespace TwitchChatTTS
}
}
});
_logger.Information("Twitch websocket client connecting...");
await twitchapiclient.Connect();
}
public async Task StopAsync(CancellationToken cancellationToken)
@ -216,18 +226,6 @@ namespace TwitchChatTTS
_logger.Warning("Application has stopped.");
}
private async Task FetchUserData(User user, HermesApiClient hermes)
{
var hermesAccount = await hermes.FetchHermesAccountDetails();
user.HermesUserId = hermesAccount.Id;
user.HermesUsername = hermesAccount.Username;
user.TwitchUsername = hermesAccount.Username;
var twitchBotToken = await hermes.FetchTwitchBotToken();
user.TwitchUserId = long.Parse(twitchBotToken.BroadcasterId!);
_logger.Information($"Username: {user.TwitchUsername} [id: {user.TwitchUserId}]");
}
private async Task InitializeHermesWebsocket()
{
try
@ -267,40 +265,42 @@ namespace TwitchChatTTS
}
}
private async Task<TwitchApiClient?> InitializeTwitchApiClient(string username, string broadcasterId)
{
_logger.Debug("Initializing twitch client.");
var twitchapiclient = _serviceProvider.GetRequiredService<TwitchApiClient>();
if (!await twitchapiclient.Authorize(broadcasterId))
{
_logger.Error("Cannot connect to Twitch API.");
return null;
}
// private async Task<TwitchApiClient?> InitializeTwitchApiClient(string username)
// {
// _logger.Debug("Initializing twitch client.");
var channels = _configuration.Twitch?.Channels ?? [username];
_logger.Information("Twitch channels: " + string.Join(", ", channels));
twitchapiclient.InitializeClient(username, channels);
twitchapiclient.InitializePublisher();
// var hermesapiclient = _serviceProvider.GetRequiredService<HermesApiClient>();
// if (!await hermesapiclient.AuthorizeTwitch())
// {
// _logger.Error("Cannot connect to Twitch API.");
// return null;
// }
var handler = _serviceProvider.GetRequiredService<ChatMessageHandler>();
twitchapiclient.AddOnNewMessageReceived(async (object? s, OnMessageReceivedArgs e) =>
{
try
{
var result = await handler.Handle(e);
if (result.Status != MessageStatus.None || result.Emotes == null || !result.Emotes.Any())
return;
// var twitchapiclient = _serviceProvider.GetRequiredService<TwitchApiClient>();
// var channels = _configuration.Twitch?.Channels ?? [username];
// _logger.Information("Twitch channels: " + string.Join(", ", channels));
// twitchapiclient.InitializeClient(username, channels);
// twitchapiclient.InitializePublisher();
await _hermes.SendEmoteUsage(e.ChatMessage.Id, result.ChatterId, result.Emotes);
}
catch (Exception ex)
{
_logger.Error(ex, "Unable to either execute a command or to send emote usage message.");
}
});
// var handler = _serviceProvider.GetRequiredService<ChatMessageHandler>();
// twitchapiclient.AddOnNewMessageReceived(async (object? s, OnMessageReceivedArgs e) =>
// {
// try
// {
// var result = await handler.Handle(e);
// if (result.Status != MessageStatus.None || result.Emotes == null || !result.Emotes.Any())
// return;
return twitchapiclient;
}
// await _hermes.SendEmoteUsage(e.ChatMessage.Id, result.ChatterId, result.Emotes);
// }
// catch (Exception ex)
// {
// _logger.Error(ex, "Unable to either execute a command or to send emote usage message.");
// }
// });
// return twitchapiclient;
// }
private async Task InitializeEmotes(SevenApiClient sevenapi, EmoteSet? channelEmotes)
{

View File

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

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 TwitchChatTTS.Helpers;
using Serilog;
using TwitchChatTTS;
using TwitchLib.Api.Core.Exceptions;
using TwitchLib.Client.Events;
using TwitchLib.Client.Models;
using TwitchLib.Communication.Events;
using TwitchLib.PubSub.Interfaces;
using TwitchLib.Client.Interfaces;
using TwitchChatTTS.Twitch.Redemptions;
using TwitchChatTTS.Twitch.Socket.Messages;
using System.Net.Http.Json;
using System.Net;
public class TwitchApiClient
{
private readonly RedemptionManager _redemptionManager;
private readonly HermesApiClient _hermesApiClient;
private readonly ITwitchClient _client;
private readonly ITwitchPubSub _publisher;
private readonly User _user;
private readonly Configuration _configuration;
private readonly TwitchBotAuth _token;
private readonly ILogger _logger;
private readonly WebClientWrap _web;
private bool _initialized;
private string _broadcasterId;
public TwitchApiClient(
ITwitchClient twitchClient,
ITwitchPubSub twitchPublisher,
RedemptionManager redemptionManager,
HermesApiClient hermesApiClient,
User user,
Configuration configuration,
TwitchBotAuth token,
ILogger logger
)
{
_redemptionManager = redemptionManager;
_hermesApiClient = hermesApiClient;
_client = twitchClient;
_publisher = twitchPublisher;
_user = user;
_configuration = configuration;
_token = token;
_logger = logger;
_initialized = false;
_broadcasterId = string.Empty;
_web = new WebClientWrap(new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
if (!string.IsNullOrWhiteSpace(_configuration.Hermes?.Token))
_web.AddHeader("x-api-key", _configuration.Hermes.Token.Trim());
}
public async Task<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}]");
var authorize = await _web.GetJson<TwitchBotAuth>($"https://{HermesApiClient.BASE_URL}/api/account/reauthorize");
if (authorize != null && broadcasterId == authorize.BroadcasterId)
{
_token.AccessToken = authorize.AccessToken;
_token.RefreshToken = authorize.RefreshToken;
_token.UserId = authorize.UserId;
_token.BroadcasterId = authorize.BroadcasterId;
_token.ExpiresIn = authorize.ExpiresIn;
_token.UpdatedAt = DateTime.Now;
_logger.Information("Updated Twitch API tokens.");
_logger.Debug($"Twitch API Auth data [user id: {_token.UserId}][id: {_token.BroadcasterId}][expires in: {_token.ExpiresIn}][expires at: {_token.ExpiresAt.ToShortTimeString()}]");
}
else if (authorize != null)
{
_logger.Error("Twitch API Authorization failed: " + authorize.AccessToken + " | " + authorize.RefreshToken + " | " + authorize.UserId + " | " + authorize.BroadcasterId);
return false;
}
_broadcasterId = broadcasterId;
_logger.Debug($"Authorized Twitch API [id: {broadcasterId}]");
return true;
_logger.Debug("Twitch API call [type: create event subscription]: " + await response.Content.ReadAsStringAsync());
return await response.Content.ReadFromJsonAsync(typeof(EventResponse<EventSubscriptionMessage>)) as EventResponse<EventSubscriptionMessage>;
}
catch (HttpResponseException e)
{
if (string.IsNullOrWhiteSpace(_configuration.Hermes!.Token))
_logger.Error("No Hermes API key found. Enter it into the configuration file.");
else
_logger.Error("Invalid Hermes API key. Double check the token. HTTP Error Code: " + e.HttpResponse.StatusCode);
}
catch (JsonException)
{
_logger.Debug($"Failed to Authorize Twitch API due to JSON error [id: {broadcasterId}]");
}
catch (Exception e)
{
_logger.Error(e, "Failed to authorize to Twitch API.");
}
return false;
_logger.Warning("Twitch api failed to create event subscription: " + await response.Content.ReadAsStringAsync());
return null;
}
public async Task Connect()
public async Task<EventResponse<EventSubscriptionMessage>?> CreateEventSubscription(string type, string version, string sessionId, string userId)
{
_client.Connect();
await _publisher.ConnectAsync();
}
public void InitializeClient(string username, IEnumerable<string> channels)
{
ConnectionCredentials credentials = new ConnectionCredentials(username, _token!.AccessToken);
_client.Initialize(credentials, channels.Distinct().ToList());
if (_initialized)
var conditions = new Dictionary<string, string>() { { "user_id", userId }, { "broadcaster_user_id", userId }, { "moderator_user_id", userId } };
var subscriptionData = new EventSubscriptionMessage(type, version, sessionId, conditions);
var response = await _web.Post("https://api.twitch.tv/helix/eventsub/subscriptions", subscriptionData);
if (response.StatusCode == HttpStatusCode.Accepted)
{
_logger.Debug("Twitch API client has already been initialized.");
return;
_logger.Debug("Twitch API call [type: create event subscription]: " + await response.Content.ReadAsStringAsync());
return await response.Content.ReadFromJsonAsync(typeof(EventResponse<EventSubscriptionMessage>)) as EventResponse<EventSubscriptionMessage>;
}
_initialized = true;
_client.OnJoinedChannel += async Task (object? s, OnJoinedChannelArgs e) =>
{
_logger.Information("Joined channel: " + e.Channel);
};
_client.OnConnected += async Task (object? s, OnConnectedArgs e) =>
{
_logger.Information("Twitch API client connected.");
};
_client.OnIncorrectLogin += async Task (object? s, OnIncorrectLoginArgs e) =>
{
_logger.Error(e.Exception, "Incorrect Login on Twitch API client.");
_logger.Information("Attempting to re-authorize.");
await Authorize(_broadcasterId);
_client.SetConnectionCredentials(new ConnectionCredentials(_user.TwitchUsername, _token!.AccessToken));
await Task.Delay(TimeSpan.FromSeconds(3));
await _client.ReconnectAsync();
};
_client.OnConnectionError += async Task (object? s, OnConnectionErrorArgs e) =>
{
_logger.Error("Connection Error: " + e.Error.Message + " (" + e.Error.GetType().Name + ")");
_logger.Information("Attempting to re-authorize.");
await Authorize(_broadcasterId);
};
_client.OnError += async Task (object? s, OnErrorEventArgs e) =>
{
_logger.Error(e.Exception, "Twitch API client error.");
};
_client.OnDisconnected += async Task (s, e) => _logger.Warning("Twitch API client disconnected.");
_logger.Error("Twitch api failed to create event subscription: " + await response.Content.ReadAsStringAsync());
return null;
}
public void InitializePublisher()
{
_publisher.OnPubSubServiceConnected += async (s, e) =>
{
_publisher.ListenToChannelPoints(_token.BroadcasterId);
_publisher.ListenToFollows(_token.BroadcasterId);
await _publisher.SendTopicsAsync(_token.AccessToken);
_logger.Information("Twitch PubSub has been connected.");
};
_publisher.OnFollow += (s, e) =>
{
_logger.Information($"New Follower [name: {e.DisplayName}][username: {e.Username}]");
};
_publisher.OnChannelPointsRewardRedeemed += async (s, e) =>
{
_logger.Information($"Channel Point Reward Redeemed [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][redeem id: {e.RewardRedeemed.Redemption.Reward.Id}][transaction: {e.RewardRedeemed.Redemption.Id}]");
try
{
var actions = _redemptionManager.Get(e.RewardRedeemed.Redemption.Reward.Id);
if (!actions.Any())
{
_logger.Debug($"No redemable actions for this redeem was found [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][redeem id: {e.RewardRedeemed.Redemption.Reward.Id}][transaction: {e.RewardRedeemed.Redemption.Id}]");
return;
}
_logger.Debug($"Found {actions.Count} actions for this Twitch channel point redemption [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][redeem id: {e.RewardRedeemed.Redemption.Reward.Id}][transaction: {e.RewardRedeemed.Redemption.Id}]");
foreach (var action in actions)
try
{
await _redemptionManager.Execute(action, e.RewardRedeemed.Redemption.User.DisplayName, long.Parse(e.RewardRedeemed.Redemption.User.Id));
}
catch (Exception ex)
{
_logger.Error(ex, $"Failed to execute redeeemable action [action: {action.Name}][action type: {action.Type}][redeem: {e.RewardRedeemed.Redemption.Reward.Title}][redeem id: {e.RewardRedeemed.Redemption.Reward.Id}][transaction: {e.RewardRedeemed.Redemption.Id}]");
}
}
catch (Exception ex)
{
_logger.Error(ex, $"Failed to fetch the redeemable actions for a redemption [redeem: {e.RewardRedeemed.Redemption.Reward.Title}][redeem id: {e.RewardRedeemed.Redemption.Reward.Id}][transaction: {e.RewardRedeemed.Redemption.Id}]");
}
};
_publisher.OnPubSubServiceClosed += async (s, e) =>
{
_logger.Warning("Twitch PubSub ran into a service close. Attempting to connect again.");
//await Task.Delay(Math.Min(3000 + (1 << psConnectionFailures), 120000));
var authorized = await Authorize(_broadcasterId);
var twitchBotData = await _hermesApiClient.FetchTwitchBotToken();
if (twitchBotData == null)
{
Console.WriteLine("The API is down. Contact the owner.");
return;
}
await _publisher.ConnectAsync();
};
}
public void AddOnNewMessageReceived(AsyncEventHandler<OnMessageReceivedArgs> handler)
{
_client.OnMessageReceived += handler;
public void Initialize(TwitchBotToken token) {
_web.AddHeader("Authorization", "Bearer " + token.AccessToken);
_web.AddHeader("Client-Id", token.ClientId);
}
}

View File

@ -14,33 +14,14 @@
<PackageReference Include="NAudio.Extras" Version="2.2.1" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2-dev-00338" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.1-dev-10391" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.1-dev-00972" />
<PackageReference Include="Serilog.Sinks.RollingFile" Version="3.3.1-dev-00771" />
<PackageReference Include="Serilog.Sinks.Trace" Version="4.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<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="TwitchLib.Api" Version="3.10.0-preview-e47ba7f" />
<PackageReference Include="YamlDotNet" Version="15.1.2" />
<PackageReference Include="YamlDotNet" Version="16.0.0" />
</ItemGroup>
<ItemGroup>

12
User.cs
View File

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