Fixed 7tv & Twitch reconnection. Added adbreak, follow, subscription handlers for Twitch. Added multi-chat support. Added support to unsubscribe from Twitch event subs.

This commit is contained in:
Tom
2024-08-06 19:29:29 +00:00
parent 75fcb8e0f8
commit 8014c12bc5
60 changed files with 1064 additions and 672 deletions

View File

@ -0,0 +1,11 @@
using HermesSocketLibrary.Requests.Messages;
namespace TwitchChatTTS.Twitch.Redemptions
{
public interface IRedemptionManager
{
Task Execute(RedeemableAction action, string senderDisplayName, long senderId);
IList<RedeemableAction> Get(string twitchRedemptionId);
void Initialize(IEnumerable<Redemption> redemptions, IDictionary<string, RedeemableAction> actions);
}
}

View File

@ -11,7 +11,7 @@ using TwitchChatTTS.OBS.Socket.Data;
namespace TwitchChatTTS.Twitch.Redemptions
{
public class RedemptionManager
public class RedemptionManager : IRedemptionManager
{
private readonly IDictionary<string, IList<RedeemableAction>> _store;
private readonly User _user;

View File

@ -0,0 +1,57 @@
using Serilog;
using TwitchChatTTS.Twitch.Redemptions;
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Twitch.Socket.Handlers
{
public class ChannelAdBreakHandler : ITwitchSocketHandler
{
public string Name => "channel.ad_break.begin";
private readonly IRedemptionManager _redemptionManager;
private readonly ILogger _logger;
public ChannelAdBreakHandler(IRedemptionManager redemptionManager, ILogger logger)
{
_redemptionManager = redemptionManager;
_logger = logger;
}
public async Task Execute(TwitchWebsocketClient sender, object data)
{
if (data is not ChannelAdBreakMessage message)
return;
bool isAutomatic = message.IsAutomatic == "true";
if (isAutomatic)
_logger.Information($"Ad break has begun [duration: {message.DurationSeconds} seconds][automatic: {isAutomatic}]");
else
_logger.Information($"Ad break has begun [duration: {message.DurationSeconds} seconds][requester: {message.RequesterUserLogin}][requester id: {message.RequesterUserId}]");
try
{
var actions = _redemptionManager.Get("adbreak");
if (!actions.Any())
{
_logger.Debug($"No redemable actions for ad break was found");
return;
}
_logger.Debug($"Found {actions.Count} actions for this Twitch ad break");
foreach (var action in actions)
try
{
await _redemptionManager.Execute(action, message.RequesterUserLogin, long.Parse(message.RequesterUserId));
}
catch (Exception ex)
{
_logger.Error(ex, $"Failed to execute redeeemable action [action: {action.Name}][action type: {action.Type}][redeem: ad break]");
}
}
catch (Exception ex)
{
_logger.Error(ex, $"Failed to fetch the redeemable actions for ad break");
}
}
}
}

View File

@ -14,7 +14,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
_logger = logger;
}
public Task Execute(TwitchWebsocketClient sender, object? data)
public Task Execute(TwitchWebsocketClient sender, object data)
{
if (data is not ChannelBanMessage message)
return Task.CompletedTask;

View File

@ -18,7 +18,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
_logger = logger;
}
public Task Execute(TwitchWebsocketClient sender, object? data)
public Task Execute(TwitchWebsocketClient sender, object data)
{
if (data is not ChannelChatClearMessage message)
return Task.CompletedTask;

View File

@ -18,14 +18,16 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
_logger = logger;
}
public Task Execute(TwitchWebsocketClient sender, object? data)
public Task Execute(TwitchWebsocketClient sender, object data)
{
if (data is not ChannelChatClearUserMessage message)
return Task.CompletedTask;
long broadcasterId = long.Parse(message.BroadcasterUserId);
long chatterId = long.Parse(message.TargetUserId);
_player.RemoveAll(chatterId);
if (_player.Playing?.ChatterId == chatterId) {
_player.RemoveAll(broadcasterId, chatterId);
if (_player.Playing != null && _player.Playing.RoomId == broadcasterId && _player.Playing.ChatterId == chatterId)
{
_playback.RemoveMixerInput(_player.Playing.Audio!);
_player.Playing = null;
}

View File

@ -18,7 +18,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
_logger = logger;
}
public Task Execute(TwitchWebsocketClient sender, object? data)
public Task Execute(TwitchWebsocketClient sender, object data)
{
if (data is not ChannelChatDeleteMessage message)
return Task.CompletedTask;

View File

@ -19,7 +19,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
private readonly User _user;
private readonly TTSPlayer _player;
private readonly CommandManager _commands;
private readonly ICommandManager _commands;
private readonly IGroupPermissionManager _permissionManager;
private readonly IChatterGroupManager _chatterGroupManager;
private readonly IEmoteDatabase _emotes;
@ -34,7 +34,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
public ChannelChatMessageHandler(
User user,
TTSPlayer player,
CommandManager commands,
ICommandManager commands,
IGroupPermissionManager permissionManager,
IChatterGroupManager chatterGroupManager,
IEmoteDatabase emotes,
@ -59,15 +59,10 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
_logger = logger;
}
public async Task Execute(TwitchWebsocketClient sender, object? data)
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;
@ -231,6 +226,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
var parts = _sfxRegex.Split(message);
var chatterId = long.Parse(e.ChatterUserId);
var broadcasterId = long.Parse(e.BroadcasterUserId);
var badgesString = string.Join(", ", e.Badges.Select(b => b.SetId + '|' + b.Id + '=' + b.Info));
if (parts.Length == 1)
@ -241,6 +237,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
Voice = voice,
Message = message,
Timestamp = DateTime.UtcNow,
RoomId = broadcasterId,
ChatterId = chatterId,
MessageId = e.MessageId,
Badges = e.Badges,
@ -271,6 +268,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
Voice = voice,
Message = parts[i * 2],
Timestamp = DateTime.UtcNow,
RoomId = broadcasterId,
ChatterId = chatterId,
MessageId = e.MessageId,
Badges = e.Badges,
@ -284,6 +282,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
Voice = voice,
File = $"sfx/{sfxName}.mp3",
Timestamp = DateTime.UtcNow,
RoomId = broadcasterId,
ChatterId = chatterId,
MessageId = e.MessageId,
Badges = e.Badges,
@ -299,6 +298,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
Voice = voice,
Message = parts.Last(),
Timestamp = DateTime.UtcNow,
RoomId = broadcasterId,
ChatterId = chatterId,
MessageId = e.MessageId,
Badges = e.Badges,

View File

@ -8,11 +8,11 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
{
public string Name => "channel.channel_points_custom_reward_redemption.add";
private readonly RedemptionManager _redemptionManager;
private readonly IRedemptionManager _redemptionManager;
private readonly ILogger _logger;
public ChannelCustomRedemptionHandler(
RedemptionManager redemptionManager,
IRedemptionManager redemptionManager,
ILogger logger
)
{
@ -20,7 +20,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
_logger = logger;
}
public async Task Execute(TwitchWebsocketClient sender, object? data)
public async Task Execute(TwitchWebsocketClient sender, object data)
{
if (data is not ChannelCustomRedemptionMessage message)
return;

View File

@ -0,0 +1,52 @@
using Serilog;
using TwitchChatTTS.Twitch.Redemptions;
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Twitch.Socket.Handlers
{
public class ChannelFollowHandler : ITwitchSocketHandler
{
public string Name => "channel.follow";
private readonly IRedemptionManager _redemptionManager;
private readonly ILogger _logger;
public ChannelFollowHandler(IRedemptionManager redemptionManager, ILogger logger)
{
_redemptionManager = redemptionManager;
_logger = logger;
}
public async Task Execute(TwitchWebsocketClient sender, object data)
{
if (data is not ChannelFollowMessage message)
return;
_logger.Information($"User followed [chatter: {message.UserLogin}][chatter id: {message.UserId}]");
try
{
var actions = _redemptionManager.Get("follow");
if (!actions.Any())
{
_logger.Debug($"No redemable actions for follow was found");
return;
}
_logger.Debug($"Found {actions.Count} actions for this Twitch follow");
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: follow]");
}
}
catch (Exception ex)
{
_logger.Error(ex, $"Failed to fetch the redeemable actions for follow");
}
}
}
}

View File

@ -0,0 +1,52 @@
using Serilog;
using TwitchChatTTS.Twitch.Redemptions;
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Twitch.Socket.Handlers
{
public class ChannelResubscriptionHandler : ITwitchSocketHandler
{
public string Name => "channel.subscription.message";
private readonly IRedemptionManager _redemptionManager;
private readonly ILogger _logger;
public ChannelResubscriptionHandler(IRedemptionManager redemptionManager, ILogger logger)
{
_redemptionManager = redemptionManager;
_logger = logger;
}
public async Task Execute(TwitchWebsocketClient sender, object data)
{
if (data is not ChannelResubscriptionMessage message)
return;
_logger.Debug("Resubscription occured.");
try
{
var actions = _redemptionManager.Get("subscription");
if (!actions.Any())
{
_logger.Debug($"No redemable actions for this subscription was found [message: {message.Message.Text}]");
return;
}
_logger.Debug($"Found {actions.Count} actions for this Twitch subscription [message: {message.Message.Text}]");
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: subscription][message: {message.Message.Text}]");
}
}
catch (Exception ex)
{
_logger.Error(ex, $"Failed to fetch the redeemable actions for subscription [message: {message.Message.Text}]");
}
}
}
}

View File

@ -0,0 +1,52 @@
using Serilog;
using TwitchChatTTS.Twitch.Redemptions;
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Twitch.Socket.Handlers
{
public class ChannelSubscriptionGiftHandler : ITwitchSocketHandler
{
public string Name => "channel.subscription.gift";
private readonly IRedemptionManager _redemptionManager;
private readonly ILogger _logger;
public ChannelSubscriptionGiftHandler(IRedemptionManager redemptionManager, ILogger logger)
{
_redemptionManager = redemptionManager;
_logger = logger;
}
public async Task Execute(TwitchWebsocketClient sender, object data)
{
if (data is not ChannelSubscriptionGiftMessage message)
return;
_logger.Debug("Gifted subscription occured.");
try
{
var actions = _redemptionManager.Get("subscription.gift");
if (!actions.Any())
{
_logger.Debug($"No redemable actions for this gifted subscription was found");
return;
}
_logger.Debug($"Found {actions.Count} actions for this Twitch gifted subscription [gifted: {message.UserLogin}][gifted id: {message.UserId}][Anonymous: {message.IsAnonymous}][cumulative: {message.CumulativeTotal ?? -1}]");
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: gifted subscription]");
}
}
catch (Exception ex)
{
_logger.Error(ex, $"Failed to fetch the redeemable actions for gifted subscription");
}
}
}
}

View File

@ -1,33 +1,54 @@
using Serilog;
using TwitchChatTTS.Twitch.Redemptions;
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Twitch.Socket.Handlers
{
public class ChannelSubscriptionHandler : ITwitchSocketHandler
{
public string Name => "channel.subscription.message";
public string Name => "channel.subscription";
private readonly TTSPlayer _player;
private readonly IRedemptionManager _redemptionManager;
private readonly ILogger _logger;
public ChannelSubscriptionHandler(TTSPlayer player, ILogger logger) {
_player = player;
public ChannelSubscriptionHandler(IRedemptionManager redemptionManager, ILogger logger)
{
_redemptionManager = redemptionManager;
_logger = logger;
}
public async Task Execute(TwitchWebsocketClient sender, object? data)
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;
if (message.IsGifted)
return;
_logger.Debug("Subscription occured.");
try
{
var actions = _redemptionManager.Get("subscription");
if (!actions.Any())
{
_logger.Debug($"No redemable actions for this subscription was found [subscriber: {message.UserLogin}][subscriber id: {message.UserId}]");
return;
}
_logger.Debug($"Found {actions.Count} actions for this Twitch subscription [subscriber: {message.UserLogin}][subscriber id: {message.UserId}]");
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: subscription][subscriber: {message.UserLogin}][subscriber id: {message.UserId}]");
}
}
catch (Exception ex)
{
_logger.Error(ex, $"Failed to fetch the redeemable actions for subscription [subscriber: {message.UserLogin}][subscriber id: {message.UserId}]");
}
}
}
}

View File

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

View File

@ -23,30 +23,30 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
_handlers = handlers.ToDictionary(h => h.Name, h => h);
_logger = logger;
_options = new JsonSerializerOptions() {
_options = new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
_messageTypes = new Dictionary<string, Type>();
_messageTypes.Add("channel.adbreak.begin", typeof(ChannelAdBreakMessage));
_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.follow", typeof(ChannelFollowMessage));
_messageTypes.Add("channel.resubscription", typeof(ChannelResubscriptionMessage));
_messageTypes.Add("channel.subscription.message", typeof(ChannelSubscriptionMessage));
_messageTypes.Add("channel.subscription.gift", typeof(ChannelSubscriptionGiftMessage));
}
public async Task Execute(TwitchWebsocketClient sender, object? data)
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;

View File

@ -0,0 +1,12 @@
namespace TwitchChatTTS.Twitch.Socket.Handlers
{
public class SessionKeepAliveHandler : ITwitchSocketHandler
{
public string Name => "session_keepalive";
public Task Execute(TwitchWebsocketClient sender, object data)
{
return Task.CompletedTask;
}
}
}

View File

@ -8,40 +8,45 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
{
public string Name => "session_reconnect";
private readonly TwitchApiClient _api;
private readonly ITwitchConnectionManager _manager;
private readonly ILogger _logger;
public SessionReconnectHandler(TwitchApiClient api, ILogger logger)
public SessionReconnectHandler(ITwitchConnectionManager manager, ILogger logger)
{
_api = api;
_manager = manager;
_logger = logger;
}
public async Task Execute(TwitchWebsocketClient sender, object? data)
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}]");
_logger.Warning($"No session id 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();
if (message.Session.ReconnectUrl == null)
{
_logger.Warning($"No reconnection info provided by Twitch [status: {message.Session.Status}]");
return;
}
sender.ReceivedReconnecting = true;
var backup = _manager.GetBackupClient();
var identified = _manager.GetWorkingClient();
if (identified != null && backup != identified)
{
await identified.DisconnectAsync(new SocketDisconnectionEventArgs("Closed", "Reconnection from another client."));
}
backup.URL = message.Session.ReconnectUrl;
await backup.Connect();
}
}
}

View File

@ -1,4 +1,3 @@
using CommonSocketLibrary.Abstract;
using Serilog;
using TwitchChatTTS.Twitch.Socket.Messages;
@ -8,26 +7,23 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
{
public string Name => "session_welcome";
private readonly HermesApiClient _hermes;
private readonly TwitchApiClient _api;
private readonly User _user;
private readonly ILogger _logger;
public SessionWelcomeHandler(TwitchApiClient api, User user, ILogger logger)
public SessionWelcomeHandler(HermesApiClient hermes, TwitchApiClient api, User user, ILogger logger)
{
_hermes = hermes;
_api = api;
_user = user;
_logger = logger;
}
public async Task Execute(TwitchWebsocketClient sender, object? data)
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)
@ -39,6 +35,11 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
return;
}
await _hermes.AuthorizeTwitch();
var token = await _hermes.FetchTwitchBotToken();
_api.Initialize(token);
string broadcasterId = _user.TwitchUserId.ToString();
string[] subscriptionsv1 = [
"channel.chat.message",
"channel.chat.message_delete",
@ -53,17 +54,36 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
string[] subscriptionsv2 = [
"channel.follow",
];
string broadcasterId = _user.TwitchUserId.ToString();
string? pagination = null;
int size = 0;
do
{
var subscriptionsData = await _api.GetSubscriptions(status: "enabled", broadcasterId: broadcasterId, after: pagination);
var subscriptionNames = subscriptionsData?.Data == null ? [] : subscriptionsData.Data.Select(s => s.Type).ToArray();
if (subscriptionNames.Length == 0)
break;
foreach (var d in subscriptionsData!.Data!)
sender.AddSubscription(broadcasterId, d.Type, d.Id);
subscriptionsv1 = subscriptionsv1.Except(subscriptionNames).ToArray();
subscriptionsv2 = subscriptionsv2.Except(subscriptionNames).ToArray();
pagination = subscriptionsData?.Pagination?.Cursor;
size = subscriptionNames.Length;
} while (size >= 100 && pagination != null && subscriptionsv1.Length + subscriptionsv2.Length > 0);
foreach (var subscription in subscriptionsv1)
await Subscribe(subscription, message.Session.Id, broadcasterId, "1");
await Subscribe(sender, 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;
await Subscribe(sender, subscription, message.Session.Id, broadcasterId, "2");
sender.Identify(message.Session.Id);
}
private async Task Subscribe(string subscriptionName, string sessionId, string broadcasterId, string version)
private async Task Subscribe(TwitchWebsocketClient sender, string subscriptionName, string sessionId, string broadcasterId, string version)
{
try
{
@ -83,6 +103,10 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers
_logger.Error($"Failed to create an event subscription [subscription type: {subscriptionName}][reason: data is empty]");
return;
}
foreach (var d in response.Data)
sender.AddSubscription(broadcasterId, d.Type, d.Id);
_logger.Information($"Sucessfully added subscription to Twitch websockets [subscription type: {subscriptionName}]");
}
catch (Exception ex)

View File

@ -0,0 +1,15 @@
namespace TwitchChatTTS.Twitch.Socket.Messages
{
public class ChannelAdBreakMessage
{
public string DurationSeconds { get; set; }
public DateTime StartedAt { get; set; }
public string IsAutomatic { get; set; }
public string BroadcasterUserId { get; set; }
public string BroadcasterUserLogin { get; set; }
public string BroadcasterUserName { get; set; }
public string RequesterUserId { get; set; }
public string RequesterUserLogin { get; set; }
public string RequesterUserName { get; set; }
}
}

View File

@ -0,0 +1,13 @@
namespace TwitchChatTTS.Twitch.Socket.Messages
{
public class ChannelFollowMessage
{
public string BroadcasterUserId { get; set; }
public string BroadcasterUserLogin { get; set; }
public string BroadcasterUserName { get; set; }
public string UserId { get; set; }
public string UserLogin { get; set; }
public string UserName { get; set; }
public DateTime FollowedAt { get; set; }
}
}

View File

@ -0,0 +1,10 @@
namespace TwitchChatTTS.Twitch.Socket.Messages
{
public class ChannelResubscriptionMessage : ChannelSubscriptionData
{
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,9 @@
namespace TwitchChatTTS.Twitch.Socket.Messages
{
public class ChannelSubscriptionGiftMessage : ChannelSubscriptionData
{
public int Total { get; set; }
public int? CumulativeTotal { get; set; }
public bool IsAnonymous { get; set; }
}
}

View File

@ -1,17 +1,18 @@
namespace TwitchChatTTS.Twitch.Socket.Messages
{
public class ChannelSubscriptionMessage
public class ChannelSubscriptionData
{
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 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; }
}
public class ChannelSubscriptionMessage : ChannelSubscriptionData
{
public bool IsGifted { get; set; }
}
}

View File

@ -6,5 +6,10 @@ namespace TwitchChatTTS.Twitch.Socket.Messages
public int Total { get; set; }
public int TotalCost { get; set; }
public int MaxTotalCost { get; set; }
public EventResponsePagination? Pagination { get; set; }
}
public class EventResponsePagination {
public string Cursor { get; set; }
}
}

View File

@ -11,7 +11,8 @@ namespace TwitchChatTTS.Twitch.Socket.Messages
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Cost { get; set; }
public EventSubscriptionMessage() {
public EventSubscriptionMessage()
{
Type = string.Empty;
Version = string.Empty;
Condition = new Dictionary<string, string>();
@ -45,7 +46,8 @@ namespace TwitchChatTTS.Twitch.Socket.Messages
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SessionId { get; }
public EventSubTransport() {
public EventSubTransport()
{
Method = string.Empty;
}

View File

@ -11,6 +11,6 @@ namespace TwitchChatTTS.Twitch.Socket.Messages
public string Id { get; set; }
public string Status { get; set; }
public DateTime CreatedAt { get; set; }
public object Event { get; set; }
public object? Event { get; set; }
}
}

View File

@ -8,7 +8,7 @@ namespace TwitchChatTTS.Twitch.Socket.Messages
public string Id { get; set; }
public string Status { get; set; }
public DateTime ConnectedAt { get; set; }
public int KeepaliveTimeoutSeconds { get; set; }
public int? KeepaliveTimeoutSeconds { get; set; }
public string? ReconnectUrl { get; set; }
public string? RecoveryUrl { get; set; }
}

View File

@ -0,0 +1,119 @@
using CommonSocketLibrary.Abstract;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using TwitchChatTTS.Twitch.Socket.Messages;
namespace TwitchChatTTS.Twitch.Socket
{
public interface ITwitchConnectionManager
{
TwitchWebsocketClient GetWorkingClient();
TwitchWebsocketClient GetBackupClient();
}
public class TwitchConnectionManager : ITwitchConnectionManager
{
private TwitchWebsocketClient? _identified;
private TwitchWebsocketClient? _backup;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger _logger;
private readonly object _lock;
public TwitchConnectionManager(IServiceProvider serviceProvider, ILogger logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
_lock = new object();
}
public TwitchWebsocketClient GetBackupClient()
{
lock (_lock)
{
if (_identified == null)
throw new InvalidOperationException("Cannot get backup Twitch client yet. Waiting for identification.");
if (_backup != null)
return _backup;
return CreateNewClient();
}
}
public TwitchWebsocketClient GetWorkingClient()
{
lock (_lock)
{
if (_identified == null)
{
return CreateNewClient();
}
return _identified;
}
}
private TwitchWebsocketClient CreateNewClient()
{
if (_backup != null)
return _backup;
var client = (_serviceProvider.GetRequiredKeyedService<SocketClient<TwitchWebsocketMessage>>("twitch-create") as TwitchWebsocketClient)!;
client.Initialize();
_backup = client;
client.OnIdentified += async (s, e) =>
{
bool clientDisconnect = false;
lock (_lock)
{
if (_identified == client)
{
_logger.Error("Twitch client has been re-identified.");
return;
}
if (_backup != client)
{
_logger.Warning("Twitch client has been identified, but isn't backup. Disconnecting.");
clientDisconnect = true;
return;
}
if (_identified != null)
{
return;
}
_identified = _backup;
_backup = null;
}
if (clientDisconnect)
await client.DisconnectAsync(new SocketDisconnectionEventArgs("Closed", "No need for a tertiary client."));
_logger.Information("Twitch client has been identified.");
};
client.OnDisconnected += (s, e) =>
{
lock (_lock)
{
if (_identified == client)
{
_identified = null;
}
else if (_backup == client)
{
_backup = null;
}
else
_logger.Error("Twitch client disconnection from unknown source.");
}
};
_logger.Debug("Created a Twitch websocket client.");
return client;
}
}
}

View File

@ -6,26 +6,32 @@ using System.Net.WebSockets;
using TwitchChatTTS.Twitch.Socket.Messages;
using System.Text;
using TwitchChatTTS.Twitch.Socket.Handlers;
using CommonSocketLibrary.Backoff;
namespace TwitchChatTTS.Twitch.Socket
{
public class TwitchWebsocketClient : SocketClient<TwitchWebsocketMessage>
{
private readonly IDictionary<string, ITwitchSocketHandler> _handlers;
private readonly IDictionary<string, Type> _messageTypes;
private readonly IDictionary<string, string> _subscriptions;
private readonly IBackoff _backoff;
private DateTime _lastReceivedMessageTimestamp;
private bool _disconnected;
private readonly object _lock;
public event EventHandler<EventArgs> OnIdentified;
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 bool Connected { get; private set; }
public bool Identified { get; private set; }
public string SessionId { get; private set; }
public bool ReceivedReconnecting { get; set; }
public TwitchWebsocketClient(
Configuration configuration,
[FromKeyedServices("twitch")] IEnumerable<ITwitchSocketHandler> handlers,
[FromKeyedServices("twitch")] IBackoff backoff,
ILogger logger
) : base(logger, new JsonSerializerOptions()
{
@ -34,14 +40,12 @@ namespace TwitchChatTTS.Twitch.Socket
})
{
_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;
_backoff = backoff;
_subscriptions = new Dictionary<string, string>();
_lock = new object();
_messageTypes = new Dictionary<string, Type>();
_messageTypes.Add("session_keepalive", typeof(object));
_messageTypes.Add("session_welcome", typeof(SessionWelcomeMessage));
_messageTypes.Add("session_reconnect", typeof(SessionWelcomeMessage));
_messageTypes.Add("notification", typeof(NotificationMessage));
@ -50,23 +54,56 @@ namespace TwitchChatTTS.Twitch.Socket
}
public void AddSubscription(string broadcasterId, string type, string id)
{
if (_subscriptions.ContainsKey(broadcasterId + '|' + type))
_subscriptions[broadcasterId + '|' + type] = id;
else
_subscriptions.Add(broadcasterId + '|' + type, id);
}
public string? GetSubscriptionId(string broadcasterId, string type)
{
if (_subscriptions.TryGetValue(broadcasterId + '|' + type, out var id))
return id;
return null;
}
public void RemoveSubscription(string broadcasterId, string type)
{
_subscriptions.Remove(broadcasterId + '|' + type);
}
public void Initialize()
{
_logger.Information($"Initializing OBS websocket client.");
_logger.Information($"Initializing Twitch websocket client.");
OnConnected += (sender, e) =>
{
Connected = true;
_reconnectTimer.Enabled = false;
_logger.Information("Twitch websocket client connected.");
_disconnected = false;
};
OnDisconnected += (sender, e) =>
OnDisconnected += async (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."));
lock (_lock)
{
if (_disconnected)
return;
_disconnected = true;
}
_logger.Information($"Twitch websocket client disconnected [status: {e.Status}][reason: {e.Reason}]");
Connected = false;
Identified = false;
if (!ReceivedReconnecting)
{
_logger.Information("Attempting to reconnect to Twitch websocket server.");
await Reconnect(_backoff, async () => await Connect());
}
};
}
@ -79,42 +116,14 @@ namespace TwitchChatTTS.Twitch.Socket
}
_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.");
}
await ConnectAsync(URL);
}
private async Task Reconnect()
public void Identify(string sessionId)
{
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.");
}
Identified = true;
SessionId = sessionId;
OnIdentified?.Invoke(this, EventArgs.Empty);
}
protected TwitchWebsocketMessage GenerateMessage<T>(string messageType, T data)
@ -134,14 +143,17 @@ namespace TwitchChatTTS.Twitch.Socket
protected override async Task OnResponseReceived(TwitchWebsocketMessage? message)
{
if (message == null || message.Metadata == null) {
if (message == null || message.Metadata == null)
{
_logger.Information("Twitch message is null");
return;
}
_lastReceivedMessageTimestamp = DateTime.UtcNow;
string content = message.Payload?.ToString() ?? string.Empty;
if (message.Metadata.MessageType != "session_keepalive")
_logger.Information("Twitch RX #" + message.Metadata.MessageType + ": " + content);
_logger.Debug("Twitch RX #" + message.Metadata.MessageType + ": " + content);
if (!_messageTypes.TryGetValue(message.Metadata.MessageType, out var type) || type == null)
{
@ -156,6 +168,11 @@ namespace TwitchChatTTS.Twitch.Socket
}
var data = JsonSerializer.Deserialize(content, type, _options);
if (data == null)
{
_logger.Warning("Twitch websocket message payload is null.");
return;
}
await handler.Execute(this, data);
}
@ -180,7 +197,7 @@ namespace TwitchChatTTS.Twitch.Socket
await _socket!.SendAsync(array, WebSocketMessageType.Text, current + size >= total, _cts!.Token);
current += size;
}
_logger.Information("TX #" + type + ": " + content);
_logger.Debug("Twitch TX #" + type + ": " + content);
}
catch (Exception e)
{

View File

@ -1,29 +0,0 @@
// using System.Text.RegularExpressions;
// using HermesSocketLibrary.Request.Message;
// using TwitchChatTTS.Hermes;
// namespace TwitchChatTTS.Twitch
// {
// public class TTSContext
// {
// public string DefaultVoice;
// public IEnumerable<TTSVoice>? EnabledVoices;
// public IDictionary<string, TTSUsernameFilter>? UsernameFilters;
// public IEnumerable<TTSWordFilter>? WordFilters;
// public IList<VoiceDetails>? AvailableVoices { get => _availableVoices; set { _availableVoices = value; EnabledVoicesRegex = GenerateEnabledVoicesRegex(); } }
// public IDictionary<long, string>? SelectedVoices;
// public Regex? EnabledVoicesRegex;
// private IList<VoiceDetails>? _availableVoices;
// private Regex? GenerateEnabledVoicesRegex() {
// if (AvailableVoices == null || AvailableVoices.Count() <= 0) {
// return null;
// }
// var enabledVoicesString = string.Join("|", AvailableVoices.Select(v => v.Name));
// return new Regex($@"\b({enabledVoicesString})\:(.*?)(?=\Z|\b(?:{enabledVoicesString})\:)", RegexOptions.IgnoreCase);
// }
// }
// }

View File

@ -24,35 +24,40 @@ public class TwitchApiClient
});
}
public async Task<EventResponse<EventSubscriptionMessage>?> CreateEventSubscription(string type, string version, string userId)
public async Task<EventResponse<NotificationInfo>?> CreateEventSubscription(string type, string version, string sessionId, string userId, string? broadcasterId = null)
{
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("Twitch API call [type: create event subscription]: " + await response.Content.ReadAsStringAsync());
return await response.Content.ReadFromJsonAsync(typeof(EventResponse<EventSubscriptionMessage>)) as EventResponse<EventSubscriptionMessage>;
}
_logger.Warning("Twitch api failed to create event subscription: " + await response.Content.ReadAsStringAsync());
return null;
}
public async Task<EventResponse<EventSubscriptionMessage>?> CreateEventSubscription(string type, string version, string sessionId, string userId)
{
var conditions = new Dictionary<string, string>() { { "user_id", userId }, { "broadcaster_user_id", userId }, { "moderator_user_id", userId } };
var conditions = new Dictionary<string, string>() { { "user_id", userId }, { "broadcaster_user_id", broadcasterId ?? userId }, { "moderator_user_id", broadcasterId ?? 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 call [type: create event subscription]: " + await response.Content.ReadAsStringAsync());
return await response.Content.ReadFromJsonAsync(typeof(EventResponse<EventSubscriptionMessage>)) as EventResponse<EventSubscriptionMessage>;
return await response.Content.ReadFromJsonAsync(typeof(EventResponse<NotificationInfo>)) as EventResponse<NotificationInfo>;
}
_logger.Error("Twitch api failed to create event subscription: " + await response.Content.ReadAsStringAsync());
_logger.Error("Twitch api failed to create event subscription for websocket: " + await response.Content.ReadAsStringAsync());
return null;
}
public void Initialize(TwitchBotToken token) {
public async Task DeleteEventSubscription(string subscriptionId)
{
await _web.Delete("https://api.twitch.tv/helix/eventsub/subscriptions?id=" + subscriptionId);
}
public async Task<EventResponse<NotificationInfo>?> GetSubscriptions(string? status = null, string? broadcasterId = null, string? after = null)
{
List<string> queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(status))
queryParams.Add("status=" + status);
if (!string.IsNullOrWhiteSpace(broadcasterId))
queryParams.Add("user_id=" + broadcasterId);
if (!string.IsNullOrWhiteSpace(after))
queryParams.Add("after=" + after);
var query = queryParams.Any() ? '?' + string.Join('&', queryParams) : string.Empty;
return await _web.GetJson<EventResponse<NotificationInfo>>("https://api.twitch.tv/helix/eventsub/subscriptions" + query);
}
public void Initialize(TwitchBotToken token)
{
_web.AddHeader("Authorization", "Bearer " + token.AccessToken);
_web.AddHeader("Client-Id", token.ClientId);
}