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

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