Super experimental replacement for the dead Twitch WS PubSub service

This commit is contained in:
barelyprofessional
2025-08-20 16:34:10 -05:00
parent 6ca1cf055c
commit 00e09d7e7d
4 changed files with 206 additions and 17 deletions

View File

@@ -22,7 +22,7 @@ public class BotServices
private readonly Logger _logger = LogManager.GetCurrentClassLogger(); private readonly Logger _logger = LogManager.GetCurrentClassLogger();
internal KickWsClient.KickWsClient? KickClient; internal KickWsClient.KickWsClient? KickClient;
private Twitch? _twitch; private TwitchGraphQl? _twitch;
private Shuffle? _shuffle; private Shuffle? _shuffle;
private DiscordService? _discord; private DiscordService? _discord;
private TwitchChat? _twitchChat; private TwitchChat? _twitchChat;
@@ -214,18 +214,18 @@ public class BotServices
private async Task BuildTwitch() private async Task BuildTwitch()
{ {
var settings = await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.TwitchBossmanJackId, BuiltIn.Keys.Proxy]); var settings = await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.TwitchBossmanJackUsername, BuiltIn.Keys.Proxy]);
if (settings[BuiltIn.Keys.TwitchBossmanJackId].Value == null) if (settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value == null)
{ {
_twitchDisabled = true; _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; return;
} }
_twitch = new Twitch([settings[BuiltIn.Keys.TwitchBossmanJackId].ToType<int>()], settings[BuiltIn.Keys.Proxy].Value, _cancellationToken); _twitch = new TwitchGraphQl(settings[BuiltIn.Keys.Proxy].Value, _cancellationToken);
_twitch.OnStreamStateUpdated += OnTwitchStreamStateUpdated; _twitch.OnStreamStateUpdated += OnTwitchStreamStateUpdated;
_twitch.OnStreamCommercial += OnTwitchStreamCommercial; //_twitch.OnStreamCommercial += OnTwitchStreamCommercial;
_twitch.OnStreamTosStrike += OnTwitchStreamTosStrike; //_twitch.OnStreamTosStrike += OnTwitchStreamTosStrike;
await _twitch.StartWsClient(); //await _twitch.StartWsClient();
_logger.Info("Built Twitch Websocket connection for livestream notifications"); _logger.Info("Built Twitch Websocket connection for livestream notifications");
} }
@@ -378,7 +378,7 @@ public class BotServices
await BuildDiscord(); await BuildDiscord();
} }
if (!_twitchDisabled && _twitch != null && !_twitch.IsConnected()) if (!_twitchDisabled && _twitch != null && !_twitch.IsTaskRunning())
{ {
_logger.Error("Twitch died, recreating it"); _logger.Error("Twitch died, recreating it");
_twitch.Dispose(); _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); _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}"); _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) 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" + _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; 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; 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; IsBmjLive = false;
} }
@@ -1137,6 +1144,7 @@ public class BotServices
$"{identity} is no longer live! :lossmanjack:", true); $"{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<bool> CheckBmjIsLive(string bmjUsername) public async Task<bool> CheckBmjIsLive(string bmjUsername)
{ {
if (IsBmjLive) if (IsBmjLive)
@@ -1158,7 +1166,7 @@ public class BotServices
_logger.Error("Twitch client has not been built!"); _logger.Error("Twitch client has not been built!");
throw new Exception("Twitch client not initialized"); throw new Exception("Twitch client not initialized");
} }
IsBmjLive = await _twitch.IsStreamLive(bmjUsername); IsBmjLive = (await _twitch.GetStream(bmjUsername)).IsLive;
_isBmjLiveSynced = true; _isBmjLiveSynced = true;
} }
if (IsBmjLive) if (IsBmjLive)

View File

@@ -15,7 +15,7 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod,
.GetMultipleValuesAsync([BuiltIn.Keys.CaptureYtDlpBinaryPath, BuiltIn.Keys.CaptureYtDlpWorkingDirectory, .GetMultipleValuesAsync([BuiltIn.Keys.CaptureYtDlpBinaryPath, BuiltIn.Keys.CaptureYtDlpWorkingDirectory,
BuiltIn.Keys.CaptureYtDlpCookiesFromBrowser, BuiltIn.Keys.CaptureYtDlpOutputFormat, BuiltIn.Keys.CaptureYtDlpParentTerminal, BuiltIn.Keys.CaptureYtDlpCookiesFromBrowser, BuiltIn.Keys.CaptureYtDlpOutputFormat, BuiltIn.Keys.CaptureYtDlpParentTerminal,
BuiltIn.Keys.CaptureYtDlpScriptPath, BuiltIn.Keys.CaptureYtDlpUserAgent, BuiltIn.Keys.CaptureStreamlinkBinaryPath, 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(); private readonly Logger _logger = LogManager.GetCurrentClassLogger();
@@ -111,12 +111,17 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod,
} }
else if (captureMethod == StreamCaptureMethods.Streamlink) 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"; $"--retry-streams 15 --retry-max 10 {streamUrl} best";
} }
else 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(); throw new UnsupportedStreamCaptureMethodException();
} }

View File

@@ -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<int>();
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<TwitchGraphQlModel> 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<string, object>
{
{ "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<JsonElement>(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
{
/// <summary>
/// Whether the streamer is live
/// </summary>
public required bool IsLive { get; set; }
/// <summary>
/// Viewer count, null if not live
/// </summary>
public int? ViewerCount { get; set; }
/// <summary>
/// Stream ID returned by the GraphQL endpoint. null if none
/// </summary>
public long? Id { get; set; }
}

View File

@@ -968,6 +968,29 @@ public static class BuiltIn
Default = "100", Default = "100",
ValueType = SettingValueType.Text, ValueType = SettingValueType.Text,
Regex = WholeNumberRegex 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 CaptureStreamlinkBinaryPath = "Capture.Streamlink.BinaryPath";
public static string CaptureStreamlinkOutputFormat = "Capture.Streamlink.OutputFormat"; public static string CaptureStreamlinkOutputFormat = "Capture.Streamlink.OutputFormat";
public static string CaptureStreamlinkRemuxScript = "Capture.Streamlink.RemuxScript"; public static string CaptureStreamlinkRemuxScript = "Capture.Streamlink.RemuxScript";
public static string CaptureStreamlinkTwitchOptions = "Capture.Streamlink.TwitchOptions";
public static string DLiveCheckInterval = "DLive.CheckInterval"; public static string DLiveCheckInterval = "DLive.CheckInterval";
public static string DLivePersistedCurrentlyLiveStreams = "DLive.PersistedCurrentlyLiveStreams"; public static string DLivePersistedCurrentlyLiveStreams = "DLive.PersistedCurrentlyLiveStreams";
public static string KiwiPeerTubePersistedCurrentlyLiveStreams = "KiwiPeerTube.PersistedCurrentlyLiveStreams"; public static string KiwiPeerTubePersistedCurrentlyLiveStreams = "KiwiPeerTube.PersistedCurrentlyLiveStreams";
@@ -1078,5 +1102,7 @@ public static class BuiltIn
public static string MoneySymbolPrefix = "Money.SymbolPrefix"; public static string MoneySymbolPrefix = "Money.SymbolPrefix";
public static string MoneyEnabled = "Money.Enabled"; public static string MoneyEnabled = "Money.Enabled";
public static string MoneyInitialBalance = "Money.InitialBalance"; public static string MoneyInitialBalance = "Money.InitialBalance";
public static string TwitchGraphQlCheckInterval = "TwitchGraphQl.CheckInterval";
public static string TwitchGraphQlPersistedCurrentlyLive = "TwitchGraphQl.PersistedCurrentlyLive";
} }
} }