diff --git a/KfChatDotNetBot/Services/BotServices.cs b/KfChatDotNetBot/Services/BotServices.cs index 526f77d..7081aa2 100644 --- a/KfChatDotNetBot/Services/BotServices.cs +++ b/KfChatDotNetBot/Services/BotServices.cs @@ -38,6 +38,7 @@ public class BotServices private DLive? _dliveStatusCheck; private PeerTube? _peerTubeStatusCheck; private Owncast? _owncastStatusCheck; + private ShuffleDotUs _shuffleDotUs; private Task? _websocketWatchdog; private Task? _howlggGetUserTimer; @@ -86,7 +87,8 @@ public class BotServices BuildParti(), BuildDLiveStatusCheck(), BuildPeerTubeLiveStatusCheck(), - BuildOwncastLiveStatusCheck() + BuildOwncastLiveStatusCheck(), + BuildShuffleDotUs() ]; try { @@ -110,6 +112,14 @@ public class BotServices _shuffle.OnLatestBetUpdated += ShuffleOnLatestBetUpdated; await _shuffle.StartWsClient(); } + + private async Task BuildShuffleDotUs() + { + _logger.Debug("Building Shuffle.us"); + _shuffleDotUs = new ShuffleDotUs((await SettingsProvider.GetValueAsync(BuiltIn.Keys.Proxy)).Value, _cancellationToken); + _shuffleDotUs.OnLatestBetUpdated += ShuffleOnLatestBetUpdated; + await _shuffleDotUs.StartWsClient(); + } private async Task BuildDiscord() { @@ -474,6 +484,14 @@ public class BotServices _parti = null!; await BuildParti(); } + + if (_shuffleDotUs != null && !_shuffleDotUs.IsConnected()) + { + _logger.Error("Shuffle.us died, recreating it"); + _shuffleDotUs.Dispose(); + _shuffleDotUs = null!; + await BuildShuffleDotUs(); + } } catch (Exception e) { @@ -867,20 +885,35 @@ public class BotServices { var settings = SettingsProvider .GetMultipleValuesAsync([ - BuiltIn.Keys.ShuffleBmjUsername, BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor + BuiltIn.Keys.ShuffleBmjUsername, BuiltIn.Keys.ShuffleDotUsBmjUsername, + BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor ]).Result; _logger.Trace("Shuffle bet has arrived"); - if (bet.Username != settings[BuiltIn.Keys.ShuffleBmjUsername].Value) + bool isDotUs; + if (bet.Username == settings[BuiltIn.Keys.ShuffleBmjUsername].Value) + { + isDotUs = false; + } + else if (bet.Username == settings[BuiltIn.Keys.ShuffleDotUsBmjUsername].Value) + { + isDotUs = true; + } + else { return; } - _logger.Info("ALERT BMJ IS BETTING"); + _logger.Info($"ALERT BMJ IS BETTING: isDotUs => {isDotUs}"); if (CheckBmjIsLive().Result) return; var payoutColor = settings[BuiltIn.Keys.KiwiFarmsGreenColor].Value; if (float.Parse(bet.Payout) < float.Parse(bet.Amount)) payoutColor = settings[BuiltIn.Keys.KiwiFarmsRedColor].Value; + var preamble = "🚨🚨 Shufflebros! 🚨🚨"; + if (isDotUs) + { + preamble = "πŸ¦…πŸ¦… Shuffle US! πŸ¦…πŸ¦…"; + } // There will be a check for live status but ignoring that while we deal with an emergency dice situation - _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($"{preamble} {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, string channelName, bool isLive) diff --git a/KfChatDotNetBot/Services/ShuffleDotUs.cs b/KfChatDotNetBot/Services/ShuffleDotUs.cs new file mode 100644 index 0000000..794076f --- /dev/null +++ b/KfChatDotNetBot/Services/ShuffleDotUs.cs @@ -0,0 +1,224 @@ +ο»Ώusing System.Net; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text.Json; +using KfChatDotNetBot.Models; +using NLog; +using Websocket.Client; + +namespace KfChatDotNetBot.Services; + +public class ShuffleDotUs : IDisposable +{ + private Logger _logger = LogManager.GetCurrentClassLogger(); + private WebsocketClient? _wsClient; + private Uri _wsUri = new("wss://shuffle.us/main-api/bp-subscription/subscription/graphql"); + private int _reconnectTimeout = 60; + private string? _proxy; + public delegate void OnLatestBetUpdatedEventHandler(object sender, ShuffleLatestBetModel bet); + public delegate void OnWsDisconnectionEventHandler(object sender, DisconnectionInfo e); + public event OnLatestBetUpdatedEventHandler? OnLatestBetUpdated; + public event OnWsDisconnectionEventHandler? OnWsDisconnection; + private CancellationToken _cancellationToken; + private CancellationTokenSource _pingCts = new(); + private Task _pingTask; + + public ShuffleDotUs(string? proxy = null, CancellationToken cancellationToken = default) + { + _proxy = proxy; + _cancellationToken = cancellationToken; + // Moved it up here as I'm concerned about the possibility of reconnections creating multiple ping tasks + _pingTask = PeriodicPing(); + } + + public async Task StartWsClient() + { + _logger.Debug("StartWsClient() called, creating client"); + await CreateWsClient(); + } + + private async Task CreateWsClient() + { + var factory = new Func(() => + { + var clientWs = new ClientWebSocket(); + clientWs.Options.AddSubProtocol("graphql-transport-ws"); + clientWs.Options.SetRequestHeader("Origin", "https://shuffle.us"); + clientWs.Options.SetRequestHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0"); + if (_proxy == null) return clientWs; + _logger.Debug($"Using proxy address {_proxy}"); + clientWs.Options.Proxy = new WebProxy(_proxy); + return clientWs; + }); + + var client = new WebsocketClient(_wsUri, factory) + { + ReconnectTimeout = TimeSpan.FromSeconds(_reconnectTimeout), + IsReconnectionEnabled = false // Watchdog will self-destruct this instead + }; + + client.ReconnectionHappened.Subscribe(WsReconnection); + client.MessageReceived.Subscribe(WsMessageReceived); + client.DisconnectionHappened.Subscribe(WsDisconnection); + + _wsClient = client; + + _logger.Debug("Websocket client has been built, about to start"); + await client.Start(); + _logger.Debug("Websocket client started!"); + } + + public bool IsConnected() + { + return _wsClient is { IsRunning: true }; + } + + private async Task PeriodicPing() + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10)); + while (await timer.WaitForNextTickAsync(_pingCts.Token)) + { + if (_wsClient == null) + { + _logger.Debug("_wsClient doesn't exist yet, not going to try ping"); + continue; + } + _logger.Debug("Sending ping to Shuffle.us"); + _wsClient.Send("{\"type\":\"ping\"}"); + } + } + + private void WsDisconnection(DisconnectionInfo disconnectionInfo) + { + _logger.Error($"Client disconnected from Shuffle.us (or never successfully connected). Type is {disconnectionInfo.Type}"); + _logger.Error($"Close Status => {disconnectionInfo.CloseStatus}; Close Status Description => {disconnectionInfo.CloseStatusDescription}"); + _logger.Error(disconnectionInfo.Exception); + OnWsDisconnection?.Invoke(this, disconnectionInfo); + } + + private void WsReconnection(ReconnectionInfo reconnectionInfo) + { + _logger.Error($"Websocket connection dropped and reconnected. Reconnection type is {reconnectionInfo.Type}"); + _logger.Info("Sending connection_init"); + var initPayload = + "{\"type\":\"connection_init\",\"payload\":{\"x-correlation-id\":\"pdvlnd9tej-di27abvq19-1.30.2-1i0nef1m7-g::anon\",\"authorization\":\"\"}}"; + _logger.Debug(initPayload); + _wsClient?.Send(initPayload); + } + + private void WsMessageReceived(ResponseMessage message) + { + if (message.Text == null) + { + _logger.Info("Shuffle.us sent a null message"); + return; + } + _logger.Trace($"Received event from Shuffle.us: {message.Text}"); + + try + { + var packet = JsonSerializer.Deserialize(message.Text); + var packetType = packet.GetProperty("type").GetString(); + + if (packetType == "connection_ack") + { + _logger.Debug("connection_ack packet, sending subscribe payload"); + _logger.Info("Sending subscription request"); + // we're super ghetto today + var payload = "{\"id\":\"" + Guid.NewGuid() + + "\",\"type\":\"subscribe\",\"payload\":{\"variables\":{},\"extensions\":{},\"operationName\":\"LatestBetUpdated\",\"query\":\"subscription LatestBetUpdated {\\n latestBetUpdated {\\n ...BetActivityFields\\n __typename\\n }\\n}\\n\\nfragment BetActivityFields on BetActivityPayload {\\n id\\n username\\n vipLevel\\n currency\\n amount\\n payout\\n multiplier\\n gameName\\n gameCategory\\n gameSlug\\n __typename\\n}\"}}"; + _logger.Debug(payload); + _wsClient?.SendInstant(payload).Wait(_cancellationToken); + return; + } + + if (packetType == "pong") + { + _logger.Info("Shuffle.us pong packet"); + return; + } + + // GAMBA + if (packetType == "next") + { + var bet = packet.GetProperty("payload").GetProperty("data").GetProperty("latestBetUpdated") + .Deserialize(); + if (bet == null) + { + _logger.Error("Caught a null before invoking bet event"); + throw new NullReferenceException("Caught a null before invoking bet event"); + } + OnLatestBetUpdated?.Invoke(this, bet); + return; + } + _logger.Info("Message from Shuffle was unhandled"); + _logger.Info(message.Text); + } + catch (Exception e) + { + _logger.Error("Failed to handle message from Shuffle"); + _logger.Error(e); + _logger.Error("--- JSON Payload ---"); + _logger.Error(message.Text); + _logger.Error("--- End of JSON Payload ---"); + } + } + + public async Task GetShuffleUser(string username) + { + var graphQl = + "query GetUserProfile($username: String!) {\n user(username: $username) {\n id\n username\n vipLevel\n createdAt\n avatar\n avatarBackground\n bets\n usdWagered\n __typename\n }\n}"; + _logger.Debug($"Grabbing details for Shuffle.us user {username}"); + var jsonBody = new Dictionary + { + { "operationName", "GetUserProfile" }, + { "query", graphQl }, + { "variables", new Dictionary { { "username", username } } } + }; + _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 = false; + handler.Proxy = new WebProxy(_proxy); + _logger.Debug($"Configured to use proxy {_proxy}"); + } + + using var client = new HttpClient(handler); + client.DefaultRequestHeaders.Add("content-type", "application/json"); + var postBody = JsonContent.Create(jsonBody); + var response = await client.PostAsync("https://shuffle.us/graphql", postBody, _cancellationToken); + var responseContent = await response.Content.ReadFromJsonAsync(cancellationToken: _cancellationToken); + _logger.Debug("Shuffle.us returned following JSON"); + _logger.Debug(responseContent.GetRawText); + var user = responseContent.GetProperty("data").GetProperty("user"); + if (user.ValueKind == JsonValueKind.Null) + { + _logger.Debug("data.user was null"); + throw new ShuffleUserNotFoundException(); + } + + return user.Deserialize() ?? throw new InvalidOperationException(); + } + + public class ShuffleUserNotFoundException : Exception; + + public void Dispose() + { + _wsClient?.Dispose(); + // Rare bug but has happened at least once + try + { + _pingCts.Cancel(); + } + catch (ObjectDisposedException e) + { + _logger.Error("Caught object disposed exception when trying to send a cancellation to the ping task"); + _logger.Error(e); + } + _pingCts.Dispose(); + _pingTask.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index 3e2a7be..32c8fb4 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -334,6 +334,13 @@ public static class BuiltIn ValueType = SettingValueType.Text }, new BuiltInSettingsModel + { + Key = Keys.ShuffleDotUsBmjUsername, + Description = "Bossman's Shuffle.us Username", + Default = "BossmanJack", + ValueType = SettingValueType.Text + }, + new BuiltInSettingsModel { Key = Keys.JuiceCooldown, Regex = WholeNumberRegex, @@ -1079,6 +1086,7 @@ public static class BuiltIn public static string TwitchIcon = "Twitch.Icon"; public static string DiscordIcon = "Discord.Icon"; public static string ShuffleBmjUsername = "Shuffle.BmjUsername"; + public static string ShuffleDotUsBmjUsername = "ShuffleDotUs.BmjUsername"; public static string JuiceCooldown = "Juice.Cooldown"; public static string JuiceAmount = "Juice.Amount"; public static string KickEnabled = "Kick.Enabled";