505 lines
25 KiB
C#
505 lines
25 KiB
C#
using System.Reflection;
|
|
using CommonSocketLibrary.Abstract;
|
|
using CommonSocketLibrary.Common;
|
|
using HermesSocketLibrary.Requests.Messages;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using org.mariuszgromada.math.mxparser;
|
|
using Serilog;
|
|
using TwitchChatTTS.Bus;
|
|
using TwitchChatTTS.Bus.Data;
|
|
using TwitchChatTTS.OBS.Socket;
|
|
using TwitchChatTTS.OBS.Socket.Data;
|
|
using TwitchChatTTS.Veadotube;
|
|
|
|
namespace TwitchChatTTS.Twitch.Redemptions
|
|
{
|
|
public class RedemptionManager : IRedemptionManager
|
|
{
|
|
private readonly IDictionary<string, RedeemableAction> _actions;
|
|
private readonly IDictionary<string, Redemption> _redemptions;
|
|
// twitch redemption id -> redemption ids
|
|
private readonly IDictionary<string, IList<string>> _redeems;
|
|
private readonly ServiceBusCentral _bus;
|
|
private readonly User _user;
|
|
private readonly OBSSocketClient _obs;
|
|
private readonly VeadoSocketClient _veado;
|
|
private readonly NightbotApiClient _nightbot;
|
|
private readonly AudioPlaybackEngine _playback;
|
|
private readonly ILogger _logger;
|
|
private readonly Random _random;
|
|
private readonly object _lock;
|
|
|
|
|
|
public RedemptionManager(
|
|
ServiceBusCentral bus,
|
|
User user,
|
|
[FromKeyedServices("obs")] SocketClient<WebSocketMessage> obs,
|
|
[FromKeyedServices("veadotube")] SocketClient<object> veado,
|
|
NightbotApiClient nightbot,
|
|
AudioPlaybackEngine playback,
|
|
ILogger logger)
|
|
{
|
|
_actions = new Dictionary<string, RedeemableAction>();
|
|
_redemptions = new Dictionary<string, Redemption>();
|
|
_redeems = new Dictionary<string, IList<string>>();
|
|
_bus = bus;
|
|
_user = user;
|
|
_obs = (obs as OBSSocketClient)!;
|
|
_veado = (veado as VeadoSocketClient)!;
|
|
_nightbot = nightbot;
|
|
_playback = playback;
|
|
_logger = logger;
|
|
_random = new Random();
|
|
_lock = new object();
|
|
|
|
var topic = _bus.GetTopic("redemptions_initiation");
|
|
topic.Subscribe(data =>
|
|
{
|
|
if (data.Value is not RedemptionInitiation init)
|
|
return;
|
|
|
|
if (init.Actions == null)
|
|
init.Actions = new Dictionary<string, RedeemableAction>();
|
|
if (init.Redemptions == null)
|
|
init.Redemptions = new List<Redemption>();
|
|
|
|
if (!init.Actions.Any())
|
|
_logger.Warning("No redeemable actions were loaded.");
|
|
|
|
if (!init.Redemptions.Any())
|
|
_logger.Warning("No redemptions were loaded.");
|
|
|
|
foreach (var action in init.Actions.Values)
|
|
Add(action);
|
|
|
|
foreach (var redemption in init.Redemptions)
|
|
Add(redemption);
|
|
|
|
Initialize();
|
|
});
|
|
}
|
|
|
|
public void Add(RedeemableAction action)
|
|
{
|
|
if (!_actions.ContainsKey(action.Name))
|
|
{
|
|
_actions.Add(action.Name, action);
|
|
_logger.Debug($"Added redeemable action to redemption manager [action name: {action.Name}]");
|
|
}
|
|
else
|
|
_logger.Debug($"Redemption manager already has this action stored [action name: {action.Name}]");
|
|
}
|
|
|
|
public void Add(Redemption redemption)
|
|
{
|
|
_redemptions.Add(redemption.Id, redemption);
|
|
Add(redemption.TwitchRedemptionId, redemption);
|
|
}
|
|
|
|
private void Add(string twitchRedemptionId, string redemptionId)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (!_redeems.TryGetValue(twitchRedemptionId, out var redeems))
|
|
_redeems.Add(twitchRedemptionId, redeems = new List<string>());
|
|
|
|
var item = _redemptions.TryGetValue(redemptionId, out var r) ? r : null;
|
|
if (item == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var redemptions = redeems.Select(r => _redemptions.TryGetValue(r, out var rr) ? rr : null);
|
|
bool added = false;
|
|
for (int i = 0; i < redeems.Count; i++)
|
|
{
|
|
if (redeems[i] != null && _redemptions.TryGetValue(redeems[i], out var rr))
|
|
{
|
|
if (item.Order > rr.Order)
|
|
{
|
|
redeems.Insert(i, redemptionId);
|
|
added = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!added)
|
|
redeems.Add(redemptionId);
|
|
}
|
|
_logger.Debug($"Added redemption action [redemption id: {redemptionId}][twitch redemption id: {twitchRedemptionId}]");
|
|
}
|
|
|
|
private void Add(string twitchRedemptionId, Redemption item)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (!_redeems.TryGetValue(twitchRedemptionId, out var redemptionNames))
|
|
_redeems.Add(twitchRedemptionId, redemptionNames = new List<string>());
|
|
|
|
var redemptions = redemptionNames.Select(r => _redemptions.TryGetValue(r, out var rr) ? rr : null);
|
|
bool added = false;
|
|
for (int i = 0; i < redemptionNames.Count; i++)
|
|
{
|
|
if (redemptionNames[i] != null && _redemptions.TryGetValue(redemptionNames[i], out var rr))
|
|
{
|
|
if (item.Order > rr.Order)
|
|
{
|
|
redemptionNames.Insert(i, item.Id);
|
|
added = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!added)
|
|
redemptionNames.Add(item.Id);
|
|
}
|
|
_logger.Debug($"Added redemption action [redemption id: {item.Id}][twitch redemption id: {twitchRedemptionId}]");
|
|
}
|
|
|
|
public async Task Execute(RedeemableAction action, string senderDisplayName, long senderId)
|
|
{
|
|
_logger.Debug($"Executing an action for a redemption [action: {action.Name}][action type: {action.Type}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
|
|
if (action.Data == null)
|
|
{
|
|
_logger.Warning($"No data was provided for an action, caused by redemption [action: {action.Name}][action type: {action.Type}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
switch (action.Type)
|
|
{
|
|
case "WRITE_TO_FILE":
|
|
Directory.CreateDirectory(Path.GetDirectoryName(action.Data["file_path"])!);
|
|
await File.WriteAllTextAsync(action.Data["file_path"], ReplaceContentText(action.Data["file_content"], senderDisplayName));
|
|
_logger.Debug($"Overwritten text to file [file: {action.Data["file_path"]}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
break;
|
|
case "APPEND_TO_FILE":
|
|
Directory.CreateDirectory(Path.GetDirectoryName(action.Data["file_path"])!);
|
|
await File.AppendAllTextAsync(action.Data["file_path"], ReplaceContentText(action.Data["file_content"], senderDisplayName));
|
|
_logger.Debug($"Appended text to file [file: {action.Data["file_path"]}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
break;
|
|
case "OBS_TRANSFORM":
|
|
var type = typeof(OBSTransformationData);
|
|
await _obs.UpdateTransformation(action.Data["scene_name"], action.Data["scene_item_name"], (d) =>
|
|
{
|
|
string[] properties = ["rotation", "position_x", "position_y"];
|
|
foreach (var property in properties)
|
|
{
|
|
if (!action.Data.TryGetValue(property, out var expressionString) || expressionString == null)
|
|
continue;
|
|
|
|
var propertyName = string.Join("", property.Split('_').Select(p => char.ToUpper(p[0]) + p.Substring(1)));
|
|
PropertyInfo? prop = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
|
|
if (prop == null)
|
|
{
|
|
_logger.Warning($"Failed to find property for OBS transformation [scene: {action.Data["scene_name"]}][source: {action.Data["scene_item_name"]}][property: {propertyName}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
continue;
|
|
}
|
|
|
|
var currentValue = prop.GetValue(d);
|
|
if (currentValue == null)
|
|
{
|
|
_logger.Warning($"Found a null value from OBS transformation [scene: {action.Data["scene_name"]}][source: {action.Data["scene_item_name"]}][property: {propertyName}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
continue;
|
|
}
|
|
|
|
Expression expression = new Expression(expressionString);
|
|
expression.addConstants(new Constant("x", (double?)currentValue ?? 0.0d));
|
|
if (!expression.checkSyntax())
|
|
{
|
|
_logger.Warning($"Could not parse math expression for OBS transformation [scene: {action.Data["scene_name"]}][source: {action.Data["scene_item_name"]}][expression: {expressionString}][property: {propertyName}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
continue;
|
|
}
|
|
|
|
var newValue = expression.calculate();
|
|
prop.SetValue(d, newValue);
|
|
_logger.Debug($"OBS transformation [scene: {action.Data["scene_name"]}][source: {action.Data["scene_item_name"]}][property: {propertyName}][old value: {currentValue}][new value: {newValue}][expression: {expressionString}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
}
|
|
_logger.Debug($"Finished applying the OBS transformation property changes [scene: {action.Data["scene_name"]}][source: {action.Data["scene_item_name"]}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
});
|
|
break;
|
|
case "TOGGLE_OBS_VISIBILITY":
|
|
await _obs.ToggleSceneItemVisibility(action.Data["scene_name"], action.Data["scene_item_name"]);
|
|
break;
|
|
case "SPECIFIC_OBS_VISIBILITY":
|
|
await _obs.UpdateSceneItemVisibility(action.Data["scene_name"], action.Data["scene_item_name"], action.Data["obs_visible"].ToLower() == "true");
|
|
break;
|
|
case "SPECIFIC_OBS_INDEX":
|
|
await _obs.UpdateSceneItemIndex(action.Data["scene_name"], action.Data["scene_item_name"], int.Parse(action.Data["obs_index"]));
|
|
break;
|
|
case "SLEEP":
|
|
_logger.Debug("Sleeping on thread due to redemption for OBS.");
|
|
await Task.Delay(int.Parse(action.Data["sleep"]));
|
|
break;
|
|
case "SPECIFIC_TTS_VOICE":
|
|
case "RANDOM_TTS_VOICE":
|
|
string voiceId = string.Empty;
|
|
bool specific = action.Type == "SPECIFIC_TTS_VOICE";
|
|
|
|
var voicesEnabled = _user.VoicesEnabled.ToList();
|
|
if (specific)
|
|
voiceId = _user.VoicesAvailable.Keys.First(id => _user.VoicesAvailable[id].ToLower() == action.Data["tts_voice"].ToLower());
|
|
else
|
|
{
|
|
if (!voicesEnabled.Any())
|
|
{
|
|
_logger.Warning($"There are no TTS voices enabled [voice pool size: {voicesEnabled.Count}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
return;
|
|
}
|
|
if (voicesEnabled.Count <= 1)
|
|
{
|
|
_logger.Warning($"There are not enough TTS voices enabled to randomize [voice pool size: {voicesEnabled.Count}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
return;
|
|
}
|
|
|
|
string? selectedId = null;
|
|
if (!_user.VoicesSelected.ContainsKey(senderId))
|
|
selectedId = _user.VoicesAvailable.Keys.First(id => _user.VoicesAvailable[id] == _user.DefaultTTSVoice);
|
|
else
|
|
selectedId = _user.VoicesSelected[senderId];
|
|
|
|
do
|
|
{
|
|
var randomVoice = voicesEnabled[_random.Next(voicesEnabled.Count)];
|
|
voiceId = _user.VoicesAvailable.Keys.First(id => _user.VoicesAvailable[id] == randomVoice);
|
|
} while (voiceId == selectedId);
|
|
}
|
|
if (string.IsNullOrEmpty(voiceId))
|
|
{
|
|
_logger.Warning($"Voice is not valid [voice: {action.Data["tts_voice"]}][voice pool size: {voicesEnabled.Count}][source: redemption][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
return;
|
|
}
|
|
var voiceName = _user.VoicesAvailable[voiceId];
|
|
if (!_user.VoicesEnabled.Contains(voiceName))
|
|
{
|
|
_logger.Warning($"Voice is not enabled [voice: {action.Data["tts_voice"]}][voice pool size: {voicesEnabled.Count}][voice id: {voiceId}][source: redemption][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
return;
|
|
}
|
|
|
|
if (_user.VoicesSelected.ContainsKey(senderId))
|
|
{
|
|
_bus.Send(this, "tts.user.voice.update", new Dictionary<string, object>() { { "chatter", senderId }, { "voice", voiceId } });
|
|
_logger.Debug($"Sent request to update chat TTS voice [voice: {voiceName}][chatter id: {senderId}][source: redemption][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
}
|
|
else
|
|
{
|
|
_bus.Send(this, "tts.user.voice.create", new Dictionary<string, object>() { { "chatter", senderId }, { "voice", voiceId } });
|
|
_logger.Debug($"Sent request to create chat TTS voice [voice: {voiceName}][chatter id: {senderId}][source: redemption][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
}
|
|
break;
|
|
case "AUDIO_FILE":
|
|
if (!File.Exists(action.Data["file_path"]))
|
|
{
|
|
_logger.Warning($"Cannot find audio file for Twitch channel point redeem [file: {action.Data["file_path"]}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
return;
|
|
}
|
|
_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;
|
|
case "NIGHTBOT_PLAY":
|
|
await _nightbot.Play();
|
|
break;
|
|
case "NIGHTBOT_PAUSE":
|
|
await _nightbot.Pause();
|
|
break;
|
|
case "NIGHTBOT_SKIP":
|
|
await _nightbot.Skip();
|
|
break;
|
|
case "NIGHTBOT_CLEAR_PLAYLIST":
|
|
await _nightbot.ClearPlaylist();
|
|
break;
|
|
case "NIGHTBOT_CLEAR_QUEUE":
|
|
await _nightbot.ClearQueue();
|
|
break;
|
|
case "VEADOTUBE_SET_STATE":
|
|
{
|
|
var state = _veado.GetStateId(action.Data["state"]);
|
|
if (state == null)
|
|
{
|
|
_logger.Warning($"Could not find the state named '{action.Data["state"]}'.");
|
|
break;
|
|
}
|
|
await _veado.SetCurrentState(state);
|
|
break;
|
|
}
|
|
case "VEADOTUBE_PUSH_STATE":
|
|
{
|
|
var state = _veado.GetStateId(action.Data["state"]);
|
|
if (state == null)
|
|
{
|
|
_logger.Warning($"Could not find the state named '{action.Data["state"]}'.");
|
|
break;
|
|
}
|
|
await _veado.PushState(state);
|
|
break;
|
|
}
|
|
case "VEADOTUBE_POP_STATE":
|
|
{
|
|
var state = _veado.GetStateId(action.Data["state"]);
|
|
if (state == null)
|
|
{
|
|
_logger.Warning($"Could not find the state named '{action.Data["state"]}'.");
|
|
break;
|
|
}
|
|
await _veado.PopState(state);
|
|
break;
|
|
}
|
|
default:
|
|
_logger.Warning($"Unknown redeemable action has occured [type: {action.Type}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Error(ex, $"Failed to execute a redemption action [action: {action.Name}][action type: {action.Type}][chatter: {senderDisplayName}][chatter id: {senderId}]");
|
|
}
|
|
}
|
|
|
|
public IEnumerable<RedeemableAction> Get(string twitchRedemptionId)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_redeems.TryGetValue(twitchRedemptionId, out var redemptionIds))
|
|
return redemptionIds.Select(r => _redemptions.TryGetValue(r, out var redemption) ? redemption : null)
|
|
.Where(r => r != null)
|
|
.Select(r => _actions.TryGetValue(r!.ActionName, out var action) ? action : null)
|
|
.Where(a => a != null)!;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
public void Initialize()
|
|
{
|
|
_logger.Debug($"Redemption manager is about to initialize [redemption count: {_redemptions.Count()}][action count: {_actions.Count}]");
|
|
|
|
lock (_lock)
|
|
{
|
|
_redeems.Clear();
|
|
|
|
var ordered = _redemptions.Select(r => r.Value).Where(r => r != null).OrderBy(r => r.Order);
|
|
foreach (var redemption in ordered)
|
|
{
|
|
if (redemption.ActionName == null)
|
|
{
|
|
_logger.Warning("Null value found for the action name of a redemption.");
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (_actions.ContainsKey(redemption.ActionName))
|
|
{
|
|
_logger.Debug($"Fetched a redeemable action [redemption id: {redemption.Id}][redemption action: {redemption.ActionName}][order: {redemption.Order}]");
|
|
Add(redemption.TwitchRedemptionId, redemption.Id);
|
|
}
|
|
else
|
|
_logger.Warning($"Could not find redeemable action [redemption id: {redemption.Id}][redemption action: {redemption.ActionName}][order: {redemption.Order}]");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.Error(e, $"Failed to add a redemption [redemption id: {redemption.Id}][redemption action: {redemption.ActionName}][order: {redemption.Order}]");
|
|
}
|
|
}
|
|
}
|
|
|
|
_logger.Debug("All redemptions added. Redemption Manager is ready.");
|
|
}
|
|
|
|
public bool RemoveAction(string actionName)
|
|
{
|
|
return _actions.Remove(actionName);
|
|
}
|
|
|
|
public bool RemoveRedemption(string redemptionId)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (!_redemptions.TryGetValue(redemptionId, out var redemption))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
_redemptions.Remove(redemptionId);
|
|
if (_redeems.TryGetValue(redemption.TwitchRedemptionId, out var redeem))
|
|
{
|
|
redeem.Remove(redemptionId);
|
|
if (!redeem.Any())
|
|
_redeems.Remove(redemption.TwitchRedemptionId);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private string ReplaceContentText(string content, string username)
|
|
{
|
|
return content.Replace("%USER%", username)
|
|
.Replace("\\n", "\n");
|
|
}
|
|
|
|
public bool Update(Redemption redemption)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_redemptions.TryGetValue(redemption.Id, out var r))
|
|
{
|
|
if (r.Order != redemption.Order && _redeems.TryGetValue(redemption.TwitchRedemptionId, out var redeems) && redeems.Count > 1)
|
|
{
|
|
var redemptions = redeems.Select(r => _redemptions.TryGetValue(r, out var rr) ? rr : null).ToArray();
|
|
int index = redeems.IndexOf(redemption.Id), i;
|
|
if (r.Order < redemption.Order)
|
|
{
|
|
for (i = index; i >= 1; i--)
|
|
{
|
|
if (redemptions[i - 1] == null || redemption.Order < redemptions[i - 1]!.Order)
|
|
redeems[i] = redeems[i - 1];
|
|
else
|
|
break;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
for (i = index; i < redeems.Count - 1; i++)
|
|
{
|
|
if (redemptions[i + 1] == null || redemption.Order > redemptions[i + 1]!.Order)
|
|
redeems[i] = redeems[i + 1];
|
|
else
|
|
break;
|
|
}
|
|
}
|
|
redeems[i] = redemption.Id;
|
|
}
|
|
else
|
|
{
|
|
r.ActionName = redemption.ActionName;
|
|
r.State = redemption.State;
|
|
r.TwitchRedemptionId = redemption.TwitchRedemptionId;
|
|
r.Order = redemption.Order;
|
|
}
|
|
_logger.Debug($"Updated redemption in redemption manager [redemption id: {redemption.Id}][redemption action: {redemption.ActionName}]");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
_logger.Warning($"Cannot find redemption by name [redemption id: {redemption.Id}][redemption action: {redemption.ActionName}]");
|
|
return false;
|
|
}
|
|
|
|
public bool Update(RedeemableAction action)
|
|
{
|
|
if (_actions.TryGetValue(action.Name, out var a))
|
|
{
|
|
a.Type = action.Type;
|
|
a.Data = action.Data;
|
|
_logger.Debug($"Updated redeemable action in redemption manager [action name: {action.Name}]");
|
|
return true;
|
|
}
|
|
|
|
_logger.Warning($"Cannot find redeemable action by name [action name: {action.Name}]");
|
|
return false;
|
|
}
|
|
}
|
|
} |