From 0ad063cebda53a6b37da7a2b10e5a896c285eb5e Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 11 Aug 2024 21:22:37 +0000 Subject: [PATCH] Added Nightbot integration. Changed from client credentials flow to implicit code grant flow. --- Chat/Commands/NightbotCommand.cs | 123 ++++++++++++++++++ Helpers/WebClientWrap.cs | 6 + Hermes/Socket/Handlers/LoginAckHandler.cs | 34 +++-- Nightbot/NightbotApiClient.cs | 60 +++++++++ Startup.cs | 4 + Twitch/Redemptions/RedemptionManager.cs | 18 +++ .../Socket/Handlers/SessionWelcomeHandler.cs | 28 ++-- Twitch/TwitchApiClient.cs | 6 +- User.cs | 4 + 9 files changed, 260 insertions(+), 23 deletions(-) create mode 100644 Chat/Commands/NightbotCommand.cs create mode 100644 Nightbot/NightbotApiClient.cs diff --git a/Chat/Commands/NightbotCommand.cs b/Chat/Commands/NightbotCommand.cs new file mode 100644 index 0000000..e5af1e5 --- /dev/null +++ b/Chat/Commands/NightbotCommand.cs @@ -0,0 +1,123 @@ +using Serilog; +using TwitchChatTTS.Hermes.Socket; +using TwitchChatTTS.Twitch.Socket.Messages; +using static TwitchChatTTS.Chat.Commands.TTSCommands; + +namespace TwitchChatTTS.Chat.Commands +{ + public class NightbotCommand : IChatCommand + { + private readonly NightbotApiClient _api; + private readonly ILogger _logger; + + public NightbotCommand(NightbotApiClient api, ILogger logger) + { + _api = api; + _logger = logger; + } + + public string Name => "nightbot"; + + public void Build(ICommandBuilder builder) + { + builder.CreateCommandTree(Name, b => + { + b.CreateStaticInputParameter("play", b => + { + b.CreateCommand(new NightbotSongQueueCommand(_api, "play", _logger)); + }) + .CreateStaticInputParameter("pause", b => + { + b.CreateCommand(new NightbotSongQueueCommand(_api, "pause", _logger)); + }) + .CreateStaticInputParameter("skip", b => + { + b.CreateCommand(new NightbotSongQueueCommand(_api, "skip", _logger)); + }) + .CreateStaticInputParameter("volume", b => + { + b.CreateUnvalidatedParameter("volume") + .CreateCommand(new NightbotSongQueueCommand(_api, "volume", _logger)); + }) + .CreateStaticInputParameter("clear_playlist", b => + { + b.CreateCommand(new NightbotSongQueueCommand(_api, "clear_playlist", _logger)); + }) + .CreateStaticInputParameter("clear_queue", b => + { + b.CreateCommand(new NightbotSongQueueCommand(_api, "volume", _logger)); + }) + .CreateStaticInputParameter("clear", b => + { + b.CreateStaticInputParameter("playlist", b => b.CreateCommand(new NightbotSongQueueCommand(_api, "clear_playlist", _logger))) + .CreateStaticInputParameter("queue", b => b.CreateCommand(new NightbotSongQueueCommand(_api, "clear_queue", _logger))); + }); + }); + } + + private sealed class NightbotSongQueueCommand : IChatPartialCommand + { + private readonly NightbotApiClient _api; + private readonly string _command; + private readonly ILogger _logger; + + public bool AcceptCustomPermission { get => true; } + + + public NightbotSongQueueCommand(NightbotApiClient api, string command, ILogger logger) + { + _api = api; + _command = command.ToLower(); + _logger = logger; + } + + public async Task Execute(IDictionary values, ChannelChatMessage message, HermesSocketClient hermes) + { + try + { + if (_command == "play") + { + await _api.Play(); + _logger.Information("Playing Nightbot song queue."); + } + else if (_command == "pause") + { + await _api.Pause(); + _logger.Information("Playing Nightbot song queue."); + } + else if (_command == "skip") + { + await _api.Skip(); + _logger.Information("Skipping Nightbot song queue."); + } + else if (_command == "volume") + { + int volume = int.Parse(values["volume"]); + await _api.Volume(volume); + _logger.Information($"Changed Nightbot volume to {volume}."); + } + else if (_command == "clear_playlist") + { + await _api.ClearPlaylist(); + _logger.Information("Cleared Nightbot playlist."); + } + else if (_command == "clear_queue") + { + await _api.ClearQueue(); + _logger.Information("Cleared Nightbot queue."); + } + } + catch (HttpRequestException e) + { + _logger.Warning("Ensure your Nightbot account is linked to your TTS account."); + } + catch (FormatException) + { + } + catch (OverflowException) + { + } + } + } + } +} \ No newline at end of file diff --git a/Helpers/WebClientWrap.cs b/Helpers/WebClientWrap.cs index de10449..6620139 100644 --- a/Helpers/WebClientWrap.cs +++ b/Helpers/WebClientWrap.cs @@ -1,3 +1,4 @@ +using System.Net.Http.Formatting; using System.Net.Http.Json; using System.Text.Json; @@ -44,6 +45,11 @@ namespace TwitchChatTTS.Helpers return await _client.PostAsJsonAsync(uri, new object(), Options); } + public async Task Put(string uri, T data) + { + return await _client.PutAsJsonAsync(uri, data, Options); + } + public async Task Delete(string uri) { return await _client.DeleteFromJsonAsync(uri, Options); diff --git a/Hermes/Socket/Handlers/LoginAckHandler.cs b/Hermes/Socket/Handlers/LoginAckHandler.cs index 69601bc..3ca981a 100644 --- a/Hermes/Socket/Handlers/LoginAckHandler.cs +++ b/Hermes/Socket/Handlers/LoginAckHandler.cs @@ -8,12 +8,14 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers public class LoginAckHandler : IWebSocketHandler { private readonly User _user; + private readonly NightbotApiClient _nightbot; private readonly ILogger _logger; public int OperationCode { get; } = 2; - public LoginAckHandler(User user, ILogger logger) + public LoginAckHandler(User user, NightbotApiClient nightbot, ILogger logger) { _user = user; + _nightbot = nightbot; _logger = logger; } @@ -24,32 +26,48 @@ namespace TwitchChatTTS.Hermes.Socket.Handlers if (sender is not HermesSocketClient client) return; - if (message.AnotherClient && client.LoggedIn) + if (message.AnotherClient) { - _logger.Warning("Another client has connected to the same account."); + if (client.LoggedIn) + _logger.Warning($"Another client has connected to the same account via {(message.WebLogin ? "web login" : "application")}."); return; } if (client.LoggedIn) { - _logger.Warning("Attempted to log in again while still logged in."); + _logger.Error("Attempted to log in again while still logged in."); return; } _user.HermesUserId = message.UserId; _user.OwnerId = message.OwnerId; + _user.DefaultTTSVoice = message.DefaultTTSVoice; + _user.VoicesAvailable = message.TTSVoicesAvailable; + _user.RegexFilters = message.WordFilters.ToArray(); + _user.VoicesEnabled = new HashSet(message.EnabledTTSVoices); + _user.TwitchConnection = message.Connections.FirstOrDefault(c => c.Default && c.Type == "twitch"); + _user.NightbotConnection = message.Connections.FirstOrDefault(c => c.Default && c.Type == "nightbot"); + client.LoggedIn = true; _logger.Information($"Logged in as {_user.TwitchUsername} {(message.WebLogin ? "via web" : "via TTS app")}."); - await client.FetchTTSVoices(); - await client.FetchEnabledTTSVoices(); - await client.FetchTTSWordFilters(); await client.FetchTTSChatterVoices(); - await client.FetchDefaultTTSVoice(); await client.FetchChatterIdentifiers(); await client.FetchEmotes(); await client.FetchRedemptions(); await client.FetchPermissions(); + if (_user.NightbotConnection != null) { + _nightbot.Initialize(_user.NightbotConnection.ClientId, _user.NightbotConnection.AccessToken); + var span = DateTime.Now - _user.NightbotConnection.ExpiresAt; + var timeLeft = span.TotalDays >= 2 ? Math.Floor(span.TotalDays) + " days" : (span.TotalHours >= 2 ? Math.Floor(span.TotalHours) + " hours" : Math.Floor(span.TotalMinutes) + " minutes"); + if (span.TotalDays >= 3) + _logger.Information($"Nightbot connection has {timeLeft} before it is revoked."); + else if (span.TotalMinutes >= 0) + _logger.Warning($"Nightbot connection has {timeLeft} before it is revoked. Refreshing the token is soon required."); + else + _logger.Error("Nightbot connection has its permissions revoked. Refresh the token. Anything related to Nightbot from this application will not work."); + } + _logger.Information("TTS is now ready."); client.Ready = true; } diff --git a/Nightbot/NightbotApiClient.cs b/Nightbot/NightbotApiClient.cs new file mode 100644 index 0000000..c4b3576 --- /dev/null +++ b/Nightbot/NightbotApiClient.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using TwitchChatTTS.Helpers; +using Serilog; +using TwitchChatTTS; + +public class NightbotApiClient +{ + private readonly ILogger _logger; + private readonly WebClientWrap _web; + + + public NightbotApiClient( + ILogger logger + ) + { + _logger = logger; + + _web = new WebClientWrap(new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = false, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }); + } + + public async Task Play() + { + await _web.Post("https://api.nightbot.tv/1/song_requests/queue/play"); + } + + public async Task Pause() + { + await _web.Post("https://api.nightbot.tv/1/song_requests/queue/pause"); + } + + public async Task Skip() + { + await _web.Post("https://api.nightbot.tv/1/song_requests/queue/skip"); + } + + public async Task Volume(int volume) + { + await _web.Put("https://api.nightbot.tv/1/song_requests", new Dictionary() { { "volume", volume } }); + } + + public async Task ClearPlaylist() + { + await _web.Delete("https://api.nightbot.tv/1/song_requests/playlist"); + } + + public async Task ClearQueue() + { + await _web.Delete("https://api.nightbot.tv/1/song_requests/queue"); + } + + public void Initialize(string clientId, string accessToken) + { + _web.AddHeader("Authorization", "Bearer " + accessToken); + _web.AddHeader("Client-Id", clientId); + } +} \ No newline at end of file diff --git a/Startup.cs b/Startup.cs index 0b03e09..a5d78d2 100644 --- a/Startup.cs +++ b/Startup.cs @@ -69,6 +69,7 @@ s.AddSingleton(new JsonSerializerOptions() s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); +s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); @@ -107,6 +108,9 @@ s.AddKeyedSingleton("7tv"); s.AddKeyedSingleton, SevenMessageTypeManager>("7tv"); s.AddKeyedSingleton, SevenSocketClient>("7tv"); +// Nightbot +s.AddSingleton(); + // twitch websocket s.AddKeyedSingleton("twitch", new ExponentialBackoff(1000, 120 * 1000)); s.AddSingleton(); diff --git a/Twitch/Redemptions/RedemptionManager.cs b/Twitch/Redemptions/RedemptionManager.cs index 9c9555d..d266d8d 100644 --- a/Twitch/Redemptions/RedemptionManager.cs +++ b/Twitch/Redemptions/RedemptionManager.cs @@ -17,6 +17,7 @@ namespace TwitchChatTTS.Twitch.Redemptions private readonly User _user; private readonly OBSSocketClient _obs; private readonly HermesSocketClient _hermes; + private readonly NightbotApiClient _nightbot; private readonly AudioPlaybackEngine _playback; private readonly ILogger _logger; private readonly Random _random; @@ -27,6 +28,7 @@ namespace TwitchChatTTS.Twitch.Redemptions User user, [FromKeyedServices("obs")] SocketClient obs, [FromKeyedServices("hermes")] SocketClient hermes, + NightbotApiClient nightbot, AudioPlaybackEngine playback, ILogger logger) { @@ -34,6 +36,7 @@ namespace TwitchChatTTS.Twitch.Redemptions _user = user; _obs = (obs as OBSSocketClient)!; _hermes = (hermes as HermesSocketClient)!; + _nightbot = nightbot; _playback = playback; _logger = logger; _random = new Random(); @@ -191,6 +194,21 @@ namespace TwitchChatTTS.Twitch.Redemptions _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; default: _logger.Warning($"Unknown redeemable action has occured. Update needed? [type: {action.Type}][chatter: {senderDisplayName}][chatter id: {senderId}]"); break; diff --git a/Twitch/Socket/Handlers/SessionWelcomeHandler.cs b/Twitch/Socket/Handlers/SessionWelcomeHandler.cs index 476eda9..438ed22 100644 --- a/Twitch/Socket/Handlers/SessionWelcomeHandler.cs +++ b/Twitch/Socket/Handlers/SessionWelcomeHandler.cs @@ -7,14 +7,12 @@ 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(HermesApiClient hermes, TwitchApiClient api, User user, ILogger logger) + public SessionWelcomeHandler(TwitchApiClient api, User user, ILogger logger) { - _hermes = hermes; _api = api; _user = user; _logger = logger; @@ -32,18 +30,24 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers } int waited = 0; - while (_user.TwitchUserId <= 0 && ++waited < 3) + while ((_user.TwitchUserId <= 0 || _user.TwitchConnection == null) && ++waited < 5) await Task.Delay(TimeSpan.FromSeconds(1)); - try + if (_user.TwitchConnection == null) { - await _hermes.AuthorizeTwitch(); - var token = await _hermes.FetchTwitchBotToken(); - _api.Initialize(token); + _logger.Error("Ensure you have linked either your Twitch account or TTS' bot to your TTS account. Twitch client will not be connecting."); + return; } - catch (Exception) - { - _logger.Error("Ensure you have your Twitch account linked on TTS. Restart application once you do."); + + _api.Initialize(_user.TwitchConnection.ClientId, _user.TwitchConnection.AccessToken); + var span = DateTime.Now - _user.TwitchConnection.ExpiresAt; + var timeLeft = span.TotalDays >= 2 ? Math.Floor(span.TotalDays) + " days" : (span.TotalHours >= 2 ? Math.Floor(span.TotalHours) + " hours" : Math.Floor(span.TotalMinutes) + " minutes"); + if (span.TotalDays >= 3) + _logger.Information($"Twitch connection has {timeLeft} before it is revoked."); + else if (span.TotalMinutes >= 0) + _logger.Warning($"Twitch connection has {timeLeft} before it is revoked. Refreshing the token is soon required."); + else { + _logger.Error("Twitch connection has its permissions revoked. Refresh the token. Twith client will not be connecting."); return; } @@ -88,7 +92,7 @@ namespace TwitchChatTTS.Twitch.Socket.Handlers await Subscribe(sender, subscription, message.Session.Id, broadcasterId, "1"); foreach (var subscription in subscriptionsv2) await Subscribe(sender, subscription, message.Session.Id, broadcasterId, "2"); - + await Subscribe(sender, "channel.raid", broadcasterId, async () => await _api.CreateChannelRaidEventSubscription("1", message.Session.Id, to: broadcasterId)); sender.Identify(message.Session.Id); diff --git a/Twitch/TwitchApiClient.cs b/Twitch/TwitchApiClient.cs index 6479230..402d478 100644 --- a/Twitch/TwitchApiClient.cs +++ b/Twitch/TwitchApiClient.cs @@ -95,9 +95,9 @@ public class TwitchApiClient return await _web.GetJson>("https://api.twitch.tv/helix/eventsub/subscriptions" + query); } - public void Initialize(TwitchBotToken token) + public void Initialize(string clientId, string accessToken) { - _web.AddHeader("Authorization", "Bearer " + token.AccessToken); - _web.AddHeader("Client-Id", token.ClientId); + _web.AddHeader("Authorization", "Bearer " + accessToken); + _web.AddHeader("Client-Id", clientId); } } \ No newline at end of file diff --git a/User.cs b/User.cs index e00acb1..21f55ba 100644 --- a/User.cs +++ b/User.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; using System.Text.RegularExpressions; using HermesSocketLibrary.Requests.Messages; +using HermesSocketLibrary.Socket.Data; using TwitchChatTTS.Twitch.Socket.Handlers; namespace TwitchChatTTS @@ -15,6 +16,9 @@ namespace TwitchChatTTS public string SevenEmoteSetId { get; set; } public long? OwnerId { get; set; } + public Connection? TwitchConnection { get; set; } + public Connection? NightbotConnection { get; set; } + public string DefaultTTSVoice { get; set; } // voice id -> voice name public IDictionary VoicesAvailable { get => _voicesAvailable; set { _voicesAvailable = value; VoiceNameRegex = GenerateEnabledVoicesRegex(); } }