Added Rainbet bet history scraping. Uses FlareSolverr to grab Cloudflare cookies then retrieves from the bet feed. Not perfect but mostly works.

This commit is contained in:
barelyprofessional
2024-08-06 00:07:08 +08:00
parent 508df3163b
commit a67641a14d
12 changed files with 575 additions and 7 deletions

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AngularNgOptimizedImage" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
</profile>
</component>

View File

@@ -14,4 +14,5 @@ public class ApplicationDbContext : DbContext
public DbSet<JuicerDbModel> Juicers { get; set; }
public DbSet<SettingDbModel> Settings { get; set; }
public DbSet<HowlggBetsDbModel> HowlggBets { get; set; }
public DbSet<RainbetBetsDbModel> RainbetBets { get; set; }
}

View File

@@ -41,6 +41,7 @@ public class ChatBot
private bool _twitchDisabled = false;
private Task _websocketWatchdog;
private Jackpot _jackpot;
private Rainbet _rainbet;
public ChatBot()
{
@@ -121,6 +122,7 @@ public class ChatBot
BuildTwitchChat();
BuildHowlgg();
BuildJackpot();
BuildRainbet();
_logger.Info("Starting websocket watchdog");
_websocketWatchdog = WebsocketWatchdog();
@@ -185,6 +187,14 @@ public class ChatBot
_jackpot = null!;
BuildJackpot();
}
// if (!_rainbet.IsConnected())
// {
// _logger.Error("Rainbet died, recreating it");
// _rainbet.Dispose();
// _rainbet = null!;
// BuildRainbet();
// }
}
catch (Exception e)
{
@@ -194,6 +204,49 @@ public class ChatBot
}
}
private void BuildRainbet()
{
_rainbet = new Rainbet(_cancellationToken);
_rainbet.OnRainbetBet += OnRainbetBet;
_rainbet.StartGameHistoryTimer();
}
private void OnRainbetBet(object sender, List<RainbetBetHistoryModel> bets)
{
var settings = Helpers
.GetMultipleValues([
BuiltIn.Keys.RainbetBmjPublicId, BuiltIn.Keys.TwitchBossmanJackUsername,
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]).Result;
_logger.Trace("Rainbet bet has arrived");
using var db = new ApplicationDbContext();
foreach (var bet in bets.Where(b => b.User.PublicId == settings[BuiltIn.Keys.RainbetBmjPublicId].Value))
//foreach (var bet in bets)
{
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,
Multiplier = bet.Multiplier,
BetId = bet.Id,
UpdatedAt = bet.UpdatedAt,
BetSeenAt = DateTimeOffset.UtcNow
});
_logger.Info("Added a Bossman Rainbet bet to the database");
}
db.SaveChanges();
}
private void BuildJackpot()
{

View File

@@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FlareSolverrSharp" Version="3.0.7" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">

View File

@@ -0,0 +1,187 @@
// <auto-generated />
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("20240802172200_Rainbet")]
partial class Rainbet
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.7");
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.HowlggBetsDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("Bet")
.HasColumnType("INTEGER");
b.Property<int>("BetId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
b.Property<string>("Game")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("GameId")
.HasColumnType("INTEGER");
b.Property<long>("Profit")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("HowlggBets");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<float>("Amount")
.HasColumnType("REAL");
b.Property<DateTimeOffset>("JuicedAt")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Juicers");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.RainbetBetsDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("BetId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("BetSeenAt")
.HasColumnType("TEXT");
b.Property<string>("GameName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<float>("Multiplier")
.HasColumnType("REAL");
b.Property<float>("Payout")
.HasColumnType("REAL");
b.Property<string>("PublicId")
.HasColumnType("TEXT");
b.Property<int>("RainbetUserId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<float>("Value")
.HasColumnType("REAL");
b.HasKey("Id");
b.ToTable("RainbetBets");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.SettingDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Default")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsSecret")
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Regex")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Settings");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Ignored")
.HasColumnType("INTEGER");
b.Property<int>("KfId")
.HasColumnType("INTEGER");
b.Property<string>("KfUsername")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("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
}
}
}

View File

@@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace KfChatDotNetBot.Migrations
{
/// <inheritdoc />
public partial class Rainbet : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RainbetBets",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PublicId = table.Column<string>(type: "TEXT", nullable: true),
RainbetUserId = table.Column<int>(type: "INTEGER", nullable: false),
GameName = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<float>(type: "REAL", nullable: false),
Payout = table.Column<float>(type: "REAL", nullable: false),
Multiplier = table.Column<float>(type: "REAL", nullable: false),
BetId = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
BetSeenAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RainbetBets", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RainbetBets");
}
}
}

View File

@@ -17,7 +17,7 @@ namespace KfChatDotNetBot.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.7");
modelBuilder.Entity("KfChatDotNetKickBot.Models.DbModels.HowlggBetsDbModel", b =>
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.HowlggBetsDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -50,7 +50,7 @@ namespace KfChatDotNetBot.Migrations
b.ToTable("HowlggBets");
});
modelBuilder.Entity("KfChatDotNetKickBot.Models.DbModels.JuicerDbModel", b =>
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -72,7 +72,46 @@ namespace KfChatDotNetBot.Migrations
b.ToTable("Juicers");
});
modelBuilder.Entity("KfChatDotNetKickBot.Models.DbModels.SettingDbModel", b =>
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.RainbetBetsDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<long>("BetId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("BetSeenAt")
.HasColumnType("TEXT");
b.Property<string>("GameName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<float>("Multiplier")
.HasColumnType("REAL");
b.Property<float>("Payout")
.HasColumnType("REAL");
b.Property<string>("PublicId")
.HasColumnType("TEXT");
b.Property<int>("RainbetUserId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<float>("Value")
.HasColumnType("REAL");
b.HasKey("Id");
b.ToTable("RainbetBets");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.SettingDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -105,7 +144,7 @@ namespace KfChatDotNetBot.Migrations
b.ToTable("Settings");
});
modelBuilder.Entity("KfChatDotNetKickBot.Models.DbModels.UserDbModel", b =>
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -129,9 +168,9 @@ namespace KfChatDotNetBot.Migrations
b.ToTable("Users");
});
modelBuilder.Entity("KfChatDotNetKickBot.Models.DbModels.JuicerDbModel", b =>
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b =>
{
b.HasOne("KfChatDotNetKickBot.Models.DbModels.UserDbModel", "User")
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)

View File

@@ -0,0 +1,18 @@
namespace KfChatDotNetBot.Models.DbModels;
public class RainbetBetsDbModel
{
public int Id { get; set; }
// Weird gibberish identifier given to users, may be hidden on bet feeds for users who opt out of the social shit
// Null if the user has opted out
public string? PublicId { get; set; }
// This is always set. Rainbet never omits the user's ID even if they're anonymous
public required int RainbetUserId { get; set; }
public required string GameName { get; set; }
public required float Value { get; set; }
public required float Payout { get; set; }
public required float Multiplier { get; set; }
public required long BetId { get; set; }
public required DateTimeOffset UpdatedAt { get; set; }
public required DateTimeOffset BetSeenAt { get; set; }
}

View File

@@ -0,0 +1,64 @@
using System.Text.Json.Serialization;
namespace KfChatDotNetBot.Models;
public class RainbetBetHistoryModel
{
[JsonPropertyName("id")]
public required int Id { get; set; }
[JsonPropertyName("value")]
public required float Value { get; set; }
[JsonPropertyName("payout")]
public required float Payout { get; set; }
[JsonPropertyName("multiplier")]
public required float Multiplier { get; set; }
[JsonPropertyName("updated_at")]
public required DateTimeOffset UpdatedAt { get; set; }
[JsonPropertyName("user")]
public required RainbetBetHistoryUserModel User { get; set; }
[JsonPropertyName("game")]
public required RainbetBetHistoryGameModel Game { get; set; }
}
public class RainbetBetHistoryGameModel
{
[JsonPropertyName("id")]
public required int Id { get; set; }
// It's actually a slug
[JsonPropertyName("url")]
public required string Url { get; set; }
[JsonPropertyName("name")]
public required string Name { get; set; }
}
public class RainbetBetHistoryUserRankModel
{
[JsonPropertyName("id")]
public required int Id { get; set; }
[JsonPropertyName("name")]
public required string Name { get; set; }
[JsonPropertyName("level")]
public required int Level { get; set; }
[JsonPropertyName("threshold")]
public required int Threshold { get; set; }
}
public class RainbetBetHistoryUserModel
{
// Can still uniquely identify users even if they're private. Bossman is ID 50
[JsonPropertyName("id")]
public required int Id { get; set; }
// Set to null on private-profiles
[JsonPropertyName("publicId")]
public string? PublicId { get; set; }
// Set to null on private profiles
[JsonPropertyName("username")]
public string? Username { get; set; }
[JsonPropertyName("wageredAmount")]
public required float WageredAmount { get; set; }
[JsonPropertyName("public_profile")]
public required int PublicProfile { get; set; }
// Null when they have no rank
[JsonPropertyName("rank")]
public RainbetBetHistoryUserRankModel? Rank { get; set; }
}

View File

@@ -0,0 +1,120 @@
using System.Net;
using System.Net.Http.Json;
using FlareSolverrSharp;
using FlareSolverrSharp.Exceptions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Settings;
using NLog;
namespace KfChatDotNetBot.Services;
public class Rainbet : IDisposable
{
private Logger _logger = LogManager.GetCurrentClassLogger();
public delegate void OnRainbetBetEventHandler(object sender, List<RainbetBetHistoryModel> bets);
public event OnRainbetBetEventHandler OnRainbetBet;
private CancellationToken _cancellationToken = CancellationToken.None;
private CancellationTokenSource _gameHistoryCts = new();
private Task? _gameHistoryTask;
private TimeSpan _gameHistoryInterval = TimeSpan.FromSeconds(60);
public Rainbet(CancellationToken? cancellationToken = null)
{
if (cancellationToken != null) _cancellationToken = cancellationToken.Value;
_logger.Info("Rainbet client created");
}
public void StartGameHistoryTimer()
{
_gameHistoryTask = GameHistoryTimer();
}
private async Task GameHistoryTimer()
{
using var timer = new PeriodicTimer(_gameHistoryInterval);
while (await timer.WaitForNextTickAsync(_gameHistoryCts.Token))
{
try
{
_logger.Info("Retrieving game history from Rainbet");
var bets = await GetGameHistory(1000);
OnRainbetBet?.Invoke(this, bets);
}
catch (FlareSolverrException e)
{
_logger.Error("Caught a FlareSolverrException, probably that retarded cookie bug that has been unfixed for 3+ years");
_logger.Error("Trying again immediately as it's pretty rare it happens twice in a row");
_logger.Error(e);
try
{
var bets = await GetGameHistory(1000);
OnRainbetBet?.Invoke(this, bets);
}
catch (Exception ee)
{
_logger.Error("Fuck my life, failed again. We'll just wait until the next tick");
_logger.Error(ee);
}
}
catch (Exception e)
{
_logger.Error("Caught error when retrieving bets and invoking the event");
_logger.Error(e);
}
}
}
// FlareSolverr C# client does not support POSTing application/json so this method involves
// 1. Getting the home page (as you're unlikely to get a CF challenge checkbox. Probably due to some config to limit
// friction for degens on VPNs, which is like 99% of the traffic for these shit casinos)
// 2. Using the cookies from that request to do the actual POST
// Cookies and UA must match or the trannies at Cloudflare will reject your cookies
// take = 10 is the default, but it can go higher
public async Task<List<RainbetBetHistoryModel>> GetGameHistory(int take = 10)
{
var settings =
await Helpers.GetMultipleValues([BuiltIn.Keys.FlareSolverrApiUrl, BuiltIn.Keys.FlareSolverrProxy]);
var flareSolverrUrl = settings[BuiltIn.Keys.FlareSolverrApiUrl];
var flareSolverrProxy = settings[BuiltIn.Keys.FlareSolverrProxy];
var handler = new ClearanceHandler(flareSolverrUrl.Value)
{
// Generally takes <5 seconds
MaxTimeout = 30000,
};
_logger.Debug($"Configured clearance handler to use FlareSolverr endpoint: {flareSolverrUrl.Value}");
// I would suggest not using a proxy. It's pretty much a miracle this works at all.
if (flareSolverrProxy.Value != null)
{
handler.ProxyUrl = flareSolverrProxy.Value;
_logger.Debug($"Configured clearance handler to use {flareSolverrProxy.Value} for proxying the request");
}
var gameHistoryUrl = "https://sportsbook.rainbet.com/v1/game-history";
var client = new HttpClient(handler);
var jsonBody = new Dictionary<string, int> { {"take", take} };
var postData = JsonContent.Create(jsonBody);
// You get CF checkbox'd if you go directly to sportsbook.rainbet.com but works ok for root
var getResponse = await client.GetAsync("https://rainbet.com/", _cancellationToken);
var postClientHandler = new HttpClientHandler();
if (flareSolverrProxy.Value != null)
{
postClientHandler.Proxy = new WebProxy(flareSolverrProxy.Value);
postClientHandler.UseProxy = true;
_logger.Debug($"Configured API request to use {flareSolverrProxy.Value}");
}
var postClient = new HttpClient(postClientHandler);
postClient.DefaultRequestHeaders.Add("Cookie", getResponse.Headers.GetValues("Set-Cookie"));
postClient.DefaultRequestHeaders.UserAgent.Clear();
postClient.DefaultRequestHeaders.UserAgent.ParseAdd(getResponse.RequestMessage.Headers.UserAgent.ToString());
var response = await postClient.PostAsync(gameHistoryUrl, postData, _cancellationToken);
var bets = await response.Content.ReadFromJsonAsync<List<RainbetBetHistoryModel>>(cancellationToken: _cancellationToken);
return bets;
}
public void Dispose()
{
_gameHistoryCts.Cancel();
_gameHistoryCts.Dispose();
_gameHistoryTask?.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -207,7 +207,16 @@ public class Shuffle : IDisposable
public void Dispose()
{
_wsClient.Dispose();
_pingCts.Cancel();
// 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);

View File

@@ -352,6 +352,30 @@ public static class BuiltIn
Description = "Bossman's username on Jackpot",
Default = "TheBossmanJack",
IsSecret = false
},
new BuiltInSettingsModel
{
Key = Keys.RainbetBmjPublicId,
Regex = ".+",
Description = "Bossman's rainbet public ID",
Default = "Ir04170wLulcjtePCL7P6lmeOlepRaNp",
IsSecret = false
},
new BuiltInSettingsModel
{
Key = Keys.FlareSolverrApiUrl,
Regex = ".+",
Description = "URL for your FlareSolverr service API",
Default = "http://localhost:8191/",
IsSecret = false
},
new BuiltInSettingsModel
{
Key = Keys.FlareSolverrProxy,
Regex = ".+",
Description = "Proxy in use specifically for FlareSolverr",
Default = null,
IsSecret = false
}
];
@@ -387,5 +411,8 @@ public static class BuiltIn
public static string KiwiFarmsGreenColor = "KiwiFarms.GreenColor";
public static string KiwiFarmsRedColor = "KiwiFarms.RedColor";
public static string JackpotBmjUsername = "Jackpot.BmjUsername";
public static string RainbetBmjPublicId = "Rainbet.BmjPublicId";
public static string FlareSolverrApiUrl = "FlareSolverr.ApiUrl";
public static string FlareSolverrProxy = "FlareSolverr.Proxy";
}
}