diff --git a/KfChatDotNetBot/Models/RainbetModels.cs b/KfChatDotNetBot/Models/RainbetModels.cs index b578900..78ba847 100644 --- a/KfChatDotNetBot/Models/RainbetModels.cs +++ b/KfChatDotNetBot/Models/RainbetModels.cs @@ -62,4 +62,82 @@ public class RainbetBetHistoryUserModel // Null when they have no rank [JsonPropertyName("rank")] public RainbetBetHistoryUserRankModel? Rank { get; set; } -} \ No newline at end of file +} + +public class RainbetWsBetModel +{ + [JsonPropertyName("id")] + public required string Id { get; set; } + [JsonPropertyName("currencyAmount")] + public required string CurrencyAmount { get; set; } + [JsonPropertyName("currency")] + public required string CurrencyName { get; set; } + [JsonPropertyName("value")] + public required string Value { get; set; } + [JsonPropertyName("payout")] + public required string Payout { get; set; } + [JsonPropertyName("currencyPayout")] + public required string CurrencyPayout { get; set; } + [JsonPropertyName("multiplier")] + public required string Multiplier { get; set; } + [JsonPropertyName("updatedAt")] + public required DateTimeOffset UpdatedAt { get; set; } + [JsonPropertyName("user")] + public required RainbetWsUserModel User { get; set; } + [JsonPropertyName("game")] + public required RainbetWsGameModel Game { get; set; } +} + +public class RainbetWsUserModel +{ + [JsonPropertyName("id")] + public required int Id { get; set; } + [JsonPropertyName("publicId")] + public required string PublicId { get; set; } + [JsonPropertyName("username")] + // null for private profiles + public string? Username { get; set; } + [JsonPropertyName("publicProfile")] + public required int PublicProfile { get; set; } +} + +public class RainbetWsGameModel +{ + [JsonPropertyName("id")] + public required int Id { get; set; } + [JsonPropertyName("url")] + public required string Url { get; set; } + [JsonPropertyName("name")] + public required string Name { get; set; } +} + +/* +{ + "id": "1a3648a7-e055-49aa-928e-7d9e0c02548a", + "currencyAmount": "4.0000", + "currency": "USD", + "value": "4.0000", + "currencyPayout": "0.0000", + "payout": "0.0000", + "multiplier": "0.0000", + "updatedAt": "2025-05-16T17:53:49.000Z", + "user": { + "id": 784907, + "publicId": "PIQ230088QABHUGXUT7UH6WUY6PDD473", + "username": "Gerr...", + "publicProfile": 1, + "__betRank__": { "name": "Silver", "level": 1 }, + "rankLevel": { "name": "Silver", "level": 1 } + }, + "game": { + "id": 752539, + "url": "evolution-marble-race", + "name": "Marble Race", + "icon": "https://contentdeliverynetwork.cc/i/s3/evolution/MarbleRace.png", + "iconMini": null, + "customBanner": null + }, + "betParameters": null, + "idString": "1a3648a7-e055-49aa-928e-7d9e0c02548a" +} +*/ \ No newline at end of file diff --git a/KfChatDotNetBot/Services/BotServices.cs b/KfChatDotNetBot/Services/BotServices.cs index 97df7eb..913fc01 100644 --- a/KfChatDotNetBot/Services/BotServices.cs +++ b/KfChatDotNetBot/Services/BotServices.cs @@ -26,7 +26,7 @@ public class BotServices private TwitchChat _twitchChat; private Jackpot _jackpot; private Howlgg _howlgg; - private Rainbet _rainbet; + private RainbetWs _rainbet; private Chipsgg _chipsgg; private Clashgg _clashgg; private BetBolt _betBolt; @@ -79,7 +79,8 @@ public class BotServices BuildClashgg(), BuildAlmanacShill(), BuildBetBolt(), - BuildYeet() + BuildYeet(), + BuildRainbet() ]; try { @@ -90,8 +91,6 @@ public class BotServices _logger.Error("A service failed, exception follows"); _logger.Error(e); } - - BuildRainbet(); _logger.Info("Starting websocket watchdog and Howl.gg user stats timer"); _websocketWatchdog = WebsocketWatchdog(); @@ -124,12 +123,12 @@ public class BotServices await _discord.StartWsClient(); } - private void BuildRainbet() + private async Task BuildRainbet() { - _rainbet = new Rainbet(_cancellationToken); + var settings = await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.Proxy, BuiltIn.Keys.RainbetEnabled]); + _rainbet = new RainbetWs(settings[BuiltIn.Keys.Proxy].Value, _cancellationToken); _rainbet.OnRainbetBet += OnRainbetBet; - _rainbet.StartGameHistoryTimer(); - _logger.Info("Built Rainbet timer"); + _logger.Info("Built Rainbet Websocket"); } private async Task BuildChipsgg() @@ -294,7 +293,8 @@ public class BotServices if (_chatBot.InitialStartCooldown) continue; var settings = await SettingsProvider.GetMultipleValuesAsync([ BuiltIn.Keys.KickEnabled, BuiltIn.Keys.HowlggEnabled, BuiltIn.Keys.ChipsggEnabled, - BuiltIn.Keys.ClashggEnabled, BuiltIn.Keys.BetBoltEnabled, BuiltIn.Keys.YeetEnabled + BuiltIn.Keys.ClashggEnabled, BuiltIn.Keys.BetBoltEnabled, BuiltIn.Keys.YeetEnabled, + BuiltIn.Keys.RainbetEnabled ]); try { @@ -385,6 +385,14 @@ public class BotServices _yeet = null!; await BuildYeet(); } + + if (settings[BuiltIn.Keys.RainbetEnabled].ToBoolean() && !_rainbet.IsConnected()) + { + _logger.Error("Rainbet died, recreating it"); + _rainbet.Dispose(); + _rainbet = null!; + await BuildRainbet(); + } } catch (Exception e) { @@ -405,7 +413,7 @@ public class BotServices } } - private void OnRainbetBet(object sender, List bets) + private void OnRainbetBet(object sender, RainbetWsBetModel bet) { var settings = SettingsProvider .GetMultipleValuesAsync([ @@ -414,57 +422,35 @@ public class BotServices ]).Result; _logger.Trace("Rainbet bet has arrived"); using var db = new ApplicationDbContext(); - var ids = settings[BuiltIn.Keys.RainbetBmjPublicIds].JsonDeserialize>(); - if (ids == null) - { - _logger.Error("BMJ Rainbet Public IDs were null"); - return; - } - var bmjBets = bets.Where(b => b.User.PublicId != null && ids.Contains(b.User.PublicId)); - if (!bmjBets.Any()) + if (!settings[BuiltIn.Keys.RainbetBmjPublicIds].JsonDeserialize>()!.Contains(bet.User.PublicId)) { return; } - foreach (var bet in bmjBets) + db.RainbetBets.Add(new RainbetBetsDbModel { - if (db.RainbetBets.Any(b => b.BetId == bet.Id)) - { - _logger.Trace($"Ignoring bet {bet.Id} as we've already logged it"); - continue; - } - - db.RainbetBets.Add(new RainbetBetsDbModel - { - PublicId = bet.User.PublicId, - RainbetUserId = bet.User.Id, - GameName = bet.Game.Name, - Value = bet.Value, - Payout = bet.Payout ?? 0, - Multiplier = bet.Multiplier ?? 0, - BetId = bet.Id, - UpdatedAt = bet.UpdatedAt, - BetSeenAt = DateTimeOffset.UtcNow - }); - _logger.Info("Added a Bossman Rainbet bet to the database"); - } + PublicId = bet.User.PublicId, + RainbetUserId = bet.User.Id, + GameName = bet.Game.Name, + Value = float.Parse(bet.Value), + Payout = float.Parse(bet.Payout), + Multiplier = float.Parse(bet.Multiplier), + BetId = bet.Id, + UpdatedAt = bet.UpdatedAt, + BetSeenAt = DateTimeOffset.UtcNow + }); + _logger.Info("Added a Bossman Rainbet bet to the database"); db.SaveChanges(); _logger.Info("ALERT BMJ IS BETTING (on Rainbet)"); if (CheckBmjIsLive(settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value ?? "usernamenotset").Result) return; - - var msg = $":!::!: {settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value} is betting on Rainbet :!::!:"; - foreach (var bet in bmjBets.GroupBy(b => b.Game.Name)) - { - var wagered = bet.Sum(s => s.Value); - var payout = bet.Sum(s => s.Payout); - var payoutColor = settings[BuiltIn.Keys.KiwiFarmsGreenColor].Value; - if (payout < wagered) payoutColor = settings[BuiltIn.Keys.KiwiFarmsRedColor].Value; - msg += $"[br]{bet.Sum(s => s.Value):C} wagered on {bet.Key} which paid out [color={payoutColor}]{payout:C}[/color] over {bet.Count()} bets"; - } - - _chatBot.SendChatMessagesAsync(msg.FancySplitMessage(partSeparator: "[br]"), true).Wait(_cancellationToken); + var wagered = float.Parse(bet.Value); + var payout = float.Parse(bet.Payout); + var payoutColor = settings[BuiltIn.Keys.KiwiFarmsGreenColor].Value; + if (payout < wagered) payoutColor = settings[BuiltIn.Keys.KiwiFarmsRedColor].Value; + _chatBot.SendChatMessage($"🚨🚨 RAINBET BETTING 🚨🚨 {settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value} just bet {wagered:C} {bet.CurrencyName} which paid out " + + $"[color={payoutColor}]{payout:C} {bet.CurrencyName}[/color] ({bet.Multiplier}x) on {bet.Game.Name} 💰💰", true); } private void OnJackpotBet(object sender, JackpotWsBetPayloadModel bet) diff --git a/KfChatDotNetBot/Services/RainbetWs.cs b/KfChatDotNetBot/Services/RainbetWs.cs new file mode 100644 index 0000000..914b771 --- /dev/null +++ b/KfChatDotNetBot/Services/RainbetWs.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Net.WebSockets; +using System.Text.Json; +using KfChatDotNetBot.Models; +using NLog; +using Websocket.Client; + +namespace KfChatDotNetBot.Services; + +public class RainbetWs : IDisposable +{ + private Logger _logger = LogManager.GetCurrentClassLogger(); + private WebsocketClient _wsClient; + private Uri _wsUri = new("wss://socket.rainbet.com/socket.io/?EIO=4&transport=websocket"); + private int _reconnectTimeout = 30; + private string? _proxy; + public delegate void OnRainbetBetEventHandler(object sender, RainbetWsBetModel bet); + public delegate void OnWsDisconnectionEventHandler(object sender, DisconnectionInfo e); + public event OnRainbetBetEventHandler OnRainbetBet; + public event OnWsDisconnectionEventHandler OnWsDisconnection; + private CancellationToken _cancellationToken = CancellationToken.None; + + public RainbetWs(string? proxy = null, CancellationToken? cancellationToken = null) + { + _proxy = proxy; + if (cancellationToken != null) _cancellationToken = cancellationToken.Value; + _logger.Info("Rainbet 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://rainbet.com"); + 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 + }; + + 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 void WsDisconnection(DisconnectionInfo disconnectionInfo) + { + _logger.Error($"Client disconnected from Rainbet (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}"); + } + + private void WsMessageReceived(ResponseMessage message) + { + if (message.Text == null) + { + _logger.Info("Rainbet sent a null message"); + return; + } + _logger.Trace($"Received event from Rainbet: {message.Text}"); + + try + { + if (message.Text.StartsWith("0{")) + { + // 0{"sid":"Pf940zhaAqb6BHBiSgLa","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":100000000} + _logger.Info("Received initial connection message from Rainbet, sending subscribe"); + _wsClient.Send("40/game-history,{}"); + return; + } + var packetType = message.Text.Split('/')[0]; + if (packetType == "2") + { + _logger.Info("Received ping from Rainbet, replying with pong"); + _wsClient.Send("3"); + return; + } + + // On subscription + //40/game-history,{"sid":"7X4UUCSv8BFXgBT1SgLd"} + if (packetType == "40") + { + _logger.Info("Subscribed to Rainbet game history"); + return; + } + + if (packetType == "42") + { + var data = JsonSerializer.Deserialize>(message.Text.Replace("42/game-history,", + string.Empty)); + if (data[0].GetString() == "new-history") + { + OnRainbetBet?.Invoke(this, data[1].Deserialize()); + return; + } + _logger.Info($"Event {data[0].GetString()} from Rainbet was not handled"); + _logger.Info(message.Text); + return; + } + } + catch (Exception e) + { + _logger.Error("Failed to handle message from Rainbet"); + _logger.Error(e); + _logger.Error("--- Payload ---"); + _logger.Error(message.Text); + _logger.Error("--- End of Payload ---"); + } + } + + public void Dispose() + { + _wsClient.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file