diff --git a/KfChatDotNetBot/ApplicationDbContext.cs b/KfChatDotNetBot/ApplicationDbContext.cs index 69cbde3..f74722c 100644 --- a/KfChatDotNetBot/ApplicationDbContext.cs +++ b/KfChatDotNetBot/ApplicationDbContext.cs @@ -16,4 +16,5 @@ public class ApplicationDbContext : DbContext public DbSet HowlggBets { get; set; } public DbSet RainbetBets { get; set; } public DbSet TwitchViewCounts { get; set; } + public DbSet ChipsggBets { get; set; } } \ No newline at end of file diff --git a/KfChatDotNetBot/ChatBot.cs b/KfChatDotNetBot/ChatBot.cs index d16b403..f1b341e 100644 --- a/KfChatDotNetBot/ChatBot.cs +++ b/KfChatDotNetBot/ChatBot.cs @@ -44,6 +44,7 @@ public class ChatBot private Task _websocketWatchdog; private Jackpot _jackpot; private Rainbet _rainbet; + private Chipsgg _chipsgg; private List _sentMessages = []; public ChatBot() @@ -126,6 +127,7 @@ public class ChatBot BuildHowlgg(); BuildJackpot(); BuildRainbet(); + BuildChipsgg(); _logger.Info("Starting websocket watchdog"); _websocketWatchdog = WebsocketWatchdog(); @@ -190,6 +192,14 @@ public class ChatBot _jackpot = null!; BuildJackpot(); } + + if (!_chipsgg.IsConnected()) + { + _logger.Error("Chips died, recreating it"); + _chipsgg.Dispose(); + _chipsgg = null!; + BuildChipsgg(); + } } catch (Exception e) { @@ -206,6 +216,65 @@ public class ChatBot _rainbet.OnRainbetBet += OnRainbetBet; _rainbet.StartGameHistoryTimer(); } + + private void BuildChipsgg() + { + var proxy = Helpers.GetValue(BuiltIn.Keys.Proxy).Result.Value; + _chipsgg = new Chipsgg(proxy, _cancellationToken); + _chipsgg.OnChipsggRecentBet += OnChipsggRecentBet; + _chipsgg.StartWsClient().Wait(_cancellationToken); + } + + private void OnChipsggRecentBet(object sender, ChipsggBetModel bet) + { + var settings = Helpers + .GetMultipleValues([ + BuiltIn.Keys.ChipsggBmjUsername, BuiltIn.Keys.TwitchBossmanJackUsername, + BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor + ]).Result; + _logger.Debug("Chips.gg bet has arrived"); + if (bet.Username != settings[BuiltIn.Keys.ChipsggBmjUsername].Value) + { + return; + } + _logger.Info("ALERT BMJ IS BETTING (on Chips.gg)"); + using var db = new ApplicationDbContext(); + db.ChipsggBets.Add(new ChipsggBetDbModel + { + Created = bet.Created, Updated = bet.Updated, UserId = bet.UserId, Username = bet.Username ?? "Unknown", Win = bet.Win, + Winnings = bet.Winnings, GameTitle = bet.GameTitle!, Amount = bet.Amount, Multiplier = bet.Multiplier, + Currency = bet.Currency!, CurrencyPrice = bet.CurrencyPrice, BetId = bet.BetId + }); + db.SaveChanges(); + if (IsBmjLive) + { + _logger.Info("Ignoring as BMJ is live"); + 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.Winnings < bet.Amount) payoutColor = settings[BuiltIn.Keys.KiwiFarmsRedColor].Value; + return; // Remove when I'm certain this is working properly + SendChatMessage( + $"🚨🚨 CHIPS BROS 🚨🚨 {bet.Username} just bet {bet.Amount:N} {bet.Currency!.ToUpper()} " + + $"({bet.Amount * bet.CurrencyPrice:C}) which paid out [color={payoutColor}]{bet.Winnings} {bet.Currency.ToUpper()} " + + $"({bet.Winnings / bet.CurrencyPrice:C})[/color] ({bet.Multiplier:N}x) on {bet.GameTitle} 💰💰", + true); + } private void OnRainbetBet(object sender, List bets) { @@ -286,7 +355,6 @@ public class ChatBot var payoutColor = settings[BuiltIn.Keys.KiwiFarmsGreenColor].Value; if (bet.Payout < bet.Wager) payoutColor = settings[BuiltIn.Keys.KiwiFarmsRedColor].Value; - // There will be a check for live status but ignoring that while we deal with an emergency dice situation SendChatMessage($"🚨🚨 JACKPOT BETTING 🚨🚨 {bet.User} just bet {bet.Wager} {bet.Currency} which paid out [color={payoutColor}]{bet.Payout} {bet.Currency}[/color] ({bet.Multiplier}x) on {bet.GameName} 💰💰", false); } @@ -602,7 +670,7 @@ public class ChatBot var messageTracker = new SentMessageTrackerModel { Reference = reference, - Message = message, + Message = message.TrimEnd(), // Sneedchat trims trailing spaces Status = SentMessageTrackerStatus.Unknown, }; if (settings[BuiltIn.Keys.KiwiFarmsSuppressChatMessages].ToBoolean()) diff --git a/KfChatDotNetBot/Migrations/20240817130843_Chipsgg.Designer.cs b/KfChatDotNetBot/Migrations/20240817130843_Chipsgg.Designer.cs new file mode 100644 index 0000000..d349e4c --- /dev/null +++ b/KfChatDotNetBot/Migrations/20240817130843_Chipsgg.Designer.cs @@ -0,0 +1,263 @@ +// +using System; +using KfChatDotNetBot; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace KfChatDotNetBot.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20240817130843_Chipsgg")] + partial class Chipsgg + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.ChipsggBetDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("REAL"); + + b.Property("BetId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CurrencyPrice") + .HasColumnType("REAL"); + + b.Property("GameTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Updated") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Win") + .HasColumnType("INTEGER"); + + b.Property("Winnings") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.ToTable("ChipsggBets"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.HowlggBetsDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bet") + .HasColumnType("INTEGER"); + + b.Property("BetId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Game") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("INTEGER"); + + b.Property("Profit") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("HowlggBets"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("REAL"); + + b.Property("JuicedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Juicers"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.RainbetBetsDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BetId") + .HasColumnType("INTEGER"); + + b.Property("BetSeenAt") + .HasColumnType("TEXT"); + + b.Property("GameName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Payout") + .HasColumnType("REAL"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RainbetUserId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.ToTable("RainbetBets"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.SettingDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Default") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsSecret") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Regex") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ServerTime") + .HasColumnType("REAL"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Viewers") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TwitchViewCounts"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("KfId") + .HasColumnType("INTEGER"); + + b.Property("KfUsername") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserRight") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b => + { + b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/KfChatDotNetBot/Migrations/20240817130843_Chipsgg.cs b/KfChatDotNetBot/Migrations/20240817130843_Chipsgg.cs new file mode 100644 index 0000000..d3a13dc --- /dev/null +++ b/KfChatDotNetBot/Migrations/20240817130843_Chipsgg.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace KfChatDotNetBot.Migrations +{ + /// + public partial class Chipsgg : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ChipsggBets", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Created = table.Column(type: "TEXT", nullable: false), + Updated = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Username = table.Column(type: "TEXT", nullable: false), + Win = table.Column(type: "INTEGER", nullable: false), + Winnings = table.Column(type: "REAL", nullable: false), + GameTitle = table.Column(type: "TEXT", nullable: false), + Amount = table.Column(type: "REAL", nullable: false), + Multiplier = table.Column(type: "REAL", nullable: false), + Currency = table.Column(type: "TEXT", nullable: false), + CurrencyPrice = table.Column(type: "REAL", nullable: false), + BetId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChipsggBets", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChipsggBets"); + } + } +} diff --git a/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs b/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs index 6cee739..76cfd3f 100644 --- a/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs @@ -17,6 +17,58 @@ namespace KfChatDotNetBot.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.ChipsggBetDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("REAL"); + + b.Property("BetId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CurrencyPrice") + .HasColumnType("REAL"); + + b.Property("GameTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Updated") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Win") + .HasColumnType("INTEGER"); + + b.Property("Winnings") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.ToTable("ChipsggBets"); + }); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.HowlggBetsDbModel", b => { b.Property("Id") diff --git a/KfChatDotNetBot/Models/ChipsggModels.cs b/KfChatDotNetBot/Models/ChipsggModels.cs new file mode 100644 index 0000000..557f309 --- /dev/null +++ b/KfChatDotNetBot/Models/ChipsggModels.cs @@ -0,0 +1,28 @@ +namespace KfChatDotNetBot.Models; + +public class ChipsggBetModel +{ + public DateTimeOffset Created { get; set; } + // Can actually get the duration of a game from this + public DateTimeOffset Updated { get; set; } + public string UserId { get; set; } + // Sometimes null for no discernible reason + public string? Username { get; set; } + // Win of any amount even if it's less than a 1x multi + public bool Win { get; set; } + public double Winnings { get; set; } + public string? GameTitle { get; set; } + public double Amount { get; set; } + public float Multiplier { get; set; } + public string? Currency { get; set; } + public float CurrencyPrice { get; set; } + public string BetId { get; set; } +} + +public class ChipsggCurrencyModel +{ + public required string Name { get; set; } + public required int Decimals { get; set; } + public float? Price { get; set; } + public required bool Hidden { get; set; } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Models/DbModels/ChipsggBetDbModel.cs b/KfChatDotNetBot/Models/DbModels/ChipsggBetDbModel.cs new file mode 100644 index 0000000..197daa0 --- /dev/null +++ b/KfChatDotNetBot/Models/DbModels/ChipsggBetDbModel.cs @@ -0,0 +1,18 @@ +namespace KfChatDotNetBot.Models.DbModels; + +public class ChipsggBetDbModel +{ + public int Id { get; set; } + public required DateTimeOffset Created { get; set; } + public required DateTimeOffset Updated { get; set; } + public required string UserId { get; set; } + public required string Username { get; set; } + public required bool Win { get; set; } + public required double Winnings { get; set; } + public required string GameTitle { get; set; } + public required double Amount { get; set; } + public required float Multiplier { get; set; } + public required string Currency { get; set; } + public required float CurrencyPrice { get; set; } + public required string BetId { get; set; } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Services/Chipsgg.cs b/KfChatDotNetBot/Services/Chipsgg.cs new file mode 100644 index 0000000..96f2234 --- /dev/null +++ b/KfChatDotNetBot/Services/Chipsgg.cs @@ -0,0 +1,332 @@ +using System.Net; +using System.Net.WebSockets; +using System.Text.Json; +using KfChatDotNetBot.Models; +using NLog; +using Websocket.Client; + +namespace KfChatDotNetBot.Services; + +public class Chipsgg : IDisposable +{ + private Logger _logger = LogManager.GetCurrentClassLogger(); + private WebsocketClient _wsClient; + private Uri _wsUri = new("wss://api.chips.gg/prod/socket"); + // Chips doesn't have a heartbeat packet + private int _reconnectTimeout = 30; + private string? _proxy; + public delegate void OnChipsggRecentBetEventHandler(object sender, ChipsggBetModel bet); + public delegate void OnWsDisconnectionEventHandler(object sender, DisconnectionInfo e); + public event OnChipsggRecentBetEventHandler OnChipsggRecentBet; + private CancellationToken _cancellationToken = CancellationToken.None; + private Dictionary _currencies = new(); + + public Chipsgg(string? proxy = null, CancellationToken? cancellationToken = null) + { + _proxy = proxy; + if (cancellationToken != null) _cancellationToken = cancellationToken.Value; + _logger.Info("Chipsgg 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://chips.gg"); + 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 Howl.gg (or never successfully connected). Type is {disconnectionInfo.Type}"); + _logger.Error($"Close Status => {disconnectionInfo.CloseStatus}; Close Status Description => {disconnectionInfo.CloseStatusDescription}"); + _logger.Error(disconnectionInfo.Exception); + } + + private void WsReconnection(ReconnectionInfo reconnectionInfo) + { + _logger.Error($"Websocket connection dropped and reconnected. Reconnection type is {reconnectionInfo.Type}"); + if (reconnectionInfo.Type == ReconnectionType.Initial) + { + _logger.Info("Sending auth payload to Chips.gg"); + _wsClient.Send("[\"auth\",1,\"token\",[]]"); + } + } + + private void WsMessageReceived(ResponseMessage message) + { + if (message.Text == null) + { + _logger.Info("Chips.gg sent a null message"); + return; + } + _logger.Trace($"Received event from Chips.gg: {message.Text}"); + + try + { + // Chipsgg has literally the most retarded "structure" to their packets I have ever seen + // I was hoping BMJ would dump their lousy site, but it hasn't happened yet + // Everything is arrays within arrays. + // For bets, each element of the outer array is a property related to the bet + // For auth / currency stuff there's only one element which contains more arrays + var payload = JsonSerializer.Deserialize>(message.Text); + if (payload == null || payload.Count == 0) + { + throw new Exception("Chips.gg sent us an empty array or I could not deserialize it"); + } + + var firstElement = payload[0].Deserialize>(); + if (firstElement == null || firstElement.Count < 3) + { + throw new Exception("Chips.gg's first element was smaller than expected or null"); + } + + if (firstElement[0].GetString() == "auth") + { + if (firstElement[1].GetInt32() == 1) + { + var guid = firstElement[2].GetString(); + _logger.Debug("Received auth packet, sending back GUID auth with " + guid); + _wsClient.Send("[\"auth\",2,\"authenticate\",[\"" + guid+ "\"]]"); + return; + } + + if (firstElement[1].GetInt32() == 2) + { + _logger.Info("Chips.gg responded to our auth with: " + firstElement[2].GetString()); + _logger.Info("Sending Chips.gg recent bets subscription packet"); + _wsClient.Send("[\"stats\",12,\"on\",[{\"game\":\"bets\",\"type\":\"recentBets\"}]]"); + return; + } + + throw new Exception("Auth packet was unhandled"); + } + + // First packet after auth is a currency + settings payload + // This will also match for periodic currency updates + if (firstElement[0].GetString() == "public") + { + var dataElement = firstElement[2].Deserialize>(); + if (dataElement == null || dataElement.Count < 2) + { + throw new Exception( + "Caught a null when grabbing data from the first element of the array or got fewer items than expected"); + } + + var path = dataElement[0].Deserialize>(); + if (path == null) + throw new Exception("Caught a null when deserializing the path element of the array"); + if (path.Count == 0) + { + _logger.Debug("Received initial currency payload as the path array was empty"); + var currencyData = dataElement[1].Deserialize>(); + if (currencyData == null) throw new Exception("Caught a null when deserializing currency data"); + if (!currencyData.TryGetValue("currencies", out var val)) throw new Exception("Currency object didn't contain expected currencies property"); + var currencies = val.Deserialize>(); + if (currencies == null) throw new Exception("Caught a null when deserializing currency dictionary"); + foreach (var currency in currencies.Keys) + { + // Should never happen but you never know + if (_currencies.ContainsKey(currency)) return; + float? price = null; + // Where a price is not set, the element is simply missing + if (currencies[currency].TryGetProperty("price", out var priceElement)) + { + price = priceElement.GetSingle(); + } + _currencies.Add(currency, new ChipsggCurrencyModel + { + Decimals = currencies[currency].GetProperty("decimals").GetInt32(), + Name = currency, + // Hidden is only present when it's true + Hidden = currencies[currency].TryGetProperty("hidden", out _), + Price = price + }); + _logger.Debug($"Ingested currency data for {currency}"); + } + return; + } + + foreach (var element in payload) + { + var data = element.Deserialize>(); + if (data == null || data.Count < 3) throw new Exception("Caught null or received fewer than 3 elements in the data array"); + var innerData = data[2].Deserialize>(); + if (innerData == null || data.Count < 2) throw new Exception("Caught null or received fewer than 2 elements in the inner data array"); + var innerDataPath = innerData[0].Deserialize>(); + if (innerDataPath == null || innerDataPath.Count == 0) throw new Exception("innerDataPath was null or contained no elements"); + if (innerDataPath.Contains("metrics")) continue; + var currency = innerDataPath[1]; + if (_currencies.TryGetValue(currency, out var updatedCurrency)) + { + updatedCurrency.Price = innerData[1].GetSingle(); + } + _logger.Debug($"Updated currency data for {currency}"); + } + + return; + } + + if (firstElement[0].GetString() == "stats") + { + if (firstElement[1].ValueKind == JsonValueKind.Number && firstElement[1].TryGetInt32(out var type)) + { + // 12 is the replay of recent bets + if (type == 12) return; + } + // Currency data may not be known until after so hold it here til we're done parsing + var amount = string.Empty; + var winnings = string.Empty; + var bet = new ChipsggBetModel(); + foreach (var element in payload) + { + var data = element.Deserialize>(); + if (data == null || data.Count < 3) throw new Exception("Caught null or received fewer than 3 elements in the data array"); + var innerData = data[2].Deserialize>(); + if (innerData == null || data.Count < 2) throw new Exception("Caught null or received fewer than 2 elements in the inner data array"); + var innerDataPath = innerData[0].Deserialize>(); + if (innerDataPath == null || innerDataPath.Count == 0) throw new Exception("innerDataPath was null or contained no elements"); + var innerDataPathJoined = string.Join(':', innerDataPath); + // For some reason there are ghostly bets sent alongside real bets whose values are all null + if (innerData[1].ValueKind == JsonValueKind.Null) continue; + if (innerDataPathJoined.EndsWith("bet:done")) + { + if (innerData[1].GetBoolean() == false) + { + _logger.Debug("Bet not yet complete, ignoring"); + return; + } + continue; + } + + // Just piggybacking it for a reliable path to grab the bet ID from + if (innerDataPathJoined.EndsWith("bet:created")) + { + bet.BetId = innerDataPath[2]; + bet.Created = DateTimeOffset.FromUnixTimeMilliseconds(innerData[1].GetInt64()); + continue; + } + + if (innerDataPathJoined.EndsWith("bet:updated")) + { + bet.Updated = DateTimeOffset.FromUnixTimeMilliseconds(innerData[1].GetInt64()); + continue; + } + + if (innerDataPathJoined.EndsWith("bet:userid")) + { + bet.UserId = innerData[1].GetString()!; + continue; + } + + if (innerDataPathJoined.EndsWith("player:username")) + { + bet.Username = innerData[1].GetString()!; + continue; + } + + if (innerDataPathJoined.EndsWith("bet:win")) + { + bet.Win = innerData[1].GetBoolean(); + continue; + } + + if (innerDataPathJoined.EndsWith("bet:winnings")) + { + winnings = innerData[1].GetString()!; + continue; + } + + if (innerDataPathJoined.EndsWith("game:title")) + { + bet.GameTitle = innerData[1].GetString()!; + continue; + } + + if (innerDataPathJoined.EndsWith("bet:amount")) + { + amount = innerData[1].GetString()!; + continue; + } + + if (innerDataPathJoined.EndsWith("bet:multiplier")) + { + bet.Multiplier = innerData[1].GetSingle(); + continue; + } + + if (innerDataPathJoined.EndsWith("bet:currency")) + { + bet.Currency = innerData[1].GetString()!; + } + } + + // Just something that randomly happens where incomplete bets are sent + // It seems that occasionally a bet is sent through with no proper game title or username + // Since the feed in theory can't display these, I'm assuming it's another ghost and not a real bet + if (bet.Currency == null || bet.GameTitle == null) return; + if (!_currencies.TryGetValue(bet.Currency, out var currencyData)) + { + throw new Exception($"Unknown currency {bet.Currency}"); + } + + // Another mysterious thing where winnings are sometimes sent and sometimes not. Presumed to be 0 + if (winnings == string.Empty) winnings = "0"; + bet.Winnings = double.Parse(winnings) / double.Parse(1.ToString().PadRight(currencyData.Decimals, '0')); + bet.Amount = double.Parse(amount) / double.Parse(1.ToString().PadRight(currencyData.Decimals, '0')); + bet.CurrencyPrice = currencyData.Price ?? 0; + OnChipsggRecentBet?.Invoke(this, bet); + return; + } + _logger.Debug("Unhandled event from Chips.gg"); + _logger.Debug(message.Text); + } + catch (Exception e) + { + _logger.Error("Failed to handle message from Chips.gg"); + _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 diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index df7d0eb..0f6eeae 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -376,6 +376,14 @@ public static class BuiltIn Description = "Proxy in use specifically for FlareSolverr", Default = null, IsSecret = false + }, + new BuiltInSettingsModel + { + Key = Keys.ChipsggBmjUsername, + Regex = ".+", + Description = "Bossman's Chips.gg username", + Default = "TheBossmanJack", + IsSecret = false } ]; @@ -414,5 +422,6 @@ public static class BuiltIn public static string RainbetBmjPublicId = "Rainbet.BmjPublicId"; public static string FlareSolverrApiUrl = "FlareSolverr.ApiUrl"; public static string FlareSolverrProxy = "FlareSolverr.Proxy"; + public static string ChipsggBmjUsername = "Chipsgg.BmjUsername"; } } \ No newline at end of file