From 00e09d7e7d1ff8c487c3e62cdac694527fe5d275 Mon Sep 17 00:00:00 2001 From: barelyprofessional <150058423+barelyprofessional@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:34:10 -0500 Subject: [PATCH] Super experimental replacement for the dead Twitch WS PubSub service --- KfChatDotNetBot/Services/BotServices.cs | 36 ++++-- KfChatDotNetBot/Services/StreamCapture.cs | 11 +- KfChatDotNetBot/Services/TwitchGraphQl.cs | 150 ++++++++++++++++++++++ KfChatDotNetBot/Settings/BuiltIn.cs | 26 ++++ 4 files changed, 206 insertions(+), 17 deletions(-) create mode 100644 KfChatDotNetBot/Services/TwitchGraphQl.cs diff --git a/KfChatDotNetBot/Services/BotServices.cs b/KfChatDotNetBot/Services/BotServices.cs index 914c379..8af1413 100644 --- a/KfChatDotNetBot/Services/BotServices.cs +++ b/KfChatDotNetBot/Services/BotServices.cs @@ -22,7 +22,7 @@ public class BotServices private readonly Logger _logger = LogManager.GetCurrentClassLogger(); internal KickWsClient.KickWsClient? KickClient; - private Twitch? _twitch; + private TwitchGraphQl? _twitch; private Shuffle? _shuffle; private DiscordService? _discord; private TwitchChat? _twitchChat; @@ -214,18 +214,18 @@ public class BotServices private async Task BuildTwitch() { - var settings = await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.TwitchBossmanJackId, BuiltIn.Keys.Proxy]); - if (settings[BuiltIn.Keys.TwitchBossmanJackId].Value == null) + var settings = await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.TwitchBossmanJackUsername, BuiltIn.Keys.Proxy]); + if (settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value == null) { _twitchDisabled = true; - _logger.Debug($"Ignoring Twitch client as {BuiltIn.Keys.TwitchBossmanJackId} is not defined"); + _logger.Debug($"Ignoring Twitch client as {BuiltIn.Keys.TwitchBossmanJackUsername} is not defined"); return; } - _twitch = new Twitch([settings[BuiltIn.Keys.TwitchBossmanJackId].ToType()], settings[BuiltIn.Keys.Proxy].Value, _cancellationToken); + _twitch = new TwitchGraphQl(settings[BuiltIn.Keys.Proxy].Value, _cancellationToken); _twitch.OnStreamStateUpdated += OnTwitchStreamStateUpdated; - _twitch.OnStreamCommercial += OnTwitchStreamCommercial; - _twitch.OnStreamTosStrike += OnTwitchStreamTosStrike; - await _twitch.StartWsClient(); + //_twitch.OnStreamCommercial += OnTwitchStreamCommercial; + //_twitch.OnStreamTosStrike += OnTwitchStreamTosStrike; + //await _twitch.StartWsClient(); _logger.Info("Built Twitch Websocket connection for livestream notifications"); } @@ -378,7 +378,7 @@ public class BotServices await BuildDiscord(); } - if (!_twitchDisabled && _twitch != null && !_twitch.IsConnected()) + if (!_twitchDisabled && _twitch != null && !_twitch.IsTaskRunning()) { _logger.Error("Twitch died, recreating it"); _twitch.Dispose(); @@ -878,19 +878,26 @@ public class BotServices _chatBot.SendChatMessage($"🚨🚨 Shufflebros! 🚨🚨 {bet.Username} just bet {bet.Amount} {bet.Currency} which paid out [color={payoutColor}]{bet.Payout} {bet.Currency}[/color] ({bet.Multiplier}x) on {bet.GameName} 💰💰", true); } - private void OnTwitchStreamStateUpdated(object sender, int channelId, bool isLive) + private void OnTwitchStreamStateUpdated(object sender, string channelName, bool isLive) { _logger.Info($"BossmanJack stream event came in. isLive => {isLive}"); - var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.RestreamUrl, BuiltIn.Keys.TwitchBossmanJackUsername]).Result; + var settings = SettingsProvider.GetMultipleValuesAsync([ + BuiltIn.Keys.RestreamUrl, BuiltIn.Keys.TwitchBossmanJackUsername, BuiltIn.Keys.CaptureEnabled + ]).Result; if (isLive) { _chatBot.SendChatMessage($"{settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value} just went live on Twitch! https://www.twitch.tv/{settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value}\r\n" + - settings[BuiltIn.Keys.RestreamUrl].Value); + settings[BuiltIn.Keys.RestreamUrl].Value, true); IsBmjLive = true; + if (settings[BuiltIn.Keys.CaptureEnabled].ToBoolean()) + { + _logger.Info("Capturing Bossman's stream"); + _ = new StreamCapture($"https://www.twitch.tv/{settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value}", StreamCaptureMethods.Streamlink, _cancellationToken).CaptureAsync(); + } return; } - _chatBot.SendChatMessage($"{settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value} is no longer live! :lossmanjack:"); + _chatBot.SendChatMessage($"{settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value} is no longer live! :lossmanjack:", true); IsBmjLive = false; } @@ -1137,6 +1144,7 @@ public class BotServices $"{identity} is no longer live! :lossmanjack:", true); } + // TODO: Fix this so it aligns with the new Persisted Live setting instead of tracking separately public async Task CheckBmjIsLive(string bmjUsername) { if (IsBmjLive) @@ -1158,7 +1166,7 @@ public class BotServices _logger.Error("Twitch client has not been built!"); throw new Exception("Twitch client not initialized"); } - IsBmjLive = await _twitch.IsStreamLive(bmjUsername); + IsBmjLive = (await _twitch.GetStream(bmjUsername)).IsLive; _isBmjLiveSynced = true; } if (IsBmjLive) diff --git a/KfChatDotNetBot/Services/StreamCapture.cs b/KfChatDotNetBot/Services/StreamCapture.cs index d6eb804..e09c5d2 100644 --- a/KfChatDotNetBot/Services/StreamCapture.cs +++ b/KfChatDotNetBot/Services/StreamCapture.cs @@ -15,7 +15,7 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod, .GetMultipleValuesAsync([BuiltIn.Keys.CaptureYtDlpBinaryPath, BuiltIn.Keys.CaptureYtDlpWorkingDirectory, BuiltIn.Keys.CaptureYtDlpCookiesFromBrowser, BuiltIn.Keys.CaptureYtDlpOutputFormat, BuiltIn.Keys.CaptureYtDlpParentTerminal, BuiltIn.Keys.CaptureYtDlpScriptPath, BuiltIn.Keys.CaptureYtDlpUserAgent, BuiltIn.Keys.CaptureStreamlinkBinaryPath, - BuiltIn.Keys.CaptureStreamlinkOutputFormat, BuiltIn.Keys.CaptureStreamlinkRemuxScript]).Result; + BuiltIn.Keys.CaptureStreamlinkOutputFormat, BuiltIn.Keys.CaptureStreamlinkRemuxScript, BuiltIn.Keys.CaptureStreamlinkTwitchOptions]).Result; private readonly Logger _logger = LogManager.GetCurrentClassLogger(); @@ -111,12 +111,17 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod, } else if (captureMethod == StreamCaptureMethods.Streamlink) { - captureLine = $"{_settings[BuiltIn.Keys.CaptureStreamlinkBinaryPath].Value} --output \"{_settings[BuiltIn.Keys.CaptureStreamlinkOutputFormat].Value}\" " + + var twitchOpts = string.Empty; + if (streamUrl.Contains("twitch.tv")) + { + twitchOpts = _settings[BuiltIn.Keys.CaptureStreamlinkTwitchOptions].Value; + } + captureLine = $"{_settings[BuiltIn.Keys.CaptureStreamlinkBinaryPath].Value} {twitchOpts} --output \"{_settings[BuiltIn.Keys.CaptureStreamlinkOutputFormat].Value}\" " + $"--retry-streams 15 --retry-max 10 {streamUrl} best"; } else { - _logger.Error($"We were given a straem capture method that doesn't exist: {captureMethod}"); + _logger.Error($"We were given a stream capture method that doesn't exist: {captureMethod}"); throw new UnsupportedStreamCaptureMethodException(); } diff --git a/KfChatDotNetBot/Services/TwitchGraphQl.cs b/KfChatDotNetBot/Services/TwitchGraphQl.cs new file mode 100644 index 0000000..4da6c55 --- /dev/null +++ b/KfChatDotNetBot/Services/TwitchGraphQl.cs @@ -0,0 +1,150 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using KfChatDotNetBot.Models.DbModels; +using KfChatDotNetBot.Settings; +using NLog; + +namespace KfChatDotNetBot.Services; + +public class TwitchGraphQl(string? proxy = null, CancellationToken cancellationToken = default) : IDisposable +{ + private Logger _logger = LogManager.GetCurrentClassLogger(); + private Uri _gqlEndpoint = new Uri("https://gql.twitch.tv/gql"); + private string _gqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko"; + private string? _proxy = proxy; + private CancellationToken _cancellationToken = cancellationToken; + private Task? _liveStatusCheckTask; + private CancellationTokenSource _liveStatusCheckTaskCts = new(); + + public delegate void OnStreamStateUpdateEventHandler(object sender, string channelName, bool isLive); + public event OnStreamStateUpdateEventHandler? OnStreamStateUpdated; + + public void StartLiveStatusCheck() + { + _liveStatusCheckTaskCts = new CancellationTokenSource(); + _liveStatusCheckTask = Task.Run(LiveStatusCheckTask, _liveStatusCheckTaskCts.Token); + } + + public bool IsTaskRunning() + { + if (_liveStatusCheckTask?.Status is not (TaskStatus.Running or TaskStatus.WaitingForActivation)) return false; + return !_liveStatusCheckTaskCts.IsCancellationRequested; + } + + private async Task LiveStatusCheckTask() + { + var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.TwitchGraphQlCheckInterval)).ToType(); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(interval)); + while (await timer.WaitForNextTickAsync(_liveStatusCheckTaskCts.Token)) + { + var ct = _liveStatusCheckTaskCts.Token; + _logger.Debug("Going to check if Bossman is live right now"); + var settings = await SettingsProvider.GetMultipleValuesAsync([ + BuiltIn.Keys.TwitchBossmanJackUsername, BuiltIn.Keys.TwitchGraphQlPersistedCurrentlyLive + ]); + TwitchGraphQlModel stream; + try + { + stream = await GetStream(settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value!); + } + catch (Exception e) + { + _logger.Error("Caught exception when trying to check if Bossman is live on Twitch"); + _logger.Error(e); + continue; + } + + if (stream.IsLive) + { + _logger.Info("Updating DB with fresh view count"); + await using var db = new ApplicationDbContext(); + await db.TwitchViewCounts.AddAsync(new TwitchViewCountDbModel + { + Topic = stream.Id.ToString()!, + Viewers = stream.ViewerCount!.Value, + Time = DateTimeOffset.UtcNow, + ServerTime = 0 + }, ct); + await db.SaveChangesAsync(ct); + } + + var persistedLive = settings[BuiltIn.Keys.TwitchGraphQlPersistedCurrentlyLive].ToBoolean(); + if (stream.IsLive == persistedLive) continue; + OnStreamStateUpdated?.Invoke(this, settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value!, stream.IsLive); + await SettingsProvider.SetValueAsBooleanAsync(BuiltIn.Keys.TwitchGraphQlPersistedCurrentlyLive, + stream.IsLive); + } + } + + public async Task GetStream(string channel) + { + var graphQl = "query {\n user(login: \"" + channel + "\") {\n stream {\n id\n viewersCount\n }\n }\n}"; + _logger.Debug($"Built GraphQL query string: {graphQl}"); + var jsonBody = new Dictionary + { + { "query", graphQl }, + { "variables", new object() } + }; + _logger.Debug("Created dictionary object for the JSON payload, should serialize to following value:"); + _logger.Debug(JsonSerializer.Serialize(jsonBody)); + var handler = new HttpClientHandler {AutomaticDecompression = DecompressionMethods.All}; + if (_proxy != null) + { + handler.UseProxy = true; + handler.Proxy = new WebProxy(_proxy); + _logger.Debug($"Configured to use proxy {_proxy}"); + } + + using var client = new HttpClient(handler); + client.DefaultRequestHeaders.Add("client-id", _gqlClientId); + var postBody = JsonContent.Create(jsonBody); + var response = await client.PostAsync(_gqlEndpoint, postBody, _cancellationToken); + var responseContent = await response.Content.ReadFromJsonAsync(cancellationToken: _cancellationToken); + _logger.Debug("Twitch API returned following JSON"); + _logger.Debug(responseContent.GetRawText); + var returnData = new TwitchGraphQlModel { IsLive = false}; + if (responseContent.GetProperty("data").GetProperty("user").ValueKind == JsonValueKind.Null) + { + _logger.Error("data.user was null"); + return returnData; + } + + if (responseContent.GetProperty("data").GetProperty("user").GetProperty("stream").ValueKind == + JsonValueKind.Null) + { + _logger.Debug("stream property was null. Means streamer is not live"); + return returnData; + } + + returnData.IsLive = true; + returnData.Id = responseContent.GetProperty("data").GetProperty("user").GetProperty("stream").GetProperty("id") + .GetInt64(); + returnData.ViewerCount = responseContent.GetProperty("data").GetProperty("user").GetProperty("stream").GetProperty("viewersCount") + .GetInt32(); + return returnData; + } + + public void Dispose() + { + _liveStatusCheckTaskCts.Cancel(); + _liveStatusCheckTask?.Dispose(); + GC.SuppressFinalize(this); + } +} + +public class TwitchGraphQlModel +{ + /// + /// Whether the streamer is live + /// + public required bool IsLive { get; set; } + /// + /// Viewer count, null if not live + /// + public int? ViewerCount { get; set; } + /// + /// Stream ID returned by the GraphQL endpoint. null if none + /// + public long? Id { get; set; } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index 9916962..6ca3235 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -968,6 +968,29 @@ public static class BuiltIn Default = "100", ValueType = SettingValueType.Text, Regex = WholeNumberRegex + }, + new BuiltInSettingsModel + { + Key = Keys.TwitchGraphQlCheckInterval, + Description = "Interval in seconds to check if Bossman is live on Twitch using GraphQL", + Default = "10", + ValueType = SettingValueType.Text, + Regex = WholeNumberRegex + }, + new BuiltInSettingsModel + { + Key = Keys.TwitchGraphQlPersistedCurrentlyLive, + Description = "Whether BossmanJack is currently live on Twitch", + Default = "false", + ValueType = SettingValueType.Boolean, + Regex = BooleanRegex + }, + new BuiltInSettingsModel + { + Key = Keys.CaptureStreamlinkTwitchOptions, + Description = "Special options for Twitch streams captured with Streamlink", + Default = "--twitch-disable-ad --twitch-proxy-playlist=https://eu.luminous.dev,https://eu2.luminous.dev,https://as.luminous.dev,https://cdn.perfprod.com", + ValueType = SettingValueType.Text } ]; @@ -1065,6 +1088,7 @@ public static class BuiltIn public static string CaptureStreamlinkBinaryPath = "Capture.Streamlink.BinaryPath"; public static string CaptureStreamlinkOutputFormat = "Capture.Streamlink.OutputFormat"; public static string CaptureStreamlinkRemuxScript = "Capture.Streamlink.RemuxScript"; + public static string CaptureStreamlinkTwitchOptions = "Capture.Streamlink.TwitchOptions"; public static string DLiveCheckInterval = "DLive.CheckInterval"; public static string DLivePersistedCurrentlyLiveStreams = "DLive.PersistedCurrentlyLiveStreams"; public static string KiwiPeerTubePersistedCurrentlyLiveStreams = "KiwiPeerTube.PersistedCurrentlyLiveStreams"; @@ -1078,5 +1102,7 @@ public static class BuiltIn public static string MoneySymbolPrefix = "Money.SymbolPrefix"; public static string MoneyEnabled = "Money.Enabled"; public static string MoneyInitialBalance = "Money.InitialBalance"; + public static string TwitchGraphQlCheckInterval = "TwitchGraphQl.CheckInterval"; + public static string TwitchGraphQlPersistedCurrentlyLive = "TwitchGraphQl.PersistedCurrentlyLive"; } } \ No newline at end of file