From b6dc7b8cbe57c28a832a34292dded4ab2ddfb348 Mon Sep 17 00:00:00 2001 From: barelyprofessional <150058423+barelyprofessional@users.noreply.github.com> Date: Sat, 22 Mar 2025 14:00:23 +0800 Subject: [PATCH] Experimental Clash.gg support, completely untested. Austin's ID is unknown at this time hence not populated --- KfChatDotNetBot/Models/ClashggModels.cs | 86 ++++++++++ KfChatDotNetBot/Services/BotServices.cs | 69 +++++++- KfChatDotNetBot/Services/Clashgg.cs | 217 ++++++++++++++++++++++++ KfChatDotNetBot/Settings/BuiltIn.cs | 22 +++ 4 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 KfChatDotNetBot/Models/ClashggModels.cs create mode 100644 KfChatDotNetBot/Services/Clashgg.cs diff --git a/KfChatDotNetBot/Models/ClashggModels.cs b/KfChatDotNetBot/Models/ClashggModels.cs new file mode 100644 index 0000000..566bda5 --- /dev/null +++ b/KfChatDotNetBot/Models/ClashggModels.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Serialization; + +namespace KfChatDotNetBot.Models; + +public enum ClashggGame +{ + Plinko, + Mines, + Keno +} + +public enum ClashggCurrency +{ + Real, // Gems + Fake // Coins +} + +public class ClashggBetModel +{ + public required ClashggGame Game { get; set; } + public required int UserId { get; set; } + // Isn't sent for Plinko + public string? Username { get; set; } + // Clash.gg uses its own currency for money in play + // Bets are in cents + public required int Bet { get; set; } + // Clash.gg has a fake worthless currency called Coins. + // Sweepstakes bullshit loophole, the real money is Gems + // It's even identified as REAL when it's Gems, PLAY when it's Coins + public required ClashggCurrency Currency { get; set; } + // Mines doesn't send a multi, but it's calculated based on payout / bet + public required float Multiplier { get; set; } + // Payouts aren't sent for Plinko but will be calculated based on multi + public required float Payout { get; set; } +} + +// There's a bunch more properties, but I don't care +// {"id":3122766,"role":"user","name":"nettspend","avatar":"https://avatars.steamstatic.com/6ceb09420f55ca4e84769169fad1436c0f1b6053_full.jpg","xp":28452,"isVerified":false,"isPrivate":false,"premiumUntil":null} +public class ClashggWsUserModel +{ + [JsonPropertyName("user")] + public required int Id { get; set; } + [JsonPropertyName("name")] + public required string Name { get; set; } +} + +// {"point":257.4683393753577,"avatarUrl":"https://avatars.steamstatic.com/ee5758f75e9ccff825ece5b1a4cb505af851025d_full.jpg","userId":635455,"rows":16,"betAmount":50,"multiplier":0.2,"currency":"REAL"} +public class ClashggWsPlinkoModel +{ + [JsonPropertyName("userId")] + public required int UserId { get; set; } + [JsonPropertyName("betAmount")] + public required int BetAmount { get; set; } + [JsonPropertyName("multiplier")] + public required float Multiplier { get; set; } + [JsonPropertyName("currency")] + public required string Currency { get; set; } +} + +// {"updatedAt":"2025-03-22T04:48:55.644Z","status":"playing","currency":"REAL","betAmount":75,"payout":91,"mineCount":1,"user":{"id":3122766,"role":"user","name":"nettspend","avatar":"https://avatars.steamstatic.com/6ceb09420f55ca4e84769169fad1436c0f1b6053_full.jpg","xp":28452,"isVerified":false,"isPrivate":false,"premiumUntil":null}} +public class ClashggWsMinesModel +{ + [JsonPropertyName("currency")] + public required string Currency { get; set; } + [JsonPropertyName("betAmount")] + public required int BetAmount { get; set; } + [JsonPropertyName("payout")] + public required int Payout { get; set; } + [JsonPropertyName("user")] + public required ClashggWsUserModel User { get; set; } +} + +// {"id":4168183,"createdAt":"2025-03-22T04:53:22.008Z","userPicks":[24,36,2,16,29,32,15,10,38,3],"kenoPicks":[11,40,21,33,17,30,6,7,20,8],"payout":0,"multiplier":0,"currency":"REAL","betAmount":50,"user":{"id":2229575,"role":"user","name":"SomeoneSomebody","avatar":"https://avatars.steamstatic.com/f1828607eac4054560a02da9ba83e4310053661a_full.jpg","xp":197137,"isVerified":false,"isPrivate":false,"premiumUntil":null}} +public class ClashggWsKenoModel +{ + [JsonPropertyName("currency")] + public required string Currency { get; set; } + [JsonPropertyName("betAmount")] + public required int BetAmount { get; set; } + [JsonPropertyName("payout")] + public required int Payout { get; set; } + [JsonPropertyName("user")] + public required ClashggWsUserModel User { get; set; } + [JsonPropertyName("multiplier")] + public required float Multiplier { get; set; } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Services/BotServices.cs b/KfChatDotNetBot/Services/BotServices.cs index cd5d79e..1cf2c6b 100644 --- a/KfChatDotNetBot/Services/BotServices.cs +++ b/KfChatDotNetBot/Services/BotServices.cs @@ -27,6 +27,7 @@ public class BotServices private Howlgg _howlgg; private Rainbet _rainbet; private Chipsgg _chipsgg; + private Clashgg _clashgg; private Task? _websocketWatchdog; private Task? _howlggGetUserTimer; @@ -145,6 +146,20 @@ public class BotServices _logger.Info("Built Jackpot Websocket connection"); } + private async Task BuildClashgg() + { + var settings = await Helpers.GetMultipleValues([BuiltIn.Keys.Proxy, BuiltIn.Keys.ClashggEnabled]); + if (!settings[BuiltIn.Keys.ClashggEnabled].ToBoolean()) + { + _logger.Debug("Clash.gg is disabled"); + return; + } + _clashgg = new Clashgg(settings[BuiltIn.Keys.Proxy].Value, _cancellationToken); + _clashgg.OnClashBet += OnClashggBet; + await _clashgg.StartWsClient(); + _logger.Info("Built Clash.gg Websocket connection"); + } + private async Task BuildTwitch() { var settings = await Helpers.GetMultipleValues([BuiltIn.Keys.TwitchBossmanJackId, BuiltIn.Keys.Proxy]); @@ -230,7 +245,7 @@ public class BotServices while (await timer.WaitForNextTickAsync(_cancellationToken)) { if (_chatBot.InitialStartCooldown) continue; - var settings = await Helpers.GetMultipleValues([BuiltIn.Keys.KickEnabled, BuiltIn.Keys.HowlggEnabled, BuiltIn.Keys.ChipsggEnabled]); + var settings = await Helpers.GetMultipleValues([BuiltIn.Keys.KickEnabled, BuiltIn.Keys.HowlggEnabled, BuiltIn.Keys.ChipsggEnabled, BuiltIn.Keys.ClashggEnabled]); try { if (!_shuffle.IsConnected()) @@ -296,6 +311,14 @@ public class BotServices KickClient = null!; await BuildKick(); } + + if (settings[BuiltIn.Keys.ClashggEnabled].ToBoolean() && !_clashgg.IsConnected()) + { + _logger.Error("Clash.gg died, recreating it"); + _clashgg.Dispose(); + _clashgg = null!; + await BuildClashgg(); + } } catch (Exception e) { @@ -397,6 +420,50 @@ public class BotServices $"[color={payoutColor}]{bet.Payout} {bet.Currency}[/color] ({bet.Multiplier}x) on {bet.GameName} 💰💰", true); } + private void OnClashggBet(object sender, ClashggBetModel bet) + { + var settings = Helpers + .GetMultipleValues([ + BuiltIn.Keys.ClashggBmjIds, BuiltIn.Keys.TwitchBossmanJackUsername, + BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor + ]).Result; + _logger.Trace("Jackpot bet has arrived"); + if (!settings[BuiltIn.Keys.ClashggBmjIds].JsonDeserialize>()!.Contains(bet.UserId)) + { + return; + } + _logger.Info("ALERT BMJ IS BETTING (on Clash.gg)"); + if (IsBmjLive) + { + _logger.Info("Ignoring as BMJ is live"); + return; + } + if (TemporarilySuppressGambaMessages) + { + _logger.Info("Ignoring as TemporarilySuppressGambaMessages is true"); + return; + } + + // Only check once because the bot should be tracking the Twitch stream + // This is just in case he's already live while the bot starts + // He was schizo betting on Dice, so I want to avoid a lot of API requests to Twitch in case they rate limit + if (!_isBmjLiveSynced) + { + IsBmjLive = _twitch.IsStreamLive(settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value!).Result; + _isBmjLiveSynced = true; + } + if (IsBmjLive) + { + _logger.Info("Double checked and he is really online"); + return; + } + + var payoutColor = settings[BuiltIn.Keys.KiwiFarmsGreenColor].Value; + if (bet.Payout < bet.Bet) payoutColor = settings[BuiltIn.Keys.KiwiFarmsRedColor].Value; + _chatBot.SendChatMessage($"🚨🚨 CLASH.GG BETTING 🚨🚨 austingambles just bet {bet.Bet} {bet.Currency.Humanize()} Money which paid out " + + $"[color={payoutColor}]{bet.Payout} {bet.Currency.Humanize()} Money[/color] ({bet.Multiplier}x) on {bet.Game.Humanize()} 💰💰", true); + } + private void OnHowlggBetHistory(object sender, HowlggBetHistoryResponseModel data) { _logger.Debug("Received bet history from Howl.gg"); diff --git a/KfChatDotNetBot/Services/Clashgg.cs b/KfChatDotNetBot/Services/Clashgg.cs new file mode 100644 index 0000000..9a8906f --- /dev/null +++ b/KfChatDotNetBot/Services/Clashgg.cs @@ -0,0 +1,217 @@ +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 Clashgg : IDisposable +{ + private Logger _logger = LogManager.GetCurrentClassLogger(); + private WebsocketClient _wsClient; + private Uri _wsUri = new("wss://ws.clash.gg/"); + // Ping interval is 30 seconds + private int _reconnectTimeout = 60; + private string? _proxy; + public delegate void OnClashBetEventHandler(object sender, ClashggBetModel data); + public delegate void OnWsDisconnectionEventHandler(object sender, DisconnectionInfo e); + public event OnClashBetEventHandler OnClashBet; + public event OnWsDisconnectionEventHandler OnWsDisconnection; + private CancellationToken _cancellationToken = CancellationToken.None; + private CancellationTokenSource _pingCts = new(); + private Task? _heartbeatTask; + // There's no smarts, it just does 30-second pings + private TimeSpan _heartbeatInterval = TimeSpan.FromSeconds(30); + + public Clashgg(string? proxy = null, CancellationToken? cancellationToken = null) + { + _proxy = proxy; + if (cancellationToken != null) _cancellationToken = cancellationToken.Value; + _logger.Info("Clash.gg WebSocket client created"); + } + + 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.SetRequestHeader("Origin", "https://clash.gg"); + clientWs.Options.SetRequestHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.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 + }; + + 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 }; + } + + // This code was copied from Jackpot which has a ping function. + // I haven't observed a ping feature for the ws.clash.gg endpoint + // Therefore going to leave the code here in case it's needed but have it not do anything + private async Task HeartbeatTimer() + { + using var timer = new PeriodicTimer(_heartbeatInterval); + 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 Jackpot ping packet"); + //_wsClient.Send("{\"id\":\"lfgkenokasino\",\"type\":\"ping\"}"); + } + } + + private void WsDisconnection(DisconnectionInfo disconnectionInfo) + { + _logger.Error($"Client disconnected from Clash.gg (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) + { + // No initial payload needs to be sent + _logger.Error($"Websocket connection dropped and reconnected. Reconnection type is {reconnectionInfo.Type}"); + } + + private void WsMessageReceived(ResponseMessage message) + { + if (message.Text == null) + { + _logger.Info("Clash.gg sent a null message"); + return; + } + _logger.Debug($"Received event from Clash.gg: {message.Text}"); + + try + { + var packet = JsonSerializer.Deserialize>(message.Text); + if (packet == null) throw new InvalidOperationException("Caught a null when deserializing Clash.gg packet"); + if (packet.Item1 == "online") + { + _logger.Info("Received online packet from Clash.gg. Subscribing to Plinko, Mines and Keno"); + _wsClient.Send("[\"subscribe\",\"plinko\"]"); + _wsClient.Send("[\"subscribe\",\"mines\"]"); + _wsClient.Send("[\"subscribe\",\"keno\"]"); + return; + } + + if (packet.Item1 == "plinko:social-game") + { + _logger.Debug("Received Plinko game from Clash.gg. Deserializing payload"); + var betPacket = packet.Item2.Value.Deserialize(); + if (betPacket == null) + { + throw new Exception("Caught a null when deserializing a Clash.gg Plinko packet"); + } + var betData = new ClashggBetModel + { + Game = ClashggGame.Plinko, + UserId = betPacket.UserId, + Username = "Unknown", + Bet = betPacket.BetAmount, + Currency = betPacket.Currency == "REAL" ? ClashggCurrency.Real : ClashggCurrency.Fake, + Multiplier = betPacket.Multiplier, + Payout = betPacket.BetAmount * betPacket.Multiplier + }; + OnClashBet?.Invoke(this, betData); + return; + } + + if (packet.Item1 == "mines:game") + { + _logger.Debug("Received Mines game from Clash.gg. Deserializing payload"); + var betPacket = packet.Item2.Value.Deserialize(); + if (betPacket == null) + { + throw new Exception("Caught a null when deserializing a Clash.gg Mines packet"); + } + var betData = new ClashggBetModel + { + Game = ClashggGame.Mines, + UserId = betPacket.User.Id, + Username = betPacket.User.Name, + Bet = betPacket.BetAmount, + Currency = betPacket.Currency == "REAL" ? ClashggCurrency.Real : ClashggCurrency.Fake, + // ReSharper disable once PossibleLossOfFraction + Multiplier = betPacket.Payout / betPacket.BetAmount, + Payout = betPacket.Payout + }; + OnClashBet?.Invoke(this, betData); + return; + } + + if (packet.Item1 == "keno:game") + { + _logger.Debug("Received Keno game from Clash.gg. Deserializing payload"); + var betPacket = packet.Item2.Value.Deserialize(); + if (betPacket == null) + { + throw new Exception("Caught a null when deserializing a Clash.gg Keno packet"); + } + var betData = new ClashggBetModel + { + Game = ClashggGame.Keno, + UserId = betPacket.User.Id, + Username = betPacket.User.Name, + Bet = betPacket.BetAmount, + Currency = betPacket.Currency == "REAL" ? ClashggCurrency.Real : ClashggCurrency.Fake, + // ReSharper disable once PossibleLossOfFraction + Multiplier = betPacket.Multiplier, + Payout = betPacket.Payout + }; + OnClashBet?.Invoke(this, betData); + return; + } + } + catch (Exception e) + { + _logger.Error("Failed to handle message from Jackpot"); + _logger.Error(e); + _logger.Error("--- Payload ---"); + _logger.Error(message.Text); + _logger.Error("--- End of Payload ---"); + } + } + + public void Dispose() + { + _wsClient.Dispose(); + _pingCts.Cancel(); + _pingCts.Dispose(); + _heartbeatTask?.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index 25ad987..317834f 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -870,6 +870,26 @@ public static class BuiltIn IsSecret = false, CacheDuration = TimeSpan.FromHours(1), ValueType = SettingValueType.Boolean + }, + new BuiltInSettingsModel + { + Key = Keys.ClashggEnabled, + Regex = "(true|false)", + Description = "Whether the Clash.gg integration should be enabled", + Default = "true", + IsSecret = false, + CacheDuration = TimeSpan.FromHours(1), + ValueType = SettingValueType.Boolean + }, + new BuiltInSettingsModel + { + Key = Keys.ClashggBmjIds, + Regex = ".+", + Description = "List of IDs that austingambles is using", + Default = "[]", + IsSecret = false, + CacheDuration = TimeSpan.FromHours(1), + ValueType = SettingValueType.Array } ]; @@ -942,5 +962,7 @@ public static class BuiltIn public static string DiscordTemporarilyBypassGambaSeshInitialValue = "Discord.TemporarilyBypassGambaSeshInitialValue"; public static string BotKeesSeen = "Bot.KeesSeen"; + public static string ClashggEnabled = "Clashgg.Enabled"; + public static string ClashggBmjIds = "Clashgg.BmjIds"; } } \ No newline at end of file