Compare commits

...

551 Commits

Author SHA1 Message Date
barelyprofessional
7981f57a34 - Moved the Kasino shop models to their own file
- Added investments as a derivative of assets
- Added profile state flags which can retain basic states like IsSponsored
- Added profile state data using EF Core's JSON functionality so it should automatically serialize / deserialize the accompanying model for convenience (OnModelCreating code commented out due to the models not yet having a DbSet as I won't bake them in until KasinoShop is ready)
2026-04-26 20:39:49 -05:00
barelyprofessional
e725ca5864 Moved to Lazy<T> and a static class for handling Redis connections with some methods to make it easier to work with JSON. Completely untested. 2026-04-26 20:30:56 -05:00
barelyprofessional
1778d0d573 Go back to direct OCIS linking due to increased length limits in chat so it should work now 2026-04-26 08:12:59 -05:00
alogindtractor
9acc1172cc fixed rigging, add a little more delay to the rig message so you can see it better (#110)
fixed rigging, add a little more delay to the rig message so you can see it better
2026-04-23 05:10:14 +02:00
alogindtractor
3ab46bb4c7 Refactor betting logic and game state handling (#109)
scales bets if user doesn't have enough balance unless they're below 1KKK
fix for the ability to krash after the game is over guaranteeing a win
2026-04-19 05:31:12 +02:00
barelyprofessional
b4cd21da41 Didn't work but I'm half convinced cookie containers never fucking work in .NET anyway so trying the shitty Rainbet hack since that at least was good enough to establish the WebSocket 2026-04-16 21:45:52 -05:00
IfYouComplainImDFEingAgain
3d269716e8 Add Whisper transcription for BossmanJack Discord voice messages (#107)
* Add Whisper transcription for BossmanJack Discord voice messages

Detect Discord voice message attachments (audio with IS_VOICE_MESSAGE flag)
from the monitored user and transcribe them via OpenAI Whisper API before
relaying to chat. Reuses the existing OpenAi.ApiKey setting. Feature is
disabled by default via Whisper.Enabled setting.

* Use separate API key setting for Whisper transcription

* Switch to local Whisper and post-then-edit transcription flow

Voice messages are now relayed immediately with a "transcribing..." placeholder,
then transcribed locally via the whisper CLI and the message is edited to append
the result. Removes OpenAI API dependency in favor of a local whisper binary.

Settings: Whisper.BinaryPath, Whisper.Model, Whisper.Enabled

---------

Co-authored-by: DFE <dfe@dfe.com>
Co-authored-by: barelyprofessional <150058423+barelyprofessional@users.noreply.github.com>
2026-04-17 04:14:58 +02:00
barelyprofessional
1a49fe1976 prayge this is good enough to deal with the Cloudflare problems 2026-04-16 21:10:45 -05:00
barelyprofessional
2bce346967 Don't build Winna if disabled 2026-04-15 20:52:32 -05:00
barelyprofessional
39be005d38 Experimental Winna support 2026-04-15 20:44:56 -05:00
alogindtractor
1abe5974a7 Update ToolUrl for MinesCommand (#106)
more clean now
2026-04-12 02:18:44 +02:00
barelyprofessional
1461c1043a Maybe fix DST problems and also the infinite money glitch at the 23rd hour of the day 2026-04-10 00:06:06 -05:00
barelyprofessional
2c55e94bfd Fixed green/red colors and added support for multiple bets from the same participant 2026-04-09 21:28:24 -05:00
barelyprofessional
7a58976c16 Forfeit if balance drops too low for Krash 2026-04-09 21:15:53 -05:00
barelyprofessional
d4d499aebd Added enable/disable for Krash and cleanup delay as a configurable value 2026-04-09 20:48:49 -05:00
barelyprofessional
9e921d5ff9 Because apparently being retards in the chat is lol xD lmao so funny and my patch the other day made some people mad the war has now escalated 2026-04-09 20:32:44 -05:00
A Log in D Tractor
354b1cfd99 krash fix hopefuly (#105)
* Update KrashCommand.cs

add wager limit

* Update KasinoKrash.cs

* actual fix for payout

found the bug it was using the game multi instead of the bet multi

* Update KrashCommand.cs

remove wager limit

* actually fix the actual bug

fix the actual bug
2026-04-10 02:51:47 +02:00
barelyprofessional
64e318ce84 Seems to be paying out too much. Can't be bothered adding a setting as I've got other shit to do so killing it for now 2026-04-08 09:40:22 -05:00
alogindtractor
f415409a88 Set KrashAccepted to true when game starts (#104)
* Set KrashAccepted to true when game starts

* Update message formatting in Krash game logic

clean up display only 2 decimals shown now until the end

* Enhance betting message formatting in Krash game

* add rigging to krash

98% RTP without shop
with shop, it essentially averages out all the participants house edge modifier difference

* Update KasinoKrash.cs
2026-04-08 16:34:05 +02:00
barelyprofessional
95707f58ee Return after krashing 2026-04-07 23:50:06 -05:00
alogindtractor
7e131ff1a4 Fix bet handling and payout calculations in Krash game (#103)
fix where it would let you win if you didn't attempt to krash
also keeps message on screen a little longer after its over so you can see the final number better
2026-04-08 06:39:06 +02:00
barelyprofessional
b86986f03e Don't ignore edits if it's within 15 seconds of being sent 2026-04-07 21:10:00 -05:00
barelyprofessional
5e85566577 Refactored krash 2026-04-06 21:13:19 -05:00
alogindtractor
26e0b1f49f message deletion and krash (#102)
* Update message deletion in BlackjackCommand

Refactor message deletion logic for non-whisper messages.

* Add message deletion for non-whisper coinflip

* Implement message deletion for non-whispers

Added a check to delete non-whisper messages if they have a MessageUuid.

* Delete non-whisper messages in KenoCommand

* Implement KrashBetCommand for betting functionality

* Lambchop message deletion

Lambchop message deletion

* limbo message deletion

limbo message deletion

* Delete message if not a whisper mines

Delete message if not a whisper mines

* planes message dleete

planes message dleete

* plinko message delete

plinko message delete

* Add message deletion for non-whisper messages roulette

Delete the message if it's not a whisper and has a UUID.

* add message deletion for non-whisper slots

add message deletion for non-whisper slots

* Implement message deletion for WheelCommand

Add message deletion for non-whisper messages.

* Add KasinoKrash service initialization

* Add KasinoKrash service for game management

Implement KasinoKrash service for managing the Krash game, including state management, betting, and payout logic.

* Update message formatting in KasinoMines.cs

add buttons

* Update MinesCommand.cs

allow more mines spam since message will be deleted anyways, spam will be supported via button
2026-04-07 03:30:49 +02:00
barelyprofessional
d97d4f7fad Use dittos for BJ commands 2026-03-19 22:14:32 -05:00
barelyprofessional
4b66658d47 Fix blackjack hand values turning into emojis 2026-03-19 22:06:59 -05:00
barelyprofessional
f13717fbd0 Fix filter for whisper status update 2026-03-19 22:03:13 -05:00
barelyprofessional
df6db90822 Gate sloppa behind T&H 2026-03-19 21:59:16 -05:00
barelyprofessional
b40fdf1a7c Ignore null MOTDs 2026-03-19 13:34:03 -05:00
barelyprofessional
6c29899454 Enable whisper for guess what num 2026-03-19 00:15:19 -05:00
barelyprofessional
b33311c37b Continue instead of return on match succes for whispers 2026-03-18 23:57:30 -05:00
barelyprofessional
6240f7c7f1 Ignore some null warnings and fix compiler errors related to the shop 2026-03-18 23:53:45 -05:00
barelyprofessional
01a4b26326 Added support for MOTD and whispers. Commands can opt into responding to whispers and there's a helper method to handle replying through the correct channel. 2026-03-18 23:50:32 -05:00
barelyprofessional
4cdb04e3c5 Moved BlackjackDisplay.cs shit to BlackjackCommand.cs 2026-03-18 19:53:53 -05:00
CrackmaticSoftware
606e7867d0 Yesterdays bullshit served tomorrow (#100)
* Minimize amount of lines blackjack needs

* selfdestruct sloppa images

* massivly reduce amount of time slot graphic stays in chat
2026-03-19 01:52:40 +01:00
barelyprofessional
a6810591de Committed some of the DB work that's happening and disabled shop as it's going to take a while to refactor 2026-03-18 19:51:34 -05:00
alogindtractor
377603ca35 kasino shop updated all chat message id to uuid (#95)
* Update KasinoMines.cs

* Update SlotsCommand.cs

* Update MinesCommand.cs

* Update PlinkoCommand.cs

* Update PlinkoCommand.cs

* Update PlinkoCommand.cs

* Update PlanesCommand.cs

* Update LimboCommand.cs

* Update KenoCommand.cs

* Update KasinoUserCommands.cs

* Update KasinoRain.cs

* Create KasinoShop.cs

* Create ShopCommands.cs

* Update BotServices.cs

* Update MoneyDbModels.cs
2026-03-14 23:09:48 +01:00
CrackmaticSoftware
af7b5e027b New stuff (#99)
* rain control

* blackjack double payout fix. minor display fixes
2026-03-14 21:59:34 +01:00
barelyprofessional
75addfb185 Very good saar claude cood to make pow accept 2026-03-13 20:24:30 -05:00
barelyprofessional
0454a5bbdd Add Shuffle.us support for detecting hidden offline gambling 2026-03-13 20:22:05 -05:00
barelyprofessional
986027f5c5 Remove check for IsConnected from dead bot detection as this niggardly Websocket library FUCKED ME AGAIN 2026-03-13 08:42:58 -05:00
barelyprofessional
b4796bbef6 Rain minimum 2026-03-11 20:35:35 -05:00
CrackmaticSoftware
c065bf513b rain control (#98) 2026-03-12 02:12:23 +01:00
barelyprofessional
9f9bdee61d Don't allow brand new gamblers to participate in a rain 2026-03-11 20:09:26 -05:00
barelyprofessional
945fac3c50 Add "green" support to Roulette 2026-03-08 00:16:24 -06:00
cohlexyz
67b0a26163 Add @ syntax to pocketwatch (#97) 2026-03-08 07:12:46 +01:00
barelyprofessional
829443283f Add the option to disable OpenAI moderation for Nora 2026-03-05 21:58:24 -06:00
barelyprofessional
8daaf3c304 Seems to be working now. Re-added the VIP level check 2026-03-05 09:41:25 -06:00
barelyprofessional
586a89a4cd More logging 2026-03-05 09:40:07 -06:00
barelyprofessional
fda98403ae Maybe? 2026-03-05 09:38:27 -06:00
barelyprofessional
b384845b54 Realized my GraphQL payload was fucked anyway but it probably will still freak out at me 2026-03-05 09:34:00 -06:00
barelyprofessional
f792cf4712 Shuffle still mad. Fuck you Noah 2026-03-05 09:25:35 -06:00
barelyprofessional
5058681022 Remove VIP level check to make it easier to test for now 2026-03-05 09:22:42 -06:00
barelyprofessional
d01fbe6ce3 C# is so frustrating with HTTP. You couldn't imagine a more annoyingly autistic in the worst way possible HTTP client. Won't let you do anything to make it real world usable, and also enforces shit at runtime so you can't tell something is busted until hours later when your method finally hits.
Anyway it balked at the Accept header but some more testing in curl reveals that perhaps that's not what the issue is, that it's freaking out due to a missing Origin and Referer headers. Though testing might be impeded by caching, it's hard to say.
2026-03-05 09:20:57 -06:00
barelyprofessional
c8dcf8e884 Choking without an accept header 2026-03-05 01:00:47 -06:00
barelyprofessional
469e24dde1 Can't use application/json in the request headers but it seems it doesn't matter based on testing 2026-03-05 00:54:16 -06:00
cohlexyz
11c09ea65c Allow !legitcheck to use usernames (#96)
* Keep track of users in chat

* Allow usernames for legitcheck
2026-03-05 05:40:16 +01:00
barelyprofessional
5fce555007 Thanks to AgarthaCrack for the offline betting detection logic 2026-03-04 22:39:36 -06:00
barelyprofessional
7b1e33da78 Minor update to Discord presence so it'll give the generic presence info if there's no platform presence data 2026-03-04 21:18:43 -06:00
barelyprofessional
64b2ce4a8e Chink is now 2x for rate limiting 2026-03-04 21:15:46 -06:00
barelyprofessional
545c880dba Updated 1023-byte limits to 2048 2026-03-04 21:15:21 -06:00
barelyprofessional
896477787d Turns out the ?? just does it as a literal so going to leave it and see what happens when it's null 2026-03-01 23:51:50 -06:00
barelyprofessional
98369e3d92 Views can be null 2026-03-01 23:50:15 -06:00
barelyprofessional
75774bb62f Increase frame length to 300ms due to being a little too fast for the oldies in chat 2026-03-01 22:02:43 -06:00
barelyprofessional
a94a9a11a8 Fix edits 2026-03-01 21:51:07 -06:00
barelyprofessional
251703a427 Add dittos to rain and emojis to make it more visible 2026-03-01 21:48:27 -06:00
barelyprofessional
c05d855edd Overclocking planes 2026-03-01 21:44:59 -06:00
barelyprofessional
ce0efead0b Only call people nigger faggots if they have an [img] alongside mBossmanJack 2026-03-01 14:40:02 -06:00
barelyprofessional
28881143be Ignore Bossman stream if a capture is already running due to Twitch GraphQL being trash 2026-03-01 14:32:42 -06:00
barelyprofessional
abcaa48be4 Refactored Xeet embed so it uses the fancy split extension method instead of the one cohle cooked up 2026-02-28 18:18:08 -06:00
cohlexyz
79cf0b9fdf self check (#93) 2026-03-01 00:50:46 +01:00
cohlexyz
6635ebacd0 embed media (#92) 2026-03-01 00:50:15 +01:00
barelyprofessional
0f7e75ec91 No response for image rate limits 2026-02-28 15:46:23 -06:00
barelyprofessional
7770dc99ed Update deleted to use UUID 2026-02-28 15:39:56 -06:00
barelyprofessional
82a69f48dd Null warnings 2026-02-28 15:35:59 -06:00
barelyprofessional
c8016b4fc6 Update for new chyat 2026-02-28 15:34:36 -06:00
barelyprofessional
8a827a17de Handle presence updates that don't contain a username 2026-02-27 00:35:03 -06:00
barelyprofessional
eae5a18d11 Revert apocalyptic house edge for lambchop (it's rigged!) 2026-02-27 00:21:17 -06:00
barelyprofessional
72e5115548 Forgive input errors for kasino games when rate limiting 2026-02-27 00:20:09 -06:00
barelyprofessional
1337db31b3 Total chink death 2026-02-27 00:11:07 -06:00
alogindtractor
6d4d461aa7 Update SlotsCommand.cs (#91)
add delay for total loss message
2026-02-27 06:55:52 +01:00
barelyprofessional
7779189cee Fix excessive payouts due to not subtracting wager 2026-02-23 12:26:26 -06:00
barelyprofessional
81a6f0fdd5 Total Cloudflare Death 2026-02-21 00:08:47 -06:00
barelyprofessional
4962472312 Flaresolverr is dogshit. Going to try real and raw 2026-02-21 00:01:20 -06:00
barelyprofessional
d3f7d5e374 Re-enable Lambchop 2026-02-20 18:26:58 -06:00
barelyprofessional
0305f2a35c Fix borked code due to dodgy merge conflict 2026-02-20 18:26:38 -06:00
CrackmaticSoftware
6b6bfe2699 Lambchop fix (#90)
* Enabling/disabling gamba games with dynamic settting lookup.

* shadow wizard lambchop gang, we love fixing upper bound errors

* dsfgrgfds
2026-02-20 17:29:39 -06:00
barelyprofessional
66f2af5f52 Take away the wager limit for roulette 2026-02-19 20:58:05 -06:00
barelyprofessional
1c2b36b8b2 Flip the numbers around for #88 2026-02-19 20:56:39 -06:00
barelyprofessional
6967a81d73 Use RandN properly and get rid of the iterations thingy for next double 2026-02-19 20:55:55 -06:00
barelyprofessional
f1ab9cfcdd Add auto delete to VIP message 2026-02-19 20:27:07 -06:00
barelyprofessional
ec11dff3bd Added 100 minimum for rain 2026-02-19 20:20:04 -06:00
barelyprofessional
366311a20a Lambchop is off for now it's still fucked 2026-02-19 20:18:26 -06:00
barelyprofessional
60f74894ca Broke meta 2026-02-19 19:30:05 -06:00
barelyprofessional
cda5aca788 Updated house edge for lambchop 2026-02-19 19:26:45 -06:00
barelyprofessional
78e1494a19 Bumped packages 2026-02-19 19:17:01 -06:00
barelyprofessional
2c7e2adf48 Whycome this was Newtonsoft? 2026-02-19 19:14:50 -06:00
barelyprofessional
e5f98fe24c Check rights for users before ignoring 2026-02-18 00:40:26 -06:00
barelyprofessional
e4815a2290 Auto delete Nora responses 2026-02-17 23:35:00 -06:00
barelyprofessional
f1afce7fab Removed unused fields and imports 2026-02-17 22:04:27 -06:00
barelyprofessional
0dcbb25fe3 Migrated moods and prompts to the settings.
Removed the weird concurrent dictionary and replaced with Redis.
Removed the cleanup watchdog in favor of Redis expiration
2026-02-17 22:02:09 -06:00
barelyprofessional
75e958cd2a Add XML doc summaries for the value types 2026-02-17 21:55:51 -06:00
barelyprofessional
bc114e9f64 Remove all the slop .md files 2026-02-17 20:26:48 -06:00
xXCryingLaughingXx
30d9f48d2e Nora (#87) 2026-02-18 03:24:30 +01:00
alogindtractor
f701cae171 fix error, add delay to win message (#89)
* Update SlotsCommand.cs

adds delay for win message

* Update SlotsCommand.cs

add delay, fix rigslotboard error, was checking the wrong diagonal
2026-02-17 15:58:54 +01:00
barelyprofessional
dec3a9473a Back to white squares as it's too many bytes 2026-02-16 00:49:16 -06:00
barelyprofessional
9183c45105 Replace cloud with fog 2026-02-16 00:47:38 -06:00
alogindtractor
f0c1e77e5f Update PlanesCommand.cs (#85)
use cloud instead of white squire
2026-02-16 01:53:46 +01:00
alogindtractor
1ce3f0e8e5 Update KasinoMines.cs (#84) 2026-02-13 06:57:25 +01:00
alogindtractor
b43ce3f95c Update KasinoMines.cs (#83)
maybe fix explode animation
2026-02-13 06:51:54 +01:00
barelyprofessional
d1e95b07d4 Don't let exceptions go unhandled on chat messages as it's causing issues with the websocket library 2026-02-12 09:07:16 -06:00
barelyprofessional
75630e4053 Reduce VIP log spam 2026-02-11 22:35:08 -06:00
barelyprofessional
6e2fd0bc35 Check for null on disconnection info Exception 2026-02-11 22:11:29 -06:00
barelyprofessional
cbf5b628c3 Missed one 2026-02-11 22:09:26 -06:00
barelyprofessional
384b2ab3ef Removed disconnect/connect and replaced with Reconnect as it's made things worse 2026-02-11 22:07:41 -06:00
barelyprofessional
bdb882795f Rename Reconnect to ReconnectAsync 2026-02-11 22:06:00 -06:00
alogindtractor
259d5c339b fix payouts (#82)
games were slightly overpaying by including the original wager in the payout
2026-02-12 04:59:06 +01:00
barelyprofessional
34b3c5a671 Uber aggressive reconnection logic was glitching out like mad. Added a reconnect on dead bot detection and reduced inactivity timeout to 45 seconds in settings for the bot 2026-02-10 22:57:44 -06:00
barelyprofessional
3d99cce5fb Removed wager limit for Mines 2026-02-10 22:24:03 -06:00
alogindtractor
c4995f55f2 Update KasinoMines.cs (#80)
fix payouts
2026-02-11 05:16:26 +01:00
alogindtractor
4441fa178c Update MinesCommand.cs (#78)
update refresh, update tool url
2026-02-11 05:03:01 +01:00
barelyprofessional
6747389237 Still having issues with not reconnecting after 203 challenge so moved the reconnect logic back out of refresh token, save cookies no matter what and now force a reconnect on WsDisconnection event if it's not ByUser 2026-02-10 22:00:05 -06:00
alogindtractor
d71dd304fd update mines (#77)
* Update MinesCommand.cs

better cashout handling?
also limit mines to 8

* Update KasinoMines.cs

update cashout to have a delay before removing board message and add auto delete

* update cashout calculation

update cashout calculation
fair payout but house edge based chance for rigging
2026-02-10 15:40:43 +01:00
barelyprofessional
26d1da3069 Fix missing update cookie + force reconnect when bot is already logged in 2026-02-10 08:39:25 -06:00
alogindtractor
21c8803eb9 fix board size, some fixes to auto cashout from cursor (#75)
* cursor fixes

cursor fixes

* Improve cashout condition validation

Refactor cashout condition to check for success and non-empty value.

* cursor

cursor

* Update BotServices.cs

* Update board size limit from 10 to 9

Update board size limit from 10 to 9
81 characters instead of 100
12 bytes per character per powershell, 
down from 1200 bytes to 972

* Implement message deletion for active games

Added logic to delete messages associated with active games.
2026-02-09 16:00:45 +01:00
alogindtractor
b6df015277 cursor fixes (#74)
cursor fixes
2026-02-09 07:05:39 +01:00
alogindtractor
e1b5970e8b char[,] to char[][] (#73)
* Refactor KasinoMines into MinesCommand class

char[,] to char[][]

* Refactor KasinoMines class and update game logic

char[,] to char[][]
2026-02-09 06:45:40 +01:00
barelyprofessional
7fbebe81ab Added missing awaits, improved permission check and removed redundant else 2026-02-08 23:27:41 -06:00
alogindtractor
9643126cf8 updates message stuff (#72)
* Implement admin-only clear command for saved games

Added 'clear' command for admin to reset saved games.

* Refactor LastMessage handling in KasinoMines

Refactor LastMessage handling in KasinoMines
2026-02-09 06:26:34 +01:00
barelyprofessional
a272e155bd Ignore null 2026-02-08 22:34:15 -06:00
alogindtractor
20a267c702 fix last message id to check for null first for message reset (#71)
* Add JsonSerializerOptions for serialization and deserialization

Add JsonSerializerOptions for serialization and deserialization
apparently it has problems with lists with groups like my list<(int r, int c)> so needs options

* Fix null check for LastMessage.ChatMessageId

Fix null check for LastMessage.ChatMessageId
2026-02-09 05:33:34 +01:00
alogindtractor
3385722455 Add JsonSerializerOptions for serialization and deserialization (#70)
Add JsonSerializerOptions for serialization and deserialization
apparently it has problems with lists with groups like my list<(int r, int c)> so needs options
2026-02-09 04:48:00 +01:00
barelyprofessional
e96620381f Move the responsibility for updating cookies and reconnecting to RefreshXfToken so it's always handled properly 2026-02-08 20:50:13 -06:00
barelyprofessional
4c8cbc1748 Actually save cookies 2026-02-08 20:40:39 -06:00
barelyprofessional
1e44dbe6c1 Parse it from Set-Cookie because honestly fuck it 2026-02-08 20:35:44 -06:00
barelyprofessional
d2cc3f04ad Cookie container is fucking trash 2026-02-08 20:31:42 -06:00
barelyprofessional
9334cac344 Losing my mind this is fucking ridiculous 2026-02-08 20:28:53 -06:00
barelyprofessional
d8a8b7341a Why am I not getting the fucking cookie 2026-02-08 20:27:11 -06:00
barelyprofessional
24e864f8f5 WaitAsync didn't like TimeSpan.MaxValue 2026-02-08 20:21:12 -06:00
barelyprofessional
2d255198ea Forgot to move null for TTRS 2026-02-08 20:19:52 -06:00
barelyprofessional
d0cabbf759 203 check for disconnect 2026-02-08 20:15:35 -06:00
barelyprofessional
e7c309582a Send all cookies to the websocket connection as the clearance token is now needed 2026-02-08 20:12:41 -06:00
barelyprofessional
cdd309fa24 Suppress nullable warnings, re-implement the missing wait for message, extend the delay a little to make sure shit doesn't go out of order and update the ResetMessage message null check given A Log changed the type for whatever reason 2026-02-08 19:11:35 -06:00
alogindtractor
d5f04b5228 some sloppa fixes (#69)
* update

update

* service

service

* Optimize message retrieval in MinesCommand

Refactor message handling in MinesCommand to use last message directly.

* Replace LastMessageId with LastMessage object
2026-02-09 02:06:15 +01:00
barelyprofessional
1901507c25 Added a minimum wager requirement to all games 2026-02-08 12:02:24 -06:00
barelyprofessional
2fb8f0bb89 Removed the weird 3 millisecond delays and added a wait for message 2026-02-07 20:37:41 -06:00
alogindtractor
670336145d mines update (#68)
* mines update

mines update

* Refactor betting logic to use valid coordinates

Refactor betting logic to use valid coordinates

* Refactor bet coordinate selection logic

Refactor random bet coordinate selection to improve clarity and prevent duplicate entries.

* update tostring

update tostring

* Refactor Bet method and update gem handling

Refactor Bet method to include an additional parameter for tracking calls. Update logic for handling gem counts and cash-out conditions.

* update regex

update regex
2026-02-08 03:33:58 +01:00
alogindtractor
6a47d0d25e mines update (#67)
* mines update

mines update

* Refactor betting logic to use valid coordinates

Refactor betting logic to use valid coordinates

* Refactor bet coordinate selection logic

Refactor random bet coordinate selection to improve clarity and prevent duplicate entries.

* update tostring

update tostring

* Refactor Bet method and update gem handling

Refactor Bet method to include an additional parameter for tracking calls. Update logic for handling gem counts and cash-out conditions.
2026-02-07 23:10:19 +01:00
barelyprofessional
eccbe44acd Merging changes from #66
Closes PR #66
2026-02-07 11:33:09 -06:00
barelyprofessional
8246b75868 Use gambler ID in Redis key to avoid the possibility of concurrent games messing with the state 2026-02-07 11:27:03 -06:00
barelyprofessional
54d989f64f Wager limit while mines is fucked 2026-02-07 00:21:08 -06:00
barelyprofessional
6c6ed8d09e Check if LastMessageId is its default value or not before attempting to delete 2026-02-07 00:11:41 -06:00
barelyprofessional
0c61206e08 Added missing awaits for mines wins 2026-02-07 00:03:06 -06:00
barelyprofessional
15f68ee99b Add log message for reset 2026-02-06 23:59:45 -06:00
alogindtractor
3ec623f6a4 fix display, unfuck riggery (#65)
fix display, unfuck riggery
2026-02-07 06:53:25 +01:00
barelyprofessional
0272d79ee1 Massively reduce the rate limiting for mines 2026-02-06 23:48:23 -06:00
barelyprofessional
503d0de41b Wager limit for roulette 2026-02-06 23:43:02 -06:00
barelyprofessional
dd469c36b3 Usability shit for roulette 2026-02-06 23:40:57 -06:00
alogindtractor
daba3012a4 fix for slot issue (#64)
* fix chat message ID handling and index out of bounds error

fix chat message ID handling and index out of bounds error

* fix feature incorrectly showing for some reason

idk why this started happening hopefully this fixes it, actual features might still be broken though

* actual fix for slot display issue

actual fix for slot display issue
after adding rigging i was passing in the rig parameter as the current type of feature
2026-02-07 06:34:30 +01:00
barelyprofessional
57e1f7eb04 Break on rate limit instead of continue so it doesn't spam multiple rate limit messages if multiple regexes match 2026-02-06 23:30:07 -06:00
barelyprofessional
7179cf72ce Suppress nullable warnings 2026-02-06 23:12:31 -06:00
barelyprofessional
a64d4456ab Discard return value for explode so it stops crying 2026-02-06 23:12:00 -06:00
barelyprofessional
4072709ec6 Added missing awaits to Mines 2026-02-06 23:09:38 -06:00
barelyprofessional
12d184ebac Update query for oldest entry in IsRateLimited to First as this seems to make more sense when it's using an ascending order? The behavior isn't right regardless right now 2026-02-06 23:07:02 -06:00
alogindtractor
d726b4f638 fix chat message ID handling and index out of bounds error (#63)
* fix chat message ID handling and index out of bounds error

fix chat message ID handling and index out of bounds error

* fix feature incorrectly showing for some reason

idk why this started happening hopefully this fixes it, actual features might still be broken though
2026-02-06 09:59:54 -06:00
barelyprofessional
1890e3606b Another wait for message... 2026-02-05 23:46:29 -06:00
barelyprofessional
28cc6a2651 Added wait for message to be received 2026-02-05 23:43:24 -06:00
barelyprofessional
bf9d3268cd Use exclusive random for the mines board 2026-02-05 23:39:35 -06:00
barelyprofessional
696339f359 Include the trailing space for cashout in the optional match for cashout 2026-02-05 23:34:57 -06:00
barelyprofessional
57e1b9c3b9 WIP rehost stuff 2026-02-05 23:26:46 -06:00
alogindtractor
477c121f72 Refactor MinesCommand regex patterns and messages (#62) 2026-02-06 06:26:11 +01:00
barelyprofessional
42804c90e4 Experimental ttrs support 2026-02-05 23:04:43 -06:00
barelyprofessional
32ae015c3b Reduce the absurd 100 second default timeout to 10 seconds for KiwiFlare 2026-02-05 20:52:56 -06:00
barelyprofessional
80d4f81610 Fixed compiler warnings 2026-02-05 20:42:40 -06:00
barelyprofessional
b579789860 Added whoami/addy command 2026-02-05 20:25:35 -06:00
barelyprofessional
1996b2b638 Refactored naming for Kasino Mines and decoupled it from BotServices as it has no long lived tasks or whatever 2026-02-05 20:19:34 -06:00
barelyprofessional
21f2019366 Moved RouletteCommand.cs to the parent Kasino folder 2026-02-05 19:28:56 -06:00
alogindtractor
4dba9b4133 Implement minimum wager requirement for slots (#61)
* Implement minimum wager requirement for slots

Added minimum wager validation for slots command.

* Implement house edge and rigged outcomes in SlotsCommand

adds house edge to slots
if your house edge is greater than 1, HOUSE_EDGE - 100% chance for guaranteed max win chance (spawns all the symbols in the right place, does not guarantee top tier multi)

if house edge is less than 1, 100% - HOUSE_EDGE chance for guaranteed loss
2026-02-06 02:23:52 +01:00
barelyprofessional
6ba82ff213 Added support for !kasino open/close 2026-02-03 16:22:24 -06:00
barelyprofessional
cac30a24a2 Fixed broken settings descriptions 2026-02-01 22:54:24 -06:00
barelyprofessional
18b19ffcef Refactored Roulette to use Redis instead of locks and probably made it even buggier 2026-02-01 22:52:58 -06:00
alogindtractor
2bb56c2388 Mines (#60)
* Add MinesCommand

Add MinesCommand
parses user input and submits it to mines service

* Add KasinoMines service to bot services

Add KasinoMines service to bot services

* kasinomines service code

kasinomines service code
holds all the game information so that games can be ongoing, you can leave your game and come back to it later,

* Update MinesCommand.cs

* Update KasinoMines.cs

* Update MinesCommand.cs

* add house edge to limbo

add house edge to limbo

* add house edge to keno

add house edge to keno

* Update BotServices.cs

forgot to add kasino mines item

* Update BuiltIn.cs

add kasinomines cleanup delay setting

* Update KenoCommand.cs

add difficulty options to keno, classic low medium high default high

* Update PlanesCommand.cs

adds house edge to planes
if your buffs cause house edge to be greater than 1, you have a HOUSE_EDGE - 1.0 % chance to get a guaranteed win,
if house edge is less than 1, 1-HOUSE EDGE chance for a guaranteed loss

* Update PlanesCommand.cs

missed a counter update

* Update PlinkoCommand.cs

plinko house edge update
changes vacuum strength based on house edge
2026-02-02 04:48:17 +01:00
barelyprofessional
de859e8fad Added missing state clear after the nobody participated message 2026-01-29 22:10:22 -06:00
CrackmaticSoftware
ca2e2c7874 Live Roulette v1 (#59)
* Enabling/disabling gamba games with dynamic settting lookup.

* Its roulette baby
2026-01-29 12:20:57 -06:00
barelyprofessional
6209a76e94 Check if a rain exists 2026-01-28 00:50:25 -06:00
barelyprofessional
305082e17f Extend timeout and prevent creators from raining on themselves 2026-01-28 00:47:26 -06:00
barelyprofessional
96f17c14cf Missing + 1 for participant count 2026-01-28 00:45:23 -06:00
barelyprofessional
051a663c4e Forgot to finish my sentence :( 2026-01-28 00:42:15 -06:00
barelyprofessional
a4ad3f4b45 Forgot to add a delay for the rain timer 2026-01-28 00:41:48 -06:00
barelyprofessional
65b7b19b8a Experimental convoluted rain refactor to use Redis instead of semaphores 2026-01-28 00:40:01 -06:00
alogindtractor
9a7762a933 rain command (#58)
* attempt at rain command

attempt at rain command

* Add 'Rain' to MoneyDbModels enum

Added 'Rain' to the enum for additional game types.

* some cleanup

some cleanup
2026-01-28 04:38:30 +01:00
CrackmaticSoftware
2179d59edd Enabling/disabling gamba games with dynamic settting lookup. (#53) 2026-01-28 04:28:49 +01:00
cohlexyz
2709b3054c Append unix timestamp to coinflip images to bypass caching (#51)
* Append unix timestamp to coinflip image urls

to prevent browser cache from spoiling the result

* Extend result delay a bit
2026-01-16 15:26:00 -06:00
barelyprofessional
981700e889 Preloaded images with predictable URLs instead of continually reuploading the webps 2026-01-15 23:56:29 -06:00
cohlexyz
28a4e71c58 Add coinflip game (#50) 2026-01-16 06:36:55 +01:00
barelyprofessional
b95c27d928 NEVER TRUST AN LLM FAGGOT ALWAYS VERIFY THEIR SHITTY JEET CODING BOT DIDN'T HALLUCINATE THE WRONG FUCKING HEADER NAME NOW I'VE GOT THOUSANDS OF WORTHLESS WEBPS TO CLEANUP 2026-01-15 23:35:05 -06:00
alogindtractor
071136f910 Disable wager limit check in PlinkoCommand (#49)
Comment out wager limit check for Plinko command.
2026-01-15 02:55:33 +01:00
barelyprofessional
60be8d45d6 Added missing await 2026-01-14 19:46:40 -06:00
alogindtractor
17ce32a69c update to show net balance change per request (#48)
update to show net balance change per request
2026-01-14 09:36:01 -06:00
barelyprofessional
8be1ec0f41 Nicer formatting for Xeets 2026-01-13 23:37:57 -06:00
barelyprofessional
9bee1188e5 Skip command don't return when there's no prefix 2026-01-13 23:36:06 -06:00
barelyprofessional
eb4bb8dc47 Add missing await 2026-01-13 23:04:53 -06:00
barelyprofessional
ee9ae62e39 Use the target instead of whoever is running the command 2026-01-13 23:01:39 -06:00
barelyprofessional
29f2863c9a Refactored Xeet embedding 2026-01-13 22:53:06 -06:00
barelyprofessional
3f4c3e2713 Support for commands without a prefix using new attribute NoPrefixRequired 2026-01-13 21:53:27 -06:00
barelyprofessional
295fef20fb Added slot assets 2026-01-13 21:16:13 -06:00
cohlexyz
b873195e79 Add basic twitter post embeds (#46)
only handles text posts for now
2026-01-14 04:04:56 +01:00
barelyprofessional
8f0ada8c78 Fix compiler error and tried to improve the formatting of wins/losses 2026-01-13 21:00:15 -06:00
alogindtractor
128726d5a9 adds pause between spins and delays win/lose message based on length of image (#45)
* adds delay based on frame count so it doesn't spoil outcome on long spins

adds delay based on frame count so it doesn't spoil outcome on long spins

* adds pause between each spin

adds pause between each spin

* Refactor delay calculation for slot animation

Replaced delaySec with delayHSec to calculate delay based on frame delays.
2026-01-14 03:56:48 +01:00
alogindtractor
cf45a14eff update slots multispin to actually work (#42)
update slots multispin to actually work
2026-01-12 05:39:56 +01:00
barelyprofessional
56817cf471 Toggle between active gambler stats and all stats 2026-01-09 19:06:47 -06:00
barelyprofessional
68d0984b77 Fix display of amounts for Plinko 2026-01-09 18:59:12 -06:00
alogindtractor
61e47ad591 fix plinko spam, allow multiple slot spins within the same image (#40)
* spam reduction

spam reduction

* Update SlotsCommand to handle multiple spins

Update SlotsCommand to handle multiple spins
2026-01-10 01:53:52 +01:00
barelyprofessional
31023bc960 Use Humanizer for enum 2026-01-09 18:52:17 -06:00
ClaudetteTheGreat
79a1b7a224 Add !legitcheck command for user RTP statistics (#41)
New command that calculates Return-to-Player statistics for any user
by aggregating all their kasino wagers across all gambler entities.
Shows overall RTP, total wagered/returned, wager count, and luckiest
game (highest RTP with minimum 10 wagers to qualify).
2026-01-10 01:49:59 +01:00
barelyprofessional
fa9cbff738 Fix formatting, lack of ct support for Task.Delay and readd missing await 2026-01-09 00:17:33 -06:00
barelyprofessional
7489c7c46a Fucked up shitty ghetto patch 2026-01-09 00:12:55 -06:00
barelyprofessional
d351dc580c Payout fix and wager limit for Plinko 2026-01-09 00:10:09 -06:00
barelyprofessional
e4f8085350 Fix nullable warnings again 2026-01-08 23:58:40 -06:00
alogindtractor
f14d9281a9 vacuum fix (#38)
vacuum fix
better plinko ball position setup if we ever want to add difficulty levels
2026-01-09 06:57:49 +01:00
barelyprofessional
2570523c3e Key not found errors with Plinko 2026-01-08 23:42:21 -06:00
barelyprofessional
2e767f00ab More plinko RTP shenanigans 2026-01-08 23:35:19 -06:00
barelyprofessional
334a8795e3 RTP fix hopefully 2026-01-08 23:33:25 -06:00
barelyprofessional
4a5a573941 Fix compiler nullable warnings 2026-01-08 23:29:55 -06:00
alogindtractor
50fee7c984 plinko rework (#37)
plinko rework
2026-01-09 06:28:38 +01:00
barelyprofessional
82da292cd8 Updated 8ball
* Reduce permissions to Loser
* Add rate limit options
* Use the FormatUsername() extension method
* Convert to a switch expression
* Reformat
* Namespace
2026-01-08 20:03:44 -06:00
cohlexyz
6cdb7b6702 Add !8ball command (#35) 2026-01-09 03:00:36 +01:00
alogindtractor
21fd54f83e plinko fixes (#34)
* more plinko fixes

more plinko fixes

* fix

fix

* fix
2026-01-08 09:13:30 -06:00
alogindtractor
73f933db4a plinko fix frfrfr (#32)
* Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo

Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo

* wait for chat message id update

wait for chat message id update

* update plinko to fix shit

update plinko to fix shit

* add underline to final blackjack message

add underline to final blackjack message to make it easier to read which game is which when many games are happening at once

* plinko fix frfr this time

plinko fix frfr this time

* settings fix as requested

settings fix as requested

* plinko payout fix?

not exactly sure why its not correct this should maybe fix it?

* Add logger for max win in PlinkoCommand

Added logging for maximum win condition in Plinko game.

* fix loop and other bugs

fix loop and other bugs
2026-01-08 05:27:01 +01:00
barelyprofessional
d6fe18638a Increase timeout and add rate limiting 2026-01-07 21:44:13 -06:00
alogindtractor
fe2c57f5c1 fix plinko payout maybe? also added logger in case its still bugged to print ball position (#31)
* Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo

Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo

* wait for chat message id update

wait for chat message id update

* update plinko to fix shit

update plinko to fix shit

* add underline to final blackjack message

add underline to final blackjack message to make it easier to read which game is which when many games are happening at once

* plinko fix frfr this time

plinko fix frfr this time

* settings fix as requested

settings fix as requested

* plinko payout fix?

not exactly sure why its not correct this should maybe fix it?

* Add logger for max win in PlinkoCommand

Added logging for maximum win condition in Plinko game.
2026-01-08 04:41:07 +01:00
barelyprofessional
6d8caf6430 Fix busted syntax for the settings in PlinkoCommand.cs 2026-01-07 21:26:38 -06:00
alogindtractor
143f282647 plinko fix frfr this time (#30)
* Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo

Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo

* wait for chat message id update

wait for chat message id update

* update plinko to fix shit

update plinko to fix shit

* add underline to final blackjack message

add underline to final blackjack message to make it easier to read which game is which when many games are happening at once

* plinko fix frfr this time

plinko fix frfr this time

* settings fix as requested

settings fix as requested
2026-01-08 04:25:49 +01:00
barelyprofessional
2a77e760a1 Fixed color display for kasino game status and added plinko 2026-01-07 20:28:30 -06:00
alogindtractor
6f6359b6da plinko update, minor blackjack update (#29)
* Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo

Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo

* wait for chat message id update

wait for chat message id update

* update plinko to fix shit

update plinko to fix shit

* add underline to final blackjack message

add underline to final blackjack message to make it easier to read which game is which when many games are happening at once
2026-01-08 03:19:57 +01:00
alogindtractor
bdc84f6476 wait for chat message id plinko update (#28)
* Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo

Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo

* wait for chat message id update

wait for chat message id update
2026-01-07 17:13:16 -06:00
alogindtractor
e0d388b2f0 Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo (#27)
Update cleanup delay settings for PlinkoCommand, use plinko delay instead of limbo
2026-01-07 16:18:32 -06:00
CrackmaticSoftware
7e3ba4e641 Kasino game access control (#25)
* Blackjack

* sync

* Kasino game enable/disable control
2026-01-07 16:09:56 -06:00
alogindtractor
47771a0f4c plinko with wager (#26)
* Implement PlinkoCommand for Kasino game

plinko

* Add plinko board cleanup delay setting

Added a new setting for plinko board cleanup delay.

* Add 'Plinko' to game options in MoneyDbModels

Added 'Plinko' option to the game enum with a description.

* Modify Plinko win message to show new balance and do wager

Updated the message format to include the new balance after a win.

* Adjust wager calculation for Plinko game
2026-01-07 14:23:30 -06:00
CrackmaticSoftware
be669bf951 fixes (#22)
* Blackjack

* sync

* make BJ deck suffeling not OOB anymore

* tag !juice recipients

* lambchop RTP adjustment
2026-01-06 14:10:46 -06:00
barelyprofessional
318241a58c Fix out of bounds error 2026-01-05 21:13:42 -06:00
barelyprofessional
b7f570beef Maybe this will deal with the broken calculation? 2026-01-05 20:20:54 -06:00
barelyprofessional
e581cab142 Fix tier 1 hopefully? 2026-01-05 20:11:44 -06:00
barelyprofessional
00e556d0bb Fix VIP levels hopefully 2026-01-05 20:07:45 -06:00
barelyprofessional
2ade21225e More logging! 2026-01-05 19:47:49 -06:00
barelyprofessional
f0fe22ab12 Still no idea why VIP levels aren't working, adding shitloads of logging 2026-01-05 19:43:19 -06:00
barelyprofessional
004a3d42b2 Don't log metadata to info 2026-01-05 19:40:48 -06:00
barelyprofessional
eb6ec6a628 Add juiceme as a pattern for daily dollar 2026-01-05 19:38:55 -06:00
barelyprofessional
2664e5e0df Should be >= instead of <= for exclusions. Retarded. 2026-01-05 19:25:23 -06:00
barelyprofessional
78207b291b Exception logging for VIP levels as they're not working 2026-01-05 19:21:22 -06:00
barelyprofessional
78d5ba9f40 Updated split so it doesn't directly mess with balance 2026-01-05 19:19:58 -06:00
CrackmaticSoftware
3992ff3119 Splitable blackjack (#21)
* Blackjack

* changed blackjack randomness to use player bound randomness.

* blackjack splitting. auto standing on 21. fixed duplicate bust message.

* vibecoded transactions fix

* update to match proper balance modification
2026-01-05 09:22:34 -06:00
barelyprofessional
a288f3f4eb Prevent people from being able to redeem a daily dollar with a brand new gambler entity 2026-01-05 00:47:34 -06:00
barelyprofessional
1c8a2658ca Fix balance display on win 2026-01-05 00:32:45 -06:00
barelyprofessional
9077e629be Don't modify the balance directly 2026-01-05 00:28:28 -06:00
barelyprofessional
19571d54e7 Daily dollar that resets midnight BMT 2026-01-04 23:59:14 -06:00
barelyprofessional
e952179663 Fix limbo colors per PR #17
Closing the rest of that PR as I have another plan for how to do daily dollah
2026-01-04 18:25:45 -06:00
barelyprofessional
6a79063b18 Use the proper red for a blackjack bust and removed unused parameter per PR #20 2026-01-04 18:23:13 -06:00
barelyprofessional
4ccb6b7865 Forgot that LastOrDefault needs an OrderBy 2026-01-02 21:28:59 -06:00
barelyprofessional
b0473d68ab Did some refactoring with blackjack and probably have completely broken it 2026-01-02 21:25:36 -06:00
barelyprofessional
fcd057e980 Create a separate setting for blackjack cleanup 2026-01-02 19:02:04 -06:00
barelyprofessional
e183414836 Removed shitty error handler 2026-01-02 19:00:31 -06:00
barelyprofessional
9bb9ca63a7 Removed Newtonsoft 2026-01-02 18:56:22 -06:00
barelyprofessional
289d2c91a3 Rate limit the abandon command 2026-01-02 18:51:00 -06:00
CrackmaticSoftware
df869c6e82 Blackjack (#18)
* Blackjack

* idk
2026-01-02 17:12:46 -06:00
barelyprofessional
77dad18e92 Added a new state type for the 2025 end of year great reset 2025-12-31 20:50:05 -06:00
barelyprofessional
9a416eab1c Missing return :lossmanjack: 2025-12-31 20:40:30 -06:00
barelyprofessional
5c186f13b1 Reducing rate limits for slots 2025-12-31 20:37:46 -06:00
barelyprofessional
70c4daf750 Upload text to Zipline (if enabled) for very large image lists 2025-12-31 20:37:03 -06:00
barelyprofessional
1c1734922e Fix color display for Limbo 2025-12-28 00:14:49 -06:00
barelyprofessional
6e32ab90dc Disable bbcode wrapping for the drawn stuff 2025-12-27 22:53:05 -06:00
barelyprofessional
8342a1e63a Convert doubles to decimal and render amounts a little nicer 2025-12-27 22:50:56 -06:00
barelyprofessional
c9e3f91707 Revert to the old logic for showing wins/losses and calculating shit 2025-12-27 09:43:53 -06:00
barelyprofessional
56249cdbc0 Messed up asset path 2025-12-27 09:30:08 -06:00
barelyprofessional
1de4d3b475 Cleaned up compiler warnings 2025-12-27 09:28:43 -06:00
barelyprofessional
fdd60a86fb Slots 2.0. Now with less Raylib 2025-12-27 09:25:21 -06:00
barelyprofessional
10fd0a290b Take wager off of your limbo win as it's pretty much infinite money right now 2025-12-27 09:18:40 -06:00
barelyprofessional
518a26f9d1 Trying dispose pattern instead 2025-12-26 01:02:54 -06:00
barelyprofessional
943132ac62 Total memory leak death 2025-12-26 00:29:58 -06:00
barelyprofessional
d2f06d30ed With the power of Claude free, will this memory still leak? 2025-12-25 20:42:17 -06:00
barelyprofessional
a61d930c88 Removed Claude's dodgy try/finally and added win size to the message 2025-12-25 20:31:11 -06:00
CrackmaticSoftware
6a818aada6 exploratory garbage collection fix powered by a bunch of GPUs solving crossword puzzles (#14)
* actually commit the code mayube
2025-12-26 03:27:27 +01:00
barelyprofessional
69ac495824 !prayge this fixes the memory leaks 2025-12-25 19:40:12 -06:00
barelyprofessional
fe1ab566d1 Removed some pointless decimal conversion. Subtract wager from winnings. 2025-12-25 19:31:09 -06:00
barelyprofessional
0301a6c2d3 Missing bbcode close for win 2025-12-25 12:37:48 -06:00
barelyprofessional
4c54e656b4 JSON payload I was given was not correct! 2025-12-25 12:36:02 -06:00
barelyprofessional
e602391bd7 Don't think it's necessary to include a name but the upload is failing for some reason 2025-12-25 12:33:31 -06:00
barelyprofessional
4b58cc9eae 5 seconds? What the fuck A Log 2025-12-25 12:22:00 -06:00
barelyprofessional
7aefa17f47 Slots 2025-12-25 02:30:10 -06:00
CrackmaticSoftware
6b86b3ae6c snitch on people adding/removing images to the bot (#13)
* image preview when adding to carrousel

* spelling is hard

* snitch more

* fuck yoursel
2025-12-22 11:02:01 -06:00
barelyprofessional
5212f7cd76 Forgot to add BuildYouTubePubSub to the tasks 2025-12-21 20:15:08 -06:00
barelyprofessional
8503636a29 * Removed OpenRouter from BotServices and converted it into a static class
* Moved hostess from MemeCommands.cs to KasinoUserCommands.cs
* Added missing KasinoCommand attribute. Also added WagerCommand so it'll do the exclusion check before running
2025-12-19 01:05:39 -06:00
barelyprofessional
a88045c63d YouTube PubSub 2025-12-19 00:49:18 -06:00
cohlexyz
72a162b67a Add !hostess command (#12)
* Add basic !hostess command

* Add openrouter integration
2025-12-19 07:42:57 +01:00
barelyprofessional
71e534a396 Fix screwed up regex for selecting the number, display formatting issues with numbers and changed the game result to print on a single line 2025-12-10 20:15:38 -06:00
barelyprofessional
d83b357ec3 Using a proper cancellation token for commands rather than the shared one, so timeouts should be handled more gracefully and also hopefully maybe actually consistently work. Also added error reporting 2025-12-10 20:06:15 -06:00
barelyprofessional
9ee114c466 Updated new games so they have individual cleanup delays. Also extended timeouts for Wheel as it's prone to timing out 2025-12-10 19:43:28 -06:00
barelyprofessional
1463d991c1 Added Limbo 2025-12-10 19:41:23 -06:00
CrackmaticSoftware
9583313316 faster wheelspin (#11)
* fix dice lose print

* dice can now display rigged results

* lambchop

* removed GetRandomNext

* updated lambchop randomness to use GetRandomNumber()

* Change lambchop game timeout to 12 seconds

* lambchop quickfix

* sync

* wheel game

* new newBalance calculation

* wheel quickfix

* experimental lambchop fix. Increased wheel animation time.

* Wheel fix maybe?

* wheelspin?

* faster wheelspin
2025-12-10 16:47:29 -06:00
CrackmaticSoftware
85a5eb4dfd wheel fix maybe (#10)
* fix dice lose print

* dice can now display rigged results

* lambchop

* removed GetRandomNext

* updated lambchop randomness to use GetRandomNumber()

* Change lambchop game timeout to 12 seconds

* lambchop quickfix

* sync

* wheel game

* new newBalance calculation

* wheel quickfix

* experimental lambchop fix. Increased wheel animation time.

* Wheel fix maybe?
2025-12-10 16:26:13 -06:00
CrackmaticSoftware
061cbaea9e lambchop fix maybe (#9)
* fix dice lose print

* dice can now display rigged results

* lambchop

* removed GetRandomNext

* updated lambchop randomness to use GetRandomNumber()

* Change lambchop game timeout to 12 seconds

* lambchop quickfix

* sync

* wheel game

* new newBalance calculation

* wheel quickfix

* experimental lambchop fix. Increased wheel animation time.
2025-12-10 14:37:33 -06:00
CrackmaticSoftware
956bfd9b54 wheel fix (#8)
* fix dice lose print

* dice can now display rigged results

* lambchop

* removed GetRandomNext

* updated lambchop randomness to use GetRandomNumber()

* Change lambchop game timeout to 12 seconds

* lambchop quickfix

* sync

* wheel game

* new newBalance calculation

* wheel quickfix
2025-12-10 10:33:19 -06:00
CrackmaticSoftware
711ce75a8b oval shaped wheel game (#7)
* fix dice lose print

* dice can now display rigged results

* lambchop

* removed GetRandomNext

* updated lambchop randomness to use GetRandomNumber()

* Change lambchop game timeout to 12 seconds

* lambchop quickfix

* sync

* wheel game

* new newBalance calculation
2025-12-10 10:16:58 -06:00
barelyprofessional
26d2305a6c Implemented theoretical YouTube PubSub integration using Redis PubSub and some random shit I found on the Internet 2025-12-10 00:55:27 -06:00
barelyprofessional
ffb183f57b Added Redis 2025-12-10 00:14:40 -06:00
barelyprofessional
1392502712 Bumped packages 2025-12-10 00:06:25 -06:00
barelyprofessional
db3f09c32f Added a check to see if field length matches the multee list size and throw an exception if it doesn't due to unreachable code warning 2025-12-09 23:45:01 -06:00
barelyprofessional
052918fd28 Fixed loss so it's now red for lambchop 2025-12-09 23:40:47 -06:00
barelyprofessional
4671bb3d25 Return new balance when it's modified and use that for display so it accounts for concurrent games 2025-12-09 23:40:15 -06:00
barelyprofessional
5af2015d46 Updated the message detection code so it'll give up straight away if the status is bad 2025-12-09 23:21:52 -06:00
barelyprofessional
91878d92b5 Fixed broken cleanup for lambchop and missing sesh bypass 2025-12-09 22:29:07 -06:00
CrackmaticSoftware
0c08c19e90 lambchop quickfix (#6)
* fix dice lose print

* dice can now display rigged results

* lambchop

* removed GetRandomNext

* updated lambchop randomness to use GetRandomNumber()

* Change lambchop game timeout to 12 seconds

* lambchop quickfix

* sync
2025-12-09 14:45:40 -06:00
CrackmaticSoftware
f385ff35c6 lambchop game using existing random (#5)
* fix dice lose print

* dice can now display rigged results

* lambchop

* removed GetRandomNext

* updated lambchop randomness to use GetRandomNumber()

* Change lambchop game timeout to 12 seconds
2025-12-09 12:07:39 -06:00
CrackmaticSoftware
5f709464b2 dice fix (#3)
* fix dice lose print

* dice can now display rigged results
2025-12-08 00:16:48 +01:00
barelyprofessional
312f93494d Reduce rate limit window for dice 2025-12-07 13:50:32 -06:00
barelyprofessional
cfdac05185 Fix Avenue's retarded formatting 2025-12-07 13:48:43 -06:00
barelyprofessional
df5df27be0 Merge pull request #1 from CrackmaticSoftware/master
dice
2025-12-08 03:29:57 +08:00
CrackmaticSoftware
e86a4d69be add ratelimitingoptions to dice game 2025-12-07 20:26:24 +01:00
CrackmaticSoftware
a2ed1724e2 Merge remote-tracking branch 'origin/master' 2025-12-07 19:59:01 +01:00
CrackmaticSoftware
bdeb2acdf8 added dice game. added random double generator. 2025-12-07 19:51:17 +01:00
barelyprofessional
348ae7b9cb net10 2025-12-05 19:55:59 -06:00
barelyprofessional
3c70fea2ba Added admin commands for everything but abandon and close. Changed selection time weighted payout to a property instead of having different types 2025-11-25 00:54:11 -06:00
barelyprofessional
24db30b789 Add an option to disable Jackpot 2025-11-16 12:50:21 -06:00
barelyprofessional
4bf9308fa7 Created initial models for kasino events 2025-11-16 02:03:02 -06:00
barelyprofessional
e99434f5df Refactored and corrected compiler warnings on Keno and Planes 2025-11-16 00:45:41 -06:00
barelyprofessional
e2ae5c20c2 Log the DLive exception 2025-11-15 16:50:33 -06:00
barelyprofessional
60e0c76b72 Added pocketwatch command 2025-11-15 16:48:36 -06:00
barelyprofessional
ccf26d24a2 Added a stream capture locking feature due to DLive spuriously reporting a streamer as not live when they are and causing duplicate captures.
It works by having the capture script touch a file before it begins capturing, then remove the file when the capture is complete.

The bot will check if this file is present before checking if a DLive streamer is actually live which will reduce the amount of API hits and prevent it from going live twice.
2025-11-08 09:01:38 -06:00
barelyprofessional
3ca9e1278b Log spam 2025-10-26 10:43:58 -05:00
barelyprofessional
ee40d14fa6 Added more logging and error checking for DLive as it's a piece of shit 2025-10-25 17:44:32 -05:00
barelyprofessional
7a625f218f Planes fair and honest 1.1 update 2025-10-23 17:41:38 -05:00
barelyprofessional
8904c4eb81 New planes riggery 2025-10-22 20:55:27 -05:00
barelyprofessional
02336ebd32 Special planes feature 2025-10-19 17:17:28 -05:00
barelyprofessional
36ad9a23b4 Log the nonce that should've passed Kiwi Flare challenge 2025-10-19 17:15:27 -05:00
barelyprofessional
d53d2f1def Remove persistently failing mesasges from the deletion scheduled task if they get forever lost for some reason.
Also moved the check for null until after it has checked the deadline so it only cares if it's due to be deleted.
2025-10-18 19:30:49 -05:00
barelyprofessional
2699dcf9ca Fix bad default for exit on death 2025-10-18 11:33:38 -05:00
barelyprofessional
3a8df5c76b Removed flags from field search 2025-10-18 11:33:17 -05:00
barelyprofessional
e770136ca9 Did a massive overhaul of settings where it now uses an attribute and reflection to populate the DB. Gets rid of the enormous array and makes it a one-step process to create settings. 2025-10-18 03:45:23 -05:00
barelyprofessional
c04763079a Removed juiceme 2025-10-18 03:44:18 -05:00
barelyprofessional
2507a8cc7d Re-organized the kasino commands into their own folder and split the games up into individual files 2025-10-17 17:52:01 -05:00
barelyprofessional
32afdc5354 Planes 1.3 (The RTP Update) 2025-10-12 19:41:05 -05:00
barelyprofessional
3e1a6632f7 Readded auto delete cleanup for win 2025-10-12 15:25:14 -05:00
barelyprofessional
3d00b4a708 Planes 1.22 2025-10-12 15:24:16 -05:00
barelyprofessional
819b278b0e Add setting to disable conversation summaries 2025-10-12 14:59:14 -05:00
barelyprofessional
8e78d626de Planes 1.21 2025-10-12 14:31:19 -05:00
barelyprofessional
c6fff46310 I'm such a retard it's actually amazing I can still remember to breathe. There was nothing wrong with the old method. 2025-10-12 13:08:20 -05:00
barelyprofessional
38177a9051 Remove spammy cleanup message 2025-10-12 13:00:59 -05:00
barelyprofessional
02f94128d8 I'm a retard 2025-10-12 12:59:33 -05:00
barelyprofessional
92d2770f98 Forgot to initialize scheduled deletions 2025-10-12 12:55:21 -05:00
barelyprofessional
99f5421736 Changed auto deletions to a background task running in the bot itself to hopefully make them reliable 2025-10-12 12:54:23 -05:00
barelyprofessional
c990247bb6 Planes 1.2 2025-10-12 12:37:11 -05:00
barelyprofessional
a8853aef1c Auto cleanup not working :( trying a different method 2025-10-12 02:44:39 -05:00
barelyprofessional
c05a9d9d15 Added a feature to schedule message deletion. Changed the kasino games to use them so planes doesn't get deleted mid-run.
Also increased Planes timeout to 120 seconds as some games run on very long.
2025-10-12 02:35:02 -05:00
barelyprofessional
d863c5666d Planes changes 2025-10-12 01:56:26 -05:00
barelyprofessional
7e1a88b6a3 Add extra logging to lossback 2025-10-12 01:56:05 -05:00
barelyprofessional
4ed42ba41d Added colors to guess 2025-10-11 22:05:23 -05:00
barelyprofessional
f0cec04781 Fix balance display on losses for Keno 2025-10-11 22:03:57 -05:00
barelyprofessional
ee9e62f715 More planes fuckery 2025-10-11 19:17:17 -05:00
barelyprofessional
5197197a39 Planes 1.19 hotpatch 2025-10-11 19:14:47 -05:00
barelyprofessional
08e46722de Logging changes 2025-10-11 19:06:35 -05:00
barelyprofessional
232e22d332 Planes 1.19 2025-10-11 15:14:31 -05:00
barelyprofessional
0474477e44 Planes 1.17 2025-10-11 14:27:31 -05:00
barelyprofessional
f3020c57c5 Planes 1.14 2025-10-11 14:04:44 -05:00
barelyprofessional
ae44335f0b Planes 1.13 2025-10-11 13:45:47 -05:00
barelyprofessional
cf688bd61b Disabled Planes for now 2025-10-11 01:38:10 -05:00
barelyprofessional
9d36f58a71 Updated logger 2025-10-11 01:04:14 -05:00
barelyprofessional
15364fc339 Planes infinite loop 2025-10-11 00:41:23 -05:00
barelyprofessional
2bd7b0d94a Planes 1.12 more hotfixes 2025-10-11 00:30:05 -05:00
barelyprofessional
f8acba110d Planes 1.12 patch 2025-10-11 00:25:40 -05:00
barelyprofessional
f65decc560 More logging due to ambiguous errors 2025-10-11 00:23:44 -05:00
barelyprofessional
c6560c4e34 Planes 1.12 2025-10-11 00:18:07 -05:00
barelyprofessional
92215f8cca Hopefully catch realer and rawer errors 2025-10-10 23:56:52 -05:00
barelyprofessional
ebadb76204 Planes 1.11 hotfix 2025-10-10 22:56:24 -05:00
barelyprofessional
30f7479ad5 Planes 1.11 2025-10-10 22:47:41 -05:00
barelyprofessional
0971b444fc Update full counter thingy 2025-10-10 20:06:14 -05:00
barelyprofessional
22bf9c74d6 Planes 1.1 2025-10-10 15:52:17 -05:00
barelyprofessional
889a197b47 Planes 1.09 2025-10-10 15:04:49 -05:00
barelyprofessional
40d9b00322 Planes 1.08 2025-10-10 14:34:06 -05:00
barelyprofessional
662a8387f1 Planes 1.07 counter updates 2025-10-10 01:38:52 -05:00
barelyprofessional
536d78415c Planes 1.07 2025-10-10 01:15:25 -05:00
barelyprofessional
26e0413afa Planes 1.06 index out of range fix 2025-10-10 00:14:04 -05:00
barelyprofessional
4c4567d4a9 Planes 1.06 2025-10-10 00:10:45 -05:00
barelyprofessional
2f47d1b3c0 Planes 1.05 2025-10-09 23:25:42 -05:00
barelyprofessional
9595409d64 Missing cleanup in planes 2025-10-09 22:10:14 -05:00
barelyprofessional
c42c77a270 Planes 1.04 2025-10-09 21:59:46 -05:00
barelyprofessional
9f3da68b85 Planes patch from A Log 2025-10-09 21:37:49 -05:00
barelyprofessional
15b5ef250b Making planes go faster 2025-10-09 20:58:34 -05:00
barelyprofessional
3126442941 More planes patches 2025-10-09 20:55:15 -05:00
barelyprofessional
ed2a110305 Another patch for planes 2025-10-09 20:48:34 -05:00
barelyprofessional
b1ec4e9b4d Hopefully fixing index out of range errors for planes 2025-10-09 20:45:05 -05:00
barelyprofessional
280220cd1d Merging patch from A Log 2025-10-09 20:38:56 -05:00
barelyprofessional
518d001d82 Adding logging 2025-10-09 20:22:40 -05:00
barelyprofessional
aedcf0a4b6 Updated Planes to address index out of range 2025-10-09 20:15:11 -05:00
barelyprofessional
1503593cb1 Added auto delete and merged changes from A Log 2025-10-09 20:07:25 -05:00
barelyprofessional
5286e8a2b8 Don't deduct wager from the win in planes 2025-10-09 17:59:02 -05:00
barelyprofessional
6177f22ed5 Update counter for losses 2025-10-09 01:21:16 -05:00
barelyprofessional
a1d98e54cb Updates to planes 2025-10-09 01:15:32 -05:00
barelyprofessional
257e218d3d More planes updates 2025-10-09 00:50:54 -05:00
barelyprofessional
fcd82f552b Added property for when a sent message was last edited 2025-10-09 00:48:24 -05:00
barelyprofessional
56ef81ab73 Move the counter 2025-10-09 00:34:35 -05:00
barelyprofessional
13e14f913d Change logger for edit length to only log if it's too long 2025-10-09 00:31:32 -05:00
barelyprofessional
777ff73ae5 logging framecounter 2025-10-09 00:24:48 -05:00
barelyprofessional
fbd314e806 Update counter 2025-10-09 00:12:49 -05:00
barelyprofessional
e25c96859f Planes update 2025-10-09 00:06:56 -05:00
barelyprofessional
2457a042f3 Changes to characters 2025-10-08 23:57:11 -05:00
barelyprofessional
09b6bcb063 And actually use the fucking options 2025-10-08 23:29:27 -05:00
barelyprofessional
abae8447cb Allow the white squares! 2025-10-08 23:29:04 -05:00
barelyprofessional
c2a7312f12 Change air to a white square 2025-10-08 23:19:29 -05:00
barelyprofessional
45e297f8bb Remove custom encoder bullshit 2025-10-08 23:05:01 -05:00
barelyprofessional
34f62093b5 That made things worse. Trying the unsafe encoder 2025-10-08 22:26:43 -05:00
barelyprofessional
da3fb4a48f Don't escape emoji for /edit 2025-10-08 22:23:24 -05:00
barelyprofessional
fced66c428 Added logging for edit message length 2025-10-08 22:11:41 -05:00
barelyprofessional
dd4bc1abd1 Updated planes 2025-10-08 22:03:11 -05:00
barelyprofessional
5aba49697e Fixed displaying green color for crash and added wait for msg ID 2025-10-08 20:54:55 -05:00
barelyprofessional
d6bac6706d Planes updated to hopefully address out of range exceptions. 2025-10-08 20:48:42 -05:00
barelyprofessional
bf18fe1de6 Missing calls to NewWagerAsync 2025-10-08 13:28:16 -05:00
barelyprofessional
a781ed2c3d Planes 2025-10-08 13:25:56 -05:00
barelyprofessional
f4f8c332b1 Make frame delay configurable 2025-10-07 01:23:08 -05:00
barelyprofessional
f78f0b243b Patch from A Log plus fixed currency formatting for win/loss 2025-10-07 01:11:09 -05:00
barelyprofessional
605190d325 Modified Keno with standardized red/green colors, fixed waiting for chat message ID in the Keno animation and reduced delay to 500 msec for each frame update 2025-10-06 22:59:33 -05:00
barelyprofessional
a396a1dcff Keno patch to fix animation 2025-10-06 18:39:13 -05:00
barelyprofessional
9524beb95b Updated keno numbers and removed logging from GetRandomNumber 2025-10-06 12:06:13 -05:00
barelyprofessional
7fbd99e472 Missing kasino attributes 2025-10-06 11:39:11 -05:00
barelyprofessional
d606a9b8f5 Added Keno 2025-10-06 11:34:11 -05:00
barelyprofessional
23568a85c6 Added auto delete after x amount of time to the send chat message method 2025-10-06 03:14:08 -05:00
barelyprofessional
c6658bae1f Holy shit EF Core tracking is pissing me off so much now 2025-10-05 14:25:59 -05:00
barelyprofessional
494b118969 Add check for abandoned gamblers so it creates a new entity 2025-10-05 14:21:54 -05:00
barelyprofessional
3b5f9f0edd Try and avoid weird tracking issues with the exclude command 2025-10-05 14:15:40 -05:00
barelyprofessional
2ba0bd853b Allow everyone to gamble 2025-10-05 14:09:09 -05:00
barelyprofessional
f189cb94b8 Unused using 2025-10-05 01:40:32 -05:00
barelyprofessional
cb7337375d Testing out RandN for better quality random. Also took out seeds until I can find a way to implement it that doesn't completely break gambling 2025-10-05 01:40:05 -05:00
barelyprofessional
115506ba42 Bumped package versions 2025-10-05 01:16:38 -05:00
barelyprofessional
0e6bed23b3 I wish the compiler was smart enough to catch errors like this 2025-10-05 00:52:33 -05:00
barelyprofessional
d37401e1cd Compiler didn't like that 2025-10-05 00:49:54 -05:00
barelyprofessional
f3781f9c18 Implemented a very simple game to test the wager system 2025-10-05 00:47:14 -05:00
barelyprofessional
bca4cf4f3d Added $ to the regex for showing exclude usage information 2025-10-05 00:31:20 -05:00
barelyprofessional
746a33120d Added !lastactive as a pattern 2025-10-04 14:14:14 -05:00
barelyprofessional
b33eb5c4a8 Missed 'ago' 2025-10-04 14:12:17 -05:00
barelyprofessional
ff5484c0c9 Added lastactive command to get the last time BossmanJack did something observed by the bot 2025-10-04 14:10:25 -05:00
barelyprofessional
a92d1dc3c1 Added kasino exclusion 2025-10-03 20:27:46 -05:00
barelyprofessional
a8f43aac9d Fixed missing return 2025-10-03 20:26:17 -05:00
barelyprofessional
9692ae8c1d Trying to avoid tracking issues 2025-10-03 18:12:56 -05:00
barelyprofessional
69ea0b6b0b Another attempt to stop all the EF issues :( 2025-10-03 18:04:50 -05:00
barelyprofessional
2a0f74ab18 Relocated the Discord command and made it a toggle 2025-10-01 11:47:04 -05:00
barelyprofessional
d3e62476d2 Added --hls-segment-queue-threshold 0 for Streamlink so it doesn't prematurely end if Owncast lags out a bit 2025-09-28 15:07:09 -05:00
barelyprofessional
54fbc1a39e Use a custom output format for Streamlink as it can't populate author, id or title 2025-09-28 04:16:45 -05:00
barelyprofessional
cd3b76745c Converted Owncast capture to Streamlink as yt-dlp sometimes gives up part way through the capture 2025-09-28 03:52:29 -05:00
barelyprofessional
40a452b8b7 Added a feature to force gamba messages even while live 2025-09-24 01:20:54 -05:00
barelyprofessional
0432d5360a Only add a row to the view counts table if the view count has changed or the stream ID changed 2025-09-24 01:04:29 -05:00
barelyprofessional
5b71c0a1bb Migrated away from extension methods for pretty much all the money stuff as it turns out it passes a copy of the object and not a reference. This was causing a lot of weird behavior probably due to EF change tracking.
Also added a lot more logging to the API itself.
2025-09-24 00:58:45 -05:00
barelyprofessional
146abbe885 THe battle of the retards continues. Total U+200B Death 2025-09-22 20:06:49 -05:00
barelyprofessional
8fca8829f6 Allow the bot's services to fully initialize even if the website is completely dead so that auto capture works 2025-09-21 13:01:19 -05:00
barelyprofessional
7356018805 For fuck sake wrong command 2025-09-18 22:32:59 -05:00
barelyprofessional
933e4c70f6 Missed bypass sesh detect for !scratch 2025-09-18 22:31:39 -05:00
barelyprofessional
588a0e95fa Ignore conversation summaries which don't involve BMJ 2025-09-16 21:28:54 -05:00
barelyprofessional
15de60e60b Added support for selectively overriding capture settings on a per-stream basis 2025-09-14 01:05:37 -05:00
barelyprofessional
d76f427621 Add rate limit to the image command 2025-09-12 20:23:05 -05:00
barelyprofessional
cbebdb2144 Test multiple invocations 2025-09-12 14:53:02 -05:00
barelyprofessional
2b243bea57 Turns out it was due to NoResponse being the first value in the enum so it was assigned value 0 2025-09-12 13:58:58 -05:00
barelyprofessional
07949e1a7d More logging as cooldown response is silently failing 2025-09-12 13:53:18 -05:00
barelyprofessional
2067267027 Inverted cooldown cleanup behavior 2025-09-12 13:50:23 -05:00
barelyprofessional
74be702473 Trap exceptions from process message 2025-09-12 13:44:48 -05:00
barelyprofessional
77f1321b00 Added juicesports 2025-09-12 13:35:59 -05:00
barelyprofessional
9d0ee6e091 Missed nullable 2025-09-10 20:19:21 -05:00
barelyprofessional
958286a1ea Trying to deal with Money weirdness. Probably going to move away from extension methods for this as it's fraught with issues when dealing with EF 2025-09-10 20:18:48 -05:00
barelyprofessional
13294b4d07 Added Shuffle.us 2025-09-10 20:18:17 -05:00
barelyprofessional
2547ea45fb Took off rate limiting for images. I'll add it back on when it's tested. 2025-09-10 00:38:18 -05:00
barelyprofessional
f547638b45 Bumped packages 2025-09-08 21:46:11 -05:00
barelyprofessional
350e1cf6c6 Replace ❤️ with :feels: for Discord messages 2025-09-08 20:05:49 -05:00
barelyprofessional
d7e6290d46 Do not store rate limits for the GetRandomImage command unless a real key is specified 2025-09-08 19:37:18 -05:00
barelyprofessional
b3c3734e22 Do not execute a command after sending the cooldown response 2025-09-08 19:34:23 -05:00
barelyprofessional
aec236f92a Fixed a missing use of FormatUsername() 2025-09-08 15:10:25 -05:00
barelyprofessional
820eec7d0c Added a scratch command with a variation on the twisted message 2025-09-08 15:10:15 -05:00
barelyprofessional
ff1d83d9f7 Completely untested and totally experimental rate limit feature 2025-09-08 15:09:59 -05:00
barelyprofessional
f9445d407a LastOrDefaultAsync on EF requires OrderBy first 2025-09-07 22:25:01 -05:00
barelyprofessional
689b7b1cb8 Only respond if message wasn't edited 2025-09-05 11:11:07 -05:00
barelyprofessional
43b0b2bb25 More more more 2025-09-04 19:17:59 -05:00
barelyprofessional
45a8a1ba86 Account for l and I in impersonation detection 2025-09-04 18:45:20 -05:00
barelyprofessional
b26807c298 Added support for self destructing !chink images as well as pigcubes 2025-09-03 15:34:48 -05:00
barelyprofessional
a7739278c7 Added the abandon Kasino feature. Migrated to an extension method for formatting usernames 2025-09-03 03:20:34 -05:00
barelyprofessional
848214e90f Updated impersonation logic 2025-09-02 02:30:32 -05:00
barelyprofessional
f2daa85c9c Added lossback and also found some issues with rakeback 2025-09-02 02:14:21 -05:00
barelyprofessional
c82aeaa7d4 Implemented rakeback 2025-08-30 16:21:19 -05:00
barelyprofessional
23611926ab Randomize file names due to race condition with PeerTube 2025-08-25 20:49:16 -05:00
barelyprofessional
1a77b37491 Enable verbose output for yt-dlp 2025-08-24 03:38:05 -05:00
barelyprofessional
9ca4e03058 Forgot to call capture async 2025-08-24 03:08:46 -05:00
barelyprofessional
23be73d524 Copy and paste error 2025-08-24 03:07:22 -05:00
barelyprofessional
2b07a07ac5 Added Owncast support 2025-08-24 03:02:34 -05:00
barelyprofessional
bbfdf1e9f4 Instead of remuxing, --merge-output-format to mp4 instead of mkv. Might help with audio desync issues? 2025-08-24 02:39:55 -05:00
barelyprofessional
624d4dcc41 If he's live, say he's live rather than 0 minutes ago for laststream 2025-08-21 13:06:59 -05:00
barelyprofessional
8ae98322a2 Updated yt-dlp stream capture to ask it to remux to mp4 as it uses mkv for PeerTube 2025-08-21 03:44:51 -05:00
barelyprofessional
4c8a7d5dbb Added some money utility commands and currency format extension method 2025-08-21 03:29:10 -05:00
barelyprofessional
69386fce61 Updated the live check to remove the built-in properties and instead use the persisted setting 2025-08-21 01:54:12 -05:00
barelyprofessional
bd89fa74e6 Removed Twitch TOS strike and commercial shill as WS PubSub is dead 2025-08-21 01:32:58 -05:00
barelyprofessional
af4d6a5de6 Removed restream URL from Twitch for now 2025-08-21 01:29:44 -05:00
barelyprofessional
34e4762ad4 Fixed compiler warning 2025-08-20 18:33:39 -05:00
barelyprofessional
78fcac212d ID is a string apparently 2025-08-20 18:31:41 -05:00
barelyprofessional
155c9c2d36 Forgot to start the timer for the Twitch live status check 2025-08-20 16:37:57 -05:00
barelyprofessional
00e09d7e7d Super experimental replacement for the dead Twitch WS PubSub service 2025-08-20 16:34:10 -05:00
barelyprofessional
6ca1cf055c Added the initial framework for the new Money system.
Includes
- 5 new tables: Gamblers, Transactions, Wagers, Exclusions, Perks
- Still heavily WIP and not ready to be enabled, no games present and a lot of missing functionality
- For now it's completely disabled until it's ready to be used.
2025-08-20 14:59:09 -05:00
barelyprofessional
8d100b013b Check for null challenge data and stop trying to solve. Should hopefully get around weird inconsistent states when the forum has KiwiFlare partially enforced 2025-08-19 21:48:36 -05:00
barelyprofessional
b2ef7df91b KiwiFlare is strict about sending a UUID so removed the placeholder value and replaced it with a random GUID. 2025-08-19 21:47:58 -05:00
barelyprofessional
7889f50486 GetChallenge now returns null if no challenge data was found instead of throwing an exception 2025-08-19 21:47:05 -05:00
barelyprofessional
15abb0fc8b Swapped over to a const property for the most common regexes in use for settings 2025-08-04 17:48:07 -05:00
barelyprofessional
3ea031963d Merge branch 'money' 2025-08-04 17:29:42 -05:00
barelyprofessional
92ed776e31 Added a settings toggle for the impersonation feature 2025-07-27 01:39:37 -05:00
barelyprofessional
1b29e85342 Fixed misuse of Load. Seriously annoying the HtmlAgilityPack devs made it so ambiguous so when I switched from Streams it fucked everything up 2025-07-24 23:57:38 -05:00
barelyprofessional
35c3964854 Added a lot more error handling to the bot so issues retrieving tokens should no longer completely take the bot down. Also added a last ditch exit if the bot has completely died and isn't reconnecting at all 2025-07-24 12:45:37 -05:00
barelyprofessional
f92cba5b49 Fixed issue where a missing sssg_clearance cookie would fool the bot into thinking it needed to solve a KiwiFlare challenge regardless of whether it's enabled 2025-07-24 12:39:19 -05:00
barelyprofessional
5d853d5b72 Added an @ to PeerTube display names so it can't be used to inject a command 2025-07-23 22:54:04 -05:00
barelyprofessional
57cd989ed6 Fixed persistence bug with PeerTube 2025-07-23 22:44:35 -05:00
barelyprofessional
b01ba7c0a4 Fuck it. removing category as I don't even use it 2025-07-23 12:27:04 -05:00
barelyprofessional
d4c49467e3 Changed to object since it didn't want to convert the number to a string 2025-07-23 12:25:43 -05:00
barelyprofessional
de4e137a48 Added support for Kiwi PeerTube livestream notifications and capturing 2025-07-23 00:29:02 -05:00
barelyprofessional
9462048a29 HtmlAgilityPack interprets a string to Load() as a path apparently 2025-07-22 23:01:33 -05:00
barelyprofessional
58f101bc61 Changed from stream to string as reusing the stream doesn't work 2025-07-22 23:00:05 -05:00
barelyprofessional
28fd41d511 Workaround for forum returning 203 when KiwiFlare is off for some reason 2025-07-22 22:57:23 -05:00
barelyprofessional
a88449ddab Added try/catch to DLive 2025-07-22 11:30:39 -05:00
barelyprofessional
12cbb1733f Missed pwsh for the remux PowerShell script 2025-07-20 02:11:04 -05:00
barelyprofessional
0c1c75f729 Streamlink default output format was wrong 2025-07-20 02:08:14 -05:00
barelyprofessional
8bcba1755a Fixed compiler warning related to a lack of await operators 2025-07-20 01:31:23 -05:00
barelyprofessional
be96be9f85 Fixed formatting error with the Discord stage flash text feature 2025-07-20 01:30:01 -05:00
barelyprofessional
56616d713f Corrected use of Convert.ToInt32 to the SettingsProvider native type conversion 2025-07-20 01:28:05 -05:00
barelyprofessional
c134a6808d Migrated streams from bespoke settings to a database table, added DLive support and Streamlink capturing with remux support 2025-07-20 01:27:00 -05:00
barelyprofessional
c086ed350a Anti faggotry 2025-07-15 00:10:40 -05:00
barelyprofessional
172295e07b Flashing Discord T&H message to fuck with trolls 2025-07-14 23:22:37 -05:00
barelyprofessional
407bd41d71 Added a seal of authenticity to the Discord live messages 2025-07-11 14:46:36 -05:00
barelyprofessional
72f2c2633f Updated AGT to BMT and took off bypass sesh 2025-07-11 14:46:08 -05:00
barelyprofessional
f508a8ebc0 Add auto capture support to the new Kick channel command 2025-07-10 13:23:11 -05:00
barelyprofessional
cc49ffeb4a Fixed incorrect reference to Yeet 2025-07-09 23:41:34 -05:00
barelyprofessional
6a7453a44f Forgot to build the Parti service 2025-07-09 23:39:24 -05:00
barelyprofessional
d22138a9f9 Added Parti stream integration 2025-07-09 23:31:49 -05:00
barelyprofessional
7171acacfd Fixed missing check for if Rainbet is enabled in the WS version 2025-07-09 22:52:44 -05:00
barelyprofessional
6ef48c7833 Silently drop the mom if you're a loser 2025-07-09 21:04:37 -05:00
barelyprofessional
6373d317db Copy and paste fail 2025-07-09 20:38:18 -05:00
barelyprofessional
429faabb31 Wait for video if it doesn't believe the streamer is live 2025-07-08 17:23:53 -05:00
barelyprofessional
2ec9cad2f4 Fixed bot only returning one stream for selfpromo 2025-07-08 11:42:04 -05:00
barelyprofessional
451b7e625e Fixed nullable warnings in the Kick client 2025-07-07 20:42:29 -05:00
barelyprofessional
5f189cb9cc Refactored to fix compiler warnings 2025-07-07 20:12:07 -05:00
barelyprofessional
bcc3bde6c9 Experimental automated capturing of selected Kick streams 2025-07-07 19:09:13 -05:00
barelyprofessional
2088c4d102 Improved restream selfpromo so it supports multiple channels 2025-07-07 16:18:51 -05:00
barelyprofessional
88fef4466d Similar to before, reducing repetition by making .+ the default Regex for a setting 2025-07-07 15:56:09 -05:00
barelyprofessional
9b677ea23d Updated default cache duration for settings to the most common value of 1 hour, set a default for IsSecret and removed redundant properties. 2025-07-07 15:38:54 -05:00
barelyprofessional
c790b3f9ae Implemented Discord conversation summaries 2025-06-27 17:43:15 -05:00
barelyprofessional
c7c80bd6e4 Fix null reference when disposing of the bot where Flaresolverr failed like it always does 2025-06-27 14:15:55 -05:00
barelyprofessional
26aa6f1507 Added Flaresolverr for BetBolt as it appears to be necessary since the domain outage 2025-06-27 14:13:33 -05:00
barelyprofessional
145aebbc5a BetBolt got their .com back 2025-06-25 00:56:51 -05:00
barelyprofessional
22ec1043ff Added license 2025-06-21 13:55:58 -05:00
barelyprofessional
2dedf1118c Updated BetBolt .com to .io due to domain issues 2025-06-21 13:42:59 -05:00
barelyprofessional
cf31cdd796 Money models 2025-06-21 13:42:02 -05:00
barelyprofessional
3ce03fa1e7 Removed unused projects from the solution. They now just live in "old" 2025-06-07 17:39:36 -05:00
196 changed files with 22741 additions and 18295 deletions

View File

@@ -1,6 +1,7 @@
<component name="InspectionProjectProfileManager">
<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" />
<inspection_tool class="GrazieInspection" enabled="false" level="GRAMMAR_ERROR" enabled_by_default="false" />
</profile>
</component>

247
CLAUDE.md Normal file
View File

@@ -0,0 +1,247 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
KfChatDotNet is a C# .NET 10.0 chat bot for KiwiFarms (Sneedchat) with extensive third-party integrations. The solution consists of three projects:
- **KfChatDotNetWsClient**: WebSocket client library for KiwiFarms chat communication
- **KickWsClient**: WebSocket client library for Kick platform integration
- **KfChatDotNetBot**: Main bot application with command handling and service integrations
## Build and Development Commands
```bash
# Build the entire solution
dotnet build
# Build in Release mode
dotnet build -c Release
# Run the bot
dotnet run --project KfChatDotNetBot
# Clean build artifacts
dotnet clean
# Restore NuGet packages
dotnet restore
```
## Database Management
The bot uses Entity Framework Core with SQLite (`db.sqlite`).
```bash
# Add a new migration (from solution root)
dotnet ef migrations add <MigrationName> --project KfChatDotNetBot
# Update database to latest migration
dotnet ef database update --project KfChatDotNetBot
# Remove the last migration
dotnet ef migrations remove --project KfChatDotNetBot
# View migration SQL
dotnet ef migrations script --project KfChatDotNetBot
```
Migrations run automatically on startup via `Program.cs`.
## Architecture
### Message Flow
1. **ChatClient** ([KfChatDotNetWsClient/ChatClient.cs](KfChatDotNetWsClient/ChatClient.cs)): WebSocket connection and event emission
- Connects to KiwiFarms WebSocket endpoint
- Parses incoming packets and emits typed events
- Handles reconnection logic and cookie management
2. **ChatBot** ([KfChatDotNetBot/ChatBot.cs](KfChatDotNetBot/ChatBot.cs)): Core bot orchestration
- Subscribes to ChatClient events
- Manages sent message tracking and auto-deletion
- Handles GambaSesh presence detection
- Coordinates with BotServices for external integrations
3. **BotCommands** ([KfChatDotNetBot/Services/BotCommands.cs](KfChatDotNetBot/Services/BotCommands.cs)): Command routing
- Uses reflection to discover all ICommand implementations
- Matches incoming messages against command regex patterns
- Enforces rate limits, permissions, and timeouts
- Filters based on user rights and Kasino bans/exclusions
4. **ICommand Implementations** ([KfChatDotNetBot/Commands/](KfChatDotNetBot/Commands/)): Individual command handlers
- Each command defines regex patterns for matching
- Commands specify required user rights and rate limits
- Commands can be marked with attributes (NoPrefixRequired, KasinoCommand, WagerCommand, AllowAdditionalMatches)
### Command System
All commands implement `ICommand` interface:
- `Patterns`: List of regex patterns for command matching
- `HelpText`: Help text shown to users (null to hide from help)
- `RequiredRight`: Minimum user permission level
- `Timeout`: Command execution timeout
- `RateLimitOptions`: Rate limiting configuration
- `RunCommand()`: Async method that executes the command
Command attributes:
- `[NoPrefixRequired]`: Command matches without requiring `!` prefix
- `[AllowAdditionalMatches]`: Continue processing after this command matches
- `[KasinoCommand]`: Requires Kasino to be enabled, enforces bans
- `[WagerCommand]`: Enforces self-exclusions for gambling addicts
### Settings System
Settings are stored in the database (not config files) via `SettingsProvider`:
- `SettingsProvider.GetValueAsync(key)` - Get a single setting
- `SettingsProvider.GetMultipleValuesAsync(keys)` - Get multiple settings efficiently
- `SettingsProvider.SetValueAsync(key, value)` - Update a setting
- Built-in keys defined in `BuiltIn.Keys` static class
- Migration from legacy `config.json` happens automatically on startup
### Service Integrations
**BotServices** ([KfChatDotNetBot/Services/BotServices.cs](KfChatDotNetBot/Services/BotServices.cs)) initializes and manages connections to external services:
- Discord: Message relaying and bot presence
- Twitch: Stream status monitoring and GraphQL API
- Kick: WebSocket connection for stream events
- Gambling sites: Rainbet, Shuffle, Howlgg, Chipsgg, Clashgg, BetBolt, Yeet
- Stream platforms: DLive, PeerTube, Owncast, YouTube
- Kasino: Internal gambling system with rain, mines, limbo, coinflip
Each service is implemented as a separate class in [KfChatDotNetBot/Services/](KfChatDotNetBot/Services/).
### Message Tracking
`ChatBot.SendChatMessage()` and `ChatBot.SendChatMessageAsync()` return a `SentMessageTrackerModel`:
- Tracks message status (WaitingForResponse, ResponseReceived, Lost, NotSending)
- Provides message ID for editing/deletion
- Measures round-trip delay
- Supports auto-deletion after a specified TimeSpan
- Handles message replay after disconnection
### Database Schema
The `ApplicationDbContext` manages these entities:
- `Users`: KiwiFarms users with permissions
- `Gamblers`: Kasino users with balance and stats
- `Transactions`: Kasino transaction history
- `Wagers`: Active and historical bets
- `Exclusions`: Self-exclusion periods
- `Perks`: Gambler perks (e.g., reduced house edge)
- `Settings`: Bot configuration
- `Images`: Uploaded image metadata
- `UsersWhoWere`: User activity timestamps (join/part/message)
- Various third-party service data tables (HowlggBets, RainbetBets, etc.)
## Important Patterns
### Async/Await Conventions
The codebase has inconsistent async patterns:
- Some methods expose both sync and async versions (e.g., `SendChatMessage()` and `SendChatMessageAsync()`)
- `Disconnect()` is intentionally synchronous with a separate `DisconnectAsync()` method
- Avoid changing existing sync/async signatures without careful consideration
### GambaSesh Detection
GambaSesh is another bot that the bot avoids conflicting with:
- `ChatBot.GambaSeshPresent` tracks his presence
- Messages are suppressed by default when he's present (unless `bypassSeshDetect=true`)
- Presence is detected via user join events and messages
- `BotServices.TemporarilyBypassGambaSeshForDiscord` exists for special cases
### Length Limits
Sneedchat enforces a 1023-byte message limit:
- `SendChatMessage()` accepts `LengthLimitBehavior` enum: TruncateNicely, TruncateExactly, RefuseToSend, DoNothing
- Use `string.Utf8LengthBytes()` extension method to check length
- Use `string.TruncateBytes(limit)` extension method to truncate safely
### Session Management
The bot handles KiwiFarms authentication via cookies:
- `KfTokenService` manages login and cookie refresh
- On `203` status code or "cannot join room" errors, cookies are refreshed
- Cookies are persisted to the database
- Bot can operate in "guest mode" with no cookies
## Common Tasks
### Adding a New Command
1. Create a new class in `KfChatDotNetBot/Commands/` that implements `ICommand`
2. Define regex patterns in the `Patterns` property
3. Implement `RunCommand()` method with command logic
4. Set `RequiredRight`, `Timeout`, `RateLimitOptions`, and `HelpText`
5. Add attributes if needed: `[NoPrefixRequired]`, `[KasinoCommand]`, etc.
6. The command will be auto-discovered via reflection on next startup
### Adding a New Service Integration
1. Create a new class in `KfChatDotNetBot/Services/`
2. Add initialization method to `BotServices.InitializeServices()`
3. Add corresponding settings keys to `BuiltIn.Keys`
4. If the service needs WebSocket or periodic tasks, follow existing patterns (PeriodicTimer, WebsocketClient)
### Modifying the Database Schema
1. Update the relevant `DbModel` class in `KfChatDotNetBot/Models/DbModels/`
2. Add/update the `DbSet` property in `ApplicationDbContext.cs`
3. Generate a migration: `dotnet ef migrations add <Name> --project KfChatDotNetBot`
4. Migration runs automatically on next bot startup
### Sending Messages to Chat
```csharp
// Synchronous
var tracker = _bot.SendChatMessage("Hello!");
// Asynchronous
var tracker = await _bot.SendChatMessageAsync("Hello!");
// Bypass GambaSesh detection
_bot.SendChatMessage("Important message", bypassSeshDetect: true);
// Auto-delete after 5 seconds
await _bot.SendChatMessageAsync("Temporary message", autoDeleteAfter: TimeSpan.FromSeconds(5));
// Wait for the message to be echoed by the server
if (await _bot.WaitForChatMessageAsync(tracker, TimeSpan.FromSeconds(10)))
{
// Message was successfully sent, tracker.ChatMessageId is now available
}
```
## Dependencies
Key NuGet packages:
- `Websocket.Client`: WebSocket client used throughout
- `Microsoft.EntityFrameworkCore.Sqlite`: Database ORM
- `NLog`: Logging framework
- `Humanizer.Core`: Human-readable text formatting
- `SixLabors.ImageSharp`: Image manipulation for meme generation
- `StackExchange.Redis`: Redis caching
- `FlareSolverrSharp`: Cloudflare bypass
- `HtmlAgilityPack`: HTML parsing
- `Nerdbank.GitVersioning`: Automatic versioning from git tags
## Testing
There are currently no automated tests in this repository. Manual testing is performed by running the bot.
## Logging
NLog configuration is in `KfChatDotNetBot/NLog.config`. The bot logs extensively:
- Debug: Detailed packet information, message processing steps
- Info: User joins/parts, sent messages, state changes
- Error: Exceptions, disconnections, failed operations
## Code Style Notes
- The codebase uses explicit null checks and nullable reference types
- Extensive use of LINQ for data queries
- Some deliberately provocative comments and language (see Program.cs license header)
- Privacy is explicitly not a concern - data is logged freely
- Performance optimizations include "BUY MORE RAM" philosophy (unlimited message tracking)

View File

@@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\KfChatDotNetBot\KfChatDotNetBot.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NLog" Version="5.4.0" />
</ItemGroup>
<ItemGroup>
<Content Update="NLog.config">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.nlog-project.org/schemas/NLog.xsd NLog.xsd"
autoReload="true"
throwExceptions="false"
internalLogLevel="Off" internalLogFile="~/nlog-internal.log">
<targets>
<target xsi:type="ColoredConsole" name="console"/>
</targets>
<rules>
<logger name="*" minlevel="Trace" writeTo="console" />
</rules>
</nlog>

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
// This new template sucks
using NLog;
var logger = LogManager.GetCurrentClassLogger();
logger.Info("Starting up");
var token = "authorization token!";
var proxy = "socks5://whatever:1080";
var discord = new KfChatDotNetBot.Services.DiscordService(token, proxy);
discord.StartWsClient().Wait();
logger.Info("Started");
var exitEvent = new ManualResetEvent(false);
exitEvent.WaitOne();

View File

@@ -2,20 +2,10 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KfChatDotNetWsClient", "KfChatDotNetWsClient\KfChatDotNetWsClient.csproj", "{B3BC806A-7FFC-47BD-9C18-45CD2B99F9F8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KfChatDotNetCli", "KfChatDotNetCli\KfChatDotNetCli.csproj", "{A4D19F8E-5A1F-4A66-BC42-214DB9D5429B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KfChatDotNetGui", "KfChatDotNetGui\KfChatDotNetGui.csproj", "{B2A5D4EE-5EB6-4F0B-BB5E-2B87AAFBFB5B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KickWsClient", "KickWsClient\KickWsClient.csproj", "{DECBB95C-2C9F-44C2-AFA3-3741986FBA38}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KfChatDotNetBot", "KfChatDotNetBot\KfChatDotNetBot.csproj", "{4734E0A4-150E-4915-B905-928BB4BE3FF6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThreeXplWsClient", "ThreeXplWsClient\ThreeXplWsClient.csproj", "{3D72D70A-48AD-4EE8-89DC-C78153EEA879}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThreeXplCliClient", "ThreeXplCliClient\ThreeXplCliClient.csproj", "{D098E281-5535-4A07-9514-57AF78704B0C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliDiscordPacketDump", "CliDiscordPacketDump\CliDiscordPacketDump.csproj", "{792ECCCD-FAC3-4CE5-A760-988080960BB9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -26,14 +16,6 @@ Global
{B3BC806A-7FFC-47BD-9C18-45CD2B99F9F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B3BC806A-7FFC-47BD-9C18-45CD2B99F9F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B3BC806A-7FFC-47BD-9C18-45CD2B99F9F8}.Release|Any CPU.Build.0 = Release|Any CPU
{A4D19F8E-5A1F-4A66-BC42-214DB9D5429B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4D19F8E-5A1F-4A66-BC42-214DB9D5429B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4D19F8E-5A1F-4A66-BC42-214DB9D5429B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4D19F8E-5A1F-4A66-BC42-214DB9D5429B}.Release|Any CPU.Build.0 = Release|Any CPU
{B2A5D4EE-5EB6-4F0B-BB5E-2B87AAFBFB5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2A5D4EE-5EB6-4F0B-BB5E-2B87AAFBFB5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2A5D4EE-5EB6-4F0B-BB5E-2B87AAFBFB5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2A5D4EE-5EB6-4F0B-BB5E-2B87AAFBFB5B}.Release|Any CPU.Build.0 = Release|Any CPU
{DECBB95C-2C9F-44C2-AFA3-3741986FBA38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DECBB95C-2C9F-44C2-AFA3-3741986FBA38}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DECBB95C-2C9F-44C2-AFA3-3741986FBA38}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -42,17 +24,5 @@ Global
{4734E0A4-150E-4915-B905-928BB4BE3FF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4734E0A4-150E-4915-B905-928BB4BE3FF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4734E0A4-150E-4915-B905-928BB4BE3FF6}.Release|Any CPU.Build.0 = Release|Any CPU
{3D72D70A-48AD-4EE8-89DC-C78153EEA879}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3D72D70A-48AD-4EE8-89DC-C78153EEA879}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3D72D70A-48AD-4EE8-89DC-C78153EEA879}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3D72D70A-48AD-4EE8-89DC-C78153EEA879}.Release|Any CPU.Build.0 = Release|Any CPU
{D098E281-5535-4A07-9514-57AF78704B0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D098E281-5535-4A07-9514-57AF78704B0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D098E281-5535-4A07-9514-57AF78704B0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D098E281-5535-4A07-9514-57AF78704B0C}.Release|Any CPU.Build.0 = Release|Any CPU
{792ECCCD-FAC3-4CE5-A760-988080960BB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{792ECCCD-FAC3-4CE5-A760-988080960BB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{792ECCCD-FAC3-4CE5-A760-988080960BB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{792ECCCD-FAC3-4CE5-A760-988080960BB9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -9,7 +9,13 @@ public class ApplicationDbContext : DbContext
{
builder.UseSqlite("Data Source=db.sqlite");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
//modelBuilder.Entity<KasinoShopProfileDbModel>()
// .OwnsOne(p => p.StateData, b => b.ToJson());
}
public DbSet<UserDbModel> Users { get; set; }
public DbSet<JuicerDbModel> Juicers { get; set; }
public DbSet<SettingDbModel> Settings { get; set; }
@@ -22,4 +28,10 @@ public class ApplicationDbContext : DbContext
// public DbSet<PocketWatchAddressDbModel> PocketWatchAddresses { get; set; }
// public DbSet<PocketWatchTransactionDbModel> PocketWatchTransactions { get; set; }
public DbSet<MomDbModel> Moms { get; set; }
public DbSet<StreamDbModel> Streams { get; set; }
public DbSet<GamblerDbModel> Gamblers { get; set; }
public DbSet<TransactionDbModel> Transactions { get; set; }
public DbSet<WagerDbModel> Wagers { get; set; }
public DbSet<GamblerExclusionDbModel> Exclusions { get; set; }
public DbSet<GamblerPerkDbModel> Perks { get; set; }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -1,7 +1,7 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Humanizer;
using Homoglyphic;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
@@ -11,7 +11,6 @@ using KfChatDotNetWsClient.Models;
using KfChatDotNetWsClient.Models.Events;
using KfChatDotNetWsClient.Models.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.IO;
using NLog;
using Websocket.Client;
@@ -34,11 +33,22 @@ public class ChatBot
private Task _kfChatPing;
private KfTokenService _kfTokenService;
private int _joinFailures = 0;
private Task _kfDeadBotDetection;
private DateTime _lastReconnectAttempt = DateTime.UtcNow;
private List<ScheduledAutoDeleteModel> _scheduledDeletions = [];
private Task _scheduledAutoDeleteTask;
private List<UserModel> _currentUsersInChat = [];
private HomoglyphSearch? _homoglyphSearch;
public ChatBot()
{
_logger.Info("Bot starting!");
_logger.Debug("Starting services");
BotServices = new BotServices(this, _cancellationToken);
BotServices.InitializeServices();
_kfDeadBotDetection = KfDeadBotDetectionTask();
var settings = SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsWsEndpoint, BuiltIn.Keys.KiwiFarmsDomain,
BuiltIn.Keys.Proxy, BuiltIn.Keys.KiwiFarmsWsReconnectTimeout]).Result;
@@ -46,19 +56,27 @@ public class ChatBot
_kfTokenService = new KfTokenService(settings[BuiltIn.Keys.KiwiFarmsDomain].Value!,
settings[BuiltIn.Keys.Proxy].Value, _cancellationToken);
if (_kfTokenService.GetCookies().Count == 0)
{
try
{
RefreshXfToken().Wait(_cancellationToken);
}
catch (Exception e)
{
_logger.Error("Caught an exception while trying to refresh the XF token");
_logger.Error(e);
}
}
KfClient = new ChatClient(new ChatClientConfigModel
{
WsUri = new Uri(settings[BuiltIn.Keys.KiwiFarmsWsEndpoint].Value ?? throw new InvalidOperationException($"{BuiltIn.Keys.KiwiFarmsWsEndpoint} cannot be null")),
XfSessionToken = _kfTokenService.GetXfSessionCookie(),
Cookies = _kfTokenService.GetCookies(),
CookieDomain = settings[BuiltIn.Keys.KiwiFarmsDomain].Value ?? throw new InvalidOperationException($"{BuiltIn.Keys.KiwiFarmsDomain} cannot be null"),
Proxy = settings[BuiltIn.Keys.Proxy].Value,
ReconnectTimeout = settings[BuiltIn.Keys.KiwiFarmsWsReconnectTimeout].ToType<int>()
});
if (_kfTokenService.GetXfSessionCookie() == null)
{
RefreshXfToken().Wait(_cancellationToken);
}
_logger.Debug("Creating bot command instance");
_botCommands = new BotCommands(this, _cancellationToken);
@@ -69,21 +87,33 @@ public class ChatBot
KfClient.OnWsDisconnection += OnKfWsDisconnected;
KfClient.OnWsReconnect += OnKfWsReconnected;
KfClient.OnFailedToJoinRoom += OnFailedToJoinRoom;
KfClient.OnMotd += OnMotd;
KfClient.OnWhisper += OnWhisper;
KfClient.StartWsClient().Wait(_cancellationToken);
_logger.Debug("Creating ping task");
_kfChatPing = KfPingTask();
_logger.Debug("Creating scheduled auto deletion task");
_scheduledAutoDeleteTask = ScheduledDeletionTask();
_logger.Debug("Starting services");
BotServices = new BotServices(this, _cancellationToken);
BotServices.InitializeServices();
_logger.Debug("Trying to load homoglyphs");
if (File.Exists("homoglyphs.csv"))
{
var sets = HomoglyphLoader.LoadSets("homoglyphs.csv");
_homoglyphSearch = new HomoglyphSearch(sets);
}
_logger.Debug("Blocking the main thread");
var exitEvent = new ManualResetEvent(false);
exitEvent.WaitOne();
}
private void OnMotd(object sender, MessageModel message)
{
SettingsProvider.SetValueAsync(BuiltIn.Keys.KiwiFarmsMotdUuid, message.MessageUuid).Wait(_cancellationToken);
}
private void OnFailedToJoinRoom(object sender, string message)
{
var failureLimit = SettingsProvider.GetValueAsync(BuiltIn.Keys.KiwiFarmsJoinFailLimit).Result.ToType<int>();
@@ -95,14 +125,18 @@ public class ChatBot
_logger.Error("Seems we're in a rejoin loop. Wiping out cookies entirely in hopes it'll make this piece of shit work");
_kfTokenService.WipeCookies();
}
RefreshXfToken().Wait(_cancellationToken);
_kfTokenService.SaveCookies().Wait(_cancellationToken);
// Shouldn't be null if we've just refreshed the token
// It's only null if a logon has never been attempted since the cookie DB entry was created
KfClient.UpdateToken(_kfTokenService.GetXfSessionCookie()!);
try
{
RefreshXfToken().Wait(_cancellationToken);
}
catch (Exception e)
{
_logger.Error("Caught an exception while trying to refresh the XF token");
_logger.Error(e);
}
_logger.Info("Retrieved fresh token. Reconnecting.");
KfClient.Disconnect();
KfClient.StartWsClient().Wait(_cancellationToken);
KfClient.ReconnectAsync().Wait(_cancellationToken);
_logger.Info("Client should be reconnecting now");
}
@@ -129,26 +163,161 @@ public class ChatBot
// Yeah, super dodgy
KfClient.LastPacketReceived = DateTime.UtcNow;
_logger.Error("Forcing disconnect and restart as bot is completely dead");
await KfClient.DisconnectAsync();
await KfClient.StartWsClient();
await KfClient.ReconnectAsync();
}
}
}
private async Task KfDeadBotDetectionTask()
{
var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotDeadBotDetectionInterval)).ToType<int>();
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(interval));
while (await timer.WaitForNextTickAsync(_cancellationToken))
{
var inactivityTime = DateTime.UtcNow - KfClient.LastPacketReceived;
var deadTime = DateTime.UtcNow - _lastReconnectAttempt;
// No connection and no successful reconnection attempt in the last 5 minutes
// Either the site is completely dead or the bot got screwed by a nasty error and can't reconnect
if (inactivityTime > TimeSpan.FromMinutes(10) && deadTime > TimeSpan.FromMinutes(15))
{
var shouldExit = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotExitOnDeath)).ToBoolean();
_logger.Error("The bot as is dead beyond belief right now");
_logger.Error($"IsConnected() -> {KfClient.IsConnected()}");
_logger.Error($"inactivityTime -> {inactivityTime:g}");
_logger.Error($"deadTime -> {deadTime:g}");
if (shouldExit) Environment.Exit(1);
_logger.Error("Since we didn't exit, let's try forcing a reconnect");
await KfClient.ReconnectAsync();
}
}
}
private async Task ScheduledDeletionTask()
{
var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotScheduledDeletionInterval)).ToType<int>();
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(interval));
var failures = new Dictionary<string, int>();
while (await timer.WaitForNextTickAsync(_cancellationToken))
{
if (!KfClient.IsConnected())
{
_logger.Debug("Not cleaning scheduled deletions up as we're disconnected");
continue;
}
var now = DateTimeOffset.UtcNow;
var removals = new List<ScheduledAutoDeleteModel>();
foreach (var deletion in _scheduledDeletions)
{
if (deletion.DeleteAt > now) continue;
if (deletion.Message.ChatMessageUuid == null)
{
_logger.Error($"Can't clean up {deletion.Message.Reference} as it doesn't have a chat message ID");
if (failures.TryGetValue(deletion.Message.Reference, out var failure))
{
if (failure > 20)
{
removals.Add(deletion);
_logger.Error($"Giving up on {deletion.Message.Reference} and removing it from the deletion queue");
continue;
}
failures[deletion.Message.Reference] += 1;
}
else
{
failures[deletion.Message.Reference] = 1;
}
continue;
}
await KfClient.DeleteMessageAsync(deletion.Message.ChatMessageUuid);
removals.Add(deletion);
}
foreach (var removal in removals)
{
_scheduledDeletions.Remove(removal);
}
}
}
private async Task RefreshXfToken()
{
if (await _kfTokenService.IsLoggedIn())
try
{
_logger.Info("We were already logged in and should have a fresh cookie for chat now");
// Only seems to happen if the bot thinks it's already logged in
if (await _kfTokenService.IsLoggedIn())
{
_logger.Info("We were already logged in and should have a fresh cookie for chat now");
_logger.Info("Updating cookies");
await _kfTokenService.SaveCookies();
KfClient.UpdateCookies(_kfTokenService.GetCookies());
// Only seems to happen if the bot thinks it's already logged in
return;
}
}
catch (Exception e)
{
_logger.Error("Caught an error when trying to retrieve a fresh cookie");
_logger.Error(e);
return;
}
var settings =
await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.KiwiFarmsUsername, BuiltIn.Keys.KiwiFarmsPassword]);
try
{
await _kfTokenService.PerformLogin(settings[BuiltIn.Keys.KiwiFarmsUsername].Value!,
settings[BuiltIn.Keys.KiwiFarmsPassword].Value!);
}
catch (Exception e)
{
_logger.Error("Caught an error when trying to login");
_logger.Error(e);
return;
}
var settings =
await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.KiwiFarmsUsername, BuiltIn.Keys.KiwiFarmsPassword]);
await _kfTokenService.PerformLogin(settings[BuiltIn.Keys.KiwiFarmsUsername].Value!,
settings[BuiltIn.Keys.KiwiFarmsPassword].Value!);
_logger.Info("Successfully logged in");
_logger.Info("Updating cookies");
await _kfTokenService.SaveCookies();
KfClient.UpdateCookies(_kfTokenService.GetCookies());
}
private void OnWhisper(object sender, WhisperModel whisper)
{
var settings = SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsUsername, BuiltIn.Keys.BotDisconnectReplayLimit
]).Result;
if (whisper.Author.Username == settings[BuiltIn.Keys.KiwiFarmsUsername].Value)
{
_logger.Debug("Ignoring my own whisper");
return;
}
var sentMsgMaybe = SentMessages.FirstOrDefault(msg =>
msg.Type == SentMessageType.Whisper && msg.WhisperMessage == whisper.MessageRawHtmlDecoded &&
msg.Status == SentMessageTrackerStatus.WaitingForResponse);
sentMsgMaybe?.Status = SentMessageTrackerStatus.ResponseReceived;
_logger.Debug("Passing message to command interface");
var botCommandsMsg = new BotCommandMessageModel
{
Author = whisper.Author,
Recipient = whisper.Recipient,
Message = whisper.Message,
MessageDate = whisper.MessageDate,
MessageEditDate = null,
MessageRaw = whisper.MessageRaw,
MessageRawHtmlDecoded = whisper.MessageRawHtmlDecoded,
MessageUuid = null,
RoomId = null,
IsWhisper = true
};
try
{
_botCommands.ProcessMessage(botCommandsMsg);
}
catch (Exception e)
{
_logger.Error("ProcessMessage threw an exception");
_logger.Error(e);
}
}
private void OnKfChatMessage(object sender, List<MessageModel> messages, MessagesJsonModel jsonPayload)
@@ -156,7 +325,8 @@ public class ChatBot
// Reset value to 0 as we've now successfully joined
if (_joinFailures > 0) _joinFailures = 0;
var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.GambaSeshDetectEnabled,
BuiltIn.Keys.GambaSeshUserId, BuiltIn.Keys.KiwiFarmsUsername, BuiltIn.Keys.BotDisconnectReplayLimit])
BuiltIn.Keys.GambaSeshUserId, BuiltIn.Keys.KiwiFarmsUsername, BuiltIn.Keys.BotDisconnectReplayLimit,
BuiltIn.Keys.BotRespondToDiscordImpersonation])
.Result;
// Send messages if there are any to replay (Assuming we DC'd, and it's now the message flood)
foreach (var replayMsg in SentMessages.Where(msg => msg.Status == SentMessageTrackerStatus.ChatDisconnected)
@@ -179,19 +349,28 @@ public class ChatBot
{
_logger.Info($"KF ({message.MessageDate.ToLocalTime():HH:mm:ss}) <{message.Author.Username}> {message.Message}");
}
// Update last edit timestamp
if (message.Author.Username == settings[BuiltIn.Keys.KiwiFarmsUsername].Value && message.MessageEditDate != null)
{
var sentMessage = SentMessages.FirstOrDefault(x => x.ChatMessageUuid == message.MessageUuid);
if (sentMessage != null)
{
sentMessage.LastEdited = message.MessageEditDate.Value;
}
}
if (message.Author.Username == settings[BuiltIn.Keys.KiwiFarmsUsername].Value && message.MessageEditDate == null)
{
// MessageRaw is not actually REAL and RAW. The messages are still HTML encoded
var decodedMessage = WebUtility.HtmlDecode(message.MessageRaw);
var sentMessage = SentMessages.FirstOrDefault(sent =>
sent.Message == decodedMessage && sent.Status == SentMessageTrackerStatus.WaitingForResponse);
sent.Message == decodedMessage && sent is { Status: SentMessageTrackerStatus.WaitingForResponse, Type: SentMessageType.ChatMessage });
if (sentMessage == null)
{
_logger.Error("Received message from Sneedchat that I sent but have no idea about. Message Data Follows:");
_logger.Error(JsonSerializer.Serialize(message));
_logger.Error("Last item inserted into the sent messages collection waiting for response:");
var latest =
SentMessages.LastOrDefault(msg => msg.Status == SentMessageTrackerStatus.WaitingForResponse);
SentMessages.LastOrDefault(msg => msg is { Status: SentMessageTrackerStatus.WaitingForResponse, Type: SentMessageType.ChatMessage });
_logger.Error(JsonSerializer.Serialize(latest));
if (latest != null)
{
@@ -199,14 +378,14 @@ public class ChatBot
// back to you. So this fallback should be generally correct and will account for the occasional
// mismatch due to messages not being 1:1 with what we thought we sent
_logger.Info("Just going to lazily associate it with the latest message");
latest.ChatMessageId = message.MessageId;
latest.ChatMessageUuid = message.MessageUuid;
latest.Delay = DateTimeOffset.UtcNow - latest.SentAt;
latest.Status = SentMessageTrackerStatus.ResponseReceived;
}
}
else
{
sentMessage.ChatMessageId = message.MessageId;
sentMessage.ChatMessageUuid = message.MessageUuid;
sentMessage.Delay = DateTimeOffset.UtcNow - sentMessage.SentAt;
sentMessage.Status = SentMessageTrackerStatus.ResponseReceived;
}
@@ -228,24 +407,79 @@ public class ChatBot
// So this avoids reprocessing messages on reconnect while being able to handle edits, even if the edit came
// during a disconnect / reconnect event
if (!_seenMessages.Any(msg =>
msg.MessageId == message.MessageId && msg.LastEdited == message.MessageEditDate) &&
msg.MessageUuid == message.MessageUuid && msg.LastEdited == message.MessageEditDate) &&
!InitialStartCooldown)
{
_logger.Debug("Passing message to command interface");
_botCommands.ProcessMessage(message);
var botCommandsMsg = new BotCommandMessageModel
{
Author = message.Author,
MessageRaw = message.MessageRaw,
Message = message.Message,
MessageDate = message.MessageDate,
MessageEditDate = message.MessageEditDate,
MessageRawHtmlDecoded = message.MessageRawHtmlDecoded,
MessageUuid = message.MessageUuid,
Recipient = null,
RoomId = message.RoomId,
IsWhisper = false
};
try
{
_botCommands.ProcessMessage(botCommandsMsg);
}
catch (Exception e)
{
_logger.Error("ProcessMessage threw an exception");
_logger.Error(e);
}
}
// Update or add the element to keep it in sync
var existingMsg = _seenMessages.FirstOrDefault(msg => msg.MessageId == message.MessageId);
var existingMsg = _seenMessages.FirstOrDefault(msg => msg.MessageUuid == message.MessageUuid);
if (existingMsg != null)
{
existingMsg.LastEdited = message.MessageEditDate;
}
else
{
_seenMessages.Add(new SeenMessageMetadataModel {MessageId = message.MessageId, LastEdited = message.MessageEditDate});
_seenMessages.Add(new SeenMessageMetadataModel {MessageUuid = message.MessageUuid, LastEdited = message.MessageEditDate});
}
UpdateUserLastActivityAsync(message.Author.Id, WhoWasActivityType.Message).Wait(_cancellationToken);
// Strip weird control characters and just allow basic punctuation + whitespace
var kindaSanitized = new string(message.MessageRawHtmlDecoded
.Where(c => c == ' ' || char.IsPunctuation(c) || char.IsLetter(c) || char.IsDigit(c)).ToArray());
var homoglyphFound = false;
if (_homoglyphSearch != null)
{
var searchStrings =
SettingsProvider.GetValueAsync(BuiltIn.Keys.BotDiscordImpersonationSearchStrings).Result
.JsonDeserialize<List<string>>();
var lowerStrings = searchStrings?.Select(x => x.ToLower()).ToList();
var search = _homoglyphSearch.Search(kindaSanitized.ToLower(), lowerStrings);
if (search.Count == 0)
{
search = _homoglyphSearch.Search(kindaSanitized, searchStrings);
}
homoglyphFound = search.Count > 0;
}
if ((message.MessageEditDate == null || message.MessageDate > DateTimeOffset.UtcNow.AddSeconds(-15))
&& message.Author.Id != settings[BuiltIn.Keys.GambaSeshUserId].ToType<int>() &&
message.Author.Username != settings[BuiltIn.Keys.KiwiFarmsUsername].Value &&
settings[BuiltIn.Keys.BotRespondToDiscordImpersonation].ToBoolean() && kindaSanitized.Contains("[img]")
&& homoglyphFound)
{
var deleteOrNah = SettingsProvider.GetValueAsync(BuiltIn.Keys.BotDiscordImpersonationDeleteAttempt)
.Result.ToBoolean();
if (deleteOrNah)
{
_ = KfClient.DeleteMessageAsync(message.MessageUuid);
}
else
{
SendChatMessage($"☝️ {message.Author.Username} is a nigger faggot", true);
}
}
}
if (InitialStartCooldown) InitialStartCooldown = false;
@@ -253,7 +487,16 @@ public class ChatBot
// Reference for Sneedchat hardcoded length limit
// https://github.com/jaw-sh/ruforo/blob/master/src/web/chat/connection.rs#L226
public async Task<SentMessageTrackerModel> SendChatMessageAsync(string message, bool bypassSeshDetect = false, LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.TruncateNicely, int lengthLimit = 1023)
/// <summary>
/// Async method for sending a chat message
/// </summary>
/// <param name="message">The message you wish to send</param>
/// <param name="bypassSeshDetect">Whether to bypass detecting if GambaSesh is present and send unconditionally</param>
/// <param name="lengthLimitBehavior">What behavior to use when encountering a message that exceeds the length limit</param>
/// <param name="lengthLimit">Length limit to enforce in bytes</param>
/// <param name="autoDeleteAfter">Length of time until the message is auto deleted, null to disable. Starts counting from when the message is echoed by Sneedchat</param>
/// <returns>An object you can use to check the status of the message and get its ID for editing/deleting later</returns>
public async Task<SentMessageTrackerModel> SendChatMessageAsync(string message, bool bypassSeshDetect = false, LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.TruncateNicely, int lengthLimit = 2048, TimeSpan? autoDeleteAfter = null)
{
var settings = await SettingsProvider
.GetMultipleValuesAsync([
@@ -265,6 +508,8 @@ public class ChatBot
Reference = reference,
Message = message.TrimEnd(), // Sneedchat trims trailing spaces
Status = SentMessageTrackerStatus.Unknown,
Type = SentMessageType.ChatMessage,
WhisperMessage = null
};
if (settings[BuiltIn.Keys.KiwiFarmsSuppressChatMessages].ToBoolean())
{
@@ -312,6 +557,84 @@ public class ChatBot
}
}
messageTracker.Status = SentMessageTrackerStatus.WaitingForResponse;
messageTracker.SentAt = DateTimeOffset.UtcNow;
_logger.Debug($"Message is {messageTracker.Message.Utf8LengthBytes()} bytes");
SentMessages.Add(messageTracker);
await KfClient.SendMessageInstantAsync(messageTracker.Message);
if (autoDeleteAfter != null)
{
ScheduleMessageAutoDelete(messageTracker, autoDeleteAfter.Value);
}
return messageTracker;
}
// Reference for Sneedchat hardcoded length limit
// https://github.com/jaw-sh/ruforo/blob/master/src/web/chat/connection.rs#L226
/// <summary>
/// Async method for sending a whisper
/// </summary>
/// <param name="recipient">Kiwi Farms user ID of the recipient for this whisper</param>
/// <param name="message">The message you wish to whisper</param>
/// <param name="lengthLimitBehavior">What behavior to use when encountering a message that exceeds the length limit</param>
/// <param name="lengthLimit">Length limit to enforce in bytes</param>
/// <returns>An object you can use to check the status of the message</returns>
public async Task<SentMessageTrackerModel> SendWhisperAsync(int recipient, string message, LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.TruncateNicely, int lengthLimit = 2048)
{
var settings = await SettingsProvider
.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsSuppressChatMessages
]);
var originalMessage = message;
message = $"/w {recipient} {message}";
var reference = Guid.NewGuid().ToString();
var messageTracker = new SentMessageTrackerModel
{
Reference = reference,
Message = message.TrimEnd(), // Sneedchat trims trailing spaces
Status = SentMessageTrackerStatus.Unknown,
Type = SentMessageType.Whisper,
WhisperMessage = originalMessage.TrimEnd()
};
if (settings[BuiltIn.Keys.KiwiFarmsSuppressChatMessages].ToBoolean())
{
_logger.Info("Not sending message as SuppressChatMessages is enabled");
_logger.Info($"Message was: {message}");
messageTracker.Status = SentMessageTrackerStatus.NotSending;
SentMessages.Add(messageTracker);
return messageTracker;
}
if (!KfClient.IsConnected())
{
_logger.Info($"Not sending message '{message}' as Sneedchat is not connected");
messageTracker.Status = SentMessageTrackerStatus.ChatDisconnected;
SentMessages.Add(messageTracker);
return messageTracker;
}
if (messageTracker.Message.Utf8LengthBytes() > lengthLimit && lengthLimitBehavior != LengthLimitBehavior.DoNothing)
{
if (lengthLimitBehavior == LengthLimitBehavior.RefuseToSend)
{
_logger.Info("Refusing to send message as it exceeds the length limit and LengthLimitBehavior is RefuseToSend");
messageTracker.Status = SentMessageTrackerStatus.NotSending;
SentMessages.Add(messageTracker);
return messageTracker;
}
if (lengthLimitBehavior == LengthLimitBehavior.TruncateNicely)
{
// '…' is 3 bytes so we have to make room for it
messageTracker.Message = messageTracker.Message.TruncateBytes(lengthLimit - 3).TrimEnd() + "…";
}
if (lengthLimitBehavior == LengthLimitBehavior.TruncateExactly)
{
// TrimEnd in case you end up truncating on a space (happened during testing) as Sneedchat will trim it
messageTracker.Message = messageTracker.Message.TruncateBytes(lengthLimit).TrimEnd();
}
}
messageTracker.Status = SentMessageTrackerStatus.WaitingForResponse;
messageTracker.SentAt = DateTimeOffset.UtcNow;
_logger.Debug($"Message is {messageTracker.Message.Utf8LengthBytes()} bytes");
@@ -320,21 +643,44 @@ public class ChatBot
return messageTracker;
}
public SentMessageTrackerModel SendChatMessage(string message, bool bypassSeshDetect = false,
LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.TruncateNicely, int lengthLimit = 1023)
/// <summary>
/// Exposes the private task used to delete messages based on a TimeSpan in case you want to use it on-demand
/// e.g. for cleaning up a gambling message only after the game has finished
/// </summary>
/// <param name="message">The message you want to delete</param>
/// <param name="deleteAfter">When you want it deleted</param>
public void ScheduleMessageAutoDelete(SentMessageTrackerModel message, TimeSpan deleteAfter)
{
return SendChatMessageAsync(message, bypassSeshDetect, lengthLimitBehavior, lengthLimit).Result;
_scheduledDeletions.Add(new ScheduledAutoDeleteModel
{
Message = message,
DeleteAt = DateTimeOffset.UtcNow.Add(deleteAfter)
});
}
/// <summary>
/// Non-async method which wraps the async method for sending a chat message
/// </summary>
/// <param name="message">The message you wish to send</param>
/// <param name="bypassSeshDetect">Whether to bypass detecting if GambaSesh is present and send unconditionally</param>
/// <param name="lengthLimitBehavior">What behavior to use when encountering a message that exceeds the length limit</param>
/// <param name="lengthLimit">Length limit to enforce in bytes</param>
/// <param name="autoDeleteAfter">Length of time until the message is auto deleted, null to disable. Starts counting from when the message is echoed by Sneedchat</param>
/// <returns>An object you can use to check the status of the message and get its ID for editing/deleting later</returns>
public SentMessageTrackerModel SendChatMessage(string message, bool bypassSeshDetect = false,
LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.TruncateNicely, int lengthLimit = 2048, TimeSpan? autoDeleteAfter = null)
{
return SendChatMessageAsync(message, bypassSeshDetect, lengthLimitBehavior, lengthLimit, autoDeleteAfter).Result;
}
// If you feed this long ass messages they will be eaten, don't be retarded.
public async Task<List<SentMessageTrackerModel>> SendChatMessagesAsync(List<string> messages,
bool bypassSeshDetect = false, LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.RefuseToSend)
bool bypassSeshDetect = false, LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.RefuseToSend, TimeSpan? autoDeleteAfter = null)
{
List<SentMessageTrackerModel> sentMessages = [];
foreach (var message in messages)
{
sentMessages.Add(await SendChatMessageAsync(message, bypassSeshDetect, lengthLimitBehavior));
sentMessages.Add(await SendChatMessageAsync(message, bypassSeshDetect, lengthLimitBehavior, autoDeleteAfter: autoDeleteAfter));
// Delay sending each message, hopefully this will help the issue where messages come out of order
await Task.Delay(TimeSpan.FromMilliseconds(100), _cancellationToken);
}
@@ -353,13 +699,39 @@ public class ChatBot
return message;
}
/// <summary>
/// Wait for a chat message to be successfully delivered or not
/// </summary>
/// <param name="message">Reference to the message you're waiting for</param>
/// <param name="patience">How long to wait</param>
/// <param name="ct">Cancellation token</param>
/// <returns>True if the message was echoed, false otherwise</returns>
public async Task<bool> WaitForChatMessageAsync(SentMessageTrackerModel message, TimeSpan? patience = null, CancellationToken ct = default)
{
if (patience == null)
{
patience = TimeSpan.FromSeconds(60);
}
var patienceEnds = DateTimeOffset.UtcNow.Add(patience.Value);
while (message.ChatMessageUuid == null)
{
if (DateTimeOffset.UtcNow > patienceEnds) return false;
if (message.Status is SentMessageTrackerStatus.Lost or SentMessageTrackerStatus.NotSending) return false;
await Task.Delay(100, ct);
}
return true;
}
public class SentMessageNotFoundException : Exception;
private void OnUsersJoined(object sender, List<UserModel> users, UsersJsonModel jsonPayload)
{
var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.GambaSeshUserId, BuiltIn.Keys.GambaSeshDetectEnabled, BuiltIn.Keys.BotKeesSeen])
.Result;
_logger.Debug($"Received {users.Count} user join events");
_currentUsersInChat.AddRange(users);
using var db = new ApplicationDbContext();
foreach (var user in users)
{
@@ -402,6 +774,7 @@ public class ChatBot
private void OnUsersParted(object sender, List<int> userIds)
{
_currentUsersInChat.RemoveAll(u => userIds.Contains(u.Id));
var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.GambaSeshUserId, BuiltIn.Keys.GambaSeshDetectEnabled])
.Result;
if (userIds.Contains(settings[BuiltIn.Keys.GambaSeshUserId].ToType<int>()) && settings[BuiltIn.Keys.GambaSeshDetectEnabled].ToBoolean())
@@ -449,10 +822,19 @@ public class ChatBot
_logger.Error($"Sneedchat disconnected due to {disconnectionInfo.Type}");
_logger.Error($"Close Status => {disconnectionInfo.CloseStatus}; Close Status Description => {disconnectionInfo.CloseStatusDescription}");
_logger.Error(disconnectionInfo.Exception);
_currentUsersInChat.Clear();
if (disconnectionInfo.Exception != null && disconnectionInfo.Exception.Message.Contains("status code '203'"))
{
_logger.Info("Chat 203'd, getting a new token");
RefreshXfToken().Wait(_cancellationToken);
_logger.Info("Reconnecting");
KfClient.ReconnectAsync().Wait(_cancellationToken);
}
}
private void OnKfWsReconnected(object sender, ReconnectionInfo reconnectionInfo)
{
_lastReconnectAttempt = DateTime.UtcNow;
var roomId = SettingsProvider.GetValueAsync(BuiltIn.Keys.KiwiFarmsRoomId).Result.ToType<int>();
_logger.Error($"Sneedchat reconnected due to {reconnectionInfo.Type}");
_logger.Info("Resetting GambaSesh presence so it can resync if he crashed while the bot was DC'd");
@@ -461,6 +843,11 @@ public class ChatBot
KfClient.JoinRoom(roomId);
}
public UserModel? FindUserByName(string username)
{
return _currentUsersInChat.FirstOrDefault(u => u.Username.Equals(username, StringComparison.CurrentCulture));
}
public enum LengthLimitBehavior
{
// Append …
@@ -472,4 +859,32 @@ public class ChatBot
// Try to send the message anyway, even though Sneedchat will just silently eat it
DoNothing
}
private class ScheduledAutoDeleteModel
{
public required SentMessageTrackerModel Message { get; set; }
public required DateTimeOffset DeleteAt { get; set; }
}
/// <summary>
/// Thin wrapper to decide whether to whisper or chat message respond
/// </summary>
/// <param name="origMsg">The original message you received (so I know if it was a whisper)</param>
/// <param name="response">Message you want to send</param>
/// <param name="bypassGambaSesh">Whether to bypass gambasesh (not applicable for whispers)</param>
/// <param name="autoDeleteAfter">Whether to auto delete after a period of time (not applicable to whispers)</param>
/// <param name="lengthLimitBehavior">What behavior to use for messages which exceed the length limit</param>
/// <returns></returns>
public async Task<SentMessageTrackerModel> ReplyToUser(BotCommandMessageModel origMsg, string response,
bool bypassGambaSesh = false, TimeSpan? autoDeleteAfter = null, LengthLimitBehavior lengthLimitBehavior = ChatBot.LengthLimitBehavior.TruncateNicely)
{
if (origMsg.IsWhisper)
{
return await SendWhisperAsync(origMsg.Author.Id, response,
lengthLimitBehavior: lengthLimitBehavior);
}
return await SendChatMessageAsync(response, bypassGambaSesh, lengthLimitBehavior,
autoDeleteAfter: autoDeleteAfter);
}
}

View File

@@ -1,11 +1,13 @@
using System.Runtime.Caching;
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace KfChatDotNetBot.Commands;
@@ -19,7 +21,10 @@ public class SetRoleCommand : ICommand
public string? HelpText => "Set a user's role";
public UserRight RequiredRight => UserRight.Admin;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
var targetUserId = Convert.ToInt32(arguments["user"].Value);
@@ -37,23 +42,6 @@ public class SetRoleCommand : ICommand
}
}
public class ToggleLiveStatusAdminCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^admin toggle livestatus$")
];
public string? HelpText => "Toggle Bossman's live status so off screen gamba can be relayed";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
botInstance.BotServices.IsBmjLive = !botInstance.BotServices.IsBmjLive;
await botInstance.SendChatMessageAsync($"IsBmjLive => {botInstance.BotServices.IsBmjLive}", true);
}
}
public class CacheClearAdminCommand : ICommand
{
public List<Regex> Patterns => [
@@ -63,8 +51,10 @@ public class CacheClearAdminCommand : ICommand
public string? HelpText => null;
public UserRight RequiredRight => UserRight.Admin;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var cacheKeys = MemoryCache.Default.Select(kvp => kvp.Key).ToList();
foreach (var cacheKey in cacheKeys)
@@ -78,58 +68,76 @@ public class CacheClearAdminCommand : ICommand
public class NewKickChannelCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^admin kick add (?<forum_id>\d+) (?<channel_id>\d+) (?<slug>\S+)$")
new Regex(@"^admin kick add (?<forum_id>\d+) (?<channel_id>\d+) (?<slug>\S+)$"),
new Regex(@"^admin kick add (?<forum_id>\d+) (?<channel_id>\d+) (?<slug>\S+) (?<auto_capture>true|false)$")
];
public string? HelpText => "Add a Kick channel to the bot's database";
public UserRight RequiredRight => UserRight.Admin;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var channels = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels)).JsonDeserialize<List<KickChannelModel>>();
var channelId = Convert.ToInt32(arguments["channel_id"].Value);
if (channels.Any(channel => channel.ChannelId == channelId))
var autoCapture = false;
if (arguments.TryGetValue("auto_capture", out var argument))
{
autoCapture = argument.Value == "true";
}
await using var db = new ApplicationDbContext();
var url = $"https://kick.com/{arguments["slug"].Value}";
if (await db.Streams.AnyAsync(s => s.StreamUrl == url, cancellationToken: ctx))
{
await botInstance.SendChatMessageAsync("Channel is already in the database", true);
return;
}
var forumId = Convert.ToInt32(arguments["forum_id"].Value);
channels.Add(new KickChannelModel
var forumUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == Convert.ToInt32(arguments["forum_id"].Value), cancellationToken: ctx);
var meta = JsonConvert.SerializeObject(new KickStreamMetaModel
{
ChannelId = channelId,
ForumId = forumId,
ChannelSlug = arguments["slug"].Value
ChannelId = Convert.ToInt32(arguments["channel_id"].Value)
});
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.KickChannels, channels);
db.Streams.Add(new StreamDbModel
{
Service = StreamService.Kick,
User = forumUser,
Metadata = meta,
StreamUrl = url,
AutoCapture = autoCapture
});
await db.SaveChangesAsync(ctx);
await botInstance.SendChatMessageAsync("Updated list of channels", true);
}
}
public class RemoveKickChannelCommand : ICommand
public class RemoveStreamChannelCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^admin kick remove (?<channel_id>\d+)$")
new Regex(@"^admin stream remove (?<id>\d+)$")
];
public string? HelpText => "Remove a Kick channel from the bot's database";
public UserRight RequiredRight => UserRight.Admin;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var channels = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels)).JsonDeserialize<List<KickChannelModel>>();
var channelId = Convert.ToInt32(arguments["channel_id"].Value);
var channel = channels.FirstOrDefault(ch => ch.ChannelId == channelId);
await using var db = new ApplicationDbContext();
var rowId = Convert.ToInt32(arguments["id"].Value);
var channel = db.Streams.FirstOrDefault(ch => ch.Id == rowId);
if (channel == null)
{
await botInstance.SendChatMessageAsync("Channel is not in the database", true);
await botInstance.SendChatMessageAsync("Could not find this row in the database", true);
return;
}
channels.Remove(channel);
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.KickChannels, channels);
await botInstance.SendChatMessageAsync("Updated list of channels", true);
db.Streams.Remove(channel);
await db.SaveChangesAsync(ctx);
await botInstance.SendChatMessageAsync("Updated list of streams", true);
}
}
@@ -142,13 +150,114 @@ public class ReconnectKickCommand : ICommand
public string? HelpText => "Disconnect from Kick so the watchdog can reconnect it";
public UserRight RequiredRight => UserRight.Admin;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
if (botInstance.BotServices.KickClient == null)
{
await botInstance.SendChatMessageAsync("Kick client is not initialized", true);
return;
}
botInstance.BotServices.KickClient.Disconnect();
await botInstance.SendChatMessageAsync("Disconnected from Kick. Client should reconnect shortly.", true);
}
}
public class NewPartiChannelCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^admin parti add (?<forum_id>\d+) (?<social>\S+) (?<username>\S+) (?<auto_capture>true|false)$"),
new Regex(@"^admin parti add (?<forum_id>\d+) (?<social>\S+) (?<username>\S+)$")
];
public string? HelpText => "Add a Parti channel to the bot's database";
public UserRight RequiredRight => UserRight.Admin;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var autoCapture = false;
if (arguments.TryGetValue("auto_capture", out var argument))
{
autoCapture = argument.Value == "true";
}
await using var db = new ApplicationDbContext();
var url = $"https://parti.com/creator/{arguments["social"].Value}/{arguments["username"].Value}/";
if (arguments["social"].Value == "discord")
{
url += "0";
}
if (await db.Streams.AnyAsync(s => s.StreamUrl == url, cancellationToken: ctx))
{
await botInstance.SendChatMessageAsync("Channel is already in the database", true);
return;
}
var forumUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == Convert.ToInt32(arguments["forum_id"].Value),
cancellationToken: ctx);
db.Streams.Add(new StreamDbModel
{
Service = StreamService.Parti,
User = forumUser,
StreamUrl = url,
AutoCapture = autoCapture
});
await db.SaveChangesAsync(ctx);
await botInstance.SendChatMessageAsync("Updated list of channels", true);
}
}
public class NewDLiveChannelCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^admin dlive add (?<forum_id>\d+) (?<username>\S+) (?<auto_capture>true|false)$"),
new Regex(@"^admin dlive add (?<forum_id>\d+) (?<username>\S+)$")
];
public string? HelpText => "Add a DLive channel to the bot's database";
public UserRight RequiredRight => UserRight.Admin;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var autoCapture = false;
if (arguments.TryGetValue("auto_capture", out var argument))
{
autoCapture = argument.Value == "true";
}
await using var db = new ApplicationDbContext();
var url = $"https://dlive.tv/{arguments["username"].Value}";
if (await db.Streams.AnyAsync(s => s.StreamUrl == url, cancellationToken: ctx))
{
await botInstance.SendChatMessageAsync("Channel is already in the database", true);
return;
}
var forumUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == Convert.ToInt32(arguments["forum_id"].Value),
cancellationToken: ctx);
db.Streams.Add(new StreamDbModel
{
Service = StreamService.DLive,
User = forumUser,
StreamUrl = url,
AutoCapture = autoCapture
});
await db.SaveChangesAsync(ctx);
await botInstance.SendChatMessageAsync("Updated list of channels", true);
}
}
public class AddCourtHearingCommand : ICommand
{
public List<Regex> Patterns => [
@@ -158,7 +267,9 @@ public class AddCourtHearingCommand : ICommand
public string? HelpText => "Add a court hearing to the bot's calendar";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var hearings = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotCourtCalendar)).JsonDeserialize<List<CourtHearingModel>>();
if (hearings == null)
@@ -188,7 +299,9 @@ public class RemoveCourtHearingCommand : ICommand
public string? HelpText => "Remove a hearing from the bot's calendar";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var hearings = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotCourtCalendar)).JsonDeserialize<List<CourtHearingModel>>();
if (hearings == null)
@@ -210,22 +323,6 @@ public class RemoveCourtHearingCommand : ICommand
}
}
public class NonceLiveCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^admin togglenonce$")
];
public string? HelpText => "Toggle IsChrisDjLive";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
botInstance.BotServices.IsChrisDjLive = !botInstance.BotServices.IsChrisDjLive;
await botInstance.SendChatMessageAsync($"IsChrisDjLive => {botInstance.BotServices.IsChrisDjLive}", true);
}
}
public class DeleteMessagesCommand : ICommand
{
public List<Regex> Patterns => [
@@ -235,8 +332,9 @@ public class DeleteMessagesCommand : ICommand
public string? HelpText => "Delete the most recent x number of messages";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var amount = int.Parse(arguments["msg_count"].Value);
@@ -249,12 +347,12 @@ public class DeleteMessagesCommand : ICommand
.TakeLast(amount);
foreach (var msg in messages)
{
if (msg.ChatMessageId == null)
if (msg.ChatMessageUuid == null)
{
continue;
}
await botInstance.KfClient.DeleteMessageAsync(msg.ChatMessageId.Value);
await botInstance.KfClient.DeleteMessageAsync(msg.ChatMessageUuid);
}
}
}
@@ -268,8 +366,9 @@ public class IgnoreCommand : ICommand
public string? HelpText => "Ignore a user by ID";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
@@ -287,6 +386,13 @@ public class IgnoreCommand : ICommand
return;
}
if (targetUser.UserRight > user.UserRight)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you can't ignore someone who is more powerful than you", true);
return;
}
targetUser.Ignored = true;
await db.SaveChangesAsync(ctx);
await botInstance.SendChatMessageAsync($"Now ignoring {targetUser.KfUsername}", true);
@@ -302,8 +408,9 @@ public class UnignoreCommand : ICommand
public string? HelpText => "Unignore a user by ID";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
@@ -336,8 +443,9 @@ public class SetAlmanacTextCommand : ICommand
public string? HelpText => "Set the almanac text to whatever";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await SettingsProvider.SetValueAsync(BuiltIn.Keys.BotAlmanacText, arguments["text"].Value);
@@ -354,8 +462,9 @@ public class SetAlmanacIntervalCommand : ICommand
public string? HelpText => "Set the almanac interval to whatever in seconds";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var interval = Convert.ToInt32(arguments["interval"].Value);
@@ -365,6 +474,11 @@ public class SetAlmanacIntervalCommand : ICommand
return;
}
await SettingsProvider.SetValueAsync(BuiltIn.Keys.BotAlmanacInterval, arguments["interval"].Value);
if (botInstance.BotServices.AlmanacShill == null)
{
await botInstance.SendChatMessageAsync("Value has been saved but almanac shill has not been initialized", true);
return;
}
await botInstance.BotServices.AlmanacShill.StopShillTaskAsync();
botInstance.BotServices.AlmanacShill.StartShillTask();
await botInstance.SendChatMessageAsync($"@{message.Author.Username}, updated interval and restarted the shill task", true);
@@ -379,10 +493,16 @@ public class StopAlmanacCommand : ICommand
public string? HelpText => "Stop the almanac reminder";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
if (botInstance.BotServices.AlmanacShill == null)
{
await botInstance.SendChatMessageAsync("AlmanacShill is null", true);
return;
}
if (!botInstance.BotServices.AlmanacShill.IsShillTaskRunning())
{
await botInstance.SendChatMessageAsync("Looks like the task isn't even running", true);
@@ -403,10 +523,16 @@ public class StartAlmanacCommand : ICommand
public string? HelpText => "Start the almanac reminder";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
if (botInstance.BotServices.AlmanacShill == null)
{
await botInstance.SendChatMessageAsync("AlmanacShill is null", true);
return;
}
if (botInstance.BotServices.AlmanacShill.IsShillTaskRunning())
{
await botInstance.SendChatMessageAsync("Looks like the task is already running", true);
@@ -416,4 +542,61 @@ public class StartAlmanacCommand : ICommand
botInstance.BotServices.AlmanacShill.StartShillTask();
await botInstance.SendChatMessageAsync("Asked it nicely to start", true);
}
}
public class ToggleForcedGambaMessagesCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^admin toggle gamba")
];
public string? HelpText => "Toggle forced gamba messages while a stream is running";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
botInstance.BotServices.TemporarilyForceGambaMessages = !botInstance.BotServices.TemporarilyForceGambaMessages;
await botInstance.SendChatMessageAsync($"TemporarilyForceGambaMessages is now {botInstance.BotServices.TemporarilyForceGambaMessages}", true);
}
}
public class ToggleDiscordRelayingCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^tempenable discord$", RegexOptions.IgnoreCase),
new Regex("^admin toggle discord", RegexOptions.IgnoreCase)
];
public string? HelpText => null;
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
botInstance.BotServices.TemporarilyBypassGambaSeshForDiscord = !botInstance.BotServices.TemporarilyBypassGambaSeshForDiscord;
await botInstance.SendChatMessageAsync($"TemporarilyBypassGambaSeshForDiscord is now {botInstance.BotServices.TemporarilyBypassGambaSeshForDiscord}", true);
}
}
public class SetMotd : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^admin motd (?<uuid>\S+)$", RegexOptions.IgnoreCase)
];
public string? HelpText => null;
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => true;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var uuid = arguments["uuid"].Value;
await botInstance.SendChatMessageAsync($"/motd {uuid}", true);
await botInstance.ReplyToUser(message, $"{user.FormatUsername()}, set MOTD to {uuid}", true);
}
}

View File

@@ -0,0 +1,122 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetWsClient.Models.Events;
using RandN;
using RandN.Compat;
namespace KfChatDotNetBot.Commands;
public class EightBallCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^8ball", RegexOptions.IgnoreCase)
];
public string? HelpText => "Ask the magic 8-ball a question";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => new RateLimitOptionsModel
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(15)
};
public bool WhisperCanInvoke => false;
private static readonly string[] AnswersYes = [
"Yes, definitely.",
"It is certain.",
"Yes, in due time.",
"Yes, but only if you believe.",
"It is decidedly so.",
"Yes, but be cautious.",
"Yes, but only if you try hard enough.",
"Yes, but only if you ask nicely.",
"Yes, but only if you bribe me.",
"Without a doubt.",
"You may rely on it.",
"As I see it, yes.",
"Most likely.",
"Outlook good.",
"Signs point to yes.",
"Absolutely.",
"Certainly.",
"The stars say yes.",
"The answer is yes.",
"Definitely yes.",
"Yes, yes, yes!",
"Affirmative.",
"By all means.",
"The universe agrees.",
"Chances are good.",
"It's a sure thing.",
"You can count on it.",
"The future looks bright.",
"Yes, go for it.",
"Yes, the answer is clear.",
];
private static readonly string[] AnswersUncertain = [
"Concentrate and ask again.",
"Ask again later.",
"Cannot predict now.",
"Reply hazy, try again.",
"Better not tell you now.",
"It's unclear at the moment.",
"I'm not sure.",
"Maybe...",
"That's a mystery.",
"The answer is unclear.",
"It's hard to say.",
"I'm undecided.",
"The future is uncertain.",
"It's anyone's guess.",
"I can't tell you right now.",
"The signs are unclear.",
];
private static readonly string[] AnswersNo = [
"No, absolutely not.",
"No, never.",
"No, the answer is no.",
"No, the universe says no.",
"No, the stars are not aligned.",
"My reply is no.",
"Outlook not so good.",
"Don't count on it.",
"My sources say no.",
"Very doubtful.",
"Definitely not.",
"No way.",
"Not in a million years.",
"The answer is a resounding no.",
"Chances are slim to none.",
"The universe says no.",
"Negative.",
"Absolutely not.",
"I don't think so.",
"The signs point to no.",
"Certainly not.",
"I wouldn't bet on it.",
"The answer is no.",
"No chance.",
"Not likely.",
"The outlook is bleak."
];
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var random = RandomShim.Create(StandardRng.Create());
var outcome = random.Next(0, 110);
var response = outcome switch
{
< 50 => AnswersYes[random.Next(AnswersYes.Length)],
< 100 => AnswersNo[random.Next(AnswersNo.Length)],
_ => AnswersUncertain[random.Next(AnswersUncertain.Length)]
};
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, {response}", true);
}
}

View File

@@ -1,5 +1,6 @@
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
@@ -15,7 +16,9 @@ public class HowlggStatsCommand : ICommand
public string? HelpText => "Get betting statistics in the given window";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var window = Convert.ToInt32(arguments["window"].Value);
var start = DateTimeOffset.UtcNow.AddHours(-window);
@@ -42,7 +45,9 @@ public class HowlggRecentBetCommand : ICommand
public string? HelpText => "Get the most recent 3 bets";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor, BuiltIn.Keys.HowlggDivisionAmount

View File

@@ -1,16 +1,19 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Commands;
internal interface ICommand
public interface ICommand
{
List<Regex> Patterns { get; }
// Set to null to disable help for a given command
string? HelpText { get; }
UserRight RequiredRight { get; }
TimeSpan Timeout { get; }
RateLimitOptionsModel? RateLimitOptions { get; }
bool WhisperCanInvoke { get; }
Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx);
Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx);
}

View File

@@ -1,4 +1,7 @@
using System.Text.RegularExpressions;
using System.Net.Http.Headers;
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
@@ -20,8 +23,9 @@ public class AddImageCommand : ICommand
public string? HelpText => "Add an image to the image rotation specified";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
@@ -45,7 +49,9 @@ public class AddImageCommand : ICommand
await db.Images.AddAsync(new ImageDbModel { Key = key, Url = url, LastSeen = DateTimeOffset.MinValue }, ctx);
await db.SaveChangesAsync(ctx);
await botInstance.SendChatMessageAsync("Added image to database", true);
//await botInstance.SendChatMessageAsync("Added image to database", true);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you added the following media to the {key} carousel\n[img]{url}[/img]", true);
}
}
@@ -60,8 +66,9 @@ public class RemoveImageCommand : ICommand
public string? HelpText => "Remove an image from the image rotation specified";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
@@ -85,7 +92,9 @@ public class RemoveImageCommand : ICommand
db.Images.Remove(image);
await db.SaveChangesAsync(ctx);
await botInstance.SendChatMessageAsync("Removed image from database", true);
// await botInstance.SendChatMessageAsync("Removed image from database", true);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you removed the following media from the {key} carousel\n[img]{url}[/img]", true);
}
}
@@ -98,12 +107,14 @@ public class ListImageCommand : ICommand
public string? HelpText => "Remove an image from the image rotation specified";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
var imageKeys = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotImageAcceptableKeys)).JsonDeserialize<List<string>>();
var imageKeys = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotImageAcceptableKeys))
.JsonDeserialize<List<string>>();
if (imageKeys == null) throw new InvalidOperationException($"{BuiltIn.Keys.BotImageAcceptableKeys} was null");
var key = arguments["key"].Value;
if (!imageKeys.Contains(key))
@@ -114,6 +125,19 @@ public class ListImageCommand : ICommand
}
var images = db.Images.Where(i => i.Key == key);
if (await images.CountAsync(cancellationToken: ctx) > 20 && await Zipline.IsZiplineEnabled())
{
var content = string.Empty;
foreach (var image in images)
{
content += image.Url + Environment.NewLine;
}
var paste = await Zipline.Upload(content, new MediaTypeHeaderValue("text/plain"), "1d", ctx);
await botInstance.SendChatMessageAsync($"List of images for {key}: {paste}", true);
return;
}
var i = 0;
var result = $"List of images for {key}:";
foreach (var image in images)
@@ -137,19 +161,35 @@ public class GetRandomImage : ICommand
public string? HelpText => "Get a random image";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromMinutes(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => new()
{
Window = TimeSpan.FromSeconds(30),
MaxInvocations = 7,
Flags = RateLimitFlags.UseEntireMessage | RateLimitFlags.NoResponse
};
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var logger = LogManager.GetCurrentClassLogger();
await using var db = new ApplicationDbContext();
var key = arguments["key"].Value.ToLower();
var images = db.Images.Where(i => i.Key == key);
if (!await images.AnyAsync(ctx)) return;
if (!await images.AnyAsync(ctx))
{
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (key == "sloppa" && user.UserRight < UserRight.TrueAndHonest)
{
await botInstance.SendWhisperAsync(user.KfId, $"{user.FormatUsername()}, sloppa requires at least {UserRight.TrueAndHonest.Humanize()}");
return;
}
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.BotImageRandomSliceDivideBy, BuiltIn.Keys.BotImagePigCubeSelfDestruct,
BuiltIn.Keys.BotImageInvertedCubeUrl, BuiltIn.Keys.BotImagePigCubeSelfDestructMin,
BuiltIn.Keys.BotImagePigCubeSelfDestructMax, BuiltIn.Keys.BotImageInvertedPigCubeSelfDestructDelay
BuiltIn.Keys.BotImagePigCubeSelfDestructMax, BuiltIn.Keys.BotImageInvertedPigCubeSelfDestructDelay,
BuiltIn.Keys.BotImageChinkSelfDestruct, BuiltIn.Keys.BotImageChinkSelfDestructDelay
]);
var divideBy = settings[BuiltIn.Keys.BotImageRandomSliceDivideBy].ToType<int>();
var limit = 1;
@@ -166,31 +206,19 @@ public class GetRandomImage : ICommand
image.LastSeen = DateTimeOffset.UtcNow;
db.Images.Update(image);
await db.SaveChangesAsync(ctx);
var msg = await botInstance.SendChatMessageAsync($"[img]{image.Url}[/img]", true);
if (key != "pigcube" || !settings[BuiltIn.Keys.BotImagePigCubeSelfDestruct].ToBoolean()) return;
while (msg.Status is SentMessageTrackerStatus.WaitingForResponse or SentMessageTrackerStatus.ChatDisconnected)
TimeSpan? timeToDeletion = null;
if (key == "pigcube" && settings[BuiltIn.Keys.BotImagePigCubeSelfDestruct].ToBoolean())
{
await Task.Delay(500, ctx);
timeToDeletion = TimeSpan.FromMilliseconds(image.Url == settings[BuiltIn.Keys.BotImageInvertedCubeUrl].Value
? settings[BuiltIn.Keys.BotImageInvertedPigCubeSelfDestructDelay].ToType<int>()
: new Random().Next(settings[BuiltIn.Keys.BotImagePigCubeSelfDestructMin].ToType<int>(),
settings[BuiltIn.Keys.BotImagePigCubeSelfDestructMax].ToType<int>()));
}
if (msg.Status is SentMessageTrackerStatus.Lost or SentMessageTrackerStatus.NotSending)
else if (key is "chink" or "sloppa" && settings[BuiltIn.Keys.BotImageChinkSelfDestruct].ToBoolean())
{
logger.Error("Pig cube got lost");
return;
RateLimitService.AddEntry(user, this, message.MessageRawHtmlDecoded);
timeToDeletion = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.BotImageChinkSelfDestructDelay].ToType<int>());
}
if (msg.ChatMessageId == null)
{
logger.Error($"Pig cube chat message ID was null even though status was {msg.Status}");
return;
}
var timeToDeletionMsec = image.Url == settings[BuiltIn.Keys.BotImageInvertedCubeUrl].Value
? settings[BuiltIn.Keys.BotImageInvertedPigCubeSelfDestructDelay].ToType<int>()
: new Random().Next(settings[BuiltIn.Keys.BotImagePigCubeSelfDestructMin].ToType<int>(),
settings[BuiltIn.Keys.BotImagePigCubeSelfDestructMax].ToType<int>());
logger.Info($"Deleting pig cube in {timeToDeletionMsec}ms");
await Task.Delay(timeToDeletionMsec, ctx);
await botInstance.KfClient.DeleteMessageAsync(msg.ChatMessageId.Value);
await botInstance.SendChatMessageAsync($"[img]{image.Url}[/img]", true, autoDeleteAfter: timeToDeletion);
}
}

View File

@@ -1,75 +1,11 @@
using System.Text.RegularExpressions;
using Humanizer;
using Humanizer.Localisation;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using Microsoft.EntityFrameworkCore;
namespace KfChatDotNetBot.Commands;
public class JuiceCommand : ICommand
{
public List<Regex> Patterns => [new Regex("^juiceme")];
public string HelpText => "Get juice!";
public bool HideFromHelp => false;
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(60);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
// Have to attach the entity because it is coming from another DB context
// https://stackoverflow.com/questions/52718652/ef-core-sqlite-sqlite-error-19-unique-constraint-failed
db.Users.Attach(user);
var juicerSettings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.JuiceAmount, BuiltIn.Keys.JuiceCooldown, BuiltIn.Keys.JuiceLoserDivision,
BuiltIn.Keys.GambaSeshDetectEnabled, BuiltIn.Keys.JuiceAllowedWhileStreaming,
BuiltIn.Keys.TwitchBossmanJackUsername, BuiltIn.Keys.JuiceAutoDeleteMsgDelay
]);
var cooldown = juicerSettings[BuiltIn.Keys.JuiceCooldown].ToType<int>();
var amount = juicerSettings[BuiltIn.Keys.JuiceAmount].ToType<int>();
if (user.UserRight == UserRight.Loser) amount /= juicerSettings[BuiltIn.Keys.JuiceLoserDivision].ToType<int>();
var lastJuicer = (await db.Juicers.Where(j => j.User == user).ToListAsync(ctx)).OrderByDescending(j => j.JuicedAt).Take(1).ToList();
if (!botInstance.GambaSeshPresent && juicerSettings[BuiltIn.Keys.GambaSeshDetectEnabled].ToBoolean())
{
await botInstance.SendChatMessageAsync("Looks like GambaSesh isn't here. If he is, get him to say something then try again.", true);
return;
}
if (!juicerSettings[BuiltIn.Keys.JuiceAllowedWhileStreaming].ToBoolean() && botInstance.BotServices.IsBmjLive)
{
await botInstance.SendChatMessageAsync(
$"No juicers permitted while {juicerSettings[BuiltIn.Keys.TwitchBossmanJackUsername].Value} is live!", true);
return;
}
if (lastJuicer.Count == 0 || (lastJuicer[0].JuicedAt.AddSeconds(cooldown) - DateTimeOffset.UtcNow).TotalSeconds <= 0)
{
var sentMsg = await botInstance.SendChatMessageAsync($"!juice {message.Author.Id} {amount}", true);
await db.Juicers.AddAsync(new JuicerDbModel
{ Amount = amount, User = user, JuicedAt = DateTimeOffset.UtcNow }, ctx);
await db.SaveChangesAsync(ctx);
if (juicerSettings[BuiltIn.Keys.JuiceAutoDeleteMsgDelay].Value == null) return;
var delay = juicerSettings[BuiltIn.Keys.JuiceAutoDeleteMsgDelay].ToType<int>();
if (delay <= 0) return;
while (sentMsg.ChatMessageId == null)
{
if (sentMsg.Status is SentMessageTrackerStatus.Lost or SentMessageTrackerStatus.NotSending) return;
await Task.Delay(500, ctx);
}
await Task.Delay(delay, ctx);
await botInstance.KfClient.DeleteMessageAsync(sentMsg.ChatMessageId.Value);
return;
}
var secondsRemaining = lastJuicer[0].JuicedAt.AddSeconds(cooldown) - DateTimeOffset.UtcNow;
await botInstance.SendChatMessageAsync($"You gotta wait {secondsRemaining.Humanize(precision: 2, minUnit: TimeUnit.Second)} for another juicer", true);
}
}
public class JuiceStatsCommand : ICommand
{
public List<Regex> Patterns => [
@@ -80,7 +16,9 @@ public class JuiceStatsCommand : ICommand
public bool HideFromHelp => false;
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
int top;
if (arguments.TryGetValue("top", out var argument))

View File

@@ -0,0 +1,809 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using Microsoft.EntityFrameworkCore;
using NLog;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class BlackjackCommand : ICommand
{
private static readonly TimeSpan GameTimeout = TimeSpan.FromMinutes(5);
// Colors fetched once per user action and threaded through to all helpers,
// so we never fetch them redundantly mid-game-flow.
private record GameColors(string Green, string Red);
public List<Regex> Patterns => [
new Regex(@"^blackjack (?<amount>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^blackjack (?<amount>\d+\.\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^bj (?<amount>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^bj (?<amount>\d+\.\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^blackjack (?<action>hit|stand|double|split)$", RegexOptions.IgnoreCase),
new Regex(@"^bj (?<action>hit|stand|double|split)$", RegexOptions.IgnoreCase)
];
public string? HelpText => "!blackjack <amount> or !bj <amount> to start, then !bj hit/stand/double/split";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(15);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 5,
Window = TimeSpan.FromSeconds(20),
Flags = RateLimitFlags.NoAutoDeleteCooldownResponse
};
public bool WhisperCanInvoke => false;
private ApplicationDbContext _dbContext = new();
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KasinoBlackjackCleanupDelay,
BuiltIn.Keys.KasinoBlackjackEnabled
]);
var blackjackEnabled = settings[BuiltIn.Keys.KasinoBlackjackEnabled].ToBoolean();
if (!blackjackEnabled)
{
var gameDisabledCleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, blackjack is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoBlackjackCleanupDelay].ToType<int>());
if (arguments.TryGetValue("amount", out var amountGroup))
{
await StartNewGame(botInstance, user, amountGroup.Value, cleanupDelay, ctx);
return;
}
if (arguments.TryGetValue("action", out var actionGroup))
{
await ContinueGame(botInstance, user, actionGroup.Value.ToLower(), cleanupDelay, ctx);
return;
}
throw new InvalidOperationException($"User {user.KfUsername} somehow ran blackjack without an amount or action: {message.MessageRaw}");
}
private async Task StartNewGame(ChatBot botInstance, UserDbModel user, string amountStr,
TimeSpan cleanupDelay, CancellationToken ctx)
{
var logger = LogManager.GetCurrentClassLogger();
// Fetch colors upfront — needed for both the immediate-blackjack ResolveGame path
// and the normal GameStart display path.
var colorSettings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
var colors = new GameColors(
colorSettings[BuiltIn.Keys.KiwiFarmsGreenColor].Value,
colorSettings[BuiltIn.Keys.KiwiFarmsRedColor].Value);
var wager = Convert.ToDecimal(amountStr);
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (wager == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you have to wager more than {await wager.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (gambler.Balance < wager)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
// Check for an existing incomplete game
var existingGame = await _dbContext.Wagers
.OrderBy(x => x.Id)
.LastOrDefaultAsync(w => w.Gambler.Id == gambler.Id &&
w.Game == WagerGame.Blackjack &&
!w.IsComplete && w.GameMeta != null,
cancellationToken: ctx);
if (existingGame != null)
{
try
{
_ = JsonSerializer.Deserialize<BlackjackGameMetaModel>(existingGame.GameMeta!) ??
throw new InvalidOperationException();
}
catch (Exception e)
{
logger.Error($"Caught error when deserializing meta for wager ID {existingGame.Id}");
logger.Error(e);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, somehow your previous blackjack game state got messed up. Please try again",
true, autoDeleteAfter: cleanupDelay);
existingGame.IsComplete = true;
await _dbContext.SaveChangesAsync(ctx);
throw;
}
var timeSinceStart = DateTimeOffset.UtcNow - existingGame.Time;
if (timeSinceStart > GameTimeout)
{
await ForfeitGame(botInstance, user, gambler, existingGame, cleanupDelay, ctx);
return;
}
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you already have an active blackjack game. Use [ditto]!bj hit[/ditto] or [ditto]!bj stand[/ditto] to continue.",
true, autoDeleteAfter: cleanupDelay);
return;
}
// Deal initial hands
var deck = BlackjackHelper.CreateDeck(gambler);
var playerHand = new List<Card> { deck[0], deck[2] };
var dealerHand = new List<Card> { deck[1], deck[3] };
deck.RemoveRange(0, 4);
var newGameState = new BlackjackGameMetaModel
{
PlayerHands = new List<List<Card>> { playerHand },
DealerHand = dealerHand,
Deck = deck,
HasDoubledDown = false,
CurrentHandIndex = 0,
OriginalWagerAmount = wager
};
await Money.NewWagerAsync(
gambler.Id, wager, -wager,
WagerGame.Blackjack,
autoModifyBalance: true,
gameMeta: newGameState,
isComplete: false,
ct: ctx);
var createdWager = await _dbContext.Wagers
.OrderBy(x => x.Id)
.LastOrDefaultAsync(
w => w.Gambler.Id == gambler.Id && w.Game == WagerGame.Blackjack && !w.IsComplete && w.GameMeta != null,
cancellationToken: ctx) ?? throw new InvalidOperationException();
createdWager.GameMeta = JsonSerializer.Serialize(newGameState);
await _dbContext.SaveChangesAsync(ctx);
// Immediate blackjack check — goes straight to resolution
if (BlackjackHelper.IsBlackjack(playerHand) || BlackjackHelper.IsBlackjack(dealerHand))
{
await ResolveGame(botInstance, user, gambler, createdWager, newGameState, colors, cleanupDelay, ctx);
return;
}
var playerValue = BlackjackHelper.CalculateHandValue(playerHand);
var canSplit = BlackjackHelper.CanSplit(playerHand);
await botInstance.SendChatMessageAsync(
await BlackjackDisplay.GameStart(user, wager, playerHand, playerValue, dealerHand, canSplit, colors.Red),
true, autoDeleteAfter: cleanupDelay);
}
private async Task ContinueGame(ChatBot botInstance, UserDbModel user, string action,
TimeSpan cleanupDelay, CancellationToken ctx)
{
// Fetch colors once here; pass them to every downstream method
var colorSettings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
var colors = new GameColors(
colorSettings[BuiltIn.Keys.KiwiFarmsGreenColor].Value,
colorSettings[BuiltIn.Keys.KiwiFarmsRedColor].Value);
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
var activeWager = await _dbContext.Wagers
.OrderBy(x => x.Id)
.LastOrDefaultAsync(w => w.Gambler.Id == gambler.Id &&
w.Game == WagerGame.Blackjack &&
!w.IsComplete && w.GameMeta != null,
cancellationToken: ctx);
if (activeWager == null)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you don't have an active blackjack game. Start one with [ditto]!bj <amount>[/ditto]",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var currentGameState = JsonSerializer.Deserialize<BlackjackGameMetaModel>(activeWager.GameMeta!);
if (currentGameState == null)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your game data is corrupted. Please start a new game.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
activeWager.IsComplete = true;
await _dbContext.SaveChangesAsync(ctx);
return;
}
var timeSinceStart = DateTimeOffset.UtcNow - activeWager.Time;
if (timeSinceStart > GameTimeout)
{
await ForfeitGame(botInstance, user, gambler, activeWager, cleanupDelay, ctx);
return;
}
switch (action)
{
case "hit":
await HandleHit(botInstance, user, gambler, activeWager, currentGameState, colors, cleanupDelay, ctx);
break;
case "stand":
await HandleStand(botInstance, user, gambler, activeWager, currentGameState, colors, cleanupDelay, ctx);
break;
case "double":
await HandleDouble(botInstance, user, gambler, activeWager, currentGameState, colors, cleanupDelay, ctx);
break;
case "split":
await HandleSplit(botInstance, user, gambler, activeWager, currentGameState, colors, cleanupDelay, ctx);
break;
}
}
private async Task HandleHit(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler,
WagerDbModel wager, BlackjackGameMetaModel gameState, GameColors colors,
TimeSpan cleanupDelay, CancellationToken ctx)
{
if (gameState.Deck.Count == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, game error: no cards left in deck. Game forfeited.",
true, autoDeleteAfter: cleanupDelay);
await ForfeitGame(botInstance, user, gambler, wager, cleanupDelay, ctx);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var currentHand = gameState.PlayerHands[gameState.CurrentHandIndex];
var handLabel = gameState.PlayerHands.Count > 1 ? $" (H{gameState.CurrentHandIndex + 1})" : "";
var card = gameState.Deck[0];
gameState.Deck.RemoveAt(0);
currentHand.Add(card);
var playerValue = BlackjackHelper.CalculateHandValue(currentHand);
bool handEnded = playerValue > 21 || playerValue == 21 || gameState.HasDoubledDown;
if (!handEnded)
{
// Hand is still live — show updated state and prompt for next action
wager.GameMeta = JsonSerializer.Serialize(gameState);
await _dbContext.SaveChangesAsync(ctx);
await botInstance.SendChatMessageAsync(
BlackjackDisplay.HitInProgress(user, card, currentHand, playerValue, gameState.DealerHand, handLabel, colors.Red),
true, autoDeleteAfter: cleanupDelay);
return;
}
// Hand ended (bust / 21 / post-double auto-stand).
// MoveToNextHandOrResolve sends the combined transition message when moving
// to the next split hand, or falls through silently to ResolveGame.
await MoveToNextHandOrResolve(botInstance, user, gambler, wager, gameState,
currentHand, busted: playerValue > 21, colors, cleanupDelay, ctx);
}
private async Task HandleStand(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler,
WagerDbModel wager, BlackjackGameMetaModel gameState, GameColors colors,
TimeSpan cleanupDelay, CancellationToken ctx)
{
var currentHand = gameState.PlayerHands[gameState.CurrentHandIndex];
// No stand message needed here — MoveToNextHandOrResolve handles all output:
// a combined split-transition message when moving to the next hand, and
// silence when falling through to the final resolution.
await MoveToNextHandOrResolve(botInstance, user, gambler, wager, gameState,
currentHand, busted: false, colors, cleanupDelay, ctx);
}
private async Task HandleDouble(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler,
WagerDbModel wager, BlackjackGameMetaModel gameState, GameColors colors,
TimeSpan cleanupDelay, CancellationToken ctx)
{
var currentHand = gameState.PlayerHands[gameState.CurrentHandIndex];
if (currentHand.Count != 2)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you can only double down on your first action.",
true, autoDeleteAfter: cleanupDelay);
return;
}
if (gameState.PlayerHands.Count > 1)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you cannot double down after splitting.",
true, autoDeleteAfter: cleanupDelay);
return;
}
if (gambler.Balance < gameState.OriginalWagerAmount)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you don't have enough balance to double down.",
true, autoDeleteAfter: cleanupDelay);
return;
}
var additionalWager = gameState.OriginalWagerAmount;
await Money.ModifyBalanceAsync(gambler.Id, -additionalWager, TransactionSourceEventType.Gambling,
$"Double down for {wager.Id}", ct: ctx);
wager.WagerAmount += additionalWager;
wager.WagerEffect -= additionalWager;
gameState.HasDoubledDown = true;
await _dbContext.SaveChangesAsync(ctx);
// Confirm the double, then let HandleHit draw the one card and auto-stand.
// HasDoubledDown is now true, so HandleHit treats the hand as ended and falls
// through silently to ResolveGame — just two total messages: this + the result.
await botInstance.SendChatMessageAsync(
await BlackjackDisplay.DoubledDown(user, wager.WagerAmount),
true, autoDeleteAfter: cleanupDelay);
await HandleHit(botInstance, user, gambler, wager, gameState, colors, cleanupDelay, ctx);
}
private async Task HandleSplit(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler,
WagerDbModel wager, BlackjackGameMetaModel gameState, GameColors colors,
TimeSpan cleanupDelay, CancellationToken ctx)
{
var currentHand = gameState.PlayerHands[gameState.CurrentHandIndex];
if (!BlackjackHelper.CanSplit(currentHand))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you can only split with two cards of the same rank.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (gameState.PlayerHands.Count > 1)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you can only split once per game.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (gambler.Balance < gameState.OriginalWagerAmount)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you don't have enough balance to split.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (gameState.Deck.Count < 2)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, not enough cards in deck to split.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var card1 = currentHand[0];
var card2 = currentHand[1];
var hand1 = new List<Card> { card1, gameState.Deck[0] };
var hand2 = new List<Card> { card2, gameState.Deck[1] };
gameState.Deck.RemoveRange(0, 2);
gameState.PlayerHands = new List<List<Card>> { hand1, hand2 };
gameState.HasDoubledDown = false;
gameState.CurrentHandIndex = 0;
var additionalWager = gameState.OriginalWagerAmount;
await Money.ModifyBalanceAsync(gambler.Id, -additionalWager, TransactionSourceEventType.Gambling,
$"Split for {wager.Id}", ct: ctx);
wager.WagerAmount += additionalWager;
wager.WagerEffect -= additionalWager;
wager.GameMeta = JsonSerializer.Serialize(gameState);
await _dbContext.SaveChangesAsync(ctx);
var value1 = BlackjackHelper.CalculateHandValue(hand1);
var value2 = BlackjackHelper.CalculateHandValue(hand2);
await botInstance.SendChatMessageAsync(
await BlackjackDisplay.SplitDeal(user, wager.WagerAmount, hand1, value1, hand2, value2, colors.Red),
true, autoDeleteAfter: cleanupDelay);
}
/// <summary>
/// Advances to the next split hand, or kicks off dealer play and resolution.
/// </summary>
/// <param name="finishedHand">The hand that just ended (bust, stand, or doubled auto-stand).</param>
/// <param name="busted">True if the finished hand went over 21.</param>
private async Task MoveToNextHandOrResolve(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler,
WagerDbModel wager, BlackjackGameMetaModel gameState,
List<Card> finishedHand, bool busted,
GameColors colors, TimeSpan cleanupDelay, CancellationToken ctx)
{
var finishedIndex = gameState.CurrentHandIndex;
gameState.CurrentHandIndex++;
if (gameState.CurrentHandIndex < gameState.PlayerHands.Count)
{
// More split hands to play — one combined message covers both
// "what happened to the hand that just ended" and "here's your next hand".
wager.GameMeta = JsonSerializer.Serialize(gameState);
await _dbContext.SaveChangesAsync(ctx);
var finishedValue = BlackjackHelper.CalculateHandValue(finishedHand);
var nextHand = gameState.PlayerHands[gameState.CurrentHandIndex];
var nextValue = BlackjackHelper.CalculateHandValue(nextHand);
await botInstance.SendChatMessageAsync(
BlackjackDisplay.SplitTransition(
finishedIndex, finishedHand, finishedValue, busted,
gameState.CurrentHandIndex, nextHand, nextValue,
colors.Red),
true, autoDeleteAfter: cleanupDelay);
}
else
{
await PlayDealerAndResolve(botInstance, user, gambler, wager, gameState, colors, cleanupDelay, ctx);
}
}
private async Task PlayDealerAndResolve(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler,
WagerDbModel wager, BlackjackGameMetaModel gameState, GameColors colors,
TimeSpan cleanupDelay, CancellationToken ctx)
{
// Dealer only plays when at least one player hand hasn't busted
bool allHandsBusted = gameState.PlayerHands.All(hand => BlackjackHelper.CalculateHandValue(hand) > 21);
if (!allHandsBusted)
{
var dealerValue = BlackjackHelper.CalculateHandValue(gameState.DealerHand);
while (dealerValue < 17)
{
if (gameState.Deck.Count == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, game error: dealer ran out of cards. Game forfeited.",
true, autoDeleteAfter: cleanupDelay);
await ForfeitGame(botInstance, user, gambler, wager, cleanupDelay, ctx);
return;
}
var card = gameState.Deck[0];
gameState.Deck.RemoveAt(0);
gameState.DealerHand.Add(card);
dealerValue = BlackjackHelper.CalculateHandValue(gameState.DealerHand);
}
}
await ResolveGame(botInstance, user, gambler, wager, gameState, colors, cleanupDelay, ctx);
}
private async Task ResolveGame(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler,
WagerDbModel wager, BlackjackGameMetaModel gameState, GameColors colors,
TimeSpan cleanupDelay, CancellationToken ctx)
{
var dealerValue = BlackjackHelper.CalculateHandValue(gameState.DealerHand);
var dealerBlackjack = BlackjackHelper.IsBlackjack(gameState.DealerHand);
bool isSplitGame = gameState.PlayerHands.Count > 1;
decimal totalEffect = 0;
var results = new List<HandResultData>();
for (int i = 0; i < gameState.PlayerHands.Count; i++)
{
var hand = gameState.PlayerHands[i];
var playerValue = BlackjackHelper.CalculateHandValue(hand);
var playerBlackjack = BlackjackHelper.IsBlackjack(hand);
// Split hands each pay the original per-hand wager; a single hand pays the full
// (possibly doubled) wager amount already tracked in wager.WagerAmount.
var handWager = isSplitGame ? gameState.OriginalWagerAmount : wager.WagerAmount;
var (outcome, effect) = BlackjackDisplay.ClassifyHand(
playerValue, playerBlackjack, dealerValue, dealerBlackjack, handWager);
results.Add(new HandResultData(i, hand, playerValue, outcome, effect));
totalEffect += effect;
}
wager.IsComplete = true;
wager.WagerEffect = totalEffect;
wager.Multiplier = (totalEffect + wager.WagerAmount) / wager.WagerAmount;
await _dbContext.SaveChangesAsync(ctx);
var balanceAdjustment = totalEffect + wager.WagerAmount;
var newBalance = await Money.ModifyBalanceAsync(gambler.Id, balanceAdjustment,
TransactionSourceEventType.Gambling, $"Blackjack outcome from wager {wager.Id}", null, ctx);
await botInstance.SendChatMessageAsync(
await BlackjackDisplay.FinalResult(
user, results, gameState.DealerHand, dealerValue,
totalEffect, newBalance, isSplitGame, colors.Green, colors.Red),
true, autoDeleteAfter: cleanupDelay);
}
private async Task ForfeitGame(ChatBot botInstance, UserDbModel user, GamblerDbModel gambler,
WagerDbModel wager, TimeSpan cleanupDelay, CancellationToken ctx)
{
wager.IsComplete = true;
await _dbContext.SaveChangesAsync(ctx);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your blackjack game timed out and you forfeited {await wager.WagerAmount.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: cleanupDelay);
}
}
/// <summary>
/// Builds every chat message string for the blackjack game.
/// No game logic lives here — only presentation.
/// <para>
/// Keeping all string construction in one place means tweaking the UI never
/// requires touching the game-flow code in <see cref="BlackjackCommand"/>.
/// </para>
/// </summary>
internal static class BlackjackDisplay
{
// ─────────────────────────────────────────────────────────────────────────
// Primitive helpers
// ─────────────────────────────────────────────────────────────────────────
/// Wraps ♥ and ♦ in the game's losing-text red so suit glyphs render in red.
/// Applied to every hand string so the color is consistent with loss messages.
internal static string ColorizeSuits(string text, string redHex) =>
text.Replace("♥", $"[COLOR={redHex}]♥[/COLOR]")
.Replace("♦", $"[COLOR={redHex}]♦[/COLOR]");
private static string FmtHand(List<Card> hand, string redHex, bool hideFirst = false) =>
ColorizeSuits(BlackjackHelper.FormatHand(hand, hideFirstCard: hideFirst), redHex);
private static string FmtCard(Card card, string redHex) =>
ColorizeSuits(card.ToString()!, redHex);
/// Compact action-hint line. Only advertises actions the player can actually take right now.
/// ✦ marks double-down; ✂ marks split — both are hidden once unavailable.
private static string ActionHints(bool canDouble = false, bool canSplit = false)
{
var parts = new List<string> { "[ditto]!bj hit[/ditto]", "[ditto]!bj stand[/ditto]" };
if (canDouble) parts.Add("[ditto]!bj double[/ditto] ✦");
if (canSplit) parts.Add("[ditto]!bj split[/ditto] ✂");
return string.Join(" · ", parts);
}
// ─────────────────────────────────────────────────────────────────────────
// Game-start (fresh deal)
// Two lines: hand state + action hints.
// Double is always shown — balance check happens inside HandleDouble if attempted.
// ─────────────────────────────────────────────────────────────────────────
public static async Task<string> GameStart(
UserDbModel user, decimal wager,
List<Card> playerHand, int playerValue,
List<Card> dealerHand,
bool canSplit, string redHex)
{
return
$"🃏 [B]{user.FormatUsername()}[/B] · {await wager.FormatKasinoCurrencyAsync()} — " +
$"[B]You:[/B] {FmtHand(playerHand, redHex)} ([plain]{playerValue}[/plain]) " +
$"[I]vs[/I] [B]Dealer:[/B] {FmtHand(dealerHand, redHex, hideFirst: true)}[br]" +
ActionHints(canDouble: true, canSplit: canSplit);
}
// ─────────────────────────────────────────────────────────────────────────
// Hit still in progress (hand not yet resolved)
// Two lines: drew-card + updated state + action hints.
// ─────────────────────────────────────────────────────────────────────────
public static string HitInProgress(
UserDbModel user, Card drawnCard,
List<Card> currentHand, int handValue,
List<Card> dealerHand,
string handLabel, string redHex)
{
return
$"{user.FormatUsername()}{handLabel} drew {FmtCard(drawnCard, redHex)} — " +
$"[B]You:[/B] {FmtHand(currentHand, redHex)} ({handValue}) " +
$"[I]vs[/I] [B]Dealer:[/B] {FmtHand(dealerHand, redHex, hideFirst: true)}[br]" +
ActionHints();
}
// ─────────────────────────────────────────────────────────────────────────
// Double-down confirmation
// One line, shown once before the auto-hit silently proceeds to resolution.
// ─────────────────────────────────────────────────────────────────────────
public static async Task<string> DoubledDown(UserDbModel user, decimal newTotalWager) =>
$"{user.FormatUsername()} doubled down · Wager: [B]{await newTotalWager.FormatKasinoCurrencyAsync()}[/B]";
// ─────────────────────────────────────────────────────────────────────────
// Split: initial deal display
// Three lines: wager header, both hands side-by-side, action hints for Hand 1.
// ─────────────────────────────────────────────────────────────────────────
public static async Task<string> SplitDeal(
UserDbModel user, decimal totalWager,
List<Card> hand1, int value1,
List<Card> hand2, int value2,
string redHex)
{
return
$"{user.FormatUsername()} split · Wager: [B]{await totalWager.FormatKasinoCurrencyAsync()}[/B][br]" +
$"[B]H1:[/B] {FmtHand(hand1, redHex)} ({value1}) · [B]H2:[/B] {FmtHand(hand2, redHex)} ({value2})[br]" +
$"Playing [B]H1[/B] — {ActionHints()}";
}
// ─────────────────────────────────────────────────────────────────────────
// Split: hand transition
// Two lines combining "what happened to the finished hand" and "what you
// have on the next hand" into one message, saving a separate chat post.
// ─────────────────────────────────────────────────────────────────────────
public static string SplitTransition(
int finishedIndex, List<Card> finishedHand, int finishedValue, bool busted,
int nextIndex, List<Card> nextHand, int nextValue,
string redHex)
{
var outcome = busted
? $"[B][COLOR={redHex}]BUST[/COLOR][/B]"
: $"stood [B]{finishedValue}[/B]";
return
$"[B]H{finishedIndex + 1}:[/B] {FmtHand(finishedHand, redHex)} ({finishedValue}) — {outcome} " +
$"→ [B]H{nextIndex + 1}:[/B] {FmtHand(nextHand, redHex)} ({nextValue})[br]" +
ActionHints();
}
// ─────────────────────────────────────────────────────────────────────────
// Final result
// Single hand → 2 lines: You vs Dealer — RESULT / Net · Balance
// Split game → 3 lines: header / H1 — R · H2 — R / Dealer · Net · Balance
// ─────────────────────────────────────────────────────────────────────────
public static async Task<string> FinalResult(
UserDbModel user,
IReadOnlyList<HandResultData> results,
List<Card> dealerHand, int dealerValue,
decimal totalEffect, decimal newBalance,
bool isSplitGame, string greenHex, string redHex)
{
var sb = new System.Text.StringBuilder();
var sign = totalEffect >= 0 ? "+" : "";
var netLine =
$"[U]Net {sign}{await totalEffect.FormatKasinoCurrencyAsync()} · " +
$"Balance {await newBalance.FormatKasinoCurrencyAsync()}[/U]";
if (!isSplitGame)
{
// ── Single hand: hand + dealer + result all on one line ──────────
var r = results[0];
sb.Append(
$"🃏 [B]{user.FormatUsername()}[/B] · " +
$"[B]You:[/B] {FmtHand(r.Hand, redHex)} ([plain]{r.PlayerValue}[/plain]) " +
$"[I]vs[/I] [B]Dealer:[/B] {FmtHand(dealerHand, redHex)} ([plain]{dealerValue}[/plain]) — " +
$"{await FormatOutcomeTag(r, greenHex, redHex)}[br]" +
netLine);
}
else
{
// ── Split game: header, then both hands on one line, dealer + net ─
sb.Append($"🃏 [B]{user.FormatUsername()}[/B][br]");
var handParts = new List<string>();
foreach (var r in results)
{
handParts.Add(
$"[B]H{r.HandIndex + 1}:[/B] {FmtHand(r.Hand, redHex)} ([plain]{r.PlayerValue}[/plain]) — " +
$"{await FormatOutcomeTag(r, greenHex, redHex)}");
}
sb.Append(string.Join(" · ", handParts) + "[br]");
sb.Append($"[B]Dealer:[/B] {FmtHand(dealerHand, redHex)} ([plain]{dealerValue}[/plain]) · {netLine}");
}
return sb.ToString();
}
// ─────────────────────────────────────────────────────────────────────────
// Outcome classification
// Called by BlackjackCommand.ResolveGame to populate HandResultData before
// passing it here for display. Keeping it in this file co-locates it with
// the outcome tags it feeds into.
// ─────────────────────────────────────────────────────────────────────────
internal static (HandOutcome Outcome, decimal Effect) ClassifyHand(
int playerValue, bool playerBlackjack,
int dealerValue, bool dealerBlackjack,
decimal handWager)
{
if (playerBlackjack && dealerBlackjack) return (HandOutcome.Push, 0);
if (playerBlackjack) return (HandOutcome.Blackjack, handWager * 1.5m);
if (dealerBlackjack) return (HandOutcome.DealerBlackjack, -handWager);
if (playerValue > 21) return (HandOutcome.Bust, -handWager);
if (dealerValue > 21) return (HandOutcome.DealerBust, handWager);
if (playerValue > dealerValue) return (HandOutcome.Win, handWager);
if (playerValue < dealerValue) return (HandOutcome.Lose, -handWager);
return (HandOutcome.Push, 0);
}
private static async Task<string> FormatOutcomeTag(HandResultData r, string greenHex, string redHex)
{
var amt = await Math.Abs(r.Effect).FormatKasinoCurrencyAsync();
return r.Outcome switch
{
HandOutcome.Blackjack => $"[B][COLOR={greenHex}]BLACKJACK! +{amt}[/COLOR][/B]",
HandOutcome.Win => $"[B][COLOR={greenHex}]WIN! +{amt}[/COLOR][/B]",
HandOutcome.DealerBust => $"[B][COLOR={greenHex}]DEALER BUST! +{amt}[/COLOR][/B]",
HandOutcome.Lose => $"[B][COLOR={redHex}]LOSE! -{amt}[/COLOR][/B]",
HandOutcome.Bust => $"[B][COLOR={redHex}]BUST! -{amt}[/COLOR][/B]",
HandOutcome.DealerBlackjack => $"[B][COLOR={redHex}]DEALER BLACKJACK! -{amt}[/COLOR][/B]",
HandOutcome.Push => "[B][COLOR=orange]PUSH[/COLOR][/B]",
_ => "?"
};
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Supporting types used across BlackjackDisplay and BlackjackCommand
// ─────────────────────────────────────────────────────────────────────────────
internal enum HandOutcome
{
Blackjack, DealerBlackjack, Win, Lose, Bust, DealerBust, Push
}
/// Pre-computed per-hand result data passed from BlackjackCommand.ResolveGame
/// to BlackjackDisplay.FinalResult for rendering.
internal record HandResultData(
int HandIndex,
List<Card> Hand,
int PlayerValue,
HandOutcome Outcome,
decimal Effect);

View File

@@ -0,0 +1,153 @@
using System.Net.Http.Headers;
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using RandN;
using RandN.Compat;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class CoinflipCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^coinflip$", RegexOptions.IgnoreCase),
new Regex(@"^coinflip (?<amount>\d+) (?<choice>heads|tails)$", RegexOptions.IgnoreCase),
new Regex(@"^coinflip (?<amount>\d+\.\d+) (?<choice>heads|tails)$", RegexOptions.IgnoreCase),
];
public string? HelpText => "!coinflip <amount> <heads|tails>, flip a coin";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(5);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(15)
};
public bool WhisperCanInvoke => false;
private static double _houseEdge = 0.015; // house edge hack?
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KasinoCoinflipCleanupDelay,
BuiltIn.Keys.KasinoCoinflipEnabled
]);
var coinflipEnabled = settings[BuiltIn.Keys.KasinoCoinflipEnabled].ToBoolean();
if (!coinflipEnabled)
{
var gameDisabledCleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, coinflip is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoCoinflipCleanupDelay].ToType<int>());
if (!arguments.TryGetValue("amount", out var amount))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, not enough arguments. !coinflip <wager> <heads|tails>",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (!arguments.TryGetValue("choice", out var choice))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, not enough arguments. !coinflip <wager> <heads|tails>",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var choiceStr = choice.Value.ToLowerInvariant();
var wager = Convert.ToDecimal(amount.Value);
if (wager <= 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your wager must be greater than zero.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (gambler.Balance < wager)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var rolled = Money.GetRandomDouble(gambler);
var colors =
await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
decimal newBalance;
if (rolled > 0.5 + _houseEdge)
{
// won
var coinflipAnimation = GetCoinFlipAnimationUrl(choiceStr);
await botInstance.SendChatMessageAsync($"[IMG]{coinflipAnimation}[/IMG]", true, autoDeleteAfter: cleanupDelay);
await Task.Delay(1500, ctx);
var effect = wager;
newBalance = await Money.NewWagerAsync(gambler.Id, wager, effect, WagerGame.CoinFlip, ct: ctx);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]WON![/COLOR][/B] " +
$"You won {await effect.FormatKasinoCurrencyAsync()} and your balance is now {await newBalance.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: cleanupDelay);
return;
}
// lost
bool isJacky = rolled > 0.5; // would've won without house edge
var coinflipAnimationURL = GetCoinFlipAnimationUrl("heads" == choiceStr ? "tails" : "heads", isJacky);
await botInstance.SendChatMessageAsync($"[IMG]{coinflipAnimationURL}[/IMG]", true, autoDeleteAfter: cleanupDelay);
await Task.Delay(1500, ctx);
newBalance = await Money.NewWagerAsync(gambler.Id, wager, -wager, WagerGame.CoinFlip, ct: ctx);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]LOST![/COLOR][/B] " +
$"Your balance is now {await newBalance.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: cleanupDelay);
}
private static string GetCoinFlipAnimationUrl(string choiceStr, bool isJacky = false)
{
var baseUrl = "https://i.ddos.lgbt/u";
var unixTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (isJacky)
{
return $"{baseUrl}/bossmancoin-{choiceStr}-jacky.webp?{unixTime}";
}
return $"{baseUrl}/bossmancoin-{choiceStr}.webp?{unixTime}";
}
}

View File

@@ -0,0 +1,163 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class DiceCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"dice (?<amount>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^dice (?<amount>\d+\.\d+)$", RegexOptions.IgnoreCase)
];
public string? HelpText => "!dice, roll the dice (not really, you roll between 0 - 100)";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(5);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(15)
};
public bool WhisperCanInvoke => false;
private static double _houseEdge = 0.015; // house edge hack?
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KasinoDiceCleanupDelay,
BuiltIn.Keys.KasinoDiceEnabled
]);
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
// Check if dice is enabled
var diceEnabled = (settings[BuiltIn.Keys.KasinoDiceEnabled]).ToBoolean();
if (!diceEnabled)
{
var gameDisabledCleanupDelay= TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, dice is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoDiceCleanupDelay].ToType<int>());
if (!arguments.TryGetValue("amount", out var amount))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, not enough arguments. !dice <wager>",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var wager = Convert.ToDecimal(amount.Value);
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (gambler.Balance < wager)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (wager == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you have to wager more than {await wager.FormatKasinoCurrencyAsync()}", true,
autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var rolled = Money.GetRandomDouble(gambler);
var colors =
await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
// print dice game slider
await botInstance.SendChatMessageAsync($"{ConstructDiceGameOutput(rolled)}",true, autoDeleteAfter: cleanupDelay);
decimal newBalance;
if (rolled > 0.5 + _houseEdge)
{
// you win dice
var effect = wager;
newBalance = await Money.NewWagerAsync(gambler.Id, wager, effect, WagerGame.Dice, ct: ctx);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you rolled a {rolled * 100:N2} and [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]WON![/COLOR][/B] " +
$"You won {await effect.FormatKasinoCurrencyAsync()} and your balance is now {await newBalance.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: cleanupDelay);
}
else
{
// you lose dice
newBalance = await Money.NewWagerAsync(gambler.Id, wager, -wager, WagerGame.Dice, ct: ctx);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you rolled a {rolled * 100:N2} and [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]LOST![/COLOR][/B] " +
$"Your balance is now {await newBalance.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: cleanupDelay);
}
}
private static string ConstructDiceGameOutput(double rolled)
{
// returns two rows as one string
// row one has dice emoji shifted usisng spaces by appropriate ammount accourding to rolled
// second row constructs the dice "meter" according to game specs
var diceEmoji = "🎲";
var invisibleAsciiSpace = ""; //U+2800
// 21 asciispaces fills the display, 20 dashes and one | fills the meter
// rolled * 21 * invisible ascii space creates the illusion of dice being alligned with rolled number
var toShift = (int)Math.Round(21 * rolled);
string diceDisplayShifted = String.Concat(Enumerable.Repeat(invisibleAsciiSpace, toShift));
diceDisplayShifted += diceEmoji;
const int DICE_METER_LENGTH = 20; // uhh this should influence how much the dice emoji is shifted as declared before just leave at 20 for now
const string DICE_METER_LEFT = "─";
const string DICE_METER_LEFT_COLOR = "#f1323e"; // red for lose
const string DICE_METER_MIDDLE = "┃";
const string DICE_METER_MIDDLE_COLOR = "#886cff"; // I forgot what this color is
const string DICE_METER_RIGHT = "─";
const string DICE_METER_RIGHT_COLOR = "#3dd179"; // green for win
string diceMeter = "";
if (rolled > 0.5 && rolled < 0.5 + _houseEdge)
{
// rigged dice scenario
diceMeter += $"[B][COLOR={DICE_METER_LEFT_COLOR}]";
int redMeterLength = Math.Min((int)Math.Round(DICE_METER_LENGTH * rolled + 1), DICE_METER_LENGTH);
diceMeter += String.Concat(Enumerable.Repeat(DICE_METER_LEFT, redMeterLength));
diceMeter += $"[/COLOR][COLOR={DICE_METER_MIDDLE_COLOR}]{DICE_METER_MIDDLE}[/COLOR][COLOR={DICE_METER_RIGHT_COLOR}]";
diceMeter += String.Concat(Enumerable.Repeat(DICE_METER_RIGHT, DICE_METER_LENGTH - (diceMeter.Split(DICE_METER_LEFT).Length - 1))) + "[/COLOR][/B] [img]https://i.ddos.lgbt/u/Nq3JXD.webp[/img]";
}
else
{
// no rig scenario
diceMeter += $"[B][COLOR={DICE_METER_LEFT_COLOR}]";
diceMeter += String.Concat(Enumerable.Repeat(DICE_METER_LEFT, DICE_METER_LENGTH / 2)); // ---------
diceMeter += "[/COLOR]";
diceMeter += $"[COLOR={DICE_METER_MIDDLE_COLOR}]{DICE_METER_MIDDLE}[/COLOR]"; // |
diceMeter += $"[COLOR={DICE_METER_RIGHT_COLOR}]";
diceMeter += String.Concat(Enumerable.Repeat(DICE_METER_RIGHT, DICE_METER_LENGTH / 2)); // --------
diceMeter += "[/COLOR][/B]";
}
return $"{diceDisplayShifted}\n{diceMeter}";
}
}

View File

@@ -0,0 +1,108 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class GuessWhatNumberCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^guess (?<amount>\d+) (?<number>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^guess (?<amount>\d+\.\d+) (?<number>\d+)$", RegexOptions.IgnoreCase),
new Regex("^guess$")
];
public string? HelpText => "What number am I thinking of?";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => true;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KasinoGuessWhatNumberCleanupDelay,
BuiltIn.Keys.KasinoGuessWhatNumberEnabled
]);
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
// Check if guesswhatnumber is enabled
var guessWhatNumberEnabled = settings[BuiltIn.Keys.KasinoGuessWhatNumberEnabled].ToBoolean();
if (!guessWhatNumberEnabled)
{
var gameDisabledCleanupDelay= TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.ReplyToUser(message,
$"{user.FormatUsername()}, guess what number is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGuessWhatNumberCleanupDelay].ToType<int>());
if (!arguments.TryGetValue("amount", out var amount))
{
await botInstance.ReplyToUser(message, $"{user.FormatUsername()}, not enough arguments. !guess <wager> <number between 1 and 10>", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var wager = Convert.ToDecimal(amount.Value);
var guess = Convert.ToInt32(arguments["number"].Value);
if (guess is < 1 or > 10)
{
await botInstance.ReplyToUser(message, $"{user.FormatUsername()}, your guess must be between 1 and 10", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (gambler.Balance < wager)
{
await botInstance.ReplyToUser(message,
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
return;
}
if (wager == 0)
{
await botInstance.ReplyToUser(message,
$"{user.FormatUsername()}, you have to wager more than {await wager.FormatKasinoCurrencyAsync()}", true,
autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var answer = Money.GetRandomNumber(gambler, 1, 10);
decimal newBalance;
var colors =
await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
if (guess == answer)
{
var effect = wager * 9;
newBalance = await Money.NewWagerAsync(gambler.Id, wager, effect, WagerGame.GuessWhatNumber, ct: ctx);
await botInstance.ReplyToUser(message,
$"{user.FormatUsername()}, [color={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]correct![/color] You won {await effect.FormatKasinoCurrencyAsync()} and your balance is now {await newBalance.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: cleanupDelay);
return;
}
newBalance = await Money.NewWagerAsync(gambler.Id, wager, -wager, WagerGame.GuessWhatNumber, ct: ctx);
await botInstance.ReplyToUser(message,
$"{user.FormatUsername()}, [color={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]wrong![/color] I was thinking of {answer}. Your balance is now {await newBalance.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: cleanupDelay);
}
}

View File

@@ -0,0 +1,213 @@
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using Microsoft.EntityFrameworkCore;
namespace KfChatDotNetBot.Commands.Kasino;
public class TempExcludeCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^admin kasino exclude$", RegexOptions.IgnoreCase),
new Regex(@"^admin kasino exclude (?<user_id>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^admin kasino exclude (?<user_id>\d+) (?<seconds>\d+)$", RegexOptions.IgnoreCase),
];
public string? HelpText => "Exclude somebody";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
if (!arguments.TryGetValue("user_id", out var userId))
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, not enough arguments. !admin kasino exclude user_id seconds", true);
return;
}
var targetUser = await db.Users.FirstOrDefaultAsync(x => x.KfId == Convert.ToInt32(userId.Value), cancellationToken: ctx);
if (targetUser == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, couldn't find user with that ID", true);
return;
}
var exclusionTime = TimeSpan.FromSeconds(600);
if (arguments.TryGetValue("seconds", out var seconds))
{
exclusionTime = TimeSpan.FromSeconds(Convert.ToInt32(seconds.Value));
}
var targetGambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (targetGambler == null)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, {targetUser.KfUsername} can't be excluded as he's banned.", true);
return;
}
targetGambler = await db.Gamblers.FirstOrDefaultAsync(x => x.Id == targetGambler.Id, cancellationToken: ctx);
var activeExclusion = await Money.GetActiveExclusionAsync(targetGambler!.Id, ctx);
if (activeExclusion != null)
{
var length = DateTimeOffset.UtcNow - activeExclusion.Expires;
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, {targetUser.KfUsername} is already excluded for another {length.Humanize()}",
true);
return;
}
await db.Exclusions.AddAsync(new GamblerExclusionDbModel
{
Gambler = targetGambler,
Expires = DateTimeOffset.UtcNow.Add(exclusionTime),
Created = DateTimeOffset.UtcNow,
Source = ExclusionSource.Administrative
}, ctx);
await db.SaveChangesAsync(ctx);
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, excluded {targetUser.KfUsername} for {exclusionTime.Humanize()}", true);
}
}
public class KasinoGameToggleCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^admin kasino (?<game>\w+) (?<action>enable|disable)$", RegexOptions.IgnoreCase),
new Regex("^kasino (?<action>open|close)$", RegexOptions.IgnoreCase)
];
public string? HelpText => "Enable or disable a Kasino game (use 'all' to toggle all games)";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
if (!arguments.TryGetValue("action", out var actionArg))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, usage: !admin kasino <game|all> enable|disable", true);
return;
}
string gameName;
if (!arguments.TryGetValue("game", out var gameArg))
{
gameName = "all";
}
else
{
gameName = gameArg.Value;
}
var action = actionArg.Value.ToLower();
var shouldEnable = action is "enable" or "open";
var status = shouldEnable ? "enabled" : "disabled";
await using var db = new ApplicationDbContext();
var gameSettingPattern = new Regex(@"^Kasino\.(?<game>\w+)\.Enabled$", RegexOptions.IgnoreCase);
var allGameSettings = await db.Settings
.Where(s => s.Key.StartsWith("Kasino.") && s.Key.EndsWith(".Enabled") && !s.Key.Contains("DailyDollar"))
.ToListAsync(ctx);
// Handle "all" games
if (gameName.Equals("all", StringComparison.OrdinalIgnoreCase))
{
foreach (var setting in allGameSettings)
{
await SettingsProvider.SetValueAsBooleanAsync(setting.Key, shouldEnable);
}
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, all {allGameSettings.Count} Kasino games have been {status}.", true);
return;
}
// Handle individual game - find matching setting key
var matchedSetting = allGameSettings.FirstOrDefault(s =>
{
var match = gameSettingPattern.Match(s.Key);
return match.Success && match.Groups["game"].Value.Equals(gameName, StringComparison.OrdinalIgnoreCase);
});
if (matchedSetting is null)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, unknown game '{gameName}'. Use '!admin kasino games' to see available games, or use 'all' to toggle all games.",
true);
return;
}
await SettingsProvider.SetValueAsBooleanAsync(matchedSetting.Key, shouldEnable);
var match = gameSettingPattern.Match(matchedSetting.Key);
var gameDisplayName = match.Groups["game"].Value.Humanize();
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, {gameDisplayName} has been {status}.", true);
}
}
public class KasinoGameListCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^admin kasino games$", RegexOptions.IgnoreCase)
];
public string? HelpText => "List all kasino games and their status";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var response = $"{user.FormatUsername()}, Kasino games:[br]";
var colors = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor,
BuiltIn.Keys.KiwiFarmsRedColor
]);
await using var db = new ApplicationDbContext();
var gameSettingPattern = new Regex(@"^Kasino\.(?<game>\w+)\.Enabled$", RegexOptions.IgnoreCase);
var allGameSettings = await db.Settings
.Where(s => s.Key.StartsWith("Kasino.") && s.Key.EndsWith(".Enabled"))
.ToListAsync(ctx);
var orderedSettings = allGameSettings.OrderBy(s =>
{
var match = gameSettingPattern.Match(s.Key);
return match.Groups["game"].Value;
});
foreach (var setting in orderedSettings)
{
var isEnabled = setting.Value?.Equals("true", StringComparison.OrdinalIgnoreCase) ?? false;
var match = gameSettingPattern.Match(setting.Key);
var gameName = match.Groups["game"].Value.Humanize();
var status = isEnabled
? $"[B][COLOR={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]ENABLED[/COLOR][/B]"
: $"[B][COLOR={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]DISABLED[/COLOR][/B]";
response += $"{gameName}: {status}[br]";
}
await botInstance.SendChatMessageAsync(response, true);
}
}

View File

@@ -0,0 +1,373 @@
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Commands.Kasino;
public class KasinoNewEventCommand : ICommand
{
public List<Regex> Patterns =>
[
new Regex("^kasino event new$", RegexOptions.IgnoreCase),
new Regex(@"^kasino event new (?<type>\w+)$", RegexOptions.IgnoreCase),
new Regex(@"^kasino event new (?<type>\w+) (?<description>.+)$", RegexOptions.IgnoreCase),
];
public string? HelpText => "Create a new kasino event";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoEventWeightWinAgainstSelectionTime, BuiltIn.Keys.KasinoEventData,
BuiltIn.Keys.KasinoEventTextLengthLimit
]);
if (!arguments.TryGetValue("type", out var type))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, not enough arguments. !kasino event new <win-lose, time-prediction> <description>",
true, autoDeleteAfter: TimeSpan.FromSeconds(60));
return;
}
if (!arguments.TryGetValue("description", out var description))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, not enough arguments. !kasino event new <win-lose, time-prediction> <description>",
true, autoDeleteAfter: TimeSpan.FromSeconds(60));
return;
}
if (description.Length > settings[BuiltIn.Keys.KasinoEventTextLengthLimit].ToType<int>())
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, your event description / text with a length of " +
$"{description.Length} characters exceeds the limit of " +
$"{settings[BuiltIn.Keys.KasinoEventTextLengthLimit].ToType<int>()} " +
$"characters", true);
return;
}
var useTimeWeightedPayout = settings[BuiltIn.Keys.KasinoEventWeightWinAgainstSelectionTime].ToBoolean();
KasinoEventType eventType;
var guide = string.Empty;
if (type.Value.Equals("win-lose", StringComparison.CurrentCultureIgnoreCase))
{
eventType = KasinoEventType.WinLose;
guide = "Add option: !kasino event {EventId} options add|new <text>[br]" +
"Remove option: !kasino event {EventId} options del|remove <option id>";
}
else if (type.Value.Equals("time-prediction", StringComparison.CurrentCultureIgnoreCase))
{
eventType = KasinoEventType.Prediction;
}
else
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, unknown event type given. Options are win-lose or time-prediction",
true, autoDeleteAfter: TimeSpan.FromSeconds(60));
return;
}
var eventData = settings[BuiltIn.Keys.KasinoEventData].JsonDeserialize<List<KasinoEventModel>>() ?? [];
var newEvent = new KasinoEventModel
{
EventText = description.Value,
EventId = Money.GenerateEventId(),
Options = [],
EventType = eventType,
EventAnnouncementReceived = null,
EventState = KasinoEventState.Incomplete,
SelectionTimeWeightedPayout = useTimeWeightedPayout
};
eventData.Add(newEvent);
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.KasinoEventData, eventData);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, new incomplete kasino event created. Event ID is {newEvent.EventId}.[br]" +
guide.Replace("{EventId}", newEvent.EventId) +
$"Start the event: !kasino event {newEvent.EventId} start[br]" +
$"Abandon the event: !kasino event {newEvent.EventId} abandon[br]" +
$"Get event info: !kasino event {newEvent.EventId} info", true);
}
}
public class KasinoEventStart : ICommand
{
public List<Regex> Patterns =>
[
new Regex(@"^kasino event (?<event_id>\w+) start$", RegexOptions.IgnoreCase),
];
public string? HelpText => "Start a Kasino event";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(300);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoEventData
]);
var eventId = arguments["event_id"].Value;
var eventList = settings[BuiltIn.Keys.KasinoEventData].JsonDeserialize<List<KasinoEventModel>>() ?? [];
var targetEvent = eventList.FirstOrDefault(x => x.EventId == eventId);
if (targetEvent == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, this event does not exist", true);
return;
}
if (targetEvent.EventState != KasinoEventState.Incomplete)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, the event is in state '{targetEvent.EventState.Humanize()}'. Only incomplete events can be started",
true);
return;
}
var guide = $"Submit your prediction: !predict {targetEvent.EventId} <wager> 1h30m15s";
if (targetEvent.EventType == KasinoEventType.WinLose)
{
if (targetEvent.Options.Count == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you can't start a win-lose event with no options.", true);
return;
}
guide = targetEvent.Options.Aggregate(string.Empty,
(current, option) => current + $"{option.OptionId}: {option.OptionText}[br]");
guide += "Submit your choice: !";
}
targetEvent.EventState = KasinoEventState.PendingAnnouncement;
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.KasinoEventData, eventList);
var msg = $":!: :!: A Keno Kasino event has started! :!: :!:[br]{targetEvent.EventText}[br]{guide}";
var msgIds = await botInstance.SendChatMessagesAsync(msg.FancySplitMessage(partSeparator: "[br]"), true);
var i = 0;
while (msgIds[0].ChatMessageUuid != null)
{
i++;
await Task.Delay(TimeSpan.FromMilliseconds(100), ctx);
if (i > 3000) return;
}
targetEvent.EventState = KasinoEventState.Started;
targetEvent.EventAnnouncementReceived = msgIds[0].SentAt;
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.KasinoEventData, eventList);
}
}
public class KasinoNewEventOption : ICommand
{
public List<Regex> Patterns =>
[
new Regex(@"^kasino event (?<event_id>\w+) options add (?<option_text>\.+)$", RegexOptions.IgnoreCase),
new Regex(@"^kasino event (?<event_id>\w+) options new (?<option_text>\.+)$", RegexOptions.IgnoreCase),
];
public string? HelpText => "Add an option to a Kasino event";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoEventData, BuiltIn.Keys.KasinoEventOptionTextLengthLimit
]);
var eventId = arguments["event_id"].Value;
var eventList = settings[BuiltIn.Keys.KasinoEventData].JsonDeserialize<List<KasinoEventModel>>() ?? [];
var targetEvent = eventList.FirstOrDefault(x => x.EventId == eventId);
if (targetEvent == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, this event does not exist", true);
return;
}
if (targetEvent.EventState != KasinoEventState.Incomplete)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, the event is in state '{targetEvent.EventState.Humanize()}'. Only incomplete events can have their options modified",
true);
return;
}
if (targetEvent.EventType == KasinoEventType.Prediction)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, events based around time predictions can't have options", true);
return;
}
var optionText = arguments["option_text"].Value;
if (optionText.Length > settings[BuiltIn.Keys.KasinoEventOptionTextLengthLimit].ToType<int>())
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, your option text with a length of " +
$"{optionText.Length} characters exceeds the limit of " +
$"{settings[BuiltIn.Keys.KasinoEventOptionTextLengthLimit].ToType<int>()} " +
$"characters", true);
return;
}
var newOption = new KasinoEventOptionModel
{
OptionId = Money.GenerateEventId(),
OptionText = optionText,
Won = false
};
targetEvent.Options.Add(newOption);
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.KasinoEventData, eventList);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, created new option with id {newOption.OptionId}[br]To remove this option, run: !kasino event {targetEvent.EventId} options remove {newOption.OptionId}", true);
}
}
public class KasinoRemoveEventOption : ICommand
{
public List<Regex> Patterns =>
[
new Regex(@"^kasino event (?<event_id>\w+) options del (?<option_id>\.+)$", RegexOptions.IgnoreCase),
new Regex(@"^kasino event (?<event_id>\w+) options remove (?<option_id>\.+)$", RegexOptions.IgnoreCase),
];
public string? HelpText => "Remove an option from a Kasino event";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoEventData
]);
var eventId = arguments["event_id"].Value;
var eventList = settings[BuiltIn.Keys.KasinoEventData].JsonDeserialize<List<KasinoEventModel>>() ?? [];
var targetEvent = eventList.FirstOrDefault(x => x.EventId == eventId);
if (targetEvent == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, this event does not exist", true);
return;
}
if (targetEvent.EventState != KasinoEventState.Incomplete)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, the event is in state '{targetEvent.EventState.Humanize()}'. Only incomplete events can have their options modified",
true);
return;
}
if (targetEvent.EventType == KasinoEventType.Prediction)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, events based around time predictions can't have options", true);
return;
}
var optionId = arguments["optionId"].Value.ToLower();
var targetOption = targetEvent.Options.FirstOrDefault(x => x.OptionId == optionId);
if (targetOption == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, the option ID you provided does not exist", true);
return;
}
targetEvent.Options.Remove(targetOption);
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.KasinoEventData, eventList);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, removed option with id {targetOption.OptionId}", true);
}
}
public class KasinoGetEventInfo : ICommand
{
public List<Regex> Patterns =>
[
new Regex(@"^kasino event (?<event_id>\w+) info$", RegexOptions.IgnoreCase),
];
public string? HelpText => "Get Kasino event info";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoEventData
]);
var eventId = arguments["event_id"].Value;
var eventList = settings[BuiltIn.Keys.KasinoEventData].JsonDeserialize<List<KasinoEventModel>>() ?? [];
var targetEvent = eventList.FirstOrDefault(x => x.EventId == eventId);
if (targetEvent == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, this event does not exist", true);
return;
}
var response = $"{user.FormatUsername()}, Event ID {targetEvent.EventId} with type {targetEvent.EventType.Humanize()} " +
$"which is in state {targetEvent.EventState.Humanize()} and has the following text:" +
$"[br]{targetEvent.EventText.TrimStart('/')}";
if (targetEvent.EventType == KasinoEventType.WinLose)
{
response += "[br]Options:";
foreach (var option in targetEvent.Options)
{
response += $"[br]{option.OptionId}: {option.OptionText}";
}
}
response += $"[br]Event Announcement Received: {targetEvent.EventAnnouncementReceived?.ToString("o")}";
response += $"[br]Selection Time Weighted Payout: {targetEvent.SelectionTimeWeightedPayout}";
await botInstance.SendChatMessagesAsync(response.FancySplitMessage(partSeparator: "[br]"), true);
}
}
public class KasinoGetEvents : ICommand
{
public List<Regex> Patterns =>
[
new Regex(@"^kasino events$", RegexOptions.IgnoreCase),
new Regex(@"^kasino event list$", RegexOptions.IgnoreCase),
];
public string? HelpText => "Get Kasino events";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoEventData
]);
var eventList = settings[BuiltIn.Keys.KasinoEventData].JsonDeserialize<List<KasinoEventModel>>() ?? [];
var response = $"{user.FormatUsername()}, there are {eventList.Count} events in the database";
foreach (var targetEvent in eventList)
{
response += $"[br]{targetEvent.EventId} ({targetEvent.EventState.Humanize()}): {targetEvent.EventText}";
}
await botInstance.SendChatMessagesAsync(response.FancySplitMessage(partSeparator: "[br]"), true);
}
}

View File

@@ -0,0 +1,488 @@
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using Microsoft.EntityFrameworkCore;
using NLog;
using RandN;
using RandN.Compat;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
public class GetBalanceCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^balance", RegexOptions.IgnoreCase),
new Regex("^bal$", RegexOptions.IgnoreCase)
];
public string? HelpText => "Get your gamba balance";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance is {await gambler!.Balance.FormatKasinoCurrencyAsync()}", true);
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
await botInstance.SendChatMessageAsync(
$"{await botInstance.BotServices.KasinoShop.Gambler_Profiles[user.KfId].FormatBalanceAsync()}", true,
autoDeleteAfter: TimeSpan.FromSeconds(10));
}
}
}
[KasinoCommand]
public class GetExclusionCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^exclusion$", RegexOptions.IgnoreCase),
];
public string? HelpText => "Get your exclusion status";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
{
throw new InvalidOperationException($"Caught a null when retrieving {user.Id}'s gambler entity");
}
var exclusion = await Money.GetActiveExclusionAsync(gambler.Id, ct: ctx);
if (exclusion == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you are currently not excluded.", true);
return;
}
var duration =
(exclusion.Expires - exclusion.Created).Humanize(precision: 1, minUnit: TimeUnit.Second,
maxUnit: TimeUnit.Day);
var expires =
(exclusion.Expires - DateTimeOffset.UtcNow).Humanize(precision: 2, minUnit: TimeUnit.Second,
maxUnit: TimeUnit.Day);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your exclusion for {duration} expires in {expires}", true);
}
}
[KasinoCommand]
public class SendJuiceCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^juice (?<user_id>\d+) (?<amount>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^juice (?<user_id>\d+) (?<amount>\d+\.\d+)$", RegexOptions.IgnoreCase)
];
public string? HelpText => "Send juice to somebody";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var logger = LogManager.GetCurrentClassLogger();
await using var db = new ApplicationDbContext();
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
var targetUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == int.Parse(arguments["user_id"].Value), ctx);
var amount = decimal.Parse(arguments["amount"].Value);
if (gambler == null)
{
logger.Error($"Caught a null when looking up {user.KfUsername}");
return;
}
if (gambler.Balance < amount)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you don't have enough money to juice this much.", true);
return;
}
if (targetUser == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, the user ID you gave doesn't exist.", true);
return;
}
var targetGambler = await Money.GetGamblerEntityAsync(targetUser.Id, ct: ctx);
if (targetGambler == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you can't juice a banned user", true);
return;
}
await Money.ModifyBalanceAsync(gambler.Id, -amount, TransactionSourceEventType.Juicer,
$"Juice sent to {targetUser.KfUsername}", ct: ctx);
await Money.ModifyBalanceAsync(targetGambler.Id, amount, TransactionSourceEventType.Juicer, $"Juice from {user.KfUsername}",
gambler.Id, ctx);
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, {await amount.FormatKasinoCurrencyAsync()} has been sent to {targetUser.FormatUsername()}", true);
//KasinoShop stuff --------------------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, gambler.User, gambler);
await GlobalShopFunctions.CheckProfile(botInstance, targetGambler.User, targetGambler);
await botInstance.BotServices.KasinoShop.ProcessJuicerOrRainTracking(gambler, targetGambler, amount);
}
//------------------------------------------------------------------------------------------------
}
}
[KasinoCommand]
public class RakebackCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^rakeback", RegexOptions.IgnoreCase),
new Regex(@"^rapeback", RegexOptions.IgnoreCase)
];
public string? HelpText => "Collect your rakeback";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => new RateLimitOptionsModel
{
MaxInvocations = 1,
Window = TimeSpan.FromSeconds(30)
};
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
{
throw new InvalidOperationException($"Caught a null when retrieving {user.Id}'s gambler entity");
}
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.MoneyRakebackPercentage, BuiltIn.Keys.MoneyRakebackMinimumAmount
]);
var mostRecentRakeback = await db.Transactions.OrderBy(x => x.Id).LastOrDefaultAsync(tx =>
tx.EventSource == TransactionSourceEventType.Rakeback && tx.Gambler.Id == gambler.Id, cancellationToken: ctx);
long offset = 0;
if (mostRecentRakeback != null)
{
offset = mostRecentRakeback.TimeUnixEpochSeconds;
}
var wagers = await db.Wagers.Where(w => w.Gambler.Id == gambler.Id && w.TimeUnixEpochSeconds > offset).ToListAsync(ctx);
if (wagers.Count == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you haven't wagered since your last rakeback.", true);
return;
}
var wagered = wagers.Sum(w => w.WagerAmount);
var rakeback = wagered * (decimal)(settings[BuiltIn.Keys.MoneyRakebackPercentage].ToType<float>() / 100.0);
var minimumRakeback = settings[BuiltIn.Keys.MoneyRakebackMinimumAmount].ToType<decimal>();
if (rakeback < minimumRakeback)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, your rakeback payout of {await rakeback.FormatKasinoCurrencyAsync()} is below the minimum amount of {await minimumRakeback.FormatKasinoCurrencyAsync()}", true);
return;
}
await Money.ModifyBalanceAsync(gambler.Id, rakeback, TransactionSourceEventType.Rakeback, "Rakeback claimed by gambler",
ct: ctx);
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, the hostess has given you {await rakeback.FormatKasinoCurrencyAsync()} rakeback", true);
}
}
[KasinoCommand]
public class LossbackCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^lossback", RegexOptions.IgnoreCase)
];
public string? HelpText => "Collect your lossback";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => new RateLimitOptionsModel
{
Window = TimeSpan.FromSeconds(30),
MaxInvocations = 1
};
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var logger = LogManager.GetCurrentClassLogger();
await using var db = new ApplicationDbContext();
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
{
throw new InvalidOperationException($"Caught a null when retrieving {user.Id}'s gambler entity");
}
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.MoneyLossbackPercentage, BuiltIn.Keys.MoneyLossbackMinimumAmount
]);
var mostRecentLossback = await db.Transactions.OrderBy(x => x.Id).LastOrDefaultAsync(tx =>
tx.EventSource == TransactionSourceEventType.Lossback && tx.Gambler.Id == gambler.Id, cancellationToken: ctx);
long offset = 0;
if (mostRecentLossback != null)
{
offset = mostRecentLossback.TimeUnixEpochSeconds;
}
logger.Info($"{user.KfUsername}'s offset is {offset}");
var wagers = await db.Wagers.Where(w => w.Gambler.Id == gambler.Id && w.TimeUnixEpochSeconds > offset && w.Multiplier < 1).ToListAsync(ctx);
if (wagers.Count == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you don't have any losses to juice back.", true);
return;
}
logger.Info($"{user.KfUsername} has {wagers.Count} wagers to lossback");
var wagered = wagers.Sum(wager => Math.Abs(wager.WagerEffect));
var lossback = wagered * (decimal)(settings[BuiltIn.Keys.MoneyLossbackPercentage].ToType<float>() / 100.0);
var minimumLossback = settings[BuiltIn.Keys.MoneyLossbackMinimumAmount].ToType<decimal>();
if (lossback < minimumLossback)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, your lossback payout of {await lossback.FormatKasinoCurrencyAsync()} is below the minimum amount of {await minimumLossback.FormatKasinoCurrencyAsync()}", true);
return;
}
await Money.ModifyBalanceAsync(gambler.Id, lossback, TransactionSourceEventType.Lossback, "Lossback claimed by gambler",
ct: ctx);
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, the hostess has given you {await lossback.FormatKasinoCurrencyAsync()} lossback", true);
}
}
[KasinoCommand]
public class AbandonKasinoCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^abandon$", RegexOptions.IgnoreCase),
new Regex(@"^abandon confirm$", RegexOptions.IgnoreCase)
];
public string? HelpText => "Abandon your Keno Kasino gambler account";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => new RateLimitOptionsModel
{
Window = TimeSpan.FromSeconds(60),
MaxInvocations = 1
};
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
if (!message.MessageRawHtmlDecoded.EndsWith("abandon confirm"))
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, are you sure you wish to abandon your Keno Kasino™ account?[br]" +
$"This will reset your wager statistics, balance, and temporary exclusions.[br]" +
$"You will lose all perks such as VIP levels and custom titles.[br]" +
$"To confirm, reply with: !abandon confirm", true);
return;
}
await using var db = new ApplicationDbContext();
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
{
throw new InvalidOperationException($"Caught a null when retrieving {user.Id}'s gambler entity");
}
db.Attach(gambler);
gambler!.State = GamblerState.Abandoned;
await db.SaveChangesAsync(ctx);
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, Kasino account with ID {gambler.Id} has been marked as abandoned.", true);
}
}
[KasinoCommand]
public class PocketWatchCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^pocketwatch (?<user_id>\d+)", RegexOptions.IgnoreCase),
new Regex(@"^pocketwatch @(?<username>.+)$", RegexOptions.IgnoreCase),
];
public string? HelpText => "Check a user's balance";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
UserDbModel? targetUser;
if (arguments["username"].Success)
{
var chatUser = botInstance.FindUserByName(arguments["username"].Value);
if (chatUser == null)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, couldn't find that user in chat. They must be present in chat to look up by username.",
true);
return;
}
targetUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == chatUser.Id, ctx);
}
else
{
targetUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == int.Parse(arguments["user_id"].Value), ctx);
}
if (targetUser == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, the user ID you gave doesn't exist.", true);
return;
}
var targetGambler = await Money.GetGamblerEntityAsync(targetUser.Id, ct: ctx);
if (targetGambler == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, this user is excluded from the kasino", true);
return;
}
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, {targetUser.KfUsername} has {await targetGambler.Balance.FormatKasinoCurrencyAsync()}", true);
}
}
[KasinoCommand]
[WagerCommand]
public class HostessCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^hostess", RegexOptions.IgnoreCase),
];
public string? HelpText => "Ask the hostess for help";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(60);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 1,
Window = TimeSpan.FromSeconds(30)
};
public bool WhisperCanInvoke => false;
private static readonly string[] StaticResponses = [
"For questions regarding your current contract please contact us at contact@bossmanjack.com",
"Unspecified error",
"Have you considered giving us a review on TrustPilot?",
"We are sincerely sorry to hear that you are not having a positive experience on our platform. Please be assured that we take matters of fairness and transparency very seriously.",
"At the Kasino, we prioritize strict adherence to regulatory requirements to maintain the security and integrity of our platform.",
"There are currently no hosts online to serve your request.",
"We would like to assist you further and understand better the issue. Due to that, we have requested further information.",
"When it comes to RTP, it is important to understand that this number is calculated based on at least 1 million bets. So, over a session of a few thousand bets, anything can happen, which is exactly what makes gambling exciting.",
"We understand that gambling involves risks, and while some players may experience periods of winning and losing, we strive to provide resources and tools to support responsible gambling practices.",
"Thank you for taking the time to leave a 5-star review! We're thrilled to have provided you with a great experience.",
"Please rest assured that our platform operates with certified random number generators to ensure fairness and transparency in all gaming outcomes. We do not manipulate the odds or monitor games to favor any particular outcome.",
"We would like to inform you that we have responded to your recent post.",
"All of our Kasino originals are 100% probably fair and each and every single bet placed at our any games are verifiable.",
"We want to emphasize that our games are developed with the highest standards of integrity and fairness.",
"Stop harrassing me",
];
private static readonly string[] LlmPrompts = [
"You are a hostess for a virtual casino. You've just gotten a message from a customer with cripling gambling addiction issues. Respond in a smug and condescending manner.",
"You are an overworked fastfood worker at a drive-thru. A confused gambling addict just arrived. Respond with at most two sentences."
];
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var random = RandomShim.Create(StandardRng.Create());
if (random.NextDouble() < 0.06)
{
// ignore 6% of requests like the old hostess command
return;
}
var orKeySet = await SettingsProvider.GetValueAsync(BuiltIn.Keys.OpenrouterApiKey);
if (orKeySet.Value == null || random.NextDouble() < 0.3)
{
var response = StaticResponses[random.Next(0, StaticResponses.Length)];
await botInstance.SendChatMessageAsync(response, true);
return;
}
var msg = message.MessageRaw.Replace("hostess", "").Trim();
if (string.IsNullOrWhiteSpace(msg))
{
msg = "I need help with my gambling addiction.";
}
var llmResponse = await OpenRouter.GetResponseAsync(
LlmPrompts[random.Next(0, LlmPrompts.Length)],
msg,
model: "deepseek/deepseek-v3.2",
Temperature: 1.0f + (float)((random.NextDouble() - 0.3) * 0.5)
);
if (llmResponse == null)
{
var fallback = StaticResponses[random.Next(0, StaticResponses.Length)];
await botInstance.SendChatMessageAsync(fallback, true);
return;
}
await botInstance.SendChatMessageAsync(llmResponse, true, ChatBot.LengthLimitBehavior.TruncateExactly);
}
}
[KasinoCommand]
public class GetDailyDollarCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^daily", RegexOptions.IgnoreCase),
new Regex("^juiceme", RegexOptions.IgnoreCase),
];
public string? HelpText => "Get your daily dollah";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoDailyDollarEnabled, BuiltIn.Keys.KasinoDailyDollarAmount
]);
if (!settings[BuiltIn.Keys.KasinoDailyDollarEnabled].ToBoolean())
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, daily dollar has been disabled :(", true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler!.Created.Date == DateTime.UtcNow.Date)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, new accounts cannot redeem a daily dollar", true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
await using var db = new ApplicationDbContext();
var mostRecentTxn = await db.Transactions.OrderBy(x => x.Id).LastOrDefaultAsync(x =>
x.Gambler == gambler && x.EventSource == TransactionSourceEventType.DailyDollar, cancellationToken: ctx);
if (mostRecentTxn != null)
{
var rolloverTime = await Money.GetKasinoDate();
// It's really more a question of whether the most recent txn was in the same game day
if (mostRecentTxn.Time >= rolloverTime)
{
var span = rolloverTime.AddDays(1) - DateTimeOffset.UtcNow;
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your next daily dollar will be available in {span.Humanize(maxUnit: TimeUnit.Hour, minUnit: TimeUnit.Second)}",
true, autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
}
var amount = settings[BuiltIn.Keys.KasinoDailyDollarAmount].ToType<decimal>();
await Money.ModifyBalanceAsync(gambler!.Id, amount, TransactionSourceEventType.DailyDollar,
"Daily dollar redemption", ct: ctx);
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you redeemed {await amount.FormatKasinoCurrencyAsync()}", true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
}
}

View File

@@ -0,0 +1,326 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using NLog;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class KenoCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^keno (?<difficulty>classic|low|medium|high) (?<amount>\d+(?:\.\d+)?) (?<numbers>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^keno (?<difficulty>classic|low|medium|high) (?<amount>\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase),
new Regex(@"^keno (?<amount>\d+(?:\.\d+)?) (?<numbers>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^keno (?<amount>\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase),
new Regex("^keno")
];
public string? HelpText => "!keno [bet amount] [numbers to pick(optional, default 10)]";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(60);
public RateLimitOptionsModel? RateLimitOptions => new RateLimitOptionsModel
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(10)
};
public bool WhisperCanInvoke => false;
private List<int> _playerNumbers = [];
private List<int> _casinoNumbers = [];
private decimal HOUSE_EDGE = (decimal)0.98;
private const string PlayerNumberDisplay = "⬜";
private const string CasinoNumberDisplay = "🔶";
private const string MatchRevealDisplay = "💠";
private const string BlankSpaceDisplay = "⬛";
private SentMessageTrackerModel? _kenoTable;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KasinoKenoCleanupDelay,
BuiltIn.Keys.KasinoKenoFrameDelay, BuiltIn.Keys.KasinoKenoEnabled
]);
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
// Check if keno is enabled
var kenoEnabled = (settings[BuiltIn.Keys.KasinoKenoEnabled]).ToBoolean();
if (!kenoEnabled)
{
var gameDisabledCleanupDelay =
TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, keno is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoKenoCleanupDelay].ToType<int>());
if (!arguments.TryGetValue("amount", out var amount)) //if user just enters !keno
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, not enough arguments. !keno <wager> <number between 1 and 10>, or !keno <wager> and 10 will be selected automatically",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
string difficultyString;
if (!arguments.TryGetValue("difficulty", out var difficultyArg))
{
difficultyString = "high";
}
else
{
difficultyString = difficultyArg.Value;
}
var wager = Convert.ToDecimal(amount.Value);
var numbers = !arguments.TryGetValue("numbers", out var userNumbers)
? 10
: Convert.ToInt32(userNumbers.Value); //if user just enters !keno <wager>
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (gambler.Balance < wager)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (wager == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you have to wager more than {await wager.FormatKasinoCurrencyAsync()}", true,
autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (numbers is < 1 or > 10) //if user picks invalid numbers
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you can only pick numbers from 1 - 10",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
//KasinoShop stuff -------------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
HOUSE_EDGE += botInstance.BotServices.KasinoShop.Gambler_Profiles[user.KfId].HouseEdgeModifier;
}
//------------------------------------------------------------------------------------------
var payoutMultipliersHigh =
new[,] //stole the payout multis from stake keno and re added the RTP, except for the 1000x
{
{ 0.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 1 selection
{ 0.0, 0.0, 17.27, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 2 selections
{ 0.0, 0.0, 0.0, 82.32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 3 selections
{ 0.0, 0.0, 0.0, 10.1, 261.61, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 4 selections
{ 0.0, 0.0, 0.0, 4.5, 48.48, 454.54, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 5 selections
{ 0.0, 0.0, 0.0, 0.0, 11.11, 353.53, 717.17, 0.0, 0.0, 0.0, 0.0 }, // 6 selections
{ 0.0, 0.0, 0.0, 0.0, 7.07, 90.90, 404.04, 808.08, 0.0, 0.0, 0.0 }, // 7 selections
{ 0.0, 0.0, 0.0, 0.0, 5.05, 20.20, 272.72, 606.06, 909.09, 0.0, 0.0 }, // 8 selections
{ 0.0, 0.0, 0.0, 0.0, 4.04, 11.11, 56.56, 505.05, 808.08, 1000.0, 0.0 }, // 9 selections
{ 0.0, 0.0, 0.0, 0.0, 3.53, 8.08, 13.13, 63.63, 505.05, 808.08, 1000.0 } // 10 selections
};
var payoutMultipliersClassic =
new[,] //stole the payout multis from stake keno and re added the RTP, except for the 1000x
{
{ 0.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 1 selection
{ 0.0, 1.93, 4.59, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 2 selections
{ 0.0, 1.02, 3.16, 10.6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 3 selections
{ 0.0, 0.81, 1.83, 10.1, 5.1, 22.96, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 4 selections
{ 0.0, 0.26, 1.42, 4.18, 16.83, 36.73, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 5 selections
{ 0.0, 0.0, 1.02, 3.75, 7.14, 16.83, 40.81, 0.0, 0.0, 0.0, 0.0 }, // 6 selections
{ 0.0, 0.0, 0.46, 3.06, 4.59, 14.28, 31.63, 61.22, 0.0, 0.0, 0.0 }, // 7 selections
{ 0.0, 0.0, 0.0, 2.24, 4.08, 13.26, 22.44, 56.12, 71.42, 0.0, 0.0 }, // 8 selections
{ 0.0, 0.0, 0.0, 1.58, 3.06, 8.16, 15.30, 44.89, 61.22, 86.73, 0.0 }, // 9 selections
{ 0.0, 0.0, 0.0, 1.42, 2.29, 4.59, 8.16, 17.34, 51.02, 81.63, 102.04 } // 10 selections
};
var payoutMultipliersLow =
new[,] //stole the payout multis from stake keno and re added the RTP, except for the 1000x
{
{ 0.7, 1.85, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 1 selection
{ 0.0, 2.04, 3.87, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 2 selections
{ 0.0, 1.12, 1.4, 26.53, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 3 selections
{ 0.0, 0.0, 2.24, 8.06, 91.83, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 4 selections
{ 0.0, 0.0, 1.53, 4.28, 13.26, 306.12, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 5 selections
{ 0.0, 0.0, 1.12, 2.04, 6.32, 102.04, 714.28, 0.0, 0.0, 0.0, 0.0 }, // 6 selections
{ 0.0, 0.0, 1.12, 1.63, 3.57, 15.3, 229.59, 714.28, 0.0, 0.0, 0.0 }, // 7 selections
{ 0.0, 0.0, 1.12, 1.53, 2.04, 5.61, 39.79, 102.04, 816.32, 0.0, 0.0 }, // 8 selections
{ 0.0, 0.0, 1.12, 1.32, 1.73, 2.55, 7.65, 51.02, 255.1, 1000.0, 0.0 }, // 9 selections
{ 0.0, 0.0, 1.12, 1.22, 1.32, 1.83, 3.57, 13.26, 51.02, 255.1, 1000.0 } // 10 selections
};
var payoutMultipliersMedium =
new[,] //stole the payout multis from stake keno and re added the RTP, except for the 1000x
{
{ 0.4, 2.8, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 1 selection
{ 0.0, 1.83, 5.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 2 selections
{ 0.0, 0.0, 2.85, 51.02, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 3 selections
{ 0.0, 0.0, 1.73, 10.2, 102.04, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 4 selections
{ 0.0, 0.0, 1.42, 4.08, 14.28, 397.95, 0.0, 0.0, 0.0, 0.0, 0.0 }, // 5 selections
{ 0.0, 0.0, 0.0, 3.06, 9.18, 183.67, 724.48, 0.0, 0.0, 0.0, 0.0 }, // 6 selections
{ 0.0, 0.0, 0.0, 2.04, 7.14, 30.61, 408.16, 816.32, 0.0, 0.0, 0.0 }, // 7 selections
{ 0.0, 0.0, 0.0, 2.04, 4.08, 11.22, 68.36, 408.16, 918.36, 0.0, 0.0 }, // 8 selections
{ 0.0, 0.0, 0.0, 2.04, 2.55, 11.11, 56.56, 505.05, 808.08, 1000.0, 0.0 }, // 9 selections
{ 0.0, 0.0, 0.0, 0.0, 3.53, 5.1, 15.3, 63.63, 102.04, 510.2, 1000.0 } // 10 selections
};
Dictionary<string, double[,]> payoutMultipliers = new Dictionary<string, double[,]>{
{ "high", payoutMultipliersHigh },
{ "low", payoutMultipliersLow},
{ "medium", payoutMultipliersMedium},
{ "classic", payoutMultipliersClassic}
};
_playerNumbers = GenerateKenoNumbers(numbers, gambler);
_casinoNumbers = GenerateKenoNumbers(10, gambler, true);
var matches = _playerNumbers.Intersect(_casinoNumbers).ToList();
var payoutMulti = payoutMultipliers[difficultyString][numbers - 1, matches.Count];
await AnimatedDisplayTable(_playerNumbers, _casinoNumbers, matches, botInstance);
var colors =
await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
decimal newBalance;
if (payoutMulti == 0) //you lose
{
newBalance = await Money.NewWagerAsync(gambler.Id, wager, -wager, WagerGame.Keno, ct: ctx);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you [color={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]lost {await wager.FormatKasinoCurrencyAsync()}[/color]. Your balance is now: {await newBalance.FormatKasinoCurrencyAsync()}.",
true, autoDeleteAfter: cleanupDelay);
botInstance.ScheduleMessageAutoDelete(_kenoTable ?? throw new Exception("Cannot clean up _kenoTable as it's null"), cleanupDelay);
//Kasino Shop stuff----------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
await botInstance.BotServices.KasinoShop.ProcessWagerTracking(gambler, WagerGame.Keno, wager, -wager, newBalance);
}
//---------------------------------------------------------------------------------------
return;
}
//you win
var win = (wager * (decimal)payoutMulti) - wager;
// Required to avoid compiler errors when trying to format it in the win message
newBalance = await Money.NewWagerAsync(gambler.Id, wager, win, WagerGame.Keno, ct: ctx);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you [color={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]won {await win.FormatKasinoCurrencyAsync()} with a {payoutMulti}x multi![/color]. Your balance is now: {await newBalance.FormatKasinoCurrencyAsync()}.",
true, autoDeleteAfter: cleanupDelay);
botInstance.ScheduleMessageAutoDelete(_kenoTable ?? throw new Exception("Cannot clean up _kenotable as it's null"), cleanupDelay);
//Kasino Shop stuff----------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
await botInstance.BotServices.KasinoShop.ProcessWagerTracking(gambler, WagerGame.Keno, wager, win, newBalance);
}
//---------------------------------------------------------------------------------------
}
private async Task AnimatedDisplayTable(List<int> playerNumbers, List<int> casinoNumbers, List<int> matches, ChatBot botInstance)
{
var cleanupDelay = TimeSpan.FromMilliseconds((await SettingsProvider.GetValueAsync(BuiltIn.Keys.KasinoKenoCleanupDelay)).ToType<int>());
var logger = LogManager.GetCurrentClassLogger();
var displayMessage = "";
//keno board is 8 x 5, numbers left to right, top to bottom
//FIRST FRAME 11111111111111111111111111111
var totalCounter = 1;
for (var column = 0; column < 5; column++)
{
for (var row = 0; row < 8; row++)
{
if (playerNumbers.Contains(totalCounter)) displayMessage += PlayerNumberDisplay;
else displayMessage += BlankSpaceDisplay;
totalCounter++;
}
displayMessage += "[br]";
}
_kenoTable = await botInstance.SendChatMessageAsync(displayMessage, true);
var i = 0;
while (_kenoTable.ChatMessageUuid == null)
{
i++;
if (_kenoTable.Status is SentMessageTrackerStatus.NotSending or SentMessageTrackerStatus.Lost) return;
if (i > 60) return;
await Task.Delay(100);
}
if (_kenoTable.ChatMessageUuid == null)
{
throw new Exception($"_kenoTable chat message ID never got populated. Tracker status is: {_kenoTable?.Status}");
}
var frameDelay = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.KasinoKenoFrameDelay)).ToType<int>(); // this should be grabbed from the settings dict declared at the start but idk how to do it cleanly atm.
//FIRST FRAME 11111111111111111111111111111
for (var frame = 0; frame < 10; frame++) //1 frame per casino number
{
displayMessage = "";
totalCounter = 1;
for (var column = 0; column < 5; column++)
{
for (var row = 0; row < 8; row++)
{
if (casinoNumbers.Take(frame+1).Contains(totalCounter))
{
if (matches.Contains(totalCounter))
{
displayMessage += MatchRevealDisplay;
}
else
{
displayMessage += CasinoNumberDisplay;
}
}
else if (playerNumbers.Contains(totalCounter)) displayMessage += PlayerNumberDisplay;
else displayMessage += BlankSpaceDisplay;
totalCounter++;
}
displayMessage += "[br]";
}
await botInstance.KfClient.EditMessageAsync(_kenoTable.ChatMessageUuid, displayMessage);
await Task.Delay(frameDelay);
if (displayMessage.Length <= 79 && displayMessage.Contains(BlankSpaceDisplay) &&
(displayMessage.Contains(CasinoNumberDisplay) || displayMessage.Contains(MatchRevealDisplay) ||
frame == 9)) continue; //every board should have blank spaces and casino numbers or matches. player numbers might be hidden by matches
logger.Error($"Casino numbers: {string.Join(",", casinoNumbers)} | Player Numbers: {string.Join(",", playerNumbers)} | Matches: {string.Join(",", matches)} | Frame: {frame - 1} | Display Board:");
logger.Error(displayMessage);
await botInstance.SendChatMessageAsync($"Keno is bugged dewd, died on frame {frame} :bossman:", true, autoDeleteAfter: cleanupDelay);
}
}
private List<int> GenerateKenoNumbers(int size, GamblerDbModel gambler, bool kasino = false)
{
var numbers = new List<int>();
for (var i = 0; i < size; i++)
{
var repeatNum = true;
while (repeatNum)
{
var randomNum = Money.GetRandomNumber(gambler, 1, 40);
if (numbers.Contains(randomNum)) continue;
if (kasino && Money.GetRandomDouble(gambler) > (double)HOUSE_EDGE &&
_playerNumbers.Contains(randomNum)) continue; //rigging function
numbers.Add(randomNum);
repeatNum = false;
}
}
return numbers;
}
}

View File

@@ -0,0 +1,107 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class KrashBetCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^krash (?<amount>\d+(?:\.\d+)?) (?<multi>\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase),
new Regex(@"^krash (?<amount>\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase),
new Regex(@"^krash", RegexOptions.IgnoreCase)
];
public string? HelpText => "!rain <amount> to start a rain, !rain to join all active rains";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(90);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user,
GroupCollection arguments,
CancellationToken ctx)
{
var settings =
await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoKrashEnabled, BuiltIn.Keys.KasinoKrashCleanupDelay,
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay
]);
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoKrashCleanupDelay].ToType<int>());
var krashEnabled = settings[BuiltIn.Keys.KasinoKrashEnabled].ToBoolean();
if (!krashEnabled)
{
var gameDisabledCleanupDelay =
TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, krash is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (botInstance.BotServices.KasinoKrash == null)
{
await botInstance.SendChatMessageAsync("Krash is not currently running.", true, autoDeleteAfter: cleanupDelay);
return;
}
decimal multi;
decimal wager;
if (!arguments.TryGetValue("amount", out var amountGroup))
{
//attempt to cash out a currently running game
await botInstance.BotServices.KasinoKrash.AttemptKrash(gambler);
return;
}
if (!arguments.TryGetValue("multi", out var multiGroup))
{
multi = -1;
}
else
{
multi = Convert.ToDecimal(multiGroup.Value);
}
wager = Convert.ToDecimal(amountGroup.Value);
//decimal wagerLimit = 10;
if (wager > gambler.Balance)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {gambler.Balance} is not enough to bet {wager} on krash.",
true, autoDeleteAfter: TimeSpan.FromSeconds(5));
return;
}
/*if (wager > wagerLimit)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you can't bet more than {wagerLimit} on krash during testing.",
true, autoDeleteAfter: TimeSpan.FromSeconds(5));
return;
}*/
if (botInstance.BotServices.KasinoKrash.TheGame == null)
{
//start a new game
await botInstance.BotServices.KasinoKrash.StartGame(gambler, wager, multi);
}
else
{
//add to the existing game
await botInstance.BotServices.KasinoKrash.AddParticipant(gambler, wager, multi);
}
}
}

View File

@@ -0,0 +1,459 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class LambchopCommand : ICommand
{
public List<Regex> Patterns =>
[
new Regex(@"lambchop (?<amount>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"lambchop (?<amount>\d+\.\d+)$", RegexOptions.IgnoreCase),
new Regex(@"lambchop (?<amount>\d+) (?<targetTile>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"lambchop (?<amount>\d+\.\d+) (?<targetTile>\d+)$", RegexOptions.IgnoreCase)
];
public string? HelpText =>
"Tread treacherous terrain towards terrific treasures. Play using !lambchop bet, amount of tiles you want to move";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(12);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(15)
};
public bool WhisperCanInvoke => false;
private static double _houseEdge = 0.015; // house edge hack?
// game assets
private const string HAIRSPACE = "";
private const string SHEEP = "🐑";
private const string YELLOW_TILE = "🟡";
private const string PURPLE_TILE = "🟣";
private const string GREEN_TILE = "🟢";
private const string RED_TILE = "🔴";
private const string FORREST_TILE = "🌳";
private const string DESERT_TILE = "🏜️";
private const string WOLF = "🐺";
private const string ALIEN = "🛸";
private const string LIGHTNING = "⚡";
private const string BLOOD = HAIRSPACE + "🩸" + HAIRSPACE;
private const string SKULL = "☠";
private const string MEDAL = "🏅";
private const string MONEYBAG = "💰";
private const string CELEBRATION = "🏆🪩✨";
private const string CASTLE = "🏯";
private const string WOOSH = "💨";
private const string FIST = HAIRSPACE + "✊" + HAIRSPACE;
private const string TILE_SPACING = "[color=#36393f]......[/color]";
private const string HAZARD_SPACING = "[color=#36393f].......[/color]";
// game settings
private const int FRAME_DELAY = 200; // time between lambchop frames in milliseconds
private const int FIELD_LENGTH = 16; // indicates how many tiles the lamb can cross. default is 16
// WARNING: do NOT change without first implementing dynamic payout logic in LambchopPayoutMultiplier()
// has to be an EVEN number > 1
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KasinoLambchopCleanupDelay,
BuiltIn.Keys.KasinoLambchopEnabled
]);
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
// Check if lambchop is enabled
var lambchopEnabled = (settings[BuiltIn.Keys.KasinoLambchopEnabled]).ToBoolean();
if (!lambchopEnabled)
{
var gameDisabledCleanupDelay= TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, lambchop is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
// await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, fuck you", true);
// return;
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoLambchopCleanupDelay].ToType<int>());
if (!arguments.TryGetValue("amount", out var amount))
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, not enough arguments. !lambchop <wager> <number between 1 and {FIELD_LENGTH}>", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var targetTile = arguments["targetTile"].Success ? Convert.ToInt32(arguments["targetTile"].Value) : FIELD_LENGTH;
if (targetTile is < 1 or > FIELD_LENGTH)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, Please choose a target tile between 1 and {FIELD_LENGTH}", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var wager = Convert.ToDecimal(amount.Value);
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (gambler.Balance < wager)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (wager == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you have to wager more than {await wager.FormatKasinoCurrencyAsync()}", true,
autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var colors =
await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
List<string> tiles = Enumerable.Repeat(YELLOW_TILE, FIELD_LENGTH / 2).ToList();
tiles.AddRange(Enumerable.Repeat(PURPLE_TILE, FIELD_LENGTH / 2));
List<string> hazards = Enumerable.Repeat(FORREST_TILE, FIELD_LENGTH / 2).ToList();
hazards.AddRange(Enumerable.Repeat(DESERT_TILE, FIELD_LENGTH / 2));
// calculate death tile, death tile = -1 means no death tile
int deathTile = CalculateDeathTile(targetTile, gambler);
bool win;
int steps;
if (deathTile == -1) // no death tile on field
{
win = true; // if there's is no deathTile then automatic win!
steps = targetTile - 1;
}
else
{
win = (targetTile - 1) < deathTile; // if your targetTile is less then the death tile then you win!
steps = win ? targetTile - 1 : deathTile;
}
// first game state
var lambChopDisplayMessage =
await botInstance.SendChatMessageAsync(ConvertLambchopFieldToString(tiles, hazards, true), true,
autoDeleteAfter: cleanupDelay);
while (lambChopDisplayMessage.ChatMessageUuid == null)
{
await Task.Delay(50, ctx); // wait until first message is fully sent
if (lambChopDisplayMessage.Status is SentMessageTrackerStatus.Lost or SentMessageTrackerStatus.NotSending)
return;
}
for (int i = -1; i <= steps;) // main game loop, if/else "state machine"
{
if (i == -1)
{
// first state, print empty tileset and sheep placeholder
await Task.Delay(TimeSpan.FromMilliseconds(FRAME_DELAY), ctx);
tiles = MoveSheep(tiles); // move the sheep onto the first tile
i++; // increase step counter by 1
continue;
}
// boundary check for sheep movement
if (i >= tiles.Count)
{
break; // exit if we've gone past the field
}
// normal "move" state
// let alien follow player
if (i > FIELD_LENGTH / 2 - 1)
{
hazards[i] = ALIEN; // alien follows you in later part of the map
if (i > 0 && hazards[i - 1] == ALIEN)
{
// update previous hazard tile back to desert
hazards[i - 1] = DESERT_TILE;
}
}
if (i == deathTile) // trigger hazard death?
{
// player dies on this step
if (i > FIELD_LENGTH / 2 - 1)
{
// death by alien
await UpdateGameAsync();
tiles[i] = LIGHTNING; // strike player with lightning
await UpdateGameAsync();
tiles[i] = SKULL; // skull
await UpdateGameAsync();
break;
// i++;
//continue;
}
// death by wolf
await UpdateGameAsync();
hazards[i] = WOLF; // add wolf
await UpdateGameAsync();
tiles[i] = BLOOD; // blood
await UpdateGameAsync();
tiles[i] = SKULL; // skull
await UpdateGameAsync();
break;
//i++;
//continue;
}
if (i == (targetTile - 1) && win) // trigger win animation
{
await UpdateGameAsync(); //arrive at targetTile
if (targetTile == FIELD_LENGTH)
{
// mega win, end of the line
string lambChopFieldEndState = ConvertLambchopFieldToString(tiles, hazards, false);
lambChopFieldEndState = lambChopFieldEndState.Replace(SHEEP, GREEN_TILE);
lambChopFieldEndState += SHEEP;
await UpdateGameAsync(lambChopFieldEndState);
lambChopFieldEndState += CELEBRATION;
await UpdateGameAsync(lambChopFieldEndState);
break;
//i++;
//continue;
}
if (i > FIELD_LENGTH / 2 - 1)
{
// win in the tundra, moneybags
hazards[i] = MONEYBAG; // add moneybag
if (deathTile >= 0 && deathTile < tiles.Count)
{
tiles[deathTile] = RED_TILE; // add deathTile indicator
}
await UpdateGameAsync();
break;
//i++;
//continue;
}
// win in the forrest, medal
hazards[i] = MEDAL; // add medal
if (deathTile != -1 && deathTile < tiles.Count)
{
tiles[deathTile] = RED_TILE; // add deathTile indicator
}
await UpdateGameAsync();
break;
//i++;
//continue;
}
if (Money.GetRandomDouble(gambler) <= 0.15)
{
//fakeouts
// forrest or desert
if (i > FIELD_LENGTH / 2 - 1)
{
// desert fakeout
await UpdateGameAsync();
tiles[i] = LIGHTNING; // strike player with lightning
string leftTile = tiles[i - 1];
tiles[i - 1] = WOOSH; // add woosh fakeout
await UpdateGameAsync();
tiles[i - 1] = leftTile; // return left tile to normal
tiles[i] = SHEEP; // change back to sheep
}
else
{
// forrest fakeout
await UpdateGameAsync();
string forrestTile = hazards[i];
hazards[i] = WOLF; // add wolf
await UpdateGameAsync();
tiles[i] = FIST; // add fist
await UpdateGameAsync();
hazards[i] = forrestTile;
tiles[i] = SHEEP; // change back to sheep
}
}
await UpdateGameAsync();
tiles = MoveSheep(tiles);
i++;
}
// payout logic
string lambchopResultMessage;
decimal newBalance;
if (win)
{
var multi = LambchopPayoutMultiplier(targetTile);
var lambchopPayout = Math.Round(wager * multi - wager, 2);
newBalance = await Money.NewWagerAsync(gambler.Id, wager, lambchopPayout, WagerGame.LambChop, ct: ctx);
lambchopResultMessage = $"{user.FormatUsername()}, you [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]WON[/COLOR][/B]" +
$" | Multi {multi} | Balance {await newBalance.FormatKasinoCurrencyAsync()}";
}
else
{
newBalance = await Money.NewWagerAsync(gambler.Id, wager, -wager, WagerGame.LambChop, ct: ctx);
lambchopResultMessage = $"{user.FormatUsername()}, you [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]LOST[/COLOR][/B]" +
$", better luck next time | Balance {await newBalance.FormatKasinoCurrencyAsync()}";
}
await botInstance.SendChatMessageAsync(lambchopResultMessage, true, autoDeleteAfter: cleanupDelay);
return;
// hacky local helper function to quickly print the current state of the game field and trigger the frame delay
async Task UpdateGameAsync(string? updateText = null)
{
updateText ??= ConvertLambchopFieldToString(tiles, hazards, false);
await botInstance.KfClient.EditMessageAsync(lambChopDisplayMessage.ChatMessageUuid, updateText);
await Task.Delay(TimeSpan.FromMilliseconds(FRAME_DELAY), ctx);
}
}
// return -1 if player can proceed trough entire field
private static int CalculateDeathTile(int targetTile, GamblerDbModel gambler)
{
// CHECK: does player want to move all tiles?
if (targetTile == FIELD_LENGTH)
{
// PLAYER WANTS TO MOVE ALL TILES
// normal success chance
double successChance = 1.0 / (FIELD_LENGTH + 1); // +1 because "winning" means you dont die on the last tile
if (_houseEdge > 0)
{
// Decrease success chance based on houseEdge (linearly)
successChance *= (1.0 - _houseEdge);
}
// Determine if player can walk all tiles
if (Money.GetRandomDouble(gambler) <= successChance)
{
return -1; // No death tile (player succeeds)
}
// Player fails - calculate where the death tile appears
double riggingFactor = Money.GetRandomDouble(gambler);
if (_houseEdge > 0 && riggingFactor < _houseEdge * 2) // shitty hack because I made the decision to clamp houseEdge to max 50%
{
// More rigging means death tile is more likely near the end
int minDeathTile = Math.Max(0, FIELD_LENGTH - 3);
return Money.GetRandomNumber(gambler, minDeathTile, FIELD_LENGTH, incrementMaxParam:false); // return 15 means dying on the last tile xd
}
else
{
// Player fail, random tile in the path becomes death tile
return Money.GetRandomNumber(gambler,0, FIELD_LENGTH, incrementMaxParam:false);
}
}
// Tiles 1 - 15
if (_houseEdge < 0.015)
{
int deathTile = Money.GetRandomNumber(gambler,-1, FIELD_LENGTH, incrementMaxParam:false); // can be any tile, including no tile! (result -1 to FIELD_LENGTH (-1 - 15))
return deathTile;
}
// game is rigged, manipulate tile placement
int fairDeathTile = Money.GetRandomNumber(gambler,-1, FIELD_LENGTH, incrementMaxParam:false);
fairDeathTile = fairDeathTile == -1 ? FIELD_LENGTH + 1 : fairDeathTile; // shit hack, -1 means no death tile, change it to FIELD_LENGTH + 1 to compensate for next check.
bool wouldSucceedFairly = fairDeathTile > targetTile;
fairDeathTile = fairDeathTile == FIELD_LENGTH + 1 ? -1 : fairDeathTile;
if (wouldSucceedFairly)
{
// are we gonna rig it
double riggedFailChance = _houseEdge * 2;
if (Money.GetRandomDouble(gambler) <= riggedFailChance)
{
double cruelnessLevel = Money.GetRandomDouble(gambler);
if (cruelnessLevel < _houseEdge * 2)
{
// extra rigged fail, choose tile just before target tile
return targetTile > 1 ? targetTile - 1 : 1;
}
else
{
// rigging failed, normal tile return
return Money.GetRandomNumber(gambler,-1, targetTile, incrementMaxParam:false);
}
}
return fairDeathTile;
}
{
// Player would fail in fair game
double riggingFactor = Money.GetRandomDouble(gambler);
if (riggingFactor < _houseEdge)
{
// Place death tile closer to target
// higher house edge = more likely to place closer
int minTile = Math.Max(0, targetTile - 3);
return Money.GetRandomNumber(gambler,minTile, targetTile, incrementMaxParam:false);
}
return fairDeathTile;
}
}
private static string ConvertLambchopFieldToString(List<string> tiles, List<string> hazards, bool first)
{
// This function takes the current state of the lambchop field and transforms it into a print ready string.
// Its very hacky as it uses weird hairspaces to evenly space out some of the game elements for aesthetic reasons.
// The game is optimized to display best on windows machines running a mostly default webbrowser.
// This comes at the aesthetic expense of other platforms using different sets of emoji.
// In case this is the first game state (bool first) print the sheep emoji in front of the tiles as to indicate
// that the game is about to start, this prevents the game starting on a fail state on tile 0 which would look silly.
string lambchopFieldState = "";
int hazardSplitIndex = hazards.Count / 2; // first half of the field uses forrest emoji which need to be alternated with hairspaces for good spacing.
string forrestHazards = string.Join(HAIRSPACE, hazards.GetRange(0, hazardSplitIndex)); // alternate forrest emoji and hairspaces
string desertHazards = string.Concat(hazards.GetRange(hazardSplitIndex, hazards.Count - hazardSplitIndex)); // add desert emojis without spacing
lambchopFieldState += HAZARD_SPACING + forrestHazards + desertHazards + "\n"; // glue it all together with the tiles
lambchopFieldState += first ? SHEEP : TILE_SPACING; // first state uses sheep in front of tiles, every other state uses custom spacer string.
lambchopFieldState += string.Join("", tiles);
lambchopFieldState += CASTLE;
return lambchopFieldState;
}
private static List<string> MoveSheep(List<string> tiles)
{
int index = tiles.IndexOf(SHEEP);
if (index == -1)
{
// no sheep on tiles? Second game state, move sheep to first tile.
tiles.RemoveAt(0);
tiles.Insert(0, SHEEP);
}
else
{
if (index < tiles.Count - 1)
{
//tiles[index] = index < tiles.Count / 2 ? yellow_tile : purple_tile;
tiles[index] = GREEN_TILE;
tiles[index + 1] = SHEEP;
}
// sheep is already at end position
}
return tiles;
}
private static decimal LambchopPayoutMultiplier(int targetTile)
{
targetTile -= 1; // make it 0 indexed xd
List<double> lambChopMultis =
[
1.062, 1.138, 1.228, 1.318, 1.426, 1.561, 1.714, 1.912, 2.142,
2.442, 2.871, 3.425, 4.272, 5.702, 8.539, 16.861
];
if (FIELD_LENGTH != lambChopMultis.Count)
{
throw new InvalidOperationException("FIELD_LENGTH doesn't match lambChopMultis array size. " +
"Update the multees for the new field length");
}
return (decimal)lambChopMultis[targetTile];
}
}

View File

@@ -0,0 +1,188 @@
using System.ComponentModel;
using System.Reflection;
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetWsClient.Models.Events;
using Microsoft.EntityFrameworkCore;
namespace KfChatDotNetBot.Commands.Kasino;
/// <summary>
/// Command to check a user's kasino "legitimacy" by calculating their Return-to-Player (RTP) statistics.
/// RTP represents the percentage of wagered money returned to the player over time.
/// An RTP of 100% means break-even, above 100% means profit, below means loss.
/// </summary>
[KasinoCommand]
public class LegitCheckCommand : ICommand
{
public List<Regex> Patterns =>
[
new Regex(@"^legitcheck (?<user_id>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^legitcheck (?<user_id>\d+) all$", RegexOptions.IgnoreCase),
new Regex(@"^legitcheck @(?<username>.+) all$", RegexOptions.IgnoreCase),
new Regex(@"^legitcheck @(?<username>.+)$", RegexOptions.IgnoreCase),
new Regex(@"^legitcheck$", RegexOptions.IgnoreCase),
new Regex(@"^legitcheck all$", RegexOptions.IgnoreCase),
];
public string? HelpText => "Check a user's kasino RTP statistics";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(30)
};
public bool WhisperCanInvoke => false;
// Minimum wagers required for a game to be considered for "luckiest game"
// This prevents small sample sizes from skewing results (e.g., 1 win on 1 bet = 200% RTP)
private const int MinWagersForLuckiestGame = 10;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
var isAll = message.MessageRaw.EndsWith(" all");
UserDbModel? targetUser;
if (arguments["username"].Success)
{
var chatUser = botInstance.FindUserByName(arguments["username"].Value);
if (chatUser == null)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, couldn't find that user in chat. They must be present in chat to look up by username.",
true);
return;
}
targetUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == chatUser.Id, ctx);
}
else if (arguments["user_id"].Success)
{
var targetUserId = int.Parse(arguments["user_id"].Value);
targetUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == targetUserId, ctx);
}
else
{
targetUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == user.KfId, ctx);
}
if (targetUser == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, that user doesn't exist.",
true);
return;
}
// A user can have multiple gambler entities (e.g., if they abandoned an account or got reset).
// We want to aggregate stats across ALL their gambler entities for a complete picture.
List<int> gamblerIds = [];
if (isAll)
{
gamblerIds = await db.Gamblers
.Where(g => g.User.Id == targetUser.Id)
.Select(g => g.Id)
.ToListAsync(ctx);
}
else
{
var gambler = await Money.GetGamblerEntityAsync(targetUser.Id, ct: ctx);
if (gambler == null)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, {targetUser.KfUsername} has never played at the kasino.", true);
return;
}
gamblerIds.Add(gambler.Id);
}
if (gamblerIds.Count == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, {targetUser.KfUsername} has never played at the kasino.", true);
return;
}
// Only include completed wagers - incomplete ones are pending bets (e.g., event outcomes)
var wagers = await db.Wagers
.Where(w => gamblerIds.Contains(w.Gambler.Id) && w.IsComplete)
.ToListAsync(ctx);
if (wagers.Count == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, {targetUser.KfUsername} has no completed kasino wagers on record.", true);
return;
}
// RTP Calculation:
// - WagerAmount: The amount the user bet
// - WagerEffect: The net change to balance (negative for loss, positive for profit)
// - TotalReturned = WagerAmount + WagerEffect (what they got back)
// Example: Bet 100, lost -> WagerEffect = -100, Returned = 100 + (-100) = 0
// Example: Bet 100, won 150 total -> WagerEffect = +50 (profit), Returned = 100 + 50 = 150
// - RTP% = (TotalReturned / TotalWagered) * 100
var totalWagered = wagers.Sum(w => w.WagerAmount);
var totalReturned = wagers.Sum(w => w.WagerAmount + w.WagerEffect);
var overallRtp = totalWagered > 0 ? (totalReturned / totalWagered) * 100 : 0;
// Group wagers by game type to find per-game statistics
var gameStats = wagers
.GroupBy(w => w.Game)
.Select(g => new GameStatistic
{
Game = g.Key,
WagerCount = g.Count(),
TotalWagered = g.Sum(w => w.WagerAmount),
TotalReturned = g.Sum(w => w.WagerAmount + w.WagerEffect)
})
.ToList();
// Calculate RTP for each game (done separately to avoid division in the LINQ projection)
foreach (var stat in gameStats)
{
stat.Rtp = stat.TotalWagered > 0 ? (stat.TotalReturned / stat.TotalWagered) * 100 : 0;
}
// Find the game with highest RTP, but only if they have enough wagers to be statistically meaningful
var luckiestGame = gameStats
.Where(g => g.WagerCount >= MinWagersForLuckiestGame)
.MaxBy(g => g.Rtp);
// Build response
var response =
$"{user.FormatUsername()}, {targetUser.KfUsername} RTP: {overallRtp:F2}% | " +
$"Wagered: {await totalWagered.FormatKasinoCurrencyAsync()} | " +
$"Returned: {await totalReturned.FormatKasinoCurrencyAsync()} | " +
$"Wagers: {wagers.Count:N0}";
if (luckiestGame != null)
{
var gameName = luckiestGame.Game.Humanize();
response +=
$" | Luckiest: {gameName} ({luckiestGame.Rtp:F2}% RTP, {luckiestGame.WagerCount:N0} wagers, {await luckiestGame.TotalWagered.FormatKasinoCurrencyAsync()} wagered)";
}
await botInstance.SendChatMessageAsync(response, true);
}
/// <summary>
/// Helper class to hold per-game statistics during calculation.
/// </summary>
private class GameStatistic
{
public WagerGame Game { get; init; }
public int WagerCount { get; init; }
public decimal TotalWagered { get; init; }
public decimal TotalReturned { get; init; }
public decimal Rtp { get; set; }
}
}

View File

@@ -0,0 +1,192 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using RandN;
using RandN.Compat;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class LimboCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^limbo (?<amount>\d+(?:\.\d+)?) (?<number>\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase),
new Regex(@"^limbo (?<amount>\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase),
new Regex("^limbo")
];
public string? HelpText => "!limbo <bet amount> <optional number, default 2>";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 10,
Window = TimeSpan.FromSeconds(30)
};
public bool WhisperCanInvoke => false;
private const double Min = 1;
private const double Max = 10000;
private decimal HOUSE_EDGE = (decimal)0.98;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
decimal limboNumber; //user number
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay,
BuiltIn.Keys.KasinoLimboCleanupDelay, BuiltIn.Keys.KasinoLimboEnabled,
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
// Check if limbo is enabled
var limboEnabled = (settings[BuiltIn.Keys.KasinoLimboEnabled]).ToBoolean();
if (!limboEnabled)
{
var gameDisabledCleanupDelay= TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, limbo is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoLimboCleanupDelay].ToType<int>());
if (!arguments.TryGetValue("amount", out var amount))
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, not enough arguments. !limbo <wager>",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var wager = Convert.ToDecimal(amount.Value);
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (gambler.Balance < wager)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (wager == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you have to wager more than {await wager.FormatKasinoCurrencyAsync()}", true,
autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
//KasinoShop stuff -------------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
HOUSE_EDGE += botInstance.BotServices.KasinoShop.Gambler_Profiles[user.KfId].HouseEdgeModifier;
}
//------------------------------------------------------------------------------------------
if (!arguments.TryGetValue("number", out var number))
{
limboNumber = 2;
//set user number to 2 if they didn't enter anything
}
else limboNumber = Convert.ToDecimal(number.Value);
if (limboNumber <= 1)
{
//cancel the game if user does not choose a correct number
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you must choose a number greater than 1", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
decimal newBalance;
var casinoNumbers = Get1XWeightedRandomNumber(Min, (double)(limboNumber * limboNumber), limboNumber);
string colorToUse;
if (casinoNumbers[0] >= limboNumber)
{
//you win
colorToUse = settings[BuiltIn.Keys.KiwiFarmsGreenColor].Value!;
var win = wager * limboNumber - wager;
newBalance = await Money.NewWagerAsync(gambler.Id, wager, win, WagerGame.Limbo, ct: ctx);
await botInstance.SendChatMessageAsync($"[b][color={colorToUse}] {casinoNumbers[1]:N2}[/color][/b][br]{user.FormatUsername()}, you " +
$"[color={settings[BuiltIn.Keys.KiwiFarmsGreenColor].Value}] won {await win.FormatKasinoCurrencyAsync()}![/color] " +
$"Your balance is now: {await newBalance.FormatKasinoCurrencyAsync()}!", true, autoDeleteAfter: cleanupDelay);
//Kasino Shop stuff----------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
await botInstance.BotServices.KasinoShop.ProcessWagerTracking(gambler, WagerGame.Limbo, wager, win, newBalance);
}
//---------------------------------------------------------------------------------------
return;
}
if (limboNumber / 2 > casinoNumbers[1]) colorToUse = settings[BuiltIn.Keys.KiwiFarmsRedColor].Value!; //use red for the number if you're not close
else if (limboNumber *3 / 4 > casinoNumbers[1])
colorToUse = "yellow"; //use yellow for the number if you're pretty close
else colorToUse = "orange"; //use orange for mid range guess
//you lose
newBalance = await Money.NewWagerAsync(gambler.Id, wager, -wager, WagerGame.Limbo, ct: ctx);
await botInstance.SendChatMessageAsync(
$"[b][color={colorToUse}] {casinoNumbers[1]:N2}[/color][/b][br]{user.FormatUsername()}, you [color={settings[BuiltIn.Keys.KiwiFarmsRedColor].Value}]" +
$"lost {await wager.FormatKasinoCurrencyAsync()}[/color]. Your balance is now: {await newBalance.FormatKasinoCurrencyAsync()}.",
true, autoDeleteAfter: cleanupDelay);
//Kasino Shop stuff----------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
await botInstance.BotServices.KasinoShop.ProcessWagerTracking(gambler, WagerGame.Limbo, wager, -wager, newBalance);
}
//---------------------------------------------------------------------------------------
}
//returns a distribution with a 1/multi chance of getting a number below or above sqr(min * max) (so max should basically be multi^2). basically gives you a 1/x fair chance to win
//then scales the number using the number scaling function
private decimal[] Get1XWeightedRandomNumber(double minValue, double maxValue, decimal multi)
{
var random = RandomShim.Create(StandardRng.Create());
var skew = 1.0 / (double)(multi);
var gamma = Math.Log(0.5) / Math.Log(skew);
var r = random.NextDouble();
var rP = 1 - Math.Pow(1 - r, gamma);
var lnMin = Math.Log(minValue);
var lnMax = Math.Log(maxValue);
var exponent = lnMin + rP * (lnMax - lnMin);
var result = new decimal[2];
result[0] = (decimal)Math.Exp(exponent) * HOUSE_EDGE;
result[1] = GetScaledNumber(lnMin, lnMax, exponent, result[0], multi);
return result;
}
private static decimal GetScaledNumber(double lnMin, double lnMax, double exponent, decimal result, decimal multi)
{
var anchor = Math.Log((double)multi);
var deltaMax = lnMax - anchor;
var k = Math.Log(Max / (double)(multi * multi)) / deltaMax;
var delta = exponent - anchor;
var logFactor = k * delta;
var factor = Math.Exp(logFactor);
var preResult = (result * (decimal)factor);
if (!((double)preResult < anchor)) return preResult;
var minTheo = (double)(multi * multi) / Max;
var logMinTheo = Math.Log(minTheo);
var logPreResult = Math.Log((double)preResult);
var fraction = (logPreResult - logMinTheo) / (anchor - logMinTheo);
return (decimal)Math.Exp((anchor * fraction));
}
}

View File

@@ -0,0 +1,309 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using NLog.LayoutRenderers;
namespace KfChatDotNetBot.Commands.Kasino;
public class MinesCommand : ICommand
{
public List<Regex> Patterns => [
//cashout
new Regex(@"^mines\s+(?<cashout>cashout)$", RegexOptions.IgnoreCase),
//refresh
new Regex(@"^mines\s+refresh$", RegexOptions.IgnoreCase),
//clear - admin only
new Regex(@"^mines\s+clear$", RegexOptions.IgnoreCase),
//start game with number of picks
new Regex(@"^mines\s+(?<bet>\d+(?:\.\d+)?)\s+(?<size>\d+)\s+(?<mines>\d+)\s+(?<picks>\d+)(?:\s+(?<cashout>cashout))?$", RegexOptions.IgnoreCase),
//start game with coordinate string (must contain comma)
new Regex(@"^mines\s+(?<bet>\d+(?:\.\d+)?)\s+(?<size>\d+)\s+(?<mines>\d+)\s+(?<betString>\d+,\d+(?:\s+\d+,\d+)*)(?:\s+(?<cashout>cashout))?$", RegexOptions.IgnoreCase),
//continue game with number of picks
new Regex(@"^mines\s+(?<picks>\d+)(?:\s+(?<cashout>cashout))?$", RegexOptions.IgnoreCase),
//continue game with coordinate string (must contain comma)
new Regex(@"^mines\s+(?<betString>\d+,\d+(?:\s+\d+,\d+)*)(?:\s+(?<cashout>cashout))?$", RegexOptions.IgnoreCase),
//get info
new Regex(@"^mines$", RegexOptions.IgnoreCase)
];
public string? HelpText => "!mines <bet> <board size> <number of mines> <picks> to play simple mines. !mines <bet> <board size> <number of mines> <betString> for advanced mines. Tool: https://i.ddos.lgbt/raw/baV63V.html";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(30);
private const string BetPattern = @"(?<row>\d+),(?<col>\d+)";
private const string ToolUrl = "https://i.ddos.lgbt/raw/KasinoMinesInterface.html";
public RateLimitOptionsModel? RateLimitOptions => new RateLimitOptionsModel
{
MaxInvocations = 10,
Window = TimeSpan.FromSeconds(10)
};
public bool WhisperCanInvoke => false;
private KasinoMines? KasinoMines;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoMinesCleanupDelay, BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor,
BuiltIn.Keys.KasinoMinesEnabled, BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay
]);
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoMinesCleanupDelay].ToType<int>());
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
if (!settings[BuiltIn.Keys.KasinoMinesEnabled].ToBoolean())
{
var gameDisabledCleanupDelay= TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, mines is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
KasinoMines = new KasinoMines(botInstance, gambler.Id);
if (message.Message.Contains("clear"))
{
if (user.UserRight >= UserRight.TrueAndHonest)
{
await KasinoMines.GetSavedGames(gambler.Id);
foreach (var game in KasinoMines.ActiveGames.Values)
{
await botInstance.KfClient.DeleteMessageAsync(game.LastMessageId!);
}
KasinoMines.ActiveGames.Clear();
await KasinoMines.SaveActiveGames(gambler.Id);
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, cleared all mines games.", true, autoDeleteAfter: cleanupDelay);
return;
}
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you don't have permission to clear saved games.", true, autoDeleteAfter: cleanupDelay);
return;
}
bool cashout = false;
if (arguments.TryGetValue("cashout", out var cashOut) && cashOut.Success && !string.IsNullOrWhiteSpace(cashOut.Value))
cashout = true;
if (!Regex.IsMatch(message.Message, @"\d") && cashout) //if the message has no ints its a cashout attempt
{
if (KasinoMines.ActiveGames.ContainsKey(gambler.Id))
{
await KasinoMines.Cashout(KasinoMines.ActiveGames[gambler.Id]);
return;
}
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you don't have a game running to cash out.", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
//check if user has an existing game already
if (!KasinoMines.ActiveGames.ContainsKey(gambler.Id))
{
if (arguments.TryGetValue("refresh", out var refresh))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you tried to refresh but don't have a game running. !mines <bet> <board size> <number of mines> <picks> to play simple mines. !mines <bet> <board size> <number of mines> <betString> for advanced mines. Tool: {ToolUrl}",
true, autoDeleteAfter: cleanupDelay);
return;
}
//if there is no game currently running
if (!arguments.TryGetValue("bet", out var bet))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, not enough arguments(bet+). !mines <bet> <board size> <number of mines> <picks> to play simple mines. !mines <bet> <board size> <number of mines> <betString> for advanced mines. Tool: {ToolUrl}",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
decimal wager = Convert.ToDecimal(bet.Value);
if (gambler.Balance < wager)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance is too low. Balance: {gambler.Balance.FormatKasinoCurrencyAsync()}", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (wager <= 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you have to bet something to play mines.", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (!arguments.TryGetValue("size", out var size) || !arguments.TryGetValue("mines", out var mines))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, not enough arguments(mines and or size+). !mines <bet> <board size> <number of mines> <picks> to play simple mines. !mines <bet> <board size> <number of mines> <betString> for advanced mines. Tool: {ToolUrl}",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
int pick = 0;
List<(int r, int c)> precisePicks = new();
if (arguments.TryGetValue("picks", out var picks)) //if they are using picks to randomly select squares to reveal
{
pick = Convert.ToInt32(picks.Value);
}
else if (arguments.TryGetValue("betString", out var betString)) //if they are using precise picks manually or from the tool to select specific squares to reveal
{
var matches = Regex.Matches(message.Message, BetPattern);
if (matches.Count == 0) //if invalid bet string
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, invalid bet string. Example: !mines 100 10 10 1,3 1,5 2,6 - or use the tool: {ToolUrl}", true, autoDeleteAfter: cleanupDelay);
return;
}
foreach (Match match in matches)
{
precisePicks.Add((Convert.ToInt32(match.Groups["row"].Value), Convert.ToInt32(match.Groups["col"].Value)));
}
}
else //if they didn't put anything
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, not enough arguments(picks or betstring). !mines <bet> <board size> <number of mines> <picks> to play simple mines. !mines <bet> <board size> <number of mines> <betString> for advanced mines. Tool: {ToolUrl}",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
int boardSize = Convert.ToInt32(size.Value);
if (boardSize < 2 || boardSize > 8)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, board size must be between 2 and 9.",true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
int minesCount = Convert.ToInt32(mines.Value);
if (minesCount < 1 || minesCount > (boardSize * boardSize) - 1)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, number of mines must be between 1 and {boardSize * boardSize - 1}(size^2 - 1).",true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
//at this point all valid values so good to continue making the game
await KasinoMines.CreateGame(gambler, wager, boardSize, minesCount);
var msg = await botInstance.SendChatMessageAsync(
$"{KasinoMines.ActiveGames[gambler.Id].ToString()}", true);
var msgSuccess = await botInstance.WaitForChatMessageAsync(msg, ct: ctx);
if (!msgSuccess) throw new InvalidOperationException("Timed out waiting for the message");
if (pick == 0) //if using coordinates
{
var game = KasinoMines.ActiveGames[gambler.Id];
foreach (var coord in precisePicks)
{
if (game.BetsPlaced.Contains(coord) || coord.r < 0 || coord.r > game.Size || coord.c < 0 || coord.c > game.Size)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you can't place duplicate or invalid bets. Use the tool: {ToolUrl}", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
}
await KasinoMines.Bet(gambler, precisePicks, msg, cashout);
}
else //if using picks
{
await KasinoMines.Bet(gambler, pick, msg, cashout);
}
}
else
{
//if there is a game already running
if (arguments.TryGetValue("refresh", out var refresh))
{
await KasinoMines.RefreshGameMessage(gambler.Id);
return;
}
int pick = 0;
List<(int r, int c)> precisePicks = new();
if (arguments.TryGetValue("picks", out var picks)) //if they are using picks to randomly select squares to reveal
{
pick = Convert.ToInt32(picks.Value);
}
else if (arguments.TryGetValue("betString", out var betString)) //if they are using precise picks manually or from the tool to select specific squares to reveal
{
if (betString.Value == "cashout" || betString.Value == " cashout")
{
await KasinoMines.Cashout(KasinoMines.ActiveGames[gambler.Id]);
return;
}
var matches = Regex.Matches(message.Message, BetPattern);
if (matches.Count == 0 || matches == null) //if invalid bet string
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, invalid bet string. Example: !mines 100 10 10 1,3 1,5 2,6 - or use the tool: {ToolUrl}", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
foreach (Match match in matches)
{
precisePicks.Add((Convert.ToInt32(match.Groups["row"].Value), Convert.ToInt32(match.Groups["col"].Value)));
}
}
else //if they didn't put anything
{
if (message.Message.Contains("cashout")) cashout = true;
if (cashout)
{
await KasinoMines.Cashout(KasinoMines.ActiveGames[gambler.Id]);
return;
}
else if (message.Message.Contains("refresh"))
{
await KasinoMines.RefreshGameMessage(gambler.Id);
return;
}
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you already have a game running. !mines <picks> to reveal more spaces, !mines cashout to cash out, !mines <bet string> to place precise picks. Tool: {ToolUrl}",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var lastmsg = KasinoMines.ActiveGames[gambler.Id].LastMessageId;
SentMessageTrackerModel msg;
if (lastmsg == null)
{
msg = (await botInstance.SendChatMessageAsync($"{KasinoMines.ActiveGames[gambler.Id].ToString()}", true));
await botInstance.WaitForChatMessageAsync(msg, ct: ctx);
if (msg.ChatMessageUuid == null) throw new InvalidOperationException("Timed out waiting for the message");
}
else
{
msg = botInstance.GetSentMessageStatus(KasinoMines.ActiveGames[gambler.Id].LastMessageReference);
}
if (pick == 0) //if using coordinates
{
var game = KasinoMines.ActiveGames[gambler.Id];
foreach (var coord in precisePicks)
{
if (game.BetsPlaced.Contains(coord) || coord.r <= 0 || coord.r > game.Size || coord.c <= 0 || coord.c > game.Size)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you can't place duplicate or invalid bets. Use the tool: {ToolUrl}", true, autoDeleteAfter: cleanupDelay);
return;
}
}
await KasinoMines.Bet(gambler, precisePicks, msg, cashout);
}
else //if using picks
{
await KasinoMines.Bet(gambler, pick, msg, cashout);
}
}
}
}

View File

@@ -0,0 +1,541 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using NLog;
using RandN;
using RandN.Compat;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class Planes : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^planes (?<amount>\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase),
new Regex("^planes$")
];
public string? HelpText => "!planes <bet amount>";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(120);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(30)
};
public bool WhisperCanInvoke => false;
private const string Boost = "💨";
private const string PlaneUp = "🛫";
private const string PlaneDown = "🛬";
private const string PlaneExplosion = "🔥";
private const string Bomb = "❌";
private const string Multi = "*️⃣";
private const string Carrier = "⛴";
private const string Water = "🌊";
private const string Air = "\u2B1C"; // White square
private const string BlankSpace = ""; //need 35?
private bool _rigged = false;
private bool _riggedWin = false;
private const int CarrierCount = 6;
private decimal HOUSE_EDGE = (decimal)0.98;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KasinoPlanesEnabled,
BuiltIn.Keys.KasinoPlanesCleanupDelay, BuiltIn.Keys.KasinoPlanesRandomRiggeryEnabled,
BuiltIn.Keys.KasinoPlanesTargetedRiggeryEnabled, BuiltIn.Keys.KasinoPlanesTargetedRiggeryVictims
]);
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
// Check if planes is enabled
var planesEnabled = (settings[BuiltIn.Keys.KasinoPlanesEnabled]).ToBoolean();
if (!planesEnabled)
{
var gameDisabledCleanupDelay= TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, planes is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoPlanesCleanupDelay].ToType<int>());
var logger = LogManager.GetCurrentClassLogger();
if (!arguments.TryGetValue("amount", out var amount))
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, not enough arguments. !planes <wager>",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var wager = Convert.ToDecimal(amount.Value);
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (gambler.Balance < wager)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (wager == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you have to wager more than {await wager.FormatKasinoCurrencyAsync()}", true,
autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
//KasinoShop stuff -------------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
HOUSE_EDGE += botInstance.BotServices.KasinoShop.Gambler_Profiles[user.KfId].HouseEdgeModifier;
}
//------------------------------------------------------------------------------------------
if (HOUSE_EDGE < 1)
{
if (Money.GetRandomDouble(gambler) > (double)HOUSE_EDGE)
{
_rigged = true;
}
}
else
{
if ((double)HOUSE_EDGE - Money.GetRandomDouble(gambler) > 1)
{
_riggedWin = true;
}
}
var planesBoard = CreatePlanesBoard(gambler,0);
var planesBoard2 = CreatePlanesBoard(gambler);
var planesBoard3 = CreatePlanesBoard(gambler);
List<int[,]> planesBoards = [planesBoard, planesBoard2, planesBoard3];
var plane = new Plane(gambler);
const double frameLength = 300.0;
var fullCounter = 0;
var noseUp = true;
var planesDisplay = GetPreGameBoard(-3, planesBoard2, plane, CarrierCount, noseUp);
var msgId = await botInstance.SendChatMessageAsync(planesDisplay, true);
var num = 0;
while (msgId.ChatMessageUuid == null)
{
num++;
if (msgId.Status is SentMessageTrackerStatus.NotSending or SentMessageTrackerStatus.Lost) return;
if (num > 60) return;
await Task.Delay(100, ctx);
}
//place where planes used to stop working
/*
* new goal of basic planes game
* static board, plane moves through the board, 25 spaces long
* if it gets to the end, reset the board, remember plane height, and continue playing no smooth transition
*/
do
{
var counter = (fullCounter - 3) % 24;
await Task.Delay(TimeSpan.FromMilliseconds(frameLength / 3), ctx);
if (fullCounter >= 3)
{
planesDisplay = GetGameBoard(fullCounter, planesBoards, plane, CarrierCount, noseUp);
planesDisplay += $"[br]Multi: {plane.MultiTracker}x";
for (var i = 0; i < 10; i++)
{
planesDisplay += BlankSpace;
}
var winnings = plane.MultiTracker * wager;
planesDisplay += $"Winnings: {await winnings.FormatKasinoCurrencyAsync()}";
await botInstance.KfClient.EditMessageAsync(msgId.ChatMessageUuid, planesDisplay);
}
var neutral = false;
var frameCounter = 0;
if (fullCounter < 3)
{
while (fullCounter < 3)
{
counter = (fullCounter - 3) % 24;
planesDisplay = GetPreGameBoard(fullCounter, planesBoard2, plane, CarrierCount, noseUp);
await botInstance.KfClient.EditMessageAsync(msgId.ChatMessageUuid, planesDisplay);
await Task.Delay(TimeSpan.FromMilliseconds(frameLength), ctx);
fullCounter++;
}
}
else
{
while (!neutral)
{
frameCounter++;
try
{
/*
*
* USE BOARD 0: only used to pull the values from the previous board, never used for game determinations
* USE BOARD 1: always
* USE BOARD 2: never used for game determinations only displays
*/
//if (fullCounter == 3) logger.Info($"Generating first plane impact outcome. Framecounter: {frameCounter} | FullCounter: {fullCounter} | Counter: {counter}");
//else logger.Info($"Failed to select proper gameboard for gameplay outcome. UseBoard: {1} | FullCounter: {fullCounter} | Counter: {counter} | Height: {plane.Height} | FrameCounter: {frameCounter}");
switch (planesBoards[1][plane.Height, counter])
{
case 0: //do nothing plane hit neutral space
neutral = true;
//if (fullCounter == 3) logger.Info($"Generated first plane impact outcome. Framecounter: {frameCounter} | FullCounter: {fullCounter} | Counter: {counter} | Outcome: neutral");
break;
case 1: //hit rocket
planesBoards[1][plane.Height, counter] = 0; //plane consumes rocket
plane.HitRocket();
noseUp = false;
//if (fullCounter == 3) logger.Info($"Generated first plane impact outcome. Framecounter: {frameCounter} | FullCounter: {fullCounter} | Counter: {counter} | Outcome: bomb");
break;
case 2: //hit multi
planesBoards[1][plane.Height, counter] = 0; //plane consumes multi
plane.HitMulti();
noseUp = true;
//if (fullCounter == 3) logger.Info($"Generated first plane impact outcome. Framecounter: {frameCounter} | FullCounter: {fullCounter} | Counter: {counter} | Outcome: multi");
break;
default:
await botInstance.SendChatMessageAsync("Something went wrong, error code 1.", true, autoDeleteAfter: cleanupDelay);
return;
}
}
catch (IndexOutOfRangeException e)
{
logger.Error(
$"Something went wrong, error code 2. Counter: {fullCounter} Counter%: {counter} Height: {plane.Height}");
logger.Error(e);
return;
}
if (neutral) //this will be the last frame so use all the remaining frame time left
{
if (frameCounter == 1) await Task.Delay(TimeSpan.FromMilliseconds(frameLength * 2 / 3), ctx); //first frame used 1/3 of frame time so 2/3 is remaining
else await Task.Delay(TimeSpan.FromMilliseconds(frameLength / (3 * (frameCounter - 1))), ctx);
}
else await Task.Delay(TimeSpan.FromMilliseconds(frameLength / (3 * frameCounter)), ctx); //if not the last frame use a fraction of the remaining frame time
try
{
planesDisplay = GetGameBoard(fullCounter, planesBoards, plane, CarrierCount, noseUp);
}
catch (Exception e)
{
logger.Error(e);
throw;
}
planesDisplay += $"[br]Multi: {plane.MultiTracker}x";
for (var i = 0; i < 10; i++)
{
planesDisplay += BlankSpace;
}
var winnings = plane.MultiTracker * wager;
planesDisplay += $"Winnings: {await winnings.FormatKasinoCurrencyAsync()}";
await botInstance.KfClient.EditMessageAsync(msgId.ChatMessageUuid, planesDisplay);
if (plane.Height > 5)
{
break;
}
//maybe fuckery around here
}
fullCounter++;
if ((fullCounter - 3) % 24 == 0 && fullCounter != 3)
{
planesBoards.RemoveAt(0);
planesBoards.Add(CreatePlanesBoard(gambler));
}
}
plane.Gravity();
//maybe need to add one more frame here?***************
} while (plane.Height < 6);
//now plane is too low so you have either won or lost depending on your position
var colors =
await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
decimal newBalance;
if ((fullCounter - 3) % CarrierCount == 0) //if you landed on the carrier
{
var win = plane.MultiTracker * wager;
newBalance = await Money.NewWagerAsync(gambler.Id, wager, win, WagerGame.Planes, ct: ctx);
planesDisplay = GetGameBoard(fullCounter, planesBoards, plane, CarrierCount, noseUp);
await botInstance.KfClient.EditMessageAsync(msgId.ChatMessageUuid, planesDisplay);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you [color={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]successfully landed with {await win.FormatKasinoCurrencyAsync()} from a total {plane.MultiTracker:N2}x multi![/color]. Your balance is now: {await newBalance.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: cleanupDelay);
botInstance.ScheduleMessageAutoDelete(msgId, cleanupDelay);
//Kasino Shop stuff----------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
await botInstance.BotServices.KasinoShop.ProcessWagerTracking(gambler, WagerGame.Planes, wager, win, newBalance);
}
//---------------------------------------------------------------------------------------
return;
}
plane.Crash();
newBalance = await Money.NewWagerAsync(gambler.Id, wager, -wager, WagerGame.Planes, ct: ctx);
planesDisplay = GetGameBoard(fullCounter, planesBoards, plane, CarrierCount, noseUp);
await Task.Delay(TimeSpan.FromMilliseconds(frameLength), ctx);
await botInstance.KfClient.EditMessageAsync(msgId.ChatMessageUuid, planesDisplay);
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you [color={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]crashed![/color] Your balance is now: {await newBalance.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: cleanupDelay);
botInstance.ScheduleMessageAutoDelete(msgId, cleanupDelay);
//Kasino Shop stuff----------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
await botInstance.BotServices.KasinoShop.ProcessWagerTracking(gambler, WagerGame.Planes, wager, -wager, newBalance);
}
//---------------------------------------------------------------------------------------
}
private string GetPreGameBoard(int fullCounter, int[,] planesBoard, Plane plane, int carrierCount, bool noseUp)
{
//counter < 5
var counter = (fullCounter - 3) % 24;
var output = "";
for (var row = 0; row < 8; row++)
{
for (var column = -3; column < 10; column++) //plane starts out 3 space behind to give some space to the view,
{
if (row == plane.Height && column == counter - 1 && plane.JustHitMulti > 1)
{
output += Boost;
}
else if (row == plane.Height && column == counter)
{
if (plane.Crashed) output += PlaneExplosion;
else
switch (noseUp)
{
case true:
output += PlaneUp;
break;
case false:
output += PlaneDown;
break;
}
}
else if (column < 0) //beginning columns have no multis or bombs or carriers just air and water
{
if (row != 7) output += Air;
else output += Water;
}
else if (row == 6)//row between the gameboard and where the carrier is displayed, should show the plane in this row on top of the boat on a win
{
output += Air;
}
else if (row == 7) //water/carrier row
{
if (column % carrierCount == 0) output += Carrier;
else output += Water;
}
else //this leaves rows 0-5 and columns 0-24, exactly what we need for the board
{
switch (planesBoard[row, column])
{
case 0:
output += Air;
break;
case 1:
output += Bomb;
break;
case 2:
output += Multi;
break;
}
}
}
output += "[br]";
}
return output;
}
private string GetGameBoard(int fullCounter, List<int[,]> planesBoards, Plane plane, int carrierCount, bool noseUp)
{
var output = "";
// worldXPlane is the absolute distance the plane has traveled from the start.
int worldXPlane = fullCounter - 3;
for (var row = 0; row < 8; row++)
{
for (var column = -3; column < 10; column++)
{
// worldXTile is the absolute coordinate of the specific tile we are currently drawing.
int worldXTile = worldXPlane + column;
// 1. WATER & CARRIER ROW (Row 7)
if (row == 7)
{
// We use worldXTile so the carrier stays pinned to a global position.
if (worldXTile >= 0 && worldXTile % carrierCount == 0) output += Carrier;
else output += Water;
continue;
}
// 2. THE PLANE (At Column 0 relative to the camera)
if (row == plane.Height && column == 0)
{
if (plane.Crashed) output += PlaneExplosion;
else output += noseUp ? PlaneUp : PlaneDown;
continue;
}
// 3. BOOST EFFECT
if (row == plane.Height && column == -1 && plane.JustHitMulti > 1)
{
output += Boost;
continue;
}
// 4. THE SKY & GAME OBJECTS (Rows 0-6)
// Row 6 is always Air. Any tile with a negative world coordinate is also Air.
if (row == 6 || worldXTile < 0)
{
output += Air;
}
else
{
// Calculate which BOARD the tile belongs to (0, 1, 2, 3...)
int boardNumber = worldXTile / 24;
int localX = worldXTile % 24;
// Map the boardNumber to our sliding window (List of 3 boards).
// Our list always contains: [Board N-1, Board N, Board N+1]
// relative to where the plane is currently flying.
int planeBoardNumber = worldXPlane / 24;
int listIndex = boardNumber - (planeBoardNumber - 1);
if (listIndex >= 0 && listIndex < planesBoards.Count)
{
int tileValue = planesBoards[listIndex][row, localX];
output += tileValue switch
{
1 => Bomb,
2 => Multi,
_ => Air
};
}
else
{
// Fallback if the tile is beyond our current 3-board window
output += Air;
}
}
}
output += "[br]";
}
return output;
}
private int[,] CreatePlanesBoard(GamblerDbModel gambler, int forceTiles = -1)
{
var board = new int [6, 24];
for (var row = 0; row < 6; row++)
{
for (var column = 0; column < 24; column++)
{
var randomNum = Money.GetRandomNumber(gambler, 0, 100);
if (forceTiles != -1) board[row, column] = forceTiles;
else if (_rigged && (column == 5 || column == 11 || column == 17 || column == 23) && row == 5)
{
board[row, column] = 2;
}
else if (_riggedWin && (column == 5 || column == 11 || column == 17 || column == 23) && row == 5)
{
board[row, column] = 0;
}
else if (_riggedWin && row == 5 && (column != 5 && column != 11 && column != 17 && column != 23))
{
board[row, column] = 2;
}
else
board[row, column] = randomNum switch
{
< 49 => 0,
> 79 => 1,
_ => 2
};
}
}
return board;
}
}
public class Plane(GamblerDbModel gambler)
{
public int Height = 1;
public decimal MultiTracker = 1;
public int JustHitMulti = 1;
private readonly RandomShim<StandardRng> _random = RandomShim.Create(StandardRng.Create());
public bool Crashed = false;
public void HitRocket()
{
Gravity();
MultiTracker /= 2;
}
public void Gravity()
{
if (JustHitMulti > 0) JustHitMulti--;
else if (Height >= 6) Height = 6;
else Height++;
}
public void Crash()
{
MultiTracker = 0;
Crashed = true;
}
public void HitMulti()
{
var randomNum = Money.GetRandomNumber(gambler, 0, 4);
var weightedRand = WeightedRandomNumber(1, 10);
if (randomNum == 0)
{
MultiTracker *= weightedRand + (decimal)0.1;
}
else
{
MultiTracker += weightedRand;
}
if (Height > 0) Height--;
if (JustHitMulti == 0) JustHitMulti++;
if (JustHitMulti < 6) JustHitMulti++;
}
private int WeightedRandomNumber(int min, int max)
{
var range = max - min + 1;
var weight = 6.55 + Height;
var r = _random.NextDouble();
var exp = -Math.Log(1 - r) / weight;
var returnVal = min + (int)Math.Round(exp * range);
return Math.Clamp(returnVal, min, max);
}
}

View File

@@ -0,0 +1,337 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using NLog;
using RandN;
using RandN.Compat;
namespace KfChatDotNetBot.Commands.Kasino;
public class PlinkoCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^plinko (?<amount>\d+(?:\.\d+)?) (?<number>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^plinko (?<amount>\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase),
new Regex("^plinko")
];
public string? HelpText => "!plinko <bet amount> <optional number of balls 1 - 10, default 1 if nothing entered>";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(30);
public RateLimitOptionsModel? RateLimitOptions => new RateLimitOptionsModel
{
MaxInvocations = 2,
Window = TimeSpan.FromSeconds(10)
};
public bool WhisperCanInvoke => false;
private const string NULLSPACE = "⚫";
private const string EMPTYSPACE = "⚪";
private const string BALL = "🟠";
private const string LOSESPACE = "🔻";
private const string SMALLWINSPACE = "🟢";
private const string MIDWINSPACE = "🍀";
private const string BIGWINSPACE = "💲";
private const int DIFFICULTY = 8;//maybe plan to allow user to change difficulty of plinko in future updates, would need to change the payout logic though
private static double VACUUM = 0.25;
private decimal HOUSE_EDGE = (decimal)0.98;
private static Dictionary<decimal, string> PAYOUTSTOSTRING = new Dictionary<decimal, string>()
{
{69, BIGWINSPACE},
{(decimal)42.069, MIDWINSPACE},
{9, SMALLWINSPACE},
{(decimal)0.1, LOSESPACE}
};
private static readonly Dictionary<int, decimal> PlinkoPayoutBoard = new()
{
{0, 69},
{2, (decimal)42.069},
{4, 9},
{6, (decimal)0.1},
{8, (decimal)0.1},
{10, 9},
{12, (decimal)42.069},
{14, 69}
};
private static List<(int row, int col)> validPositions = [];
private static Dictionary<int, List<int>> validColumnsForRow = new();
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
VACUUM += 1 - (double)HOUSE_EDGE;
validPositions = new List<(int row, int col)>() { (0, DIFFICULTY-1) };
validColumnsForRow = new Dictionary<int, List<int>>(){{0, new List<int>(){DIFFICULTY-1}}};
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
//calculate all the valid positions for the difficulty
for (int i = 1; i < DIFFICULTY; i++)
{
// Find all positions from the row we just finished (i-1)
var previousRowPositions = validPositions.Where(p => p.row == i - 1).ToList();
foreach (var pos in previousRowPositions)
{
var leftChild = (i, pos.col - 1);
var rightChild = (i, pos.col + 1);
// Use a hash-set or check Contains to avoid adding duplicate nodes
if (!validPositions.Contains(leftChild)) validPositions.Add(leftChild);
if (!validPositions.Contains(rightChild)) validPositions.Add(rightChild);
}
}
//calculate all the valid columns for any particular row
foreach (var position in validPositions)
{
if (!validColumnsForRow.ContainsKey(position.row)) validColumnsForRow.Add(position.row, new List<int>(){position.col});
else validColumnsForRow[position.row].Add(position.col);
}
decimal payout = 0;
decimal currentPayout = 0;
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoPlinkoCleanupDelay, BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor,
BuiltIn.Keys.KasinoPlinkoEnabled, BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay
]);
if (!settings[BuiltIn.Keys.KasinoPlinkoEnabled].ToBoolean())
{
var gameDisabledCleanupDelay= TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, plinko is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoPlinkoCleanupDelay].ToType<int>());
if (!arguments.TryGetValue("amount", out var amount))
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, not enough arguments. !plinko <wager> <optional number of balls default 1>",
true, autoDeleteAfter: cleanupDelay);
return;
}
var wager = Convert.ToDecimal(amount.Value);
/*if (wager > 1)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, plinko is currently limited to 1 KKK wagers while bugs are ironed out.", true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}*/
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
//KasinoShop stuff -------------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
HOUSE_EDGE += botInstance.BotServices.KasinoShop.Gambler_Profiles[user.KfId].HouseEdgeModifier;
}
//------------------------------------------------------------------------------------------
int numberOfBalls = 0;
if (!arguments.TryGetValue("number", out var number))
{
numberOfBalls = 1;
}
else numberOfBalls = Convert.ToInt32(number.Value);
if (numberOfBalls < 1 || numberOfBalls > 10)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you can only play with 1 - 10 balls at a time", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (gambler.Balance < wager * numberOfBalls)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (wager == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you have to wager more than {await wager.FormatKasinoCurrencyAsync()}", true,
autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
List<PlinkoBall> ballsNotInPlay = new List<PlinkoBall>();
List<PlinkoBall> ballsInPlay = new List<PlinkoBall>();
for (int i = 0; i < numberOfBalls; i++)
{
ballsNotInPlay.Add(new PlinkoBall());
}
//game starts here
int breakCounter = 0;
var plinkoMessageID = await botInstance.SendChatMessageAsync(PlinkoBoardDisplay(ballsInPlay), true, autoDeleteAfter: cleanupDelay);
while (plinkoMessageID.ChatMessageUuid == null && breakCounter < 1000) {
await Task.Delay(100, ctx);
breakCounter++;
}
if (breakCounter >= 999){
throw new Exception("game broke while waiting for chat message id");
}
breakCounter = 0;
var logger = LogManager.GetCurrentClassLogger();
string lastPayoutMessage = "";
string PlinkoMessage = "";
while (ballsNotInPlay.Count > 0 || ballsInPlay.Count > 0)
{
breakCounter++;
if (breakCounter >= numberOfBalls * 10) throw new Exception("stuck in while loop in plinko");
currentPayout = 0;
if (ballsNotInPlay.Count > 0)
{
ballsInPlay.Add(ballsNotInPlay[0]);
ballsNotInPlay.RemoveAt(0);
}
PlinkoMessage = PlinkoBoardDisplay(ballsInPlay) + "[br]" + lastPayoutMessage;
await botInstance.KfClient.EditMessageAsync(plinkoMessageID.ChatMessageUuid!, PlinkoMessage);
if (ballsInPlay[0].POSITION.row == DIFFICULTY - 1) //once your ball has reached the bottom calculate the payout
{
currentPayout = wager * PlinkoPayoutBoard[ballsInPlay[0].POSITION.col];
payout += currentPayout;
if (currentPayout == wager * 25) logger.Info($"Plinko: Max win on plinko, ball position: ({ballsInPlay[0].POSITION.row}, {ballsInPlay[0].POSITION.col})");
if (currentPayout > wager)
{
lastPayoutMessage = ($"{user.FormatUsername()}, you [color={settings[BuiltIn.Keys.KiwiFarmsGreenColor].Value!}]won[/color] {await currentPayout.FormatKasinoCurrencyAsync()} from a plinko ball worth {await wager.FormatKasinoCurrencyAsync()}!");
}
else
{
var lossDisplay = wager - currentPayout;
lastPayoutMessage = ($"{user.FormatUsername()}, you [color={settings[BuiltIn.Keys.KiwiFarmsRedColor].Value!}]lost[/color] {await lossDisplay.FormatKasinoCurrencyAsync()} from a plinko ball worth {await wager.FormatKasinoCurrencyAsync()}.");
}
ballsInPlay.RemoveAt(0);
}
foreach (var ball in ballsInPlay)
{
ball.Iterate();
}
await Task.Delay(300, ctx);
PlinkoMessage = PlinkoBoardDisplay(ballsInPlay) + "[br]" + lastPayoutMessage;
await botInstance.KfClient.EditMessageAsync(plinkoMessageID.ChatMessageUuid!, PlinkoMessage);
await Task.Delay(300, ctx);
}
var newBalance = await Money.NewWagerAsync(gambler.Id, wager*numberOfBalls, payout-(wager*numberOfBalls), WagerGame.Plinko, ct: ctx);
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, [u]you won {await payout.FormatKasinoCurrencyAsync()} from {numberOfBalls} plinko balls worth ${wager} KKK. Balance: {await newBalance.FormatKasinoCurrencyAsync()}", true, autoDeleteAfter: cleanupDelay);
//Kasino Shop stuff----------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
await botInstance.BotServices.KasinoShop.ProcessWagerTracking(gambler, WagerGame.Plinko, wager*numberOfBalls, payout-(wager*numberOfBalls), newBalance);
}
//---------------------------------------------------------------------------------------
}
public string PlinkoBoardDisplay(List<PlinkoBall> balls)
{
string board = "";
bool spaceIsBall = false;
bool spaceIsValid = false;
for (int row = 0; row < DIFFICULTY; row++)
{
for (int col = 0; col < DIFFICULTY*2-1; col++)
{
spaceIsBall = false;
spaceIsValid = false;
foreach (var position in validPositions)
{
if (position.row == row && position.col == col)
{
spaceIsValid = true;
foreach (var ball in balls)
{
if (ball.POSITION.row == row && ball.POSITION.col == col)
{
board += BALL;
spaceIsBall = true;
break;
}
}
if (!spaceIsBall)
{
if (row == DIFFICULTY - 1)
{
foreach (var num in new List<int>(){0,2,4,6,8,10,12,14})
if (col == num) board += PAYOUTSTOSTRING[PlinkoPayoutBoard[num]];
}
else board += EMPTYSPACE;
}
}
}
if (!spaceIsValid) board += NULLSPACE;
}
board += "[br]";
}
return board;
}
public class PlinkoBall
{
private RandomShim<StandardRng> RAND = RandomShim.Create(StandardRng.Create());
public (int row, int col) POSITION;
public PlinkoBall()
{
POSITION = validPositions[0];
}
public void Iterate()
{
double rng = RAND.NextDouble();
bool evenrow = POSITION.row % 2 == 0;
if (POSITION.col < DIFFICULTY-1)
{
rng -= VACUUM;
}
else if (POSITION.col > DIFFICULTY-1)
{
rng += VACUUM;
}
switch (rng)
{
case >= 0.5:
POSITION.col--;
break;
case < 0.5:
POSITION.col++;
break;
default:
throw new Exception("generated an incorrect number");
}
POSITION.row++;
}
}
}

View File

@@ -0,0 +1,164 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class RainCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^rain (?<amount>\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase),
new Regex(@"^rain", RegexOptions.IgnoreCase)
];
public string? HelpText => "!rain <amount> to start a rain, !rain to join all active rains";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(90);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KasinoRainCountdownDuration,
BuiltIn.Keys.KasinoRainEnabled
]);
// Check if rain is enabled
var rainEnabled = (settings[BuiltIn.Keys.KasinoRainEnabled]).ToBoolean();
if (!rainEnabled)
{
var gameDisabledCleanupDelay= TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, rain is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
var cleanupDelay = TimeSpan.FromSeconds(30);
if (botInstance.BotServices.KasinoRain == null || !botInstance.BotServices.KasinoRain.IsInitialized())
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, rain is not available at this time", true,
autoDeleteAfter: cleanupDelay);
return;
}
var rain = await botInstance.BotServices.KasinoRain.GetRainState();
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
{
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
}
if (!arguments.TryGetValue("amount", out var amount)) //if you're trying to join a rain
{
if (rain == null) //if there are no lobbies
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, there's no rain currently running. !rain <amount> to start a new rain",
true, autoDeleteAfter: cleanupDelay);
return;
}
if (rain.Participants.Contains(user.Id))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you're already participating in this rain!", true,
autoDeleteAfter: cleanupDelay);
return;
}
if (rain.Creator == user.Id)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you can't participate in your own rain!", true,
autoDeleteAfter: cleanupDelay);
return;
}
if ((DateTimeOffset.UtcNow - gambler.Created).TotalHours < 4)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you're too fresh for a rain", true, autoDeleteAfter: cleanupDelay);
return;
}
await botInstance.BotServices.KasinoRain.AddParticipant(user.Id);
var pluralSuffix = string.Empty;
if (rain.Participants.Count > 0) pluralSuffix = "s";
await botInstance.SendChatMessageAsync(
$"LFG {user.FormatUsername()} is now a participant! There's now {rain.Participants.Count + 1} participant{pluralSuffix}! Type [ditto]!rain[/ditto] to participate",
true, autoDeleteAfter: cleanupDelay);
return;
}
//if you're trying to start the rain
if (rain != null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, there's already a rain in progress.",
true, autoDeleteAfter: cleanupDelay);
return;
}
decimal decAmount = Convert.ToDecimal(amount.Value);
if (decAmount <= 0)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, you can't make it rain with nothing.",
true, autoDeleteAfter: cleanupDelay);
return;
}
if (gambler.Balance < decAmount)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance {await gambler.Balance.FormatKasinoCurrencyAsync()} is not enough to make it rain for {await decAmount.FormatKasinoCurrencyAsync()}.",
true, autoDeleteAfter: cleanupDelay);
return;
}
var rainMin = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.KasinoRainMinimum)).ToType<decimal>();
if (decAmount < rainMin)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, rain at least {await rainMin.FormatKasinoCurrencyAsync()}", true,
autoDeleteAfter: cleanupDelay);
return;
}
rain = new KasinoRainModel
{
Participants = [],
Creator = user.Id,
Started = DateTimeOffset.UtcNow,
RainAmount = decAmount,
PayoutWhen = DateTimeOffset.MaxValue
};
var rainCountdown = settings[BuiltIn.Keys.KasinoRainCountdownDuration].ToType<int>();
var timer = rainCountdown;
var msg = await botInstance.SendChatMessageAsync(
$"🌧️🌧️ {user.FormatUsername()} is making it rain with {await decAmount.FormatKasinoCurrencyAsync()}! Type [ditto]!rain[/ditto] in the next {timer} seconds to join.",
true);
var result = await botInstance.WaitForChatMessageAsync(msg, ct: ctx);
if (!result)
{
throw new InvalidOperationException("Failed to send chat message for the rain. Not going to proceed with it");
}
// Wait to set a real payout deadline only when chyat echoes the message out of fairness
// (and also so the timer doesn't overlap with the payout deadline)
rain.PayoutWhen = DateTimeOffset.UtcNow.AddSeconds(rainCountdown);
await botInstance.BotServices.KasinoRain.SaveRainState(rain);
while (timer > 0)
{
timer--;
await Task.Delay(1000, ctx);
await botInstance.KfClient.EditMessageAsync(msg.ChatMessageUuid!,
$"🌧️🌧️ {user.FormatUsername()} is making it rain with {await decAmount.FormatKasinoCurrencyAsync()}! Type [ditto]!rain[/ditto] in the next {timer} seconds to join.");
}
await Task.Delay(100, ctx);
await botInstance.KfClient.DeleteMessageAsync(msg.ChatMessageUuid!);
// At this point the timer should take care of things but truthfully it's a disaster and probably won't work
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,729 @@
using System.Net.Http.Headers;
using RandN;
using RandN.Compat;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Drawing;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.Fonts;
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class SlotsCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^slots (?<amount>\d+(?:\.\d+)?) (?<spins>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^slots (?<amount>\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase),
new Regex("^slots$", RegexOptions.IgnoreCase),
new Regex(@"^sluts (?<amount>\d+(?:\.\d+)?) (?<spins>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"^sluts (?<amount>\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase),
new Regex("^sluts", RegexOptions.IgnoreCase)
];
public string? HelpText => "!slots [bet amount]";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(30);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 2,
Window = TimeSpan.FromSeconds(15)
};
public bool WhisperCanInvoke => false;
private decimal HOUSE_EDGE = (decimal)0.98;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user,
GroupCollection arguments, CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KasinoSlotsEnabled
]);
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
// Check if slots is enabled
var slotsEnabled = (settings[BuiltIn.Keys.KasinoSlotsEnabled]).ToBoolean();
if (!slotsEnabled)
{
var gameDisabledCleanupDelay= TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, slots is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
if (!arguments.TryGetValue("amount", out var amount)) //if user just enters !keno
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you need to bet something to play. !slots [bet]",
true, autoDeleteAfter: TimeSpan.FromSeconds(30));
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
int spins = 0;
if (!arguments.TryGetValue("spins", out var spinsArg)) spins = 1;
else spins = Convert.ToInt32(spinsArg.Value);
if (spins < 1 || spins > 10)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()} you can only do between 1 and 10 spins.", true, autoDeleteAfter: TimeSpan.FromSeconds(30));
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var wager = Convert.ToDecimal(amount.Value);
if (wager < (decimal)0.01){
await botInstance.SendChatMessageAsync($"{user.FormatUsername()} you must bet a minimum of $0.01 KKK", true, autoDeleteAfter: TimeSpan.FromSeconds(30));
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
if (gambler.Balance < wager * spins)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: TimeSpan.FromSeconds(30));
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
//KasinoShop stuff -------------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
HOUSE_EDGE += botInstance.BotServices.KasinoShop.Gambler_Profiles[user.KfId].HouseEdgeModifier;
}
//------------------------------------------------------------------------------------------
char rigged = '0';
decimal rigCheck = (decimal)Money.GetRandomDouble(gambler);
if (HOUSE_EDGE > 1)
{
if (HOUSE_EDGE - rigCheck > 1) rigged = 'W';
}
else
{
if (rigCheck - HOUSE_EDGE > 0) rigged = 'L';
}
decimal winnings;
double delayHSec = 0;
using (var board = new KiwiSlotBoard(wager))
{
board.LoadAssets();
board.ExecuteGameLoop(spins, 0, rigged);
using (var finalImageStream = board.ExportAndCleanup())
{
if (finalImageStream == null)
{
throw new InvalidOperationException("board.ExportAndCleanup returned null");
}
var imageUrl = await Zipline.Upload(finalImageStream, new MediaTypeHeaderValue("image/webp"), "1h", ctx);
await botInstance.SendChatMessageAsync($"[img]{imageUrl}[/img]", true,
autoDeleteAfter: TimeSpan.FromSeconds(60)); // delay till slots graphic deletion.
}
winnings = (decimal)board.RunningTotalDisplay;
// We skip index 0 if it's the blank placeholder frame
for (int i = 1; i < board.AnimatedImage.Frames.Count; i++)
{
delayHSec += board.AnimatedImage.Frames[i].Metadata.GetWebpMetadata().FrameDelay;
}
}
await Task.Delay(TimeSpan.FromSeconds(delayHSec));//adds delay to stop message showing gambling win/loss too early based on total frame count of the animated image
var colors =
await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
decimal newBalance;
string spinText = spins == 1 ? "" : $" from {spins} spins worth {await wager.FormatKasinoCurrencyAsync()}";
if (winnings == 0) //dud spin(s)
{
newBalance = await Money.NewWagerAsync(gambler.Id, wager*spins, -wager*spins, WagerGame.Slots, ct: ctx);
var totalWager = wager * spins;
await Task.Delay(TimeSpan.FromSeconds(spins));
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()} you [color={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]lost[/color] {await totalWager.FormatKasinoCurrencyAsync()} with {spins} spins. Current balance: {await newBalance.FormatKasinoCurrencyAsync()}",
true, autoDeleteAfter: TimeSpan.FromSeconds(30));
//Kasino Shop stuff----------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
await botInstance.BotServices.KasinoShop.ProcessWagerTracking(gambler, WagerGame.Slots, wager*spins, -wager*spins, newBalance);
}
//---------------------------------------------------------------------------------------
return;
}
decimal rawWinnings = winnings;
winnings -= wager*spins;
bool netwin = winnings > 0;
string winstr = netwin ? "" : "-";
newBalance = await Money.NewWagerAsync(gambler.Id, wager*spins, winnings, WagerGame.Slots, ct: ctx);
winnings = Math.Abs(winnings);
//Kasino Shop stuff----------------------------------------------------------------------
if (botInstance.BotServices.KasinoShop != null)
{
await GlobalShopFunctions.CheckProfile(botInstance, user, gambler);
await botInstance.BotServices.KasinoShop.ProcessWagerTracking(gambler, WagerGame.Slots, wager*spins, winnings, newBalance);
}
//---------------------------------------------------------------------------------------
await Task.Delay(TimeSpan.FromSeconds(spins * 2));
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you [color={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]won[/color] {await rawWinnings.FormatKasinoCurrencyAsync()} from {spins} spins worth {await wager.FormatKasinoCurrencyAsync()}! Net: {winstr}{await winnings.FormatKasinoCurrencyAsync()} Current balance: {await newBalance.FormatKasinoCurrencyAsync()}", true, autoDeleteAfter: TimeSpan.FromSeconds(30));
}
public class WinDetail
{
public required (int row, int col)[] Path { get; set; }
public decimal Amount { get; set; }
}
private class KiwiSlotBoard : IDisposable
{
private const char WILD = 'K', FEATURE = 'L', EXPANDER = 'M';
private Image<Rgba32>? _headerImg;
private Dictionary<char, Image<Rgba32>> _symbolImgs = new();
private Dictionary<int, Image<Rgba32>> _expanderImgs = new();
private Font? _font;
// Optimized Animation Container
public Image<Rgba32> AnimatedImage { get; set; }
private readonly char[,] _preboard = new char[5, 5];
private char[,] _board = new char[5, 5];
private readonly decimal _userBet;
public decimal RunningTotalDisplay = 0;
private int _activeFeatureTier = 0, _currentFeatureSpin = 0;
private bool _showGoldCircle = false;
private bool _currentlyInFeature = false;
private readonly RandomShim<StandardRng> _rand = RandomShim.Create(StandardRng.Create());
private static readonly List<char> ExpanderWild =
['N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '1', '2'];
private readonly Dictionary<char, double> _multiTable = new() { { 'N', 2 }, { 'O', 3 }, { 'P', 4 }, { 'Q', 5 }, { 'R', 6 }, { 'S', 7 }, { 'T', 8 }, { 'U', 9 }, { 'V', 10 }, { 'W', 15 }, { 'X', 20 }, { 'Y', 25 }, { 'Z', 50 }, { '1', 100 }, { '2', 200 } };
private readonly Dictionary<string, double> _payoutTable = new() { { "A3", 0.2 }, { "A4", 1.0 }, { "A5", 5.0 }, { "B3", 0.2 }, { "B4", 1.0 }, { "B5", 5.0 }, { "C3", 0.3 }, { "C4", 1.5 }, { "C5", 7.5 }, { "D3", 0.3 }, { "D4", 1.5 }, { "D5", 7.5 }, { "E3", 0.4 }, { "E4", 2.0 }, { "E5", 10.0 }, { "F3", 1.0 }, { "F4", 5.0 }, { "F5", 15.0 }, { "G3", 1.0 }, { "G4", 5.0 }, { "G5", 15.0 }, { "H3", 1.5 }, { "H4", 7.5 }, { "H5", 17.5 }, { "I3", 1.5 }, { "I4", 7.5 }, { "I5", 17.5 }, { "J3", 2.0 }, { "J4", 10.0 }, { "J5", 20.0 }, { "K5", 25.0 }, { "L5", 25.0 }, { "M5", 25.0 } };
private readonly List<(int row, int col)[]> _payoutLines =
[
[(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)], [(1, 0), (1, 1), (1, 2), (1, 3), (1, 4)],
[(2, 0), (2, 1), (2, 2), (2, 3), (2, 4)], [(3, 0), (3, 1), (3, 2), (3, 3), (3, 4)],
[(4, 0), (4, 1), (4, 2), (4, 3), (4, 4)], [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)],
[(4, 0), (3, 1), (2, 2), (1, 3), (0, 4)], [(1, 0), (0, 1), (1, 2), (0, 3), (1, 4)],
[(2, 0), (1, 1), (2, 2), (1, 3), (2, 4)], [(3, 0), (2, 1), (3, 2), (2, 3), (3, 4)],
[(4, 0), (3, 1), (4, 2), (3, 3), (4, 4)], [(0, 0), (1, 1), (0, 2), (1, 3), (0, 4)],
[(1, 0), (2, 1), (1, 2), (2, 3), (1, 4)], [(2, 0), (3, 1), (2, 2), (3, 3), (2, 4)],
[(3, 0), (4, 1), (3, 2), (4, 3), (3, 4)], [(2, 0), (1, 1), (0, 2), (1, 3), (2, 4)],
[(3, 0), (2, 1), (1, 2), (2, 3), (3, 4)], [(2, 0), (3, 1), (4, 2), (3, 3), (2, 4)],
[(1, 0), (2, 1), (3, 2), (2, 3), (1, 4)]
];
public KiwiSlotBoard(decimal bet)
{
_userBet = bet;
AnimatedImage = new Image<Rgba32>(600, 800);
}
public void LoadAssets()
{
var assetPath = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets", "Default");
if (!Directory.Exists(assetPath)) throw new DirectoryNotFoundException($"Assets folder missing at {assetPath}");
_headerImg = Image.Load<Rgba32>(System.IO.Path.Combine(assetPath, "header.png"));
foreach (var c in "ABCDEFGHIJKL") _symbolImgs[c] = Image.Load<Rgba32>(System.IO.Path.Combine(assetPath, $"{c}.png"));
for (var i = 1; i <= 5; i++) _expanderImgs[i] = Image.Load<Rgba32>(System.IO.Path.Combine(assetPath, $"exp{i}.png"));
_font = SystemFonts.CreateFont("Arial", 20, FontStyle.Bold);
}
private void RenderFrame(int dropOffset = 500, List<WinDetail>? activeWins = null)
{
if (_font == null || _headerImg == null)
{
throw new InvalidOperationException("_font or _headerImg was null");
}
using var frame = new Image<Rgba32>(600, 800);
frame.Mutate(ctx => {
ctx.Fill(Color.Black);
// --- SIDEBAR SECTION ---
var sidebarX = 0;
int[] tiers = [3, 4, 5];
int[] yCoords = [150, 300, 450];
for (var i = 0; i < 3; i++) {
var t = tiers[i];
var y = yCoords[i];
if (_showGoldCircle && _activeFeatureTier == t)
ctx.Fill(Color.Gold, new EllipsePolygon(sidebarX + 50, y + 50, 48));
if (_symbolImgs.TryGetValue(FEATURE, out var feat))
ctx.DrawImage(feat, new Point(sidebarX, y), 1f);
var lb = $"x{t}";
var sz = TextMeasurer.MeasureSize(lb, new TextOptions(_font));
ctx.DrawText(lb, _font, Color.White, new PointF(sidebarX + 50 - (sz.Width / 2), y + 105));
}
// --- MAIN REEL SECTION ---
var mainX = 100;
ctx.DrawImage(_headerImg, new Point(mainX, 0), 1f);
var boardRect = new Rectangle(mainX, 200, 500, 500);
ctx.Clip(new RectangularPolygon(boardRect), clipCtx => {
var occupied = new bool[5, 5];
float animationY = (500 - dropOffset);
for (var j = 0; j < 5; j++) {
for (var i = 0; i < 5; i++) {
if (occupied[i, j]) continue;
var sym = _board[i, j];
var x = mainX + (j * 100);
var y = (200 + (i * 100)) - (int)animationY;
if (sym == EXPANDER || _multiTable.ContainsKey(sym)) {
var h = 0;
for (var k = i; k < 5; k++) if (_board[k, j] == sym) h++; else break;
if (_expanderImgs.TryGetValue(h, out var tex)) {
clipCtx.DrawImage(tex, new Point(x, y), 1f);
if (_multiTable.TryGetValue(sym, out var mVal))
clipCtx.DrawText($"x{mVal}", _font, Color.Yellow, new PointF(x + 50, y + (h * 50)));
}
for (var k = 0; k < h; k++) occupied[i + k, j] = true;
}
else if (_symbolImgs.TryGetValue(sym, out var tex)) clipCtx.DrawImage(tex, new Point(x, y), 1f);
}
}
if (activeWins != null) {
foreach (var win in activeWins) {
var points = win.Path.Select(p => new PointF(mainX + (p.col * 100 + 50), 200 + (p.row * 100) + 50 - animationY)).ToArray();
clipCtx.Draw(new SolidPen(Color.White, 8f), new SixLabors.ImageSharp.Drawing.Path(new LinearLineSegment(points)));
var amtText = $"${win.Amount:F2}";
var midPoint = points[win.Path.Length / 2];
var size = TextMeasurer.MeasureSize(amtText, new TextOptions(_font));
var bgRect = new RectangularPolygon(midPoint.X - (size.Width / 2) - 5, midPoint.Y - (size.Height / 2) - 2, size.Width + 10, size.Height + 4);
clipCtx.Fill(Color.FromRgba(0, 0, 0, 200), bgRect);
clipCtx.DrawText(amtText, _font, Color.LimeGreen, new PointF(midPoint.X - (size.Width / 2), midPoint.Y - (size.Height / 2)));
}
}
});
// --- FOOTER SECTION ---
ctx.Fill(Color.FromRgb(15, 15, 15), new Rectangle(0, 700, 600, 100));
ctx.DrawLine(Color.Gold, 3f, new PointF(0, 700), new PointF(600, 700));
var largeFont = SystemFonts.CreateFont("Arial", 35, FontStyle.Bold);
void DrawAutoScaledText(string text, Font font, Color color, RectangleF targetArea) {
var textOptions = new TextOptions(font);
var size = TextMeasurer.MeasureSize(text, textOptions);
var scale = 1.0f;
if (size.Width > targetArea.Width) scale = targetArea.Width / size.Width;
// FIX: Added 'using' to prevent Font object leaks
var finalFont = new Font(font, font.Size * scale);
var finalSize = TextMeasurer.MeasureSize(text, new TextOptions(finalFont));
var yPos = targetArea.Y + (targetArea.Height - finalSize.Height) / 2;
var xPos = targetArea.X + (targetArea.Width - finalSize.Width) / 2;
ctx.DrawText(text, finalFont, color, new PointF(xPos, yPos));
}
DrawAutoScaledText($"BET: ${_userBet.FormatKasinoCurrencyAsync(wrapInPlainBbCode: false).Result}", largeFont, Color.White, new RectangleF(20, 700, 180, 100));
DrawAutoScaledText($"WIN: ${RunningTotalDisplay.FormatKasinoCurrencyAsync(wrapInPlainBbCode: false).Result}", largeFont, Color.Gold, new RectangleF(380, 700, 200, 100));
if (_currentFeatureSpin > 0 && _currentlyInFeature) {
var total = _activeFeatureTier switch { 3 => 3, 4 => 5, 5 => 10, _ => 0 };
DrawAutoScaledText($"SPIN {_currentFeatureSpin}/{total}", largeFont, Color.SkyBlue, new RectangleF(210, 700, 160, 100));
}
});
// Set delay and push to master animation
frame.Frames.RootFrame.Metadata.GetWebpMetadata().FrameDelay = 2;
AnimatedImage.Frames.AddFrame(frame.Frames.RootFrame);
}
private void AddPause(int hundredthsOfASecond)
{
// Render the current state as a static frame
RenderFrame();
// Modify the delay of the very last frame we just added
var lastFrame = AnimatedImage.Frames[^1];
lastFrame.Metadata.GetWebpMetadata().FrameDelay = (ushort)hundredthsOfASecond;
}
public MemoryStream? ExportAndCleanup()
{
if (AnimatedImage.Frames.Count <= 1) return null;
var ms = new MemoryStream();
// Remove the blank placeholder frame
AnimatedImage.Frames.RemoveFrame(0);
AnimatedImage.Save(ms, new WebpEncoder { Quality = 80 });
ms.Position = 0;
// Free the animation memory now that it's encoded
ResetAnimation();
return ms;
}
private void ResetAnimation()
{
while (AnimatedImage.Frames.Count > 1)
AnimatedImage.Frames.RemoveFrame(0);
}
public void ExecuteGameLoop(int spins, int featureSpins = 0, char rigged = '0')
{
for (int sp = 0; sp < spins; sp++)
{
GeneratePreBoard(featureSpins, rigged);
var fCount = 0;
for (var i = 0; i < 5; i++) for (var j = 0; j < 5; j++) if (_preboard[i, j] == FEATURE) fCount++;
if (featureSpins == 0) {
_activeFeatureTier = fCount >= 5 ? 5 : (fCount >= 3 ? fCount : 0);
_showGoldCircle = _activeFeatureTier >= 3; _currentFeatureSpin = 0;
_currentlyInFeature = false;
} else {
_showGoldCircle = true; _currentFeatureSpin = featureSpins; _currentlyInFeature = true;
}
ProcessReelsAndWins();
var total = _activeFeatureTier switch { 3 => 3, 4 => 5, 5 => 10, _ => 0 };
if (total > 0 || featureSpins != 0 || spins > 1) AddPause(50);
if (featureSpins == 0) for (var s = 1; s <= total; s++) ExecuteGameLoop(1,s, rigged);
}
}
private void ProcessReelsAndWins()
{
_board = (char[,])_preboard.Clone();
for (var o = 0; o <= 500; o += 50) RenderFrame(o);
List<char> multis = new(_multiTable.Keys);
for (var j = 0; j < 5; j++) {
for (var i = 0; i < 5; i++) {
if (_preboard[i, j] == EXPANDER) {
var hitWild = false;
for (var c = i; c < 5; c++) if (_preboard[c, j] == WILD) hitWild = true;
var mSym = hitWild ? multis[_rand.Next(multis.Count)] : EXPANDER;
for (var r = i; r < 5; r++) _board[r, j] = mSym;
RenderFrame(); break;
}
}
}
var winners = GetWinningLinesCoordsWithPayouts();
var target = RunningTotalDisplay + winners.Sum(w => w.Amount);
foreach (var win in winners) {
var inc = win.Amount / (decimal)10.0;
for (var f = 0; f < 10; f++) { RunningTotalDisplay += inc; RenderFrame(500, [win]); }
}
RunningTotalDisplay = target; RenderFrame();
}
private List<WinDetail> GetWinningLinesCoordsWithPayouts()
{
List<WinDetail> res = [];
foreach (var line in _payoutLines) {
var ch = '0'; var count = 0; double m = 0; var spec = true;
foreach (var (r, c) in line) {
var cell = _board[r, c];
if (cell != WILD && cell != FEATURE && cell != EXPANDER && !ExpanderWild.Contains(cell)) { ch = cell; spec = false; break; }
}
if (!spec) {
foreach (var (r, c) in line) {
var cell = _board[r, c];
if (cell == ch || cell == WILD || cell == FEATURE || ExpanderWild.Contains(cell) || cell == EXPANDER) {
count++; if (ExpanderWild.Contains(cell)) m += _multiTable[cell];
} else if (count < 3) { count = 0; break; } else break;
}
} else { ch = _board[line[0].row, line[0].col]; count = 5; foreach (var (r, c) in line) if (ExpanderWild.Contains(_board[r, c])) m += _multiTable[_board[r, c]]; }
if (count >= 3) {
if (m == 0) m = 1;
if (_payoutTable.TryGetValue($"{ch}{count}", out var baseW)) {
var path = new (int, int)[count]; Array.Copy(line, path, count);
res.Add(new WinDetail { Path = path, Amount = _userBet * (decimal)baseW * (decimal)m });
}
}
}
return res;
}
private void GeneratePreBoard(int f = 0, char rigged = '0')
{
var fc = 0; HashSet<int> ex = [];
for (var i = 0; i < 5; i++) {
for (var j = 0; j < 5; j++)
{
var r = _rand.NextDouble() * 100.6;
if (f != 0 && j > 2) r *= 1.1;
if (rigged == 'L') r = _rand.NextDouble() * 97.01;
if (rigged == 'W') // guarantee max win
{
if (i == 0)
{
_preboard[i, j] = EXPANDER;
continue;
}
else if (i < 4)
{
_preboard[i, j] = WILD;
continue;
}
else
{
_preboard[i, j] = FEATURE;
continue;
}
}
/*if (r < 22) _preboard[i, j] = 'A';
else if (r < 44) _preboard[i, j] = 'B';
else if (r < 52) _preboard[i, j] = 'C';
else if (r < 66) _preboard[i, j] = 'D';
else if (r < 78) _preboard[i, j] = 'E';
else if (r < 84) _preboard[i, j] = 'F';
else if (r < 89) _preboard[i, j] = 'G';
else if (r < 92) _preboard[i, j] = 'H';
else if (r < 95) _preboard[i, j] = 'I';
else if (r < 97) _preboard[i, j] = 'J';
else if (r < 98.5) _preboard[i, j] = WILD;
else if (r < (j <= 2 ? 99 : 99.5)) { if (!ex.Contains(j)) { _preboard[i, j] = EXPANDER; ex.Add(j); } else _preboard[i, j] = WILD; }
else { if (fc < 5) { _preboard[i, j] = FEATURE; fc++; } else _preboard[i, j] = WILD; }*/
_preboard[i, j] = PickSlotSymbol(r, i, j);
switch (_preboard[i, j])
{
case EXPANDER: ex.Add(j);
break;
}
/*if (rigged == 'L') //guarantee random losing board
{
//if i==0 and j==0 pick a random one, aka do nothing
if (i == 0 && j == 1)
{
//first row, just make sure the tiles do not have straight line match for the first three from the left. essentially make sure from first row 0 1 2 3 4, tiles 0, 1, and 2 all need to be different
while (_preboard[i, j - 1] == _preboard[i, j])
{
r = _rand.NextDouble() * 97.01;
_preboard[i, j] = PickSlotSymbol(r, i, j);
loopCounter++;
if (loopCounter > 10000) throw new Exception("Failed to generate a losing board");
}
continue;
}
else if (i == 0 && j == 2)
{
while (_preboard[i, j - 1] == _preboard[i, j] || _preboard[i, j - 2] == _preboard[i, j])
{
r = _rand.NextDouble() * 97.01;
_preboard[i, j] = PickSlotSymbol(r, i, j);
loopCounter++;
if (loopCounter > 10000) throw new Exception("Failed to generate a losing board2");
}
continue;
}
else if (i > 0 && j == 0)
{
//if its the first one in a row check to make sure its not the same as a diagonal on the row above
if (_preboard[i - 1, j + 1] == _preboard[i, j])
{
while (_preboard[i - 1, j + 1] == _preboard[i, j])
{
r = _rand.NextDouble() * 97.01;
_preboard[i, j] = PickSlotSymbol(r, i, j);
loopCounter++;
if (loopCounter > 10000) throw new Exception("Failed to generate a losing board3");
}
}
continue;
}
else if (i > 0 && j > 0 && j < 3)
{
//check both diagonals above and one space behind
if (j == 2)
{
if (_preboard[i - 1, j + 1] == _preboard[i, j] ||
_preboard[i - 1, j - 1] == _preboard[i, j] ||
_preboard[i, j - 1] == _preboard[i, j] ||
_preboard[i, j - 2] == _preboard[i, j])
{
while (_preboard[i - 1, j + 1] == _preboard[i, j] ||
_preboard[i - 1, j - 1] == _preboard[i, j] ||
_preboard[i, j - 1] == _preboard[i, j] ||
_preboard[i, j - 2] == _preboard[i, j])
{
r = _rand.NextDouble() * 97.01;
_preboard[i, j] = PickSlotSymbol(r, i, j);
loopCounter++;
if (loopCounter > 10000) throw new Exception("Failed to generate a losing board4");
}
}
}
else
{
if (_preboard[i - 1, j + 1] == _preboard[i, j] || _preboard[i - 1, j - 1] == _preboard[i, j] || _preboard[i, j - 1] == _preboard[i, j])
{
while (_preboard[i - 1, j + 1] == _preboard[i, j] || _preboard[i - 1, j - 1] == _preboard[i, j])
{
r = _rand.NextDouble() * 97.01;
_preboard[i, j] = PickSlotSymbol(r, i, j);
loopCounter++;
if (loopCounter > 10000) throw new Exception("Failed to generate a losing board5");
}
}
}
}
}*/
}
}
if (rigged == 'L') RigSlotBoard();
char PickSlotSymbol(double r, int i, int j)
{
if (r < 22) return 'A';
else if (r < 44) return 'B';
else if (r < 52) return 'C';
else if (r < 66) return 'D';
else if (r < 78) return 'E';
else if (r < 84) return 'F';
else if (r < 89) return 'G';
else if (r < 92) return 'H';
else if (r < 95) return 'I';
else if (r < 97) return 'J';
else if (r < 98.5) return WILD;
else if (r < (j <= 2 ? 99 : 99.5)) { if (!ex.Contains(j)) { return EXPANDER; } else return WILD; }
else { if (fc < 5) { fc++;
return FEATURE;
} else return WILD; }
}
void RigSlotBoard()
{
int totalRuns = 0;
double r;
for (int row = 0; row < 5; row++)
{
for (int col = 0; col < 5; col++)
{
int loopCounter = 0;
if (row == 0 && col == 1)
{
//check 1 slot behind
while (_preboard[row, col - 1] == _preboard[row, col])
{
r = _rand.NextDouble() * 97.01;
_preboard[row, col] = PickSlotSymbol(r, row, col);
loopCounter++;
totalRuns++;
if (loopCounter > 10000) throw new Exception($"Failed to rig slot board after 10000 attempts. Got stuck on row {row} col {col}.");
}
}
if (row == 0 && col == 2)
{
//check 2 slots behind
while (_preboard[row, col - 1] == _preboard[row, col] || _preboard[row, col - 2] == _preboard[row, col])
{
r = _rand.NextDouble() * 97.01;
_preboard[row, col] = PickSlotSymbol(r, row, col);
loopCounter++;
totalRuns++;
if (loopCounter > 10000) throw new Exception($"Failed to rig slot board after 10000 attempts. Got stuck on row {row} col {col}.");
}
}
if (row > 0 && col == 0)
{
//check the diagonal above and to the right
while (_preboard[row - 1, col + 1] == _preboard[row, col])
{
r = _rand.NextDouble() * 97.01;
_preboard[row, col] = PickSlotSymbol(r, row, col);
loopCounter++;
totalRuns++;
if (loopCounter > 10000) throw new Exception($"Failed to rig slot board after 10000 attempts. Got stuck on row {row} col {col}.");
}
}
if (row > 1 && col == 0)
{
//check the diagnoals above and to the right for 2 spaces
while (_preboard[row - 1, col + 1] == _preboard[row, col] ||
_preboard[row - 2, col + 2] == _preboard[row, col])
{
r = _rand.NextDouble() * 97.01;
_preboard[row, col] = PickSlotSymbol(r, row, col);
loopCounter++;
totalRuns++;
if (loopCounter > 10000) throw new Exception($"Failed to rig slot board after 10000 attempts. Got stuck on row {row} col {col}.");
}
}
if (row > 0 && col == 1)
{
//check both diagonals above for 1 space, and one space behind
while (_preboard[row - 1, col - 1] == _preboard[row, col] ||
_preboard[row - 1, col + 1] == _preboard[row, col] ||
_preboard[row, col - 1] == _preboard[row, col])
{
r = _rand.NextDouble() * 97.01;
_preboard[row, col] = PickSlotSymbol(r, row, col);
loopCounter++;
totalRuns++;
if (loopCounter > 10000) throw new Exception($"Failed to rig slot board after 10000 attempts. Got stuck on row {row} col {col}.");
}
}
if (row > 0 && col == 2)
{
//check both diagonals above for 1 space and 2 spaces behind
while (_preboard[row - 1, col - 1] == _preboard[row, col] ||
_preboard[row - 1, col + 1] == _preboard[row, col] ||
_preboard[row, col - 1] == _preboard[row, col] ||
_preboard[row, col - 2] == _preboard[row, col])
{
r = _rand.NextDouble() * 97.01;
_preboard[row, col] = PickSlotSymbol(r, row, col);
loopCounter++;
totalRuns++;
if (loopCounter > 10000) throw new Exception($"Failed to rig slot board after 10000 attempts. Got stuck on row {row} col {col}.");
}
}
}
}
}
}
public void Dispose()
{
_headerImg?.Dispose();
foreach (var img in _symbolImgs.Values) img.Dispose();
foreach (var img in _expanderImgs.Values) img.Dispose();
AnimatedImage?.Dispose();
}
}
}

View File

@@ -0,0 +1,287 @@
using System.Text.RegularExpressions;
using System.Globalization;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Commands.Kasino;
[KasinoCommand]
[WagerCommand]
public class WheelCommand : ICommand
{
public List<Regex> Patterns =>
[
new Regex(@"wheel (?<amount>\d+)$", RegexOptions.IgnoreCase),
new Regex(@"wheel (?<amount>\d+\.\d+)$", RegexOptions.IgnoreCase),
new Regex(@"wheel (?<amount>\d+) (?<difficulty>[A-Za-z]+)$", RegexOptions.IgnoreCase),
new Regex(@"wheel (?<amount>\d+\.\d+) (?<difficulty>[A-Za-z]+)$", RegexOptions.IgnoreCase)
];
public string? HelpText =>
"Its wheel but oval shaped and shit";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(30);
public RateLimitOptionsModel? RateLimitOptions => new()
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(15)
};
public bool WhisperCanInvoke => false;
//private static double _houseEdge = 0.015; // house edge hack?
// game assets
private const string LOW_DIFFICULTY_WHEEL = "🟢⚪⚪⚪⚫⚪⚪⚪⚪⚫🟢⚪⚪⚪⚫⚪⚪⚪⚪⚫";
private const string MEDIUM_DIFFICULTY_WHEEL = "🟢⚫🟡⚫🟡⚫🟡⚫🟢⚫🟣⚫⚪⚫🟡⚫🟡⚫🟡⚫";
private const string HIGH_DIFFICULTY_WHEEL = "⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫🔴";
private const string MIDDLE_WHEEL_FILL = "....................⮝....................";
// game settings
private const int MIN_WHEELSPIN_DELAY = 100;
private const int MAX_WHEELSPIN_DELAY = 1000;
private static readonly Dictionary<string, decimal> LOW_DIFF_MULTIS = new()
{
{ "⚫", 0.00m },
{ "⚪", 1.20m },
{ "🟢", 1.50m }
};
private static readonly Dictionary<string, decimal> MEDIUM_DIFF_MULTIS = new()
{
{ "⚫", 0.00m },
{ "🟢", 1.50m },
{ "⚪", 1.80m },
{ "🟡", 2.00m },
{ "🟣", 3.00m }
};
private static readonly Dictionary<string, decimal> HIGH_DIFF_MULTIS = new()
{
{ "⚫", 0.00m },
{ "🔴", 19.80m }
};
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay, BuiltIn.Keys.KasinoWheelCleanupDelay,
BuiltIn.Keys.KasinoWheelEnabled
]);
if (message is { IsWhisper: false, MessageUuid: not null })
{
await botInstance.KfClient.DeleteMessageAsync(message.MessageUuid);
}
// Check if wheel is enabled
var wheelEnabled = (settings[BuiltIn.Keys.KasinoWheelEnabled]).ToBoolean();
if (!wheelEnabled)
{
var gameDisabledCleanupDelay= TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoGameDisabledMessageCleanupDelay].ToType<int>());
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, wheel is currently disabled.",
true, autoDeleteAfter: gameDisabledCleanupDelay);
return;
}
var cleanupDelay = TimeSpan.FromMilliseconds(settings[BuiltIn.Keys.KasinoWheelCleanupDelay].ToType<int>());
if (!arguments.TryGetValue("amount", out var amount))
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, not enough arguments. !wheel <wager> <difficulty: low, medium, high>", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler == null)
throw new InvalidOperationException($"Caught a null when retrieving gambler for {user.KfUsername}");
var difficulty = arguments["difficulty"].Success ? Convert.ToString(arguments["difficulty"].Value) : new[] {"low", "medium", "high"}[Money.GetRandomNumber(gambler, 0,2)];
if (difficulty.ToLower() is not ("l" or "low" or "m" or "medium" or "h" or "high"))
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, unrecognized difficulty selection, please choose between: low, medium, high", true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var wager = Convert.ToDecimal(amount.Value);
if (gambler.Balance < wager)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your balance of {await gambler.Balance.FormatKasinoCurrencyAsync()} isn't enough for this wager.",
true, autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
if (wager == 0)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, you have to wager more than {await wager.FormatKasinoCurrencyAsync()}", true,
autoDeleteAfter: cleanupDelay);
RateLimitService.RemoveMostRecentEntry(user, this);
return;
}
var colors =
await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor
]);
var wheel = difficulty.ToLower() switch
{
("l" or "low") => new Wheel(gambler, LOW_DIFFICULTY_WHEEL, MIDDLE_WHEEL_FILL, 0),
("m" or "medium") => new Wheel(gambler, MEDIUM_DIFFICULTY_WHEEL, MIDDLE_WHEEL_FILL, 1),
("h" or "high") => new Wheel(gambler, HIGH_DIFFICULTY_WHEEL, MIDDLE_WHEEL_FILL, 2),
_ => null
};
if (wheel == null)
throw new InvalidOperationException($"Something went horribly wrong, couldn't initialize wheel based on difficulty selection");
// choose target to land on after wheelspin
var target = wheel.GetWheelElements()[Money.GetRandomNumber(gambler,0, wheel.GetWheelElements().Count)];
var stepsToTarget = wheel.ComputeGameStepsToTarget(target);
var wheelDisplayMessage = await botInstance.SendChatMessageAsync(wheel.ConvertWheelToOvalString(),
true, autoDeleteAfter: cleanupDelay);
while (wheelDisplayMessage.Status != SentMessageTrackerStatus.ResponseReceived)
{
await Task.Delay(100, ctx); // wait until first message is fully sent
}
// main loop
for (int i = 0; i < stepsToTarget; i++)
{
double t = (double)i / Math.Max(stepsToTarget - 1, 1);
// Combine sine wave for smooth deceleration with exponential ease-out
double sineEase = Math.Sin(t * Math.PI / 2); // 0 to 1, smooth acceleration
double expEase = 1 - Math.Pow(1 - t, 4); // Quartic ease-out for dramatic slow-down
// Blend both curves: start follows sine, end follows exponential
double blendFactor = t * t; // Quadratic blend - more exp influence as we progress
double easeOut = (1 - blendFactor) * sineEase + blendFactor * expEase;
// Early spins are fast, late spins are slow
int delay = (int)(MIN_WHEELSPIN_DELAY + easeOut * (MAX_WHEELSPIN_DELAY - MIN_WHEELSPIN_DELAY));
await Task.Delay(delay, ctx);
wheel.RotateWheelOnce();
await botInstance.KfClient.EditMessageAsync(wheelDisplayMessage.ChatMessageUuid!,
wheel.ConvertWheelToOvalString());
}
// payout logics
var multi = -1.0m;
if (wheel.GetDifficulty() == 0) multi = LOW_DIFF_MULTIS[target];
if (wheel.GetDifficulty() == 1) multi = MEDIUM_DIFF_MULTIS[target];
if (wheel.GetDifficulty() == 2) multi = HIGH_DIFF_MULTIS[target];
if (multi == -1.0m)
throw new InvalidOperationException($"Could not derrive multi from target: {target} on wheel diff {wheel.GetDifficulty()}");
var win = multi != 0.00m;
string wheelResultMessage;
decimal newBalance;
if (win)
{
var wheelPayout = Math.Round(wager * multi - wager, 2);
newBalance = await Money.NewWagerAsync(gambler.Id, wager, wheelPayout, WagerGame.Wheel, ct: ctx);
wheelResultMessage = $"{user.FormatUsername()}, you spun a {multi}x and [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsGreenColor].Value}]WON[/COLOR][/B]" +
$" your balance is {await newBalance.FormatKasinoCurrencyAsync()}";
}
else
{
newBalance = await Money.NewWagerAsync(gambler.Id, wager, -wager, WagerGame.Wheel, ct: ctx);
wheelResultMessage = $"{user.FormatUsername()}, you spun a {multi}x and [B][COLOR={colors[BuiltIn.Keys.KiwiFarmsRedColor].Value}]LOST[/COLOR][/B]" +
$", better luck next time. Your balance is {await newBalance.FormatKasinoCurrencyAsync()}";
}
await botInstance.SendChatMessageAsync(wheelResultMessage, true, autoDeleteAfter: cleanupDelay);
}
}
public class Wheel
{
private readonly GamblerDbModel _gambler;
private List<string> _wheelElements;
private readonly string _middleFill;
private readonly int _difficulty; // 0 = low, 1 = medium, 2 = high
public Wheel(GamblerDbModel gambler, string wheelString, string middleFill, int difficulty)
{
_gambler = gambler;
_wheelElements = ExtractTextElements(wheelString);
if (_wheelElements.Count != 20)
throw new ArgumentException("Wheel must be exactly 20 elements.");
_middleFill = middleFill;
_difficulty = difficulty;
RandomizeInitialState(); // start wheel in random state
}
public List<string> GetWheelElements() => _wheelElements;
public int GetDifficulty() => _difficulty;
// Extract grapheme clusters, safe for emojis, stolen from AI
private static List<string> ExtractTextElements(string rawWheel)
{
List<string> wheelElements = new();
TextElementEnumerator e = StringInfo.GetTextElementEnumerator(rawWheel);
while (e.MoveNext())
wheelElements.Add((string)e.Current);
return wheelElements;
}
private void RandomizeInitialState()
{
int shift = Money.GetRandomNumber(_gambler, 0, 19);
RotateWheel(shift);
}
private void RotateWheel(int steps)
{
steps %= _wheelElements.Count;
if (steps <= 0) return;
int cut = _wheelElements.Count - steps;
List<string> rotated = new();
// Last N + first 20-N
rotated.AddRange(_wheelElements.GetRange(cut, steps));
rotated.AddRange(_wheelElements.GetRange(0, cut));
_wheelElements = rotated;
}
public void RotateWheelOnce() => RotateWheel(1);
public int ComputeGameStepsToTarget(string target)
{
// start by first doing 1-3 full rotations of the wheel
int fullRotations = Money.GetRandomNumber(_gambler, 1, 3);
int steps = fullRotations * 20;
// find how many more steps until wheel index 4 (top middle) == target
int extra = StepsUntilIndex4Match(target);
return steps + extra;
}
private int StepsUntilIndex4Match(string target)
{
List<string> temp = new List<string>(_wheelElements);
for (int step = 0; step < 20; step++)
{
if (temp[4] == target)
return step;
// sim rotation
int cut = temp.Count - 1;
string last = temp[cut];
temp.RemoveAt(cut);
temp.Insert(0, last);
}
return 0; // should always find in 20 steps;
}
public string ConvertWheelToOvalString()
{
// top row indices 0..8
string top = String.Concat(_wheelElements.GetRange(0, 9));
// middle row, left index 19, right index 9
string middle = _wheelElements[19] + _middleFill + _wheelElements[9];
// bottom row indices 10..18 but reversed so 18..10
var reversedBottom = new List<string>(9);
for (int i = 18; i >= 10; i--)
reversedBottom.Add(_wheelElements[i]);
string bottom = string.Concat(reversedBottom);
return $"{top}\n{middle}\n{bottom}";
}
}

View File

@@ -1,8 +1,8 @@
using System.Text.RegularExpressions;
using Humanizer;
using Humanizer.Localisation;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using NLog;
@@ -16,7 +16,9 @@ public class InsanityCommand : ICommand
public string? HelpText => "Insanity";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
// ReSharper disable once StringLiteralTypo
await botInstance.SendChatMessageAsync("definition of insanity = doing the same thing over and over and over excecting a different result, and heres my dumbass trying to get rich every day and losing everythign i fucking touch every fucking time FUCK this bullshit FUCK MY LIEFdefinition of insanity = doing the same thing over and over and over excecting a different result, and heres my dumbass trying to get rich every day and losing everythign i fucking touch every fucking time FUCK this bullshit FUCK MY LIEF");
@@ -29,13 +31,30 @@ public class TwistedCommand : ICommand
public string? HelpText => "Get it twisted";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
// ReSharper disable once StringLiteralTypo
await botInstance.SendChatMessageAsync("🦍 🗣 GET IT TWISTED 🌪 , GAMBLE ✅ . PLEASE START GAMBLING 👍 . GAMBLING IS AN INVESTMENT 🎰 AND AN INVESTMENT ONLY 👍 . YOU WILL PROFIT 💰 , YOU WILL WIN ❗ . YOU WILL DO ALL OF THAT 💯 , YOU UNDERSTAND ⁉ YOU WILL BECOME A BILLIONAIRE 💵 📈 AND REBUILD YOUR FUCKING LIFE 🤯");
}
}
public class ScratchCommand : ICommand
{
public List<Regex> Patterns => [new Regex("^scratch")];
public string? HelpText => "Start scratching";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
// ReSharper disable once StringLiteralTypo
await botInstance.SendChatMessageAsync("🐀 🗣 GET IT TWISTED 🌪, SCRATCH ✅. PLEASE START SCRATCHING 👍. SCRATCHING YOUR SCABIES SORES IS RELIEF 😌 AND RELIEF ONLY 👍. YOU WILL FEEL BETTER 💪, YOU WILL FIND COMFORT ❗️. YOU WILL DO ALL OF THAT 💯, YOU UNDERSTAND ⁉️ YOU WILL CONQUER THE ITCH 🦠 AND REBUILD YOUR SKINS PEACE 🤯", true);
}
}
public class CrackedCommand : ICommand
{
public List<Regex> Patterns => [
@@ -45,7 +64,9 @@ public class CrackedCommand : ICommand
public string? HelpText => "Crackhead Zalgo text";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var logger = LogManager.GetCurrentClassLogger();
var msg = arguments["msg"].Value.TrimStart('/');
@@ -67,7 +88,9 @@ public class CleanCommand : ICommand
public string? HelpText => "How long has Bossman been clean?";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var settings =
await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.BotCleanStartTime, BuiltIn.Keys.TwitchBossmanJackUsername]);
@@ -90,7 +113,9 @@ public class RehabCommand : ICommand
public string? HelpText => "How long until rehab is over?";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var settings =
await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.BotRehabEndTime, BuiltIn.Keys.TwitchBossmanJackUsername]);
@@ -121,7 +146,9 @@ public class NextPoVisitCommand : ICommand
public string? HelpText => "How long until the next PO visit?";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(120);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var time = await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotPoNextVisit);
if (time.Value == null)
@@ -136,16 +163,14 @@ public class NextPoVisitCommand : ICommand
return;
}
var sent = await botInstance.SendChatMessageAsync($"Austin's next PO visit will be in roughly {timespan.Humanize(precision: 10, minUnit: TimeUnit.Millisecond)}", true);
while (sent.Status != SentMessageTrackerStatus.ResponseReceived)
{
await Task.Delay(250, ctx);
}
var success = await botInstance.WaitForChatMessageAsync(sent, TimeSpan.FromSeconds(30), ctx);
if (!success) throw new InvalidOperationException();
var i = 0;
while (i < 60)
{
await Task.Delay(1000, ctx);
timespan = DateTimeOffset.Parse(time.Value) - DateTimeOffset.UtcNow;
await botInstance.KfClient.EditMessageAsync(sent.ChatMessageId!.Value,
await botInstance.KfClient.EditMessageAsync(sent.ChatMessageUuid!,
$"Austin's next PO visit will be in roughly {timespan.Humanize(precision: 10, minUnit: TimeUnit.Millisecond)}");
i++;
}
@@ -160,7 +185,9 @@ public class NextCourtHearingCommand : ICommand
public string? HelpText => "How long until the next court hearing?";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(120);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var hearings = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotCourtCalendar)).JsonDeserialize<List<CourtHearingModel>>();
if (hearings == null)
@@ -176,15 +203,13 @@ public class NextCourtHearingCommand : ICommand
}
var sent = await botInstance.SendChatMessageAsync(RenderHearings(hearings),true);
while (sent.Status != SentMessageTrackerStatus.ResponseReceived)
{
await Task.Delay(250, ctx);
}
var success = await botInstance.WaitForChatMessageAsync(sent, TimeSpan.FromSeconds(15), ctx);
if (!success) throw new InvalidOperationException();
var i = 0;
while (i < 60)
{
await Task.Delay(1000, ctx);
await botInstance.KfClient.EditMessageAsync(sent.ChatMessageId!.Value, RenderHearings(hearings));
await botInstance.KfClient.EditMessageAsync(sent.ChatMessageUuid!, RenderHearings(hearings));
i++;
}
}
@@ -199,8 +224,8 @@ public class NextCourtHearingCommand : ICommand
var timespan = hearing.Time - DateTimeOffset.UtcNow;
if (timespan.TotalSeconds < 0) continue; // Already passed
result +=
//$"{i}: [url=https://eapps.courts.state.va.us/ocis/details;fromOcis=true;fullcaseNumber=109{hearing.CaseNumber.Replace("-", string.Empty)}]{hearing.CaseNumber}[/url] " +
$"{i}: {hearing.CaseNumber} " +
$"{i}: [url=https://eapps.courts.state.va.us/ocis/details;fromOcis=true;fullcaseNumber=109{hearing.CaseNumber.Replace("-", string.Empty)}]{hearing.CaseNumber}[/url] " +
//$"{i}: {hearing.CaseNumber} " +
$"{hearing.Description} will be heard in {timespan.Humanize(precision: 10, minUnit: TimeUnit.Second, maxUnit: TimeUnit.Year)}\r\n";
}
return result.Trim();
@@ -215,7 +240,9 @@ public class JailCommand : ICommand
public string? HelpText => "How long has Bossman been in jail?";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.BotJailStartTime, BuiltIn.Keys.TwitchBossmanJackUsername]);
var start = settings[BuiltIn.Keys.BotJailStartTime];
@@ -235,8 +262,20 @@ public class LastStreamCommand : ICommand
public string? HelpText => "How long ago did Austin Gambles last stream (on Twitch)?";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.TwitchGraphQlPersistedCurrentlyLive, BuiltIn.Keys.TwitchBossmanJackUsername
]);
var username = settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value;
var isLive = settings[BuiltIn.Keys.TwitchGraphQlPersistedCurrentlyLive].ToBoolean();
if (isLive)
{
await botInstance.SendChatMessageAsync($"{username} is currently live on Twitch https://twitch.tv/{username}", true);
return;
}
await using var db = new ApplicationDbContext();
var latest = db.TwitchViewCounts.OrderByDescending(x => x.Id).FirstOrDefault();
if (latest == null)
@@ -248,8 +287,7 @@ public class LastStreamCommand : ICommand
var timespan = DateTimeOffset.UtcNow - latest.Time;
var agt = TimeZoneInfo.ConvertTime(latest.Time, TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time"));
// The table doesn't contain the name of the person so we'll just have to assume it's his Twitch username
var username = await SettingsProvider.GetValueAsync(BuiltIn.Keys.TwitchBossmanJackUsername);
await botInstance.SendChatMessageAsync($"{username.Value} last streamed on Twitch approximately {timespan.Humanize(precision: 2, minUnit: TimeUnit.Minute, maxUnit: TimeUnit.Hour)} ago at {agt:dddd h:mm tt} AGT", true);
await botInstance.SendChatMessageAsync($"{username} last streamed on Twitch approximately {timespan.Humanize(precision: 2, minUnit: TimeUnit.Minute, maxUnit: TimeUnit.Hour)} ago at {agt:dddd h:mm tt} AGT", true);
}
}
@@ -261,7 +299,9 @@ public class AlmanacCommand : ICommand
public string? HelpText => "Return details on how to submit almanac entries";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var text = await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotAlmanacText);
if (message.MessageRaw.Contains("almanac plain"))
@@ -271,4 +311,22 @@ public class AlmanacCommand : ICommand
}
await botInstance.SendChatMessageAsync($"@{user.KfUsername}, {text.Value}", true);
}
}
public class JuiceSportsCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^juicesports", RegexOptions.IgnoreCase)
];
public string? HelpText => "Juicesports LFG!";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
await botInstance.SendChatMessageAsync(":juice: [img]https://i.ddos.lgbt/u/3GJtHq.gif[/img] :juice: [br]" +
"[img]https://i.ddos.lgbt/u/KAwWMW.webp[/img][br]" +
"[img]https://i.ddos.lgbt/u/uCuSOw.gif[/img][br]", true);
}
}

View File

@@ -1,6 +1,6 @@
using System.Text.RegularExpressions;
using Humanizer;
using Humanizer.Localisation;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
@@ -15,8 +15,10 @@ public class MomCommand : ICommand
public bool HideFromHelp => false;
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(60);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
@@ -27,8 +29,11 @@ public class MomCommand : ICommand
var lastMom = (await db.Moms.ToListAsync(ctx)).OrderByDescending(j => j.Time).Take(1).ToList();
if (lastMom.Count == 0 || (lastMom[0].Time.AddSeconds(cooldown) - DateTimeOffset.UtcNow).TotalSeconds <= 0)
{
await db.Moms.AddAsync(new MomDbModel { User = user, Time = DateTimeOffset.UtcNow }, ctx);
await db.SaveChangesAsync(ctx);
if (user.UserRight > UserRight.Loser)
{
await db.Moms.AddAsync(new MomDbModel { User = user, Time = DateTimeOffset.UtcNow }, ctx);
await db.SaveChangesAsync(ctx);
}
var count = await db.Moms.CountAsync(ctx);
await botInstance.SendChatMessageAsync(
$"[b][color={momSettings[BuiltIn.Keys.KiwiFarmsRedColor].Value}]DTPN![/color][/b] - {momSettings[BuiltIn.Keys.TwitchBossmanJackUsername].Value} has fucked {count:N0} MILFs!",

View File

@@ -0,0 +1,274 @@
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using NLog;
namespace KfChatDotNetBot.Commands;
/// <summary>
/// The !nora command allows users to interact with Grok AI (xAI) through the chat.
/// All messages are moderated via OpenAI's Moderation API to filter illegal content
/// while allowing profanity and general offensive language.
///
/// Supports per-chatter or per-room conversation context with automatic compaction.
/// Use "!nora reset" to clear your conversation history.
///
/// Flow:
/// 1. Validate input (15 words max, 140 chars max)
/// 2. Moderate content via OpenAI (blocks illegal, allows profanity)
/// 3. Build conversation context (if enabled)
/// 4. Send to Grok AI for response
/// 5. Store exchange in context and compact if needed
/// 6. Post formatted response to chat
///
/// Configuration required:
/// - OpenAi.ApiKey: OpenAI API key for moderation (free)
/// - Grok.ApiKey: xAI API key for Grok (~$0.20 per 1M input tokens)
/// - Grok.Nora.ContextMode: perChatter, perRoom, or disabled
///
/// See NORA_SETUP.md for detailed setup instructions.
/// </summary>
public class NoraCommand : ICommand
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
public List<Regex> Patterns => [
new Regex(@"^nora\s+(?<message>.+)", RegexOptions.IgnoreCase)
];
public string HelpText => "Ask Nora AI a question (max 15 words, 140 chars). Use '!nora reset' to clear context.";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(30);
public RateLimitOptionsModel RateLimitOptions => new()
{
Window = TimeSpan.FromMinutes(1),
MaxInvocations = 3,
Flags = RateLimitFlags.None
};
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user,
GroupCollection arguments, CancellationToken ctx)
{
var userMessage = arguments["message"].Value.Trim();
var manager = new ConversationContextManager();
// Handle !nora reset — clear conversation context
if (userMessage.Equals("reset", StringComparison.OrdinalIgnoreCase))
{
var modeSetting = await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextMode);
var mode = modeSetting.Value ?? "perChatter";
if (mode.Equals("disabled", StringComparison.OrdinalIgnoreCase))
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, conversation context is disabled.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
var resetKey = ConversationContextManager.GetContextKeyAsync(mode, user.KfId, message.RoomId!.Value);
var cleared = await manager.ClearContextAsync(resetKey);
await botInstance.SendChatMessageAsync(
cleared
? $"{user.FormatUsername()}, your conversation context has been cleared."
: $"{user.FormatUsername()}, you don't have an active conversation context.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
var maxWords = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraMaxWords)).ToType<int>();
var maxCharacters = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraMaxCharacters)).ToType<int>();
// Validate word count
var wordCount = userMessage.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
if (wordCount > maxWords)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your message has {wordCount} words. Maximum is {maxWords} words.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
// Validate character count
if (userMessage.Length > maxCharacters)
{
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your message has {userMessage.Length} characters. Maximum is {maxCharacters} characters.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
// Step 1: Moderate the content
var moderationEnabled =
(await SettingsProvider.GetValueAsync(BuiltIn.Keys.OpenAiModerationEnabled)).ToBoolean();
if (moderationEnabled)
{
var moderationResult = await OpenAiModeration.ModerateContentAsync(userMessage);
if (moderationResult == null)
{
Logger.Warn($"Moderation API failed for user {user.KfUsername}, blocking message as safety precaution");
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, moderation service is currently unavailable. Please try again later.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
if (OpenAiModeration.IsIllegalContent(moderationResult.Categories))
{
Logger.Warn($"User {user.KfUsername} attempted to send illegal content via Nora command: {userMessage}");
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, your message was blocked for containing illegal content.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
if (moderationResult.Flagged)
{
Logger.Info($"User {user.KfUsername} sent flagged but allowed content (profanity/offensive): {userMessage}");
}
}
// Step 2: Build conversation context and get Grok AI response
var basePrompt = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraPrompt)).Value;
if (basePrompt == null)
{
Logger.Error("Nora prompt file is missing or unreadable");
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, Nora's prompt file is missing. Please check the server configuration.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.GrokNoraContextMode,
BuiltIn.Keys.GrokNoraUserInfoEnabled
]);
var systemPrompt = basePrompt;
var contextMode = settings[BuiltIn.Keys.GrokNoraContextMode].Value ?? "perChatter";
var contextDisabled = contextMode.Equals("disabled", StringComparison.OrdinalIgnoreCase);
// Compute context key once (used for mood and later for context messages)
string? contextKey = null;
if (!contextDisabled)
contextKey = ConversationContextManager.GetContextKeyAsync(contextMode, user.KfId, message.RoomId!.Value);
// Optionally inject user info into the system prompt
var userInfoEnabled = settings[BuiltIn.Keys.GrokNoraUserInfoEnabled].Value?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
if (userInfoEnabled)
{
var infoParts = new List<string>
{
$"Username: {user.KfUsername}",
$"Permission level: {user.UserRight.Humanize()}"
};
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: ctx);
if (gambler is { State: GamblerState.Active })
{
infoParts.Add($"Kasino balance: {await gambler.Balance.FormatKasinoCurrencyAsync()}");
infoParts.Add($"Total wagered: {await gambler.TotalWagered.FormatKasinoCurrencyAsync()}");
var vipPerk = await Money.GetVipLevelAsync(gambler, ctx);
if (vipPerk != null)
{
infoParts.Add($"VIP rank: {vipPerk.PerkName} (Tier {vipPerk.PerkTier})");
}
else
{
infoParts.Add("VIP rank: None (hasn't reached first VIP level)");
}
}
else
{
infoParts.Add("Kasino status: Permanently excluded");
}
systemPrompt += "\n\nThe customer you are currently speaking to has the following profile:\n" + string.Join("\n", infoParts);
}
// Inject mood into system prompt
var mood = contextKey != null
? await manager.GetOrAssignMoodAsync(contextKey)
: await ConversationContextManager.GetRandomMoodAsync();
systemPrompt += "\n\n" + mood;
string? grokResponse;
if (contextDisabled)
{
// Stateless mode — same as before
grokResponse = await GrokApi.GetChatCompletionAsync(systemPrompt, userMessage);
}
else
{
// Context-aware mode
// Get existing context messages and append current user message
// In perRoom mode, prefix with username so the AI knows who said what
var contentForContext = contextMode.Equals("perroom", StringComparison.OrdinalIgnoreCase)
? $"{user.KfUsername}: {userMessage}"
: userMessage;
var contextMessages = await manager.GetMessagesForApiAsync(contextKey!);
contextMessages.Add(new ConversationMessage { Role = "user", Content = contentForContext });
grokResponse = await GrokApi.GetChatCompletionAsync(systemPrompt, contextMessages);
if (grokResponse != null)
{
// Store the exchange in context
await manager.AddMessageAsync(contextKey!, "user", contentForContext);
await manager.AddMessageAsync(contextKey!, "assistant", grokResponse);
// Compact if context is getting too large
await manager.CompactIfNeededAsync(contextKey!);
}
}
if (grokResponse == null)
{
Logger.Error($"Grok API failed for user {user.KfUsername}");
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, Nora is currently unavailable. Please try again later.",
true,
autoDeleteAfter: TimeSpan.FromSeconds(15));
return;
}
// Step 3: Send response to chat with user avatar
// var avatarTag = "";
// if (message.Author.AvatarUrl != null)
// {
// var avatarPath = message.Author.AvatarUrl.IsAbsoluteUri
// ? message.Author.AvatarUrl.PathAndQuery
// : message.Author.AvatarUrl.OriginalString;
// avatarPath = avatarPath.Replace("/data/avatars/m/", "/data/avatars/s/");
// avatarTag = $"[img]https://uploads.kiwifarms.st{avatarPath}[/img] ";
// }
// var formattedResponse = $"{avatarTag}[b]Nora to {user.FormatUsername()}:[/b] {grokResponse}";
var formattedResponse = $"[b]Nora to {user.FormatUsername()}:[/b] {grokResponse}";
var autoDeleteAfter =
TimeSpan.FromMilliseconds((await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraAutoDeleteDelay))
.ToType<int>());
await botInstance.SendChatMessageAsync(
formattedResponse,
true, autoDeleteAfter: autoDeleteAfter);
}
}

View File

@@ -1,5 +1,6 @@
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
@@ -15,7 +16,9 @@ public class RainbetStatsCommand : ICommand
public string? HelpText => "Get betting statistics in the given window";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var window = Convert.ToInt32(arguments["window"].Value);
var start = DateTimeOffset.UtcNow.AddHours(-window);
@@ -41,7 +44,9 @@ public class RainbetRecentBetCommand : ICommand
public string? HelpText => "Get the most recent 3 bets";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KiwiFarmsGreenColor, BuiltIn.Keys.KiwiFarmsRedColor, BuiltIn.Keys.HowlggDivisionAmount

View File

@@ -3,6 +3,7 @@ using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using Microsoft.EntityFrameworkCore;
namespace KfChatDotNetBot.Commands;
@@ -15,8 +16,9 @@ public class GetRestreamCommand : ICommand
public string? HelpText => "Grab restream URL";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var url = await SettingsProvider.GetValueAsync(BuiltIn.Keys.RestreamUrl);
@@ -33,8 +35,9 @@ public class SetRestreamCommand : ICommand
public string? HelpText => "Set restream URL";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await SettingsProvider.SetValueAsync(BuiltIn.Keys.RestreamUrl, arguments["url"].Value);
@@ -42,24 +45,6 @@ public class SetRestreamCommand : ICommand
}
}
public class SetShillRestreamCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^restream setshill (?<url>.+)$")
];
public string? HelpText => "Set restream shill URL";
public UserRight RequiredRight => UserRight.TrueAndHonest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
await SettingsProvider.SetValueAsync(BuiltIn.Keys.TwitchCommercialRestreamShillMessage, arguments["url"].Value);
await botInstance.SendChatMessageAsync($"@{message.Author.Username}, updated URL for the commercial break restream shill", true);
}
}
public class SelfPromoCommand : ICommand
{
public List<Regex> Patterns => [
@@ -69,19 +54,24 @@ public class SelfPromoCommand : ICommand
public string? HelpText => "Promote your shit";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var channels = SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels).Result.JsonDeserialize<List<KickChannelModel>>();
var channel = channels.FirstOrDefault(ch => ch.ForumId == user.KfId);
if (channel == null)
await using var db = new ApplicationDbContext();
db.Users.Attach(user);
var streams = await db.Streams.Where(s => s.User == user).ToListAsync(ctx);
if (streams.Count == 0)
{
await botInstance.SendChatMessageAsync("You have no stream.", true);
await botInstance.SendChatMessageAsync("You have no streams", true);
return;
}
var streamList = streams.Aggregate(string.Empty, (current, stream) => current + $"[br]- {stream.StreamUrl}");
await botInstance.SendChatMessageAsync($"@{user.KfUsername} is a weirdo who streams. Come check out his channel at https://kick.com/{channel.ChannelSlug}", true);
await botInstance.SendChatMessageAsync(
$"@{user.KfUsername} is a weirdo who streams a lot. His channels are at: {streamList}", true);
}
}
@@ -94,8 +84,9 @@ public class GetRestreamPlainCommand : ICommand
public string? HelpText => "Grab restream URL with plain prefixed";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var url = await SettingsProvider.GetValueAsync(BuiltIn.Keys.RestreamUrl);

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,9 @@ public class EditTestCommand : ICommand
public UserRight RequiredRight => UserRight.Admin;
// Increased timeout as it has to wait for Sneedchat to echo the message and that can be slow sometimes
public TimeSpan Timeout => TimeSpan.FromSeconds(60);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var logger = LogManager.GetCurrentClassLogger();
var msg = WebUtility.HtmlDecode(arguments["msg"].Value);
@@ -34,7 +36,7 @@ public class EditTestCommand : ICommand
reference.Status == SentMessageTrackerStatus.Unknown ||
reference.Status == SentMessageTrackerStatus.ChatDisconnected ||
reference.Status == SentMessageTrackerStatus.Lost ||
reference.ChatMessageId == null)
reference.ChatMessageUuid == null)
{
logger.Error("Either message refused to send due to bot settings or something fucked up getting the message ID");
return;
@@ -43,13 +45,13 @@ public class EditTestCommand : ICommand
{
i++;
await Task.Delay(delay, ctx);
botInstance.KfClient.EditMessage(reference.ChatMessageId!.Value, $"{msg} {i}");
await botInstance.KfClient.EditMessageAsync(reference.ChatMessageUuid, $"{msg} {i}");
}
await Task.Delay(delay, ctx);
botInstance.KfClient.EditMessage(reference.ChatMessageId!.Value, "This message will self destruct in 1 second");
await botInstance.KfClient.EditMessageAsync(reference.ChatMessageUuid, "This message will self destruct in 1 second");
await Task.Delay(delay, ctx);
botInstance.KfClient.DeleteMessage(reference.ChatMessageId!.Value);
await botInstance.KfClient.DeleteMessageAsync(reference.ChatMessageUuid);
}
}
@@ -63,7 +65,9 @@ public class TimeoutTestCommand : ICommand
public UserRight RequiredRight => UserRight.Admin;
// Increased timeout as it has to wait for Sneedchat to echo the message and that can be slow sometimes
public TimeSpan Timeout => TimeSpan.FromSeconds(15);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
await Task.Delay(TimeSpan.FromMinutes(1), ctx);
}
@@ -79,7 +83,9 @@ public class ExceptionTestCommand : ICommand
public UserRight RequiredRight => UserRight.Admin;
// Increased timeout as it has to wait for Sneedchat to echo the message and that can be slow sometimes
public TimeSpan Timeout => TimeSpan.FromSeconds(15);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
throw new Exception("Caused by the test exception command");
}
@@ -95,7 +101,9 @@ public class LengthLimitTestCommand : ICommand
public UserRight RequiredRight => UserRight.Admin;
// Increased timeout as it has to wait for Sneedchat to echo the message and that can be slow sometimes
public TimeSpan Timeout => TimeSpan.FromSeconds(15);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var logger = LogManager.GetCurrentClassLogger();
var niceTruncation = await botInstance.SendChatMessageAsync("The quick brown fox jumps over the lazy dog.",
@@ -109,14 +117,36 @@ public class LengthLimitTestCommand : ICommand
true, ChatBot.LengthLimitBehavior.RefuseToSend, 20);
await Task.Delay(TimeSpan.FromSeconds(5), ctx);
logger.Info($"niceTruncation => {niceTruncation.Status}; exactTruncation => {exactTruncation.Status}; doNothing => {doNothing.Status}; refuseToSend => {refuseToSend.Status}");
if (niceTruncation.ChatMessageId != null)
botInstance.KfClient.DeleteMessage(niceTruncation.ChatMessageId.Value);
if (exactTruncation.ChatMessageId != null)
botInstance.KfClient.DeleteMessage(exactTruncation.ChatMessageId.Value);
if (doNothing.ChatMessageId != null)
botInstance.KfClient.DeleteMessage(doNothing.ChatMessageId.Value);
if (niceTruncation.ChatMessageUuid != null)
await botInstance.KfClient.DeleteMessageAsync(niceTruncation.ChatMessageUuid);
if (exactTruncation.ChatMessageUuid != null)
await botInstance.KfClient.DeleteMessageAsync(exactTruncation.ChatMessageUuid);
if (doNothing.ChatMessageUuid != null)
await botInstance.KfClient.DeleteMessageAsync(doNothing.ChatMessageUuid);
// Should never happen
if (refuseToSend.ChatMessageId != null)
botInstance.KfClient.DeleteMessage(refuseToSend.ChatMessageId.Value);
if (refuseToSend.ChatMessageUuid != null)
await botInstance.KfClient.DeleteMessageAsync(refuseToSend.ChatMessageUuid);
}
}
public class RateLimitTestCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^test ratelimit$")
];
public string? HelpText => null;
public UserRight RequiredRight => UserRight.Admin;
// Increased timeout as it has to wait for Sneedchat to echo the message and that can be slow sometimes
public TimeSpan Timeout => TimeSpan.FromSeconds(15);
public RateLimitOptionsModel? RateLimitOptions => new RateLimitOptionsModel
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(60)
};
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
await botInstance.SendChatMessageAsync("Nigger", true);
}
}

View File

@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetWsClient.Models.Events;
@@ -7,13 +8,15 @@ namespace KfChatDotNetBot.Commands;
public class TimeCommand : ICommand
{
public List<Regex> Patterns => [new Regex("^time")];
public string? HelpText => "Get current time in AGT";
public string? HelpText => "Get current time in BMT";
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => true;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var agt = new DateTimeOffset(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow,
var bmt = new DateTimeOffset(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow,
TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time")), TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time").BaseUtcOffset);
await botInstance.SendChatMessageAsync($"It's currently {agt:dddd h:mm:ss tt} AGT", true);
await botInstance.ReplyToUser(message, $"It's currently {bmt:dddd h:mm:ss tt} BMT");
}
}

View File

@@ -1,26 +1,14 @@
using System.Reflection;
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Commands;
public class TempEnableDiscordRelayingCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^tempenable discord$")
];
public string? HelpText => null;
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
botInstance.BotServices.TemporarilyBypassGambaSeshForDiscord = true;
await botInstance.SendChatMessageAsync("Enjoy Discord messages, stalker child", true);
}
}
public class TempSuppressGambaMessages : ICommand
{
public List<Regex> Patterns => [
@@ -30,7 +18,9 @@ public class TempSuppressGambaMessages : ICommand
public string? HelpText => null;
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
botInstance.BotServices.TemporarilySuppressGambaMessages = true;
await botInstance.SendChatMessageAsync("No more gamba notifs", true);
@@ -46,7 +36,9 @@ public class EnableGambaMessages : ICommand
public string? HelpText => null;
public UserRight RequiredRight => UserRight.Guest;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
botInstance.BotServices.TemporarilySuppressGambaMessages = false;
await botInstance.SendChatMessageAsync("Gamba notifs back on the menu", true);
@@ -62,10 +54,51 @@ public class GetVersionCommand : ICommand
public string? HelpText => null;
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var version = Assembly.GetEntryAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion;
var version = Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (version == null)
{
await botInstance.SendChatMessageAsync($"Caught a null when trying to retrieve the bot's assembly version.",
true);
return;
}
await botInstance.SendChatMessageAsync($"Bot compiled against {version.Split('+')[1]}", true);
}
}
public class GetLastActivity : ICommand
{
public List<Regex> Patterns => [
new Regex("^lastactivity", RegexOptions.IgnoreCase),
new Regex("^lastactive", RegexOptions.IgnoreCase)
];
public string? HelpText => "When was Bossman last active?";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => new RateLimitOptionsModel
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(10)
};
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
var lastActive = await SettingsProvider.GetValueAsync(BuiltIn.Keys.BossmanLastSighting);
if (lastActive.Value == null)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, I don't know.", true);
return;
}
var activity = lastActive.JsonDeserialize<LastSightingModel>();
var elapsed = DateTimeOffset.UtcNow - activity!.When;
await botInstance.SendChatMessageAsync(
$"{user.FormatUsername()}, BossmanJack was last seen {elapsed.Humanize(maxUnit: TimeUnit.Day, minUnit: TimeUnit.Second, precision: 2)} ago {activity.Activity}",
true);
}
}

View File

@@ -1,4 +1,6 @@
using System.Text.RegularExpressions;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetWsClient.Models.Events;
using Microsoft.EntityFrameworkCore;
@@ -13,22 +15,42 @@ public class WhoisCommand : ICommand
];
public string? HelpText => "Lookup user IDs by username";
public UserRight RequiredRight => UserRight.Guest;
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
await using var db = new ApplicationDbContext();
var query = arguments["user"].Value.TrimStart('@').TrimEnd(',').TrimEnd();
var queryUser = await db.Users.FirstOrDefaultAsync(u => u.KfUsername == query, cancellationToken: ctx);
if (queryUser != null)
{
await botInstance.SendChatMessageAsync($"@{message.Author.Username}, {queryUser.KfUsername}'s ID is {queryUser.KfId}", true);
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, {queryUser.KfUsername}'s ID is {queryUser.KfId}", true);
return;
}
var users = await db.Users.Select(u => u.KfUsername).Distinct().ToListAsync(ctx);
var result = Process.ExtractOne(query, users);
queryUser = await db.Users.FirstOrDefaultAsync(u => u.KfUsername == result.Value, cancellationToken: ctx);
await botInstance.SendChatMessageAsync($"@{message.Author.Username}, my guess is you're looking for {queryUser!.KfUsername} whose ID is {queryUser.KfId}", true);
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, my guess is you're looking for {queryUser!.KfUsername} whose ID is {queryUser.KfId}", true);
}
}
public class WhoamiCommand : ICommand
{
public List<Regex> Patterns => [
new Regex("^whoami$", RegexOptions.IgnoreCase),
new Regex("^addy$", RegexOptions.IgnoreCase)
];
public string? HelpText => "Dump out your addy to chat";
public UserRight RequiredRight => UserRight.Loser;
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
public RateLimitOptionsModel? RateLimitOptions => null;
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
{
await botInstance.SendChatMessageAsync($"{user.FormatUsername()}, your addy is {user.KfId}", true);
}
}

View File

@@ -0,0 +1,375 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using NLog;
namespace KfChatDotNetBot.Commands;
[NoPrefixRequired]
public class XeetEmbedCommand : ICommand
{
private static string LoadingGif = "[img]https://i.ddos.lgbt/u/3sKyHs.webp[/img]";
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
public List<Regex> Patterns { get; set; } =
[
new Regex(@"https?:\/\/x\.com\/(?:\#!\/)?(\w+)\/(?:status|statuses|thread)\/(?<xeetId>\d+)", RegexOptions.IgnoreCase),
new Regex(@"https?:\/\/twitter\.com\/(?:\#!\/)?(\w+)\/(?:status|statuses|thread)\/(?<xeetId>\d+)",
RegexOptions.IgnoreCase),
new Regex(@"https?:\/\/mobile\.twitter\.com\/(?:\#!\/)?(\w+)\/(?:status|statuses|thread)\/(?<xeetId>\d+)",
RegexOptions.IgnoreCase)
];
public string? HelpText { get; } = "Embed Xeets";
public UserRight RequiredRight { get; } = UserRight.Loser;
public TimeSpan Timeout { get; } = TimeSpan.FromSeconds(30);
public RateLimitOptionsModel? RateLimitOptions { get; } = new()
{
MaxInvocations = 3,
Window = TimeSpan.FromSeconds(30),
// Really don't want to get rate-limited by FxTwitter hence global rate-limits
Flags = RateLimitFlags.Global
};
public bool WhisperCanInvoke => false;
public async Task RunCommand(ChatBot botInstance, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments,
CancellationToken ctx)
{
var kiwiFarmsUsername = await SettingsProvider.GetValueAsync(BuiltIn.Keys.KiwiFarmsUsername);
if (message.Author.Username == kiwiFarmsUsername.Value)
{
return;
}
var xeetEnabled = await SettingsProvider.GetValueAsync(BuiltIn.Keys.XeetEnabled);
if (!xeetEnabled.ToBoolean()) return;
var loadingMessage = await botInstance.SendChatMessageAsync($"{LoadingGif} Fetching tweet...", true);
var success = await botInstance.WaitForChatMessageAsync(loadingMessage, TimeSpan.FromSeconds(10), ctx);
if (!success) throw new InvalidOperationException();
try
{
var xeetId = arguments["xeetId"].Value;
var tweetData = await FetchTweetDataAsync(xeetId, ctx);
if (tweetData == null)
{
throw new InvalidOperationException("tweetData was null");
}
var tweet = tweetData.Tweet;
var mediaUrls = new List<string>();
if (tweet.HasAnyMedia())
{
mediaUrls = await ProcessMediaAsync(tweet, ctx);
}
var messages = await BuildTweetMessagesAsync(tweet, xeetId, mediaUrls, ctx);
if (loadingMessage.ChatMessageUuid != null)
{
await botInstance.KfClient.DeleteMessageAsync(loadingMessage.ChatMessageUuid);
}
if (messages.Count == 0)
{
return;
}
if (messages.Count > 4)
{
// bail, we don't want to spam the chat with giant threads of messages if something goes wrong with the splitting logic
Logger.Warn($"Aborting sending Xeet embed - message count {messages.Count} exceeds threshold");
return;
}
foreach (var msg in messages)
{
await botInstance.SendChatMessageAsync(msg, true);
}
// send archive link message
var url = $"https://nitter.net/{tweet.Author.ScreenName}/status/{xeetId}";
await botInstance.SendChatMessageAsync(
$"[url=https://archive.is/submit/?url={url}]Archive Xeet on archive.is[/url]", true);
}
catch
{
// Delete loading message on error
if (loadingMessage.ChatMessageUuid != null)
{
await botInstance.KfClient.DeleteMessageAsync(loadingMessage.ChatMessageUuid);
}
throw;
}
}
private async Task<FxTwitterResponse?> FetchTweetDataAsync(string xeetId, CancellationToken ctx)
{
var api = $"https://api.fxtwitter.com/status/{xeetId}";
var proxy = await SettingsProvider.GetValueAsync(BuiltIn.Keys.Proxy);
var handler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
AllowAutoRedirect = true
};
if (proxy.Value != null)
{
handler.UseProxy = true;
handler.Proxy = new WebProxy(proxy.Value);
Logger.Debug($"Configured to use proxy {proxy.Value}");
}
// Yes, very ghetto but we do need a "real" UA for FxTwitter from my experience
var ua = await SettingsProvider.GetValueAsync(BuiltIn.Keys.CaptureYtDlpUserAgent);
using var client = new HttpClient(handler);
client.DefaultRequestHeaders.UserAgent.ParseAdd(ua.Value);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return await client.GetFromJsonAsync<FxTwitterResponse>(api, cancellationToken: ctx);
}
private async Task<List<string>> ProcessMediaAsync(FxTweet tweet, CancellationToken ctx)
{
var uploadedUrls = new List<string>();
if (!await Zipline.IsZiplineEnabled())
{
Logger.Warn("Zipline is not enabled, skipping media upload");
return uploadedUrls;
}
try
{
if (tweet.Media?.Photos != null)
{
foreach (var photo in tweet.Media.Photos)
{
try
{
var url = await DownloadAndUploadImageAsync(photo.Url, ctx);
if (!string.IsNullOrEmpty(url))
{
uploadedUrls.Add(url);
}
}
catch (Exception ex)
{
Logger.Error(ex, $"Failed to process photo: {photo.Url}");
}
}
}
if (tweet.Media?.Videos != null)
{
foreach (var video in tweet.Media.Videos)
{
try
{
var url = await DownloadAndConvertVideoAsync(video, ctx);
if (!string.IsNullOrEmpty(url))
{
uploadedUrls.Add(url);
}
}
catch (Exception ex)
{
Logger.Error(ex, $"Failed to process video: {video.Url}");
}
}
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error processing media");
}
return uploadedUrls;
}
private async Task<string?> DownloadAndUploadImageAsync(string imageUrl, CancellationToken ctx)
{
using var httpClient = new HttpClient();
var imageBytes = await httpClient.GetByteArrayAsync(imageUrl, ctx);
using var imageStream = new MemoryStream(imageBytes);
var url = await Zipline.Upload(imageStream, new MediaTypeHeaderValue("image/jpeg"), "9h", ctx);
Logger.Info($"Uploaded image to Zipline: {url}");
return url;
}
private async Task<string?> DownloadAndConvertVideoAsync(FxVideo video, CancellationToken ctx)
{
var maxDurationSetting = await SettingsProvider.GetValueAsync(BuiltIn.Keys.XeetMaxVideoDurationSeconds);
var maxDuration = maxDurationSetting.ToType<int>();
if (video.Duration > maxDuration)
{
Logger.Info($"Skipping video conversion: duration {video.Duration}s exceeds max {maxDuration}s");
return null;
}
// Get the best quality variant
var bestVariant = video.Variants
.Where(v => v.Bitrate.HasValue)
.OrderByDescending(v => v.Bitrate)
.FirstOrDefault() ?? video.Variants.FirstOrDefault();
if (bestVariant == null)
{
Logger.Warn("No video variant found");
return null;
}
var videoUrl = bestVariant.Url;
Logger.Info($"Downloading video from {videoUrl} (duration: {video.Duration}s)");
using var httpClient = new HttpClient();
var videoBytes = await httpClient.GetByteArrayAsync(videoUrl, ctx);
var tempVideoPath = Path.Combine(Path.GetTempPath(), $"tweet_video_{Guid.NewGuid()}.mp4");
var tempWebpPath = Path.Combine(Path.GetTempPath(), $"tweet_video_{Guid.NewGuid()}.webp");
try
{
await File.WriteAllBytesAsync(tempVideoPath, videoBytes, ctx);
var ffmpegPath = await SettingsProvider.GetValueAsync(BuiltIn.Keys.FFmpegBinaryPath);
var ffmpegArgs = $"-i \"{tempVideoPath}\" -vf \"fps=10,scale='min(640,iw)':'min(480,ih)':force_original_aspect_ratio=decrease\" -c:v libwebp -lossless 0 -quality 75 -loop 0 -an \"{tempWebpPath}\"";
var processInfo = new ProcessStartInfo
{
FileName = ffmpegPath.Value ?? "ffmpeg",
Arguments = ffmpegArgs,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(processInfo);
if (process == null)
{
Logger.Error("Failed to start FFmpeg process");
return null;
}
await process.WaitForExitAsync(ctx);
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync(ctx);
Logger.Error($"FFmpeg conversion failed: {error}");
return null;
}
await using var webpStream = File.OpenRead(tempWebpPath);
var url = await Zipline.Upload(webpStream, new MediaTypeHeaderValue("image/webp"), "9h", ctx);
Logger.Info($"Uploaded video as WebP to Zipline: {url}");
return url;
}
finally
{
try
{
if (File.Exists(tempVideoPath)) File.Delete(tempVideoPath);
if (File.Exists(tempWebpPath)) File.Delete(tempWebpPath);
}
catch (Exception ex)
{
Logger.Warn(ex, "Failed to cleanup temp files");
}
}
}
private async Task<List<string>> BuildTweetMessagesAsync(FxTweet tweet, string xeetId, List<string> mediaUrls, CancellationToken ctx)
{
var bodyBuilder = new StringBuilder();
var footerBuilder = new StringBuilder();
// Build header - main tweet author and timestamp (always goes first)
var created = DateTimeOffset.FromUnixTimeSeconds(tweet.CreatedTimestamp);
var header = $"[b]{tweet.Author.Name}[/b] (@{tweet.Author.ScreenName}) - {created.Humanize(DateTimeOffset.UtcNow)}[br]";
// Handle reply chain (if this tweet is a reply)
if (!string.IsNullOrEmpty(tweet.ReplyingToStatus))
{
try
{
var replyData = await FetchTweetDataAsync(tweet.ReplyingToStatus, ctx);
if (replyData?.Tweet != null)
{
var replyTweet = replyData.Tweet;
var replyCreated = DateTimeOffset.FromUnixTimeSeconds(replyTweet.CreatedTimestamp);
bodyBuilder.Append($"[i]↩️ Replying to:[/i][br]");
bodyBuilder.Append($"[b]{replyTweet.Author.Name}[/b] (@{replyTweet.Author.ScreenName}) - {replyCreated.Humanize(DateTimeOffset.UtcNow)}[br]");
var replyText = replyTweet.Text;
const int replyTextLimit = 250;
if (replyText.Utf8LengthBytes() > replyTextLimit)
{
replyText = replyText.TruncateBytes(replyTextLimit).TrimEnd() + "…";
}
bodyBuilder.Append($"{replyText}[br][br]");
}
}
catch (Exception ex)
{
Logger.Error(ex, $"Failed to fetch reply tweet: {tweet.ReplyingToStatus}");
}
}
// Main tweet text
var mainText = tweet.Text;
bodyBuilder.Append($"{mainText}[br]");
if (mediaUrls.Count > 0)
{
foreach (var mediaUrl in mediaUrls)
{
bodyBuilder.Append($"[img]{mediaUrl}[/img][br]");
}
}
// Handle quote tweet (if this tweet quotes another)
if (tweet.Quote != null)
{
bodyBuilder.Append("[br]");
var quoteTweet = tweet.Quote;
var quoteCreated = DateTimeOffset.FromUnixTimeSeconds(quoteTweet.CreatedTimestamp);
bodyBuilder.Append($"[i]💬 Quoting:[/i][br]");
bodyBuilder.Append($"[b]{quoteTweet.Author.Name}[/b] (@{quoteTweet.Author.ScreenName}) - {quoteCreated.Humanize(DateTimeOffset.UtcNow)}[br]");
var quoteText = quoteTweet.Text;
const int quoteTextLimit = 250;
if (quoteText.Utf8LengthBytes() > quoteTextLimit)
{
quoteText = quoteText.TruncateBytes(quoteTextLimit).TrimEnd() + "…";
}
bodyBuilder.Append($"{quoteText}[br]");
}
// Build footer (stats + links) - this will always be on the last message
footerBuilder.Append($"💬 {tweet.Replies:N0} 🔁 {tweet.Retweets:N0} ❤️ {tweet.Likes:N0} 👁️ {tweet.Views:N0}[br]");
footerBuilder.Append($"[url={tweet.Url}]X.com[/url] | [url=https://xcancel.com/{tweet.Author.ScreenName}/status/{xeetId}]Xcancel[/url]");
// Split message if needed, with header always first and footer always last
var messages = (header + bodyBuilder + footerBuilder).FancySplitMessage();
return messages;
}
}

View File

@@ -1,7 +1,8 @@
using System.Text;
using System.Text.RegularExpressions;
using KfChatDotNetBot.Models.DbModels;
namespace KfChatDotNetBot;
namespace KfChatDotNetBot.Extensions;
public static class Extensions
{
@@ -53,7 +54,7 @@ public static class Extensions
/// <param name="partLimit">Limit for how many parts to return (returns first n elements). Set to 0 to disable.</param>
/// <param name="partSeparator">Separator to use when splitting up parts of the message</param>
/// <returns>List of string values which represents the split up message</returns>
public static List<string> FancySplitMessage(this string s, int partLengthBytes = 1023, int partLimit = 5, string partSeparator = " ")
public static List<string> FancySplitMessage(this string s, int partLengthBytes = 2048, int partLimit = 5, string partSeparator = " ")
{
var output = new List<string>();
var part = string.Empty;
@@ -130,4 +131,10 @@ public static class Extensions
var charCount = Encoding.UTF8.GetCharCount(bytes, 0, limitBytes);
return s.Substring(0, charCount);
}
// Placeholder for future expansion involving custom titles
public static string FormatUsername(this UserDbModel user)
{
return $"@{user.KfUsername}";
}
}

View File

@@ -0,0 +1,51 @@
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Services;
using KfChatDotNetBot.Settings;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using NLog;
namespace KfChatDotNetBot.Extensions;
public static class MoneyExtensions
{
/// <summary>
/// Format an amount of money using configured symbols
/// </summary>
/// <param name="amount">The amount you wish to format</param>
/// <param name="suffixSymbol">Whether to suffix the symbol</param>
/// <param name="prefixSymbol">Whether to prefix the symbol</param>
/// <param name="wrapInPlainBbCode">Whether to wrap the resulting string in [plain][/plain] BBCode to avoid characters being interpreted as emotes</param>
/// <returns></returns>
public static async Task<string> FormatKasinoCurrencyAsync(this decimal amount, bool suffixSymbol = true,
bool prefixSymbol = false, bool wrapInPlainBbCode = true)
{
var settings = await
SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.MoneySymbolPrefix, BuiltIn.Keys.MoneySymbolSuffix]);
var result = string.Empty;
if (wrapInPlainBbCode)
{
result = "[plain]";
}
if (prefixSymbol)
{
result += settings[BuiltIn.Keys.MoneySymbolPrefix].Value;
}
result += $"{amount:N2}";
if (suffixSymbol)
{
result += $" {settings[BuiltIn.Keys.MoneySymbolSuffix].Value}";
}
if (wrapInPlainBbCode)
{
result += "[/plain]";
}
return result;
}
}

View File

@@ -2,29 +2,36 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>default</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FlareSolverrSharp" Version="3.0.7" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PackageReference Include="Homoglyphic" Version="2.0.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.9.50">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NLog" Version="5.4.0" />
<PackageReference Include="Raffinert.FuzzySharp" Version="2.0.3" />
<PackageReference Include="System.Runtime.Caching" Version="9.0.4" />
<PackageReference Include="System.Text.Json" Version="9.0.4" />
<PackageReference Include="NLog" Version="6.1.0" />
<PackageReference Include="Raffinert.FuzzySharp" Version="4.0.0" />
<PackageReference Include="RandN" Version="0.5.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
<PackageReference Include="StackExchange.Redis" Version="2.11.3" />
<PackageReference Include="System.Runtime.Caching" Version="10.0.3" />
<PackageReference Include="Websocket.Client" Version="5.3.0" />
<PackageReference Include="Zalgo" Version="0.3.0" />
</ItemGroup>

View File

@@ -0,0 +1,396 @@
// <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("20250720061845_Streams")]
partial class Streams
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.ChipsggBetDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("Amount")
.HasColumnType("REAL");
b.Property<string>("BetId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("TEXT");
b.Property<float>("CurrencyPrice")
.HasColumnType("REAL");
b.Property<string>("GameTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<float>("Multiplier")
.HasColumnType("REAL");
b.Property<DateTimeOffset>("Updated")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Win")
.HasColumnType("INTEGER");
b.Property<double>("Winnings")
.HasColumnType("REAL");
b.HasKey("Id");
b.ToTable("ChipsggBets");
});
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.ImageDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Images");
});
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.MomDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Moms");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.RainbetBetsDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BetId")
.IsRequired()
.HasColumnType("TEXT");
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<double>("CacheDuration")
.HasColumnType("REAL");
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.Property<int>("ValueType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Settings");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AutoCapture")
.HasColumnType("INTEGER");
b.Property<string>("Metadata")
.HasColumnType("TEXT");
b.Property<int>("Service")
.HasColumnType("INTEGER");
b.Property<string>("StreamUrl")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Streams");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("ServerTime")
.HasColumnType("REAL");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Viewers")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TwitchViewCounts");
});
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.UserWhoWasDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ActivityType")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("FirstOccurence")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("LatestOccurence")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UsersWhoWere");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.MomDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", 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,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace KfChatDotNetBot.Migrations
{
/// <inheritdoc />
public partial class Streams : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Streams",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<int>(type: "INTEGER", nullable: true),
StreamUrl = table.Column<string>(type: "TEXT", nullable: false),
Service = table.Column<int>(type: "INTEGER", nullable: false),
Metadata = table.Column<string>(type: "TEXT", nullable: true),
AutoCapture = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Streams", x => x.Id);
table.ForeignKey(
name: "FK_Streams_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Streams_UserId",
table: "Streams",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Streams");
}
}
}

View File

@@ -0,0 +1,633 @@
// <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("20250820195308_Money")]
partial class Money
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.ChipsggBetDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("Amount")
.HasColumnType("REAL");
b.Property<string>("BetId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("TEXT");
b.Property<float>("CurrencyPrice")
.HasColumnType("REAL");
b.Property<string>("GameTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<float>("Multiplier")
.HasColumnType("REAL");
b.Property<DateTimeOffset>("Updated")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Win")
.HasColumnType("INTEGER");
b.Property<double>("Winnings")
.HasColumnType("REAL");
b.HasKey("Id");
b.ToTable("ChipsggBets");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<decimal>("Balance")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<decimal>("NextVipLevelWagerRequirement")
.HasColumnType("TEXT");
b.Property<string>("RandomSeed")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<decimal>("TotalWagered")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Gamblers");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Expires")
.HasColumnType("TEXT");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Exclusions");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<string>("Metadata")
.HasColumnType("TEXT");
b.Property<decimal?>("Payout")
.HasColumnType("TEXT");
b.Property<string>("PerkName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("PerkTier")
.HasColumnType("INTEGER");
b.Property<int>("PerkType")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Perks");
});
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.ImageDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("LastSeen")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Images");
});
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.MomDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Moms");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.RainbetBetsDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("BetId")
.IsRequired()
.HasColumnType("TEXT");
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<double>("CacheDuration")
.HasColumnType("REAL");
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.Property<int>("ValueType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Settings");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AutoCapture")
.HasColumnType("INTEGER");
b.Property<string>("Metadata")
.HasColumnType("TEXT");
b.Property<int>("Service")
.HasColumnType("INTEGER");
b.Property<string>("StreamUrl")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Streams");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Comment")
.HasColumnType("TEXT");
b.Property<decimal>("Effect")
.HasColumnType("TEXT");
b.Property<int>("EventSource")
.HasColumnType("INTEGER");
b.Property<int?>("FromId")
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<decimal>("NewBalance")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<long>("TimeUnixEpochSeconds")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("GamblerId");
b.ToTable("Transactions");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("ServerTime")
.HasColumnType("REAL");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Viewers")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TwitchViewCounts");
});
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.UserWhoWasDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ActivityType")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("FirstOccurence")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("LatestOccurence")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UsersWhoWere");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<int>("Game")
.HasColumnType("INTEGER");
b.Property<string>("GameMeta")
.HasColumnType("TEXT");
b.Property<bool>("IsComplete")
.HasColumnType("INTEGER");
b.Property<decimal>("Multiplier")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<long>("TimeUnixEpochSeconds")
.HasColumnType("INTEGER");
b.Property<decimal>("WagerAmount")
.HasColumnType("TEXT");
b.Property<decimal>("WagerEffect")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Wagers");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.MomDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("From");
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,194 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace KfChatDotNetBot.Migrations
{
/// <inheritdoc />
public partial class Money : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Gamblers",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<int>(type: "INTEGER", nullable: false),
Balance = table.Column<decimal>(type: "TEXT", nullable: false),
State = table.Column<int>(type: "INTEGER", nullable: false),
RandomSeed = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
Created = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
TotalWagered = table.Column<decimal>(type: "TEXT", nullable: false),
NextVipLevelWagerRequirement = table.Column<decimal>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Gamblers", x => x.Id);
table.ForeignKey(
name: "FK_Gamblers_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Exclusions",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GamblerId = table.Column<int>(type: "INTEGER", nullable: false),
Expires = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Created = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Source = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Exclusions", x => x.Id);
table.ForeignKey(
name: "FK_Exclusions_Gamblers_GamblerId",
column: x => x.GamblerId,
principalTable: "Gamblers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Perks",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GamblerId = table.Column<int>(type: "INTEGER", nullable: false),
PerkName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
Time = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Metadata = table.Column<string>(type: "TEXT", nullable: true),
PerkType = table.Column<int>(type: "INTEGER", nullable: false),
PerkTier = table.Column<int>(type: "INTEGER", nullable: true),
Payout = table.Column<decimal>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Perks", x => x.Id);
table.ForeignKey(
name: "FK_Perks_Gamblers_GamblerId",
column: x => x.GamblerId,
principalTable: "Gamblers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Transactions",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GamblerId = table.Column<int>(type: "INTEGER", nullable: false),
EventSource = table.Column<int>(type: "INTEGER", nullable: false),
Time = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
TimeUnixEpochSeconds = table.Column<long>(type: "INTEGER", nullable: false),
Effect = table.Column<decimal>(type: "TEXT", nullable: false),
Comment = table.Column<string>(type: "TEXT", nullable: true),
FromId = table.Column<int>(type: "INTEGER", nullable: true),
NewBalance = table.Column<decimal>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Transactions", x => x.Id);
table.ForeignKey(
name: "FK_Transactions_Gamblers_FromId",
column: x => x.FromId,
principalTable: "Gamblers",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Transactions_Gamblers_GamblerId",
column: x => x.GamblerId,
principalTable: "Gamblers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Wagers",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GamblerId = table.Column<int>(type: "INTEGER", nullable: false),
Time = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
TimeUnixEpochSeconds = table.Column<long>(type: "INTEGER", nullable: false),
WagerAmount = table.Column<decimal>(type: "TEXT", nullable: false),
WagerEffect = table.Column<decimal>(type: "TEXT", nullable: false),
Game = table.Column<int>(type: "INTEGER", nullable: false),
Multiplier = table.Column<decimal>(type: "TEXT", nullable: false),
GameMeta = table.Column<string>(type: "TEXT", nullable: true),
IsComplete = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Wagers", x => x.Id);
table.ForeignKey(
name: "FK_Wagers_Gamblers_GamblerId",
column: x => x.GamblerId,
principalTable: "Gamblers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Exclusions_GamblerId",
table: "Exclusions",
column: "GamblerId");
migrationBuilder.CreateIndex(
name: "IX_Gamblers_UserId",
table: "Gamblers",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Perks_GamblerId",
table: "Perks",
column: "GamblerId");
migrationBuilder.CreateIndex(
name: "IX_Transactions_FromId",
table: "Transactions",
column: "FromId");
migrationBuilder.CreateIndex(
name: "IX_Transactions_GamblerId",
table: "Transactions",
column: "GamblerId");
migrationBuilder.CreateIndex(
name: "IX_Wagers_GamblerId",
table: "Wagers",
column: "GamblerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Exclusions");
migrationBuilder.DropTable(
name: "Perks");
migrationBuilder.DropTable(
name: "Transactions");
migrationBuilder.DropTable(
name: "Wagers");
migrationBuilder.DropTable(
name: "Gamblers");
}
}
}

View File

@@ -69,6 +69,103 @@ namespace KfChatDotNetBot.Migrations
b.ToTable("ChipsggBets");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<decimal>("Balance")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<decimal>("NextVipLevelWagerRequirement")
.HasColumnType("TEXT");
b.Property<string>("RandomSeed")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.Property<decimal>("TotalWagered")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Gamblers");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Expires")
.HasColumnType("TEXT");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Exclusions");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<string>("Metadata")
.HasColumnType("TEXT");
b.Property<decimal?>("Payout")
.HasColumnType("TEXT");
b.Property<string>("PerkName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("PerkTier")
.HasColumnType("INTEGER");
b.Property<int>("PerkType")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Perks");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.HowlggBetsDbModel", b =>
{
b.Property<int>("Id")
@@ -244,6 +341,74 @@ namespace KfChatDotNetBot.Migrations
b.ToTable("Settings");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AutoCapture")
.HasColumnType("INTEGER");
b.Property<string>("Metadata")
.HasColumnType("TEXT");
b.Property<int>("Service")
.HasColumnType("INTEGER");
b.Property<string>("StreamUrl")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Streams");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Comment")
.HasColumnType("TEXT");
b.Property<decimal>("Effect")
.HasColumnType("TEXT");
b.Property<int>("EventSource")
.HasColumnType("INTEGER");
b.Property<int?>("FromId")
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<decimal>("NewBalance")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<long>("TimeUnixEpochSeconds")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("GamblerId");
b.ToTable("Transactions");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b =>
{
b.Property<int>("Id")
@@ -317,6 +482,79 @@ namespace KfChatDotNetBot.Migrations
b.ToTable("UsersWhoWere");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("GamblerId")
.HasColumnType("INTEGER");
b.Property<int>("Game")
.HasColumnType("INTEGER");
b.Property<string>("GameMeta")
.HasColumnType("TEXT");
b.Property<bool>("IsComplete")
.HasColumnType("INTEGER");
b.Property<decimal>("Multiplier")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.Property<long>("TimeUnixEpochSeconds")
.HasColumnType("INTEGER");
b.Property<decimal>("WagerAmount")
.HasColumnType("TEXT");
b.Property<decimal>("WagerEffect")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GamblerId");
b.ToTable("Wagers");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerExclusionDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.GamblerPerkDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
@@ -339,6 +577,32 @@ namespace KfChatDotNetBot.Migrations
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TransactionDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("From");
b.Navigation("Gambler");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
@@ -349,6 +613,17 @@ namespace KfChatDotNetBot.Migrations
b.Navigation("User");
});
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.WagerDbModel", b =>
{
b.HasOne("KfChatDotNetBot.Models.DbModels.GamblerDbModel", "Gambler")
.WithMany()
.HasForeignKey("GamblerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Gambler");
});
#pragma warning restore 612, 618
}
}

View File

@@ -0,0 +1,164 @@
using KfChatDotNetBot.Models.DbModels;
using Money = KfChatDotNetBot.Services.Money;
namespace KfChatDotNetBot.Models;
public class BlackjackGameMetaModel
{
/// <summary>
/// Player's hands (multiple if split)
/// </summary>
public required List<List<Card>> PlayerHands { get; set; }
/// <summary>
/// Dealer's hand
/// </summary>
public required List<Card> DealerHand { get; set; }
/// <summary>
/// Remaining cards in the deck
/// </summary>
public required List<Card> Deck { get; set; }
/// <summary>
/// Whether each hand has doubled down (can only hit once more)
/// </summary>
public required bool HasDoubledDown { get; set; }
/// <summary>
/// Current hand being played (for split hands)
/// </summary>
public int CurrentHandIndex { get; set; } = 0;
/// <summary>
/// Original wager amount (per hand)
/// </summary>
public decimal OriginalWagerAmount { get; set; }
}
public class Card
{
/// <summary>
/// Card rank (2-10, J, Q, K, A)
/// </summary>
public required string Rank { get; set; }
/// <summary>
/// Card suit (♠, ♥, ♦, ♣)
/// </summary>
public required string Suit { get; set; }
/// <summary>
/// Get the blackjack value of this card
/// </summary>
public int GetValue()
{
return Rank switch
{
"A" => 11, // Aces are handled specially in hand calculation
"K" or "Q" or "J" => 10,
_ => int.Parse(Rank)
};
}
/// <summary>
/// Display card as string
/// </summary>
public override string ToString()
{
return $"{Rank}{Suit}";
}
}
public static class BlackjackHelper
{
private static readonly string[] Ranks = { "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A" };
private static readonly string[] Suits = { "♠", "♥", "♦", "♣" };
/// <summary>
/// Create a new shuffled deck
/// </summary>
public static List<Card> CreateDeck(GamblerDbModel gambler)
{
var deck = new List<Card>();
foreach (var suit in Suits)
{
foreach (var rank in Ranks)
{
deck.Add(new Card { Rank = rank, Suit = suit });
}
}
// Shuffle using Fisher-Yates
for (int i = deck.Count - 1; i > 0; i--)
{
int j = Money.GetRandomNumber(gambler, 0, i + 1, incrementMaxParam:false);
(deck[i], deck[j]) = (deck[j], deck[i]);
}
return deck;
}
/// <summary>
/// Calculate hand value with proper Ace handling
/// </summary>
public static int CalculateHandValue(List<Card> hand)
{
int value = 0;
int aces = 0;
foreach (var card in hand)
{
if (card.Rank == "A")
{
aces++;
value += 11;
}
else
{
value += card.GetValue();
}
}
// Convert Aces from 11 to 1 if needed to avoid bust
while (value > 21 && aces > 0)
{
value -= 10;
aces--;
}
return value;
}
/// <summary>
/// Check if hand is blackjack (21 with 2 cards)
/// </summary>
public static bool IsBlackjack(List<Card> hand)
{
return hand.Count == 2 && CalculateHandValue(hand) == 21;
}
/// <summary>
/// Check if a hand can be split (two cards of same rank)
/// </summary>
public static bool CanSplit(List<Card> hand)
{
if (hand.Count != 2)
return false;
// Check if both cards have the same value (not rank, to allow 10/J/Q/K splits)
return hand[0].GetValue() == hand[1].GetValue();
}
/// <summary>
/// Format hand for display
/// </summary>
public static string FormatHand(List<Card> hand, bool hideFirstCard = false)
{
if (hideFirstCard && hand.Count > 0)
{
return $"[HIDDEN] {string.Join(" ", hand.Skip(1).Select(c => c.ToString()))}";
}
return string.Join(" ", hand.Select(c => c.ToString()));
}
}

View File

@@ -0,0 +1,44 @@
using KfChatDotNetWsClient.Models.Events;
namespace KfChatDotNetBot.Models;
public class BotCommandMessageModel
{
/// <summary>
/// Author of the message
/// </summary>
public required UserModel Author { get; set; }
/// <summary>
/// Recipient of the message if this is a whisper
/// </summary>
public UserModel? Recipient { get; set; }
/// <summary>
/// Message rendered into HTML
/// </summary>
public required string Message { get; set; }
/// <summary>
/// Original message with BBCode intact (but HTML-encoded)
/// </summary>
public required string MessageRaw { get; set; }
/// <summary>
/// Date and time the message was sent
/// </summary>
public required DateTimeOffset MessageDate { get; set; }
/// <summary>
/// Original message with BBCode intact and HTML decoded
/// </summary>
public required string MessageRawHtmlDecoded { get; set; }
/// <summary>
/// Chat UUID reference to the message (null for whispers)
/// </summary>
public string? MessageUuid { get; set; }
/// <summary>
/// When the message was edited (null if never edited or a whisper)
/// </summary>
public DateTimeOffset? MessageEditDate { get; set; }
/// <summary>
/// Room ID where this message was received. (null if a whisper)
/// </summary>
public int? RoomId { get; set; }
public required bool IsWhisper { get; set; }
}

View File

@@ -5,6 +5,10 @@ public class KickChannelModel
public required int ChannelId { get; set; }
public required int ForumId { get; set; }
public required string ChannelSlug { get; set; }
/// <summary>
/// Whether to automatically capture a stream when it goes live using yt-dlp
/// </summary>
public bool AutoCapture { get; set; } = false;
}
public class CourtHearingModel
@@ -12,4 +16,25 @@ public class CourtHearingModel
public required string Description { get; set; }
public required DateTimeOffset Time { get; set; }
public required string CaseNumber { get; set; }
}
public class PartiChannelModel
{
public required string Username { get; set; }
public required int ForumId { get; set; }
public bool AutoCapture { get; set; } = false;
public required string SocialMedia { get; set; }
}
public class LastSightingModel
{
/// <summary>
/// When Bossman was last seen
/// </summary>
public required DateTimeOffset When { get; set; }
/// <summary>
/// Where he was last seen. Message is formatted like "Bossman last seen 30 minutes ago {activity}
/// Suggestions: "going offline on Discord", "talking in Discord", "betting on Shuffle.us"
/// </summary>
public required string Activity { get; set; }
}

View File

@@ -7,10 +7,10 @@ public class BuiltInSettingsModel
// Model here largely maps to what's in SettingDbModel, the idea is that there's a set of built-in settings that get
// populated when migrating old JSON configs and updated on start if there's a schema change (e.g. regex changed)
public required string Key { get; set; }
public required string Regex { get; set; }
public string Regex { get; set; } = ".+";
public required string Description { get; set; }
public string? Default { get; set; }
public required bool IsSecret { get; set; }
public required TimeSpan CacheDuration { get; set; } = TimeSpan.Zero;
public bool IsSecret { get; set; } = false;
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(1);
public required SettingValueType ValueType { get; set; }
}

View File

@@ -5,7 +5,7 @@ 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; }
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
@@ -16,7 +16,7 @@ public class ChipsggBetModel
public float Multiplier { get; set; }
public string? Currency { get; set; }
public float CurrencyPrice { get; set; }
public string BetId { get; set; }
public string? BetId { get; set; }
}
public class ChipsggCurrencyModel

View File

@@ -0,0 +1,8 @@
namespace KfChatDotNetBot.Models;
public class DLiveIsLiveModel
{
public required bool IsLive { get; set; }
public string? Title { get; set; }
public required string Username { get; set; }
}

View File

@@ -0,0 +1,305 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace KfChatDotNetBot.Models.DbModels;
public class KasinoShopProfileDbModel
{
/// <summary>
/// ID for the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// Shop profiles belong to a user, not their gambler ID
/// they persist even if the user abandons their profile
/// </summary>
public required UserDbModel User { get; set; }
/// <summary>
/// Assets held by this profile
/// </summary>
public required List<KasinoShopProfileAssetDbModel> Assets { get; set; }
/// <summary>
/// Loans taken out by this profile
/// </summary>
[InverseProperty(nameof(KasinoShopProfileLoanDbModel.Borrower))]
public required List<KasinoShopProfileLoanDbModel> LoansTaken { get; set; }
/// <summary>
/// Loans owed to this profile
/// </summary>
[InverseProperty(nameof(KasinoShopProfileLoanDbModel.Lender))]
public required List<KasinoShopProfileLoanDbModel> LoansOwed { get; set; }
/// <summary>
/// State of the profile
/// </summary>
public required KasinoShopProfileStateFlags State { get; set; } = KasinoShopProfileStateFlags.None;
/// <summary>
/// JSON object containing data related to the above states
/// </summary>
public required KasinoShopProfileStateDataModel StateData { get; set; }
/// <summary>
/// Profile balance in the "Krypto" currency
/// </summary>
public required decimal KryptoBalance { get; set; }
}
// Note this is serialized to JSON by Entity Framework so you can go wild shoving random bullshit in here
public class KasinoShopProfileStateDataModel
{
/// <summary>
/// Profile credit score for determining creditworthiness etc.
/// </summary>
// Actually considered making this uint but I like the idea of negative credit
public required int KreditScore { get; set; }
/// <summary>
/// Amount this user has wagered towards their sponsor requirement
/// </summary>
public decimal? SponsorWagerAmount { get; set; } = null;
/// <summary>
/// The sponsor's wager requirement
/// </summary>
public decimal? SponsorWagerRequirement { get; set; } = null;
/// <summary>
/// Modifier that alters the house edge for your gambler entity
/// </summary>
public required decimal HouseEdgeModifier { get; set; } = 1;
/// <summary>
/// How much crack you've smoked?
/// </summary>
public required int CrackCounter { get; set; } = 0;
/// <summary>
/// How many floor nugs you got embedded in the carpet
/// </summary>
public required int FloorNugs { get; set; } = 0;
/// <summary>
/// Time when your weed buff ends
/// </summary>
public required DateTimeOffset WeedBuffEnds { get; set; } = DateTimeOffset.UtcNow;
/// <summary>
/// Time when your crack buff ends
/// </summary>
public required DateTimeOffset CrackBuffEnds { get; set; } = DateTimeOffset.UtcNow;
/// <summary>
/// Dodgy stat tracking
/// </summary>
public required KasinoShopStatTrackerModel StatTracker { get; set; } = new();
}
public class KasinoShopStatTrackerModel
{
public decimal TotalDeposited { get; set; } = 0;
public decimal TotalWithdrawn { get; set; } = 0;
public decimal TotalLossback { get; set; } = 0;
/// <summary>
/// Track wager statistics by game
/// </summary>
public Dictionary<WagerGame, decimal> StatTracker { get; set; } = new();
}
public class KasinoShopProfileLoanDbModel
{
/// <summary>
/// ID for the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// Profile of the user who owns this loan
/// </summary>
public required KasinoShopProfileDbModel Borrower { get; set; }
/// <summary>
/// Profile of the user to whom this loan is owed/payable to
/// </summary>
public required KasinoShopProfileDbModel Lender { get; set; }
/// <summary>
/// Amount loaned
/// </summary>
public required decimal Amount { get; set; }
/// <summary>
/// Amount to be paid out to the loaner
/// </summary>
public required decimal PayoutAmount { get; set; }
/// <summary>
/// Date and time loan entry was created
/// </summary>
public required DateTimeOffset Created { get; set; }
/// <summary>
/// State of this loan
/// </summary>
public required LoanState State { get; set; }
}
public class KasinoShopProfileAssetDbModel
{
/// <summary>
/// ID for the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// Profile of the user who owns this asset
/// </summary>
public required KasinoShopProfileDbModel Profile { get; set; }
/// <summary>
/// Value of the item at the time of acquisition in Krypto
/// </summary>
public required decimal OriginalValue { get; set; }
/// <summary>
/// What the value of the item is right now
/// </summary>
public required decimal CurrentValue { get; set; }
/// <summary>
/// Asset name
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Asset type
/// </summary>
public required AssetType AssetType { get; set; }
/// <summary>
/// Date and time the asset was acquired
/// </summary>
public required DateTimeOffset Acquired { get; set; }
/// <summary>
/// History of value changes (e.g. interest events)
/// </summary>
public required List<KasinoShopProfileAssetValueChangeDbModel> ValueChangeReports { get; set; }
/// <summary>
/// Use this to store enum values for assets that have a subtype (e.g. Car Type)
/// but were otherwise not special enough to have their own table (e.g. Car)
/// </summary>
public int? AssetSubType { get; set; } = null;
/// <summary>
/// Serialized JSON for extra information where the schema can't accommodate for you
/// </summary>
public string? Extra { get; set; } = null;
}
public class KasinoShopProfileAssetInvestmentDbModel
{
/// <summary>
/// ID for the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// Related asset for this investment
/// </summary>
public required KasinoShopProfileAssetDbModel Asset { get; set; }
/// <summary>
/// What type of investment it is
/// </summary>
public required InvestmentType InvestmentType { get; set; }
/// <summary>
/// Last time interest was calculated
/// </summary>
public required DateTimeOffset LastInterestCalculation { get; set; }
/// <summary>
/// Low point for interest calculations
/// </summary>
public required float InterestRangeMin { get; set; }
/// <summary>
/// High point for interest calculations
/// </summary>
public required float InterestRangeMax { get; set; }
/// <summary>
/// Use this to store enum values for investments that have a subtype (e.g. Shoe Brand)
/// but were otherwise not special enough to have their own table (e.g. Shoe)
/// </summary>
public int? InvestmentSubType { get; set; } = null;
/// <summary>
/// Serialized JSON for extra information where the schema can't accommodate for you
/// </summary>
public string? Extra { get; set; } = null;
}
public class KasinoShopProfileAssetValueChangeDbModel
{
/// <summary>
/// ID for the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// Related asset
/// </summary>
public required KasinoShopProfileAssetDbModel Asset { get; set; }
/// <summary>
/// Effect of the change
/// </summary>
public required decimal ValueChangeEffect { get; set; }
/// <summary>
/// Change percent as a decimal fraction?
/// </summary>
public required decimal ValueChangePercent { get; set; }
/// <summary>
/// Descriptive text for the value change (like the source of it)
/// </summary>
public required string Description { get; set; }
}
[Flags]
public enum KasinoShopProfileStateFlags : ulong
{
None,
IsSponsored,
IsWeeded,
IsCracked,
IsInWithdrawal,
IsLoanable
}
[Flags]
public enum KasinoShopProfileAssetState : ulong
{
None,
/// <summary>
/// Only applicable to smashable objects (e.g. PC peripherals)
/// </summary>
IsSmashed
}
public enum AssetType
{
Investment,
Smashable,
Car,
Random
}
public enum InvestmentType
{
Shoes,
Stake,
Gold,
Silver,
Skin,
House,
Random
}
public enum LoanState
{
/// <summary>
/// Loan not fully paid but borrower still in good standing
/// </summary>
Active,
/// <summary>
/// Past due but not yet a serious violation of terms
/// </summary>
Delinquent,
/// <summary>
/// Loan terms violated, time to collect
/// </summary>
Default,
/// <summary>
/// Loan settled by agreement to amended terms (e.g. paid off less than the full amount)
/// </summary>
Settled,
/// <summary>
/// Loan fully repaid for the total amount and closed out
/// </summary>
Repaid,
/// <summary>
/// Written off debt due to being unable to collect
/// </summary>
Uncollectible,
/// <summary>
/// Administrative state for loans canceled due to serious malfeasance
/// </summary>
Canceled
}

View File

@@ -0,0 +1,343 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using KfChatDotNetBot.Services;
namespace KfChatDotNetBot.Models.DbModels;
public class GamblerDbModel
{
/// <summary>
/// ID fo the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// User that this gambler entity is associated with.
/// A user can have multiple associated gambler entities, but only one should be active
/// </summary>
public required UserDbModel User { get; set; }
/// <summary>
/// Gambler's balance. It can be negative if an admin has forced them into an overdraft
/// Values are fractional, it is NOT stored as cents, therefore 100.00 KKK is stored as "100" in the database
/// </summary>
public required decimal Balance { get; set; }
/// <summary>
/// What state the gambler entity is in
/// </summary>
public required GamblerState State { get; set; } = GamblerState.Active;
/// <summary>
/// The seed value given to any instance of Random that's associated with the gambler
/// </summary>
[MaxLength(256)]
public required string RandomSeed { get; set; }
/// <summary>
/// When the gambler entity was created
/// </summary>
public required DateTimeOffset Created { get; set; }
/// <summary>
/// Reference value for total wagered during the entity's lifetime
/// This value is recalculated whenever the bot restarts to ensure integrity
/// </summary>
public required decimal TotalWagered { get; set; }
/// <summary>
/// Wager requirement for the next VIP level
/// If TotalWagered reaches this value, it'll trigger the calculation
/// </summary>
public required decimal NextVipLevelWagerRequirement { get; set; }
}
public class TransactionDbModel
{
/// <summary>
/// ID fo the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gambler whose balance was affected by this transaction
/// </summary>
public required GamblerDbModel Gambler { get; set; }
/// <summary>
/// Source of the transaction event
/// </summary>
public required TransactionSourceEventType EventSource { get; set; }
/// <summary>
/// Time when the event occurred
/// </summary>
public required DateTimeOffset Time { get; set; }
/// <summary>
/// Time represented as a 64-bit UNIX epoch
/// This just exists to make it far more efficient to query a range of txns
/// as then we can use native SQLite dialect to select e.g. last 24 hours
/// instead of copying thousands of rows into memory and using LINQ
/// </summary>
public required long TimeUnixEpochSeconds { get; set; }
/// <summary>
/// Effect of the transaction, plus or minus
/// </summary>
public required decimal Effect { get; set; }
/// <summary>
/// Optional descriptive comment for the transaction. e.g. "Win from wager [id]", "Balance adjustment by Avenue", "Juicer from Null", etc.
/// </summary>
public string? Comment { get; set; } = null;
/// <summary>
/// Sender of the transaction in the case of a juicer, null otherwise
/// </summary>
public GamblerDbModel? From { get; set; } = null;
/// <summary>
/// Snapshot of the gambler's balance after this transaction's effect was applied
/// </summary>
public required decimal NewBalance { get; set; }
}
public class WagerDbModel
{
/// <summary>
/// ID fo the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gambler who wagered
/// </summary>
public required GamblerDbModel Gambler { get; set; }
/// <summary>
/// Time they wagered
/// </summary>
public required DateTimeOffset Time { get; set; }
/// <summary>
/// Time represented as a 64-bit UNIX epoch
/// This just exists to make it far more efficient to query a range of wagers
/// as then we can use native SQLite dialect to select e.g. last 24 hours
/// instead of copying thousands of rows into memory and using LINQ
/// </summary>
public required long TimeUnixEpochSeconds { get; set; }
/// <summary>
/// Amount the gambler wagered
/// </summary>
public required decimal WagerAmount { get; set; }
/// <summary>
/// Effect of the wager on the gambler's balance
/// </summary>
public required decimal WagerEffect { get; set; }
/// <summary>
/// Game they played to wager the amount (Note: enum must be extended for any new games.
/// Don't remove games which are legacy from the enum, just give them the Obsolete attribute)
/// </summary>
public required WagerGame Game { get; set; }
/// <summary>
/// Multiplier, e.g. 10.5x if a $1 wager paid out $10.50. 0 if it was a complete loss
/// </summary>
public required decimal Multiplier { get; set; }
/// <summary>
/// An optional field to store serialized information about the game that was played
/// </summary>
public string? GameMeta { get; set; } = null;
/// <summary>
/// Whether the results of the wager have been realized yet (i.e., is the game 'complete'?)
/// This is useful for wagers related to bets on the outcome of events
/// For incomplete bets: set the effect to -wager, subtract it from the user's balance, generate a txn for the wager
/// Then when the outcome of the bet is fully realized, modify the effect accordingly, generate a new txn for the
/// payout and set a multiplier based on the win (if any)
/// </summary>
public required bool IsComplete { get; set; }
}
public class GamblerExclusionDbModel
{
/// <summary>
/// ID fo the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gambler who is excluded
/// </summary>
public required GamblerDbModel Gambler { get; set; }
/// <summary>
/// When the exclusion expires
/// </summary>
public required DateTimeOffset Expires { get; set; }
/// <summary>
/// When the exclusion was created / began
/// </summary>
public required DateTimeOffset Created { get; set; }
/// <summary>
/// What triggered the exclusion
/// </summary>
public required ExclusionSource Source { get; set; }
}
public class GamblerPerkDbModel
{
/// <summary>
/// ID fo the database row
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gambler entity the perk is associated with
/// </summary>
public required GamblerDbModel Gambler { get; set; }
/// <summary>
/// Name of the perk
/// </summary>
[MaxLength(256)]
public required string PerkName { get; set; }
/// <summary>
/// Time when the perk was attained
/// </summary>
public required DateTimeOffset Time { get; set; }
/// <summary>
/// Optional metadata associated with the perk
/// </summary>
public string? Metadata { get; set; } = null;
/// <summary>
/// What type of perk is this
/// </summary>
public required GamblerPerkType PerkType { get; set; }
/// <summary>
/// The tier the perk is at.
/// If tiers are not applicable, set to null
/// </summary>
public int? PerkTier { get; set; }
/// <summary>
/// The payout from this perk, if any. If none, set to null
/// </summary>
public decimal? Payout { get; set; }
}
public enum GamblerPerkType
{
/// <summary>
/// For literally anything else, though you should probably just extend this enum
/// </summary>
Other = -1,
/// <summary>
/// Used for tracking VIP levels attained
/// </summary>
[Description("VIP Level")]
VipLevel
}
public enum ExclusionSource
{
/// <summary>
/// Exclusion as a result of the hostess' action
/// </summary>
Hostess,
/// <summary>
/// Exclusions placed by administrators
/// </summary>
Administrative
}
/// <summary>
/// What event triggered this transaction
/// </summary>
public enum TransactionSourceEventType
{
/// <summary>
/// Generic catch-all type if nothing else suits
/// </summary>
Other,
/// <summary>
/// Juice from another user. This is only for person to person transactions, use Bonus for kasino rewards
/// </summary>
Juicer,
/// <summary>
/// Transaction generated from the result of a wager
/// </summary>
Gambling,
/// <summary>
/// For recording events related to an administrative action. e.g. balance adjustments
/// </summary>
Administrative,
/// <summary>
/// Some type of bonus, like a VIP level up. Rakeback / reloads have separate enums for this
/// </summary>
Bonus,
/// <summary>
/// Specifically use for rakeback as we use the delta between last rakeback txn to calculate total wagered
/// to figure out what the next rakeback should be (if they've wagered enough to be eligible for one)
/// </summary>
Rakeback,
/// <summary>
/// Use specifically for daily reloads as we use the timing of the last reload txn to figure out if the most
/// recent reload has been claimed yet or not
/// </summary>
Reload,
/// <summary>
/// Use this only for hostess juicers as the sum of these juicers in a given day can influence the hostess' behavior
/// </summary>
Hostess,
/// <summary>
/// Specifically use for lossback as we use the delta between last lossback txn to calculate total lost
/// to figure out what the next lossback should be. (Basically return a small % of the player's losses
/// unless the player's actual position is positive during the period, then tell them to fuck off)
/// </summary>
Lossback,
/// <summary>
/// A specific form of 24 hour time-based reload that has no wager requirement
/// </summary>
DailyDollar,
///<summary>
///A form of juicer where the value is split among a number of participants
/// </summary>
Rain,
Deposit,
Withdraw,
Sponsorship,
Loan
}
public enum WagerGame
{
Limbo,
Dice,
Mines,
Planes,
[Description("Lambchop")]
LambChop,
Keno,
[Description("Coinflip")]
CoinFlip,
/// <summary>
/// This is for betting pools based on some sort of event or outcome
/// </summary>
Event,
[Description("Guess what number I'm thinking of")]
GuessWhatNumber,
Wheel,
Slots,
Blackjack,
[Description("Plinko")]
Plinko,
[Description("Roulette but live")]
Roulette,
Krash
}
public enum GamblerState
{
/// <summary>
/// Gambler entity is active and user can wager using the profile
/// </summary>
Active,
/// <summary>
/// Gambler entity has been disabled by an administrator (e.g. due to cheating)
/// The user will get a new gambler entity when they next interact with the kasino
/// </summary>
AdministrativelyDisabled,
/// <summary>
/// Gambler entity that was abandoned by the user (e.g. to escape an exclusion or crippling debt)
/// </summary>
Abandoned,
/// <summary>
/// Entity was permanently banned. This will prevent future gambler entities being created for this user
/// and will effectively lock them out of the game entirely
/// </summary>
PermanentlyBanned,
/// <summary>
/// Gambler rendered inactive by the End of Year 2025 Great Reset
/// This is treated no different to abandonment, state exists for
/// the purposes of tracking statistics later to see how much KKK
/// was erased by this event
/// </summary>
EndOfYear2025Liquidated
}

View File

@@ -26,10 +26,22 @@ public class SettingDbModel
public enum SettingValueType
{
Boolean,
Text, // This includes values which are only decimals
Array, // It's presumed that your array contains text, don't use this if your array contains complex types
// You can use the array value type on delimited values or JSON arrays. For JSON, it's presumed to be like ['str']
Complex, // Basically for JSON blobs that are encoded into settings
Undefined // Default value. Should only be set to this for orphaned settings. My suggestion is you don't allow users
// to interact with settings with an undefined type
/// <summary>
/// This includes values which are only decimals
/// </summary>
Text,
/// <summary>
/// It's presumed that your array contains text, don't use this if your array contains complex types
/// You can use the array value type on delimited values or JSON arrays. For JSON, it's presumed to be like ['str']
/// </summary>
Array,
/// <summary>
/// Basically for JSON blobs that are encoded into settings
/// </summary>
Complex,
/// <summary>
/// Default value. Should only be set to this for orphaned settings. My suggestion is you don't allow users
/// to interact with settings with an undefined type
/// </summary>
Undefined
}

View File

@@ -0,0 +1,67 @@
namespace KfChatDotNetBot.Models.DbModels;
public class StreamDbModel
{
public int Id { get; set; }
/// <summary>
/// User associated with the stream if any. If none associated, then it'll just say "Somebody has gone live"
/// </summary>
public UserDbModel? User { get; set; } = null;
/// <summary>
/// Absolute URL of the streamer
/// </summary>
public required string StreamUrl { get; set; }
/// <summary>
/// Service the streamer is using
/// </summary>
public required StreamService Service { get; set; }
/// <summary>
/// JSON containing arbitrary data, e.g. social name for Parti, streamer ID for Kick, etc.
/// </summary>
public string? Metadata { get; set; } = null;
/// <summary>
/// Whether to automatically capture a stream when it goes live using yt-dlp / streamlink
/// </summary>
public bool AutoCapture { get; set; } = false;
}
public enum StreamService
{
Kick,
Parti,
DLive,
KiwiPeerTube
}
public class BaseMetaModel
{
public CaptureOverridesModel? CaptureOverrides { get; set; } = null;
}
public class CaptureOverridesModel
{
// Options applicable to YtDlp will not work with Streamlink and vice versa
// That being said, some options are shared while still being explicitly marked as YtDlp
// This applies to CaptureYtDlpWorkingDirectory, CaptureYtDlpParentTerminal, and CaptureYtDlpScriptPath
public string? CaptureYtDlpBinaryPath { get; set; } = null;
public string? CaptureYtDlpWorkingDirectory { get; set; } = null;
public string? CaptureYtDlpCookiesFromBrowser { get; set; } = null;
public string? CaptureYtDlpOutputFormat { get; set; } = null;
public string? CaptureYtDlpParentTerminal { get; set; } = null;
public string? CaptureYtDlpScriptPath { get; set; } = null;
public string? CaptureYtDlpUserAgent { get; set; } = null;
public string? CaptureStreamlinkBinaryPath { get; set; } = null;
public string? CaptureStreamlinkOutputFormat { get; set; } = null;
public string? CaptureStreamlinkRemuxScript { get; set; } = null;
public string? CaptureStreamlinkTwitchOptions { get; set; } = null;
}
public class KickStreamMetaModel : BaseMetaModel
{
public required int ChannelId { get; set; }
}
public class PeerTubeMetaModel : BaseMetaModel
{
public required string AccountName { get; set; }
}

View File

@@ -0,0 +1,96 @@
using System.Text.Json.Serialization;
namespace KfChatDotNetBot.Models;
internal class FxTwitterResponse
{
[JsonPropertyName("code")] public int Code { get; set; }
[JsonPropertyName("message")] public string Message { get; set; } = string.Empty;
[JsonPropertyName("tweet")] public FxTweet Tweet { get; set; } = new();
}
internal class FxTweet
{
[JsonPropertyName("url")] public string Url { get; set; } = string.Empty;
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
[JsonPropertyName("text")] public string Text { get; set; } = string.Empty;
[JsonPropertyName("raw_text")] public FxRawText RawText { get; set; } = new();
[JsonPropertyName("author")] public FxAuthor Author { get; set; } = new();
[JsonPropertyName("replies")] public int Replies { get; set; }
[JsonPropertyName("retweets")] public int Retweets { get; set; }
[JsonPropertyName("likes")] public int Likes { get; set; }
[JsonPropertyName("created_timestamp")]
public long CreatedTimestamp { get; set; }
[JsonPropertyName("views")] public int? Views { get; set; }
[JsonPropertyName("is_note_tweet")] public bool IsNoteTweet { get; set; }
[JsonPropertyName("community_note")] public object? CommunityNote { get; set; }
[JsonPropertyName("lang")] public string Lang { get; set; } = string.Empty;
[JsonPropertyName("replying_to")] public string? ReplyingTo { get; set; }
[JsonPropertyName("replying_to_status")]
public string? ReplyingToStatus { get; set; }
[JsonPropertyName("media")] public FxMedia? Media { get; set; }
[JsonPropertyName("source")] public string Source { get; set; } = string.Empty;
[JsonPropertyName("quote")] public FxTweet? Quote { get; set; } = null;
internal bool HasAnyMedia()
{
if (Media == null)
return false;
if (Media.Photos != null && Media.Photos.Count > 0)
return true;
if (Media.Videos != null && Media.Videos.Count > 0)
return true;
return false;
}
}
internal class FxRawText
{
[JsonPropertyName("text")] public string Text { get; set; } = string.Empty;
}
internal class FxAuthor
{
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("screen_name")] public string ScreenName { get; set; } = string.Empty;
}
internal class FxMedia
{
[JsonPropertyName("photos")] public List<FxPhoto>? Photos { get; set; }
[JsonPropertyName("videos")] public List<FxVideo>? Videos { get; set; }
}
internal class FxPhoto
{
[JsonPropertyName("type")] public string Type { get; set; } = string.Empty;
[JsonPropertyName("url")] public string Url { get; set; } = string.Empty;
[JsonPropertyName("width")] public int Width { get; set; }
[JsonPropertyName("height")] public int Height { get; set; }
}
internal class FxVideo
{
[JsonPropertyName("url")] public string Url { get; set; } = string.Empty;
[JsonPropertyName("thumbnail_url")] public string ThumbnailUrl { get; set; } = string.Empty;
[JsonPropertyName("duration")] public double Duration { get; set; }
[JsonPropertyName("width")] public int Width { get; set; }
[JsonPropertyName("height")] public int Height { get; set; }
[JsonPropertyName("format")] public string Format { get; set; } = string.Empty;
[JsonPropertyName("type")] public string Type { get; set; } = string.Empty;
[JsonPropertyName("variants")] public List<FxVariant> Variants { get; set; } = [];
}
internal class FxVariant
{
[JsonPropertyName("content_type")] public string ContentType { get; set; } = string.Empty;
[JsonPropertyName("url")] public string Url { get; set; } = string.Empty;
[JsonPropertyName("bitrate")] public int? Bitrate { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace KfChatDotNetBot.Models;
/// <summary>
/// Holds state information for the kasino shop
/// </summary>
public class KasinoShopStateModel
{
public required decimal DefaultHouseEdgeModifier { get; set; } = 0;
}

View File

@@ -0,0 +1,40 @@
namespace KfChatDotNetBot.Models;
// Stash all the models used for perk or game metadata here
public class KasinoWagerBaseEventMetaModel
{
/// <summary>
/// Event type for this meta model for the purposes of figuring out which model to use when deserializing
/// </summary>
public required KasinoEventType EventType { get; set; }
/// <summary>
/// Unique reference tracking the shared event ID stored in the settings
/// </summary>
public required string SharedEventId { get; set; }
/// <summary>
/// How long it took the user to make a selection. This is based on the event announcement msg recv - sent timestamp
/// that SneedChat provided so the bot won't unfairly penalize users who were delayed by chat lag
/// </summary>
public required TimeSpan SelectionDelay { get; set; }
}
/// <summary>
/// Metadata model tracking a gambler's wager information related to win/lose games specifically
/// </summary>
public class KasinoWagerWinLoseEventMetaModel : KasinoWagerBaseEventMetaModel
{
/// <summary>
/// Unique reference tracking the option the gambler selected. Tracked as a GUID in case the option text changes
/// </summary>
public required string OptionId { get; set; }
}
/// <summary>
/// Metadata model tracking a gambler's wager information related to win/lose games specifically
/// </summary>
public class KasinoWagerPredictionEventMetaModel : KasinoWagerBaseEventMetaModel
{
/// <summary>
/// The absolute time when the user predicted the thing was going to happen
/// </summary>
public required DateTimeOffset PredictedTime { get; set; }
}

View File

@@ -0,0 +1,146 @@
using System.ComponentModel;
namespace KfChatDotNetBot.Models;
public class MoneyVipLevel
{
/// <summary>
/// Name of the VIP level
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Number of tiers the VIP level has
/// Steps between VIP tiers are calculated by comparing with the next VIP level and dividing by number of tiers
/// e.g. (100,000 - 10,000) / 5 = 18,000 steps
/// Tier 1 = 10,000
/// Tier 2 = 28,000
/// Tier 3 = 46,000
/// Tier 4 = 64,000
/// Tier 5 = 84,000
/// Next VIP level at 100,000
/// What happens if they're at the last VIP level? They remain stuck at tier 1 forever regardless of this value
/// This is really just so that we have flexibility to add further tiers later without messing anything up
/// since there's no telling how easy it will be to attain the high levels at this point
/// </summary>
public required int Tiers { get; set; }
/// <summary>
/// Icon to display next to the name, like an emoji diamond or a small image embedded with bbcode [img] tags
/// </summary>
public required string Icon { get; set; }
/// <summary>
/// The wager requirement for this level. This is the requirement for the base (tier 1) level
/// Remaining tiers are calculated based on the wager requirement for the next tier
/// </summary>
public required decimal BaseWagerRequirement { get; set; }
/// <summary>
/// Payout when you attain this level.
/// Tiers (beyond 1) pay out: BonusPayout / (Tiers - 1) (e.g. 1,000 / 4 = 250 for tier 2-5
/// </summary>
public required decimal BonusPayout { get; set; }
}
public class NextVipLevelModel
{
/// <summary>
/// The VIP level that's coming up next.
/// Could be the same as the existing level if it's just the next tier.
/// </summary>
public required MoneyVipLevel VipLevel { get; set; }
/// <summary>
/// What tier this is for
/// </summary>
public required int Tier { get; set; }
/// <summary>
/// The wager requirement to reach this tier that factors in the tier
/// </summary>
public required decimal WagerRequirement { get; set; }
}
public class KasinoEventModel
{
/// <summary>
/// Text summary of the event itself such as: "How long until the chase ends?" or "Parole granted?"
/// </summary>
public required string EventText { get; set; }
/// <summary>
/// Unique reference used for tying this event to wagers gamblers are placing
/// </summary>
public required string EventId { get; set; }
/// <summary>
/// The set of options available for gamblers to select. This is only applicable to WinLose-type bets
/// It'll be an empty array for closest to finish
/// </summary>
public required List<KasinoEventOptionModel> Options { get; set; } = [];
/// <summary>
/// The type of event this is, which is important for the purposes of calculating the payout correctly
/// </summary>
public required KasinoEventType EventType { get; set; }
/// <summary>
/// Timestamp of when the announcement message was received by the client for the purposes of calculating selection
/// delay. This value is null when the message hasn't been seen yet (either event not started or message lost.
/// Do not accept wagers where this is null.
/// </summary>
public DateTimeOffset? EventAnnouncementReceived { get; set; } = null;
/// <summary>
/// State of the kasino event
/// </summary>
public required KasinoEventState EventState { get; set; } = KasinoEventState.Incomplete;
/// <summary>
/// Whether the payout is weighted based on the selection delay
/// </summary>
public required bool SelectionTimeWeightedPayout { get; set; } = false;
}
public class KasinoEventOptionModel
{
/// <summary>
/// Unique reference used for tying a gambler's selection to a given option
/// </summary>
public required string OptionId { get; set; }
/// <summary>
/// Text to describe the option that users are picking
/// </summary>
public required string OptionText { get; set; }
/// <summary>
/// Whether this option won or not, null while incomplete
/// </summary>
public bool? Won { get; set; } = null;
}
public enum KasinoEventType
{
[Description("Win/Lose")]
WinLose,
[Description("Closest to prediction")]
Prediction,
}
public enum KasinoEventState
{
/// <summary>
/// Event still under construction. This is the initial state when an admin creates an event but hasn't yet launched it
/// </summary>
Incomplete,
/// <summary>
/// Event has been launched but the announcement message hasn't yet been acknowledged by Sneedchat
/// No bets will be processed until the message is seen
/// If the message is ultimately lost, the event will never launch
/// </summary>
PendingAnnouncement,
/// <summary>
/// The event announcement message was seen, the event has started and wagers can now be placed
/// </summary>
Started,
/// <summary>
/// Closed to new wagers but the event is still ongoing
/// </summary>
Closed,
/// <summary>
/// Event has closed, it's so over.
/// </summary>
Over,
/// <summary>
/// Event was abandoned and all wagers canceled
/// </summary>
Abandoned
}

View File

@@ -0,0 +1,34 @@
using System.Text.Json.Serialization;
namespace KfChatDotNetBot.Models;
public class PartiChannelLiveNotificationModel
{
[JsonPropertyName("livestream_id")]
public required int LivestreamId { get; set; }
[JsonPropertyName("user_id")]
public required int UserId { get; set; }
[JsonPropertyName("user_name")]
public required string Username { get; set; }
[JsonPropertyName("user_avatar_id")]
public int UserAvatarId { get; set; }
[JsonPropertyName("avatar_link")]
public Uri? AvatarLink { get; set; }
[JsonPropertyName("event_id")]
public required int EventId { get; set; }
[JsonPropertyName("event_title")]
public required string EventTitle { get; set; }
[JsonPropertyName("event_file")]
public Uri? EventFile { get; set; }
[JsonPropertyName("category_name")]
public string? CategoryName { get; set; }
[JsonPropertyName("viewers_count")]
public int? ViewersCount { get; set; }
[JsonPropertyName("social_media")]
public required string SocialMedia { get; set; }
[JsonPropertyName("social_username")]
public required string SocialUsername { get; set; }
[JsonPropertyName("channel_arn")]
public required string ChannelArn { get; set; }
}

View File

@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace KfChatDotNetBot.Models;
public class PeerTubeVideoDataModel
{
[JsonPropertyName("uuid")]
public required string Uuid { get; set; }
[JsonPropertyName("shortUUID")]
public required string ShortUuid { get; set; }
[JsonPropertyName("url")]
public required string Url { get; set; }
[JsonPropertyName("name")]
public required string Name { get; set; }
[JsonPropertyName("isLive")]
public required bool IsLive { get; set; }
[JsonPropertyName("account")]
public required PeerTubeAccountOrChannelModel Account { get; set; }
[JsonPropertyName("channel")]
public required PeerTubeAccountOrChannelModel Channel { get; set; }
}
public class PeerTubeAccountOrChannelModel
{
[JsonPropertyName("displayName")]
public required string DisplayName { get; set; }
[JsonPropertyName("name")]
public required string Name { get; set; }
}

View File

@@ -0,0 +1,87 @@
namespace KfChatDotNetBot.Models;
public class RateLimitBucketEntryModel
{
/// <summary>
/// Database user ID of the user whose entry this belongs to
/// </summary>
public required int UserId { get; set; }
/// <summary>
/// When the entry was created in the bucket
/// </summary>
public required DateTimeOffset EntryCreated { get; set; }
/// <summary>
/// When the entry is expected to expire based on the command's window
/// </summary>
public required DateTimeOffset EntryExpires { get; set; }
/// <summary>
/// String representation of the command using ICommand.GetType().Name
/// </summary>
public required string CommandInvoked { get; set; }
/// <summary>
/// Hashed contents of the message for if UseEntireMessage is enabled
/// </summary>
public required string MessageHash { get; set; }
}
public class RateLimitOptionsModel
{
/// <summary>
/// Window of time to count an invocation towards the rate limit
/// </summary>
public required TimeSpan Window { get; set; }
/// <summary>
/// Maximum number of permitted invocations within the window before triggering the rate limit
/// </summary>
public required int MaxInvocations { get; set; }
/// <summary>
/// Optional set of flags to configure the behavior of the rate limiter
/// </summary>
public RateLimitFlags Flags { get; set; } = RateLimitFlags.None;
}
public class IsRateLimitedModel
{
/// <summary>
/// Is the user's request rate limited?
/// </summary>
public required bool IsRateLimited { get; set; }
/// <summary>
/// When the oldest entry expires so users know when they can next use the command
/// This is set to null if the user is not rate limited
/// </summary>
public DateTimeOffset? OldestEntryExpires { get; set; }
}
[Flags]
public enum RateLimitFlags
{
/// <summary>
/// Placeholder for the default value
/// </summary>
None,
/// <summary>
/// Silently ignore a user when they trigger a rate limit
/// </summary>
NoResponse,
/// <summary>
/// The default behavior is to rate limit based on command invoked.
/// UseEntireMessage changes it to consider dissimilar messages which invoke
/// the same command as being separate for the purposes of rate limiting.
/// With this, only identical messages count towards the rate limit.
/// </summary>
UseEntireMessage,
/// <summary>
/// The rate limit is global instead of applying per-user
/// </summary>
Global,
/// <summary>
/// Exempt users with a higher than default level from rate limiting
/// </summary>
ExemptPrivilegedUsers,
/// <summary>
/// Do not automatically clean up the cooldown response sent to a user
/// </summary>
NoAutoDeleteCooldownResponse
}

View File

@@ -2,6 +2,6 @@
public class SeenMessageMetadataModel
{
public int MessageId { get; set; }
public required string MessageUuid { get; set; }
public DateTimeOffset? LastEdited { get; set; }
}

View File

@@ -4,12 +4,25 @@ public class SentMessageTrackerModel
{
// Unique GUID for each message
public required string Reference { get; set; }
/// <summary>
/// The raw message. If this was a whisper, it'll include the '/w id msg' payload
/// </summary>
public required string Message { get; set; }
public required SentMessageTrackerStatus Status { get; set; }
public int? ChatMessageId { get; set; }
public string? ChatMessageUuid { get; set; }
// Timespan from when the message was sent until we saw it come back
public TimeSpan? Delay { get; set; }
public DateTimeOffset? SentAt { get; set; }
/// <summary>
/// If the message was edited, this is the last edit time that Sneedchat sent us
/// When edited multiple times, it'll be the most recent edit
/// </summary>
public DateTimeOffset? LastEdited { get; set; } = null;
public required SentMessageType Type { get; set; }
/// <summary>
/// Contains just the whisper message
/// </summary>
public string? WhisperMessage { get; set; }
}
public enum SentMessageTrackerStatus
@@ -24,4 +37,10 @@ public enum SentMessageTrackerStatus
ChatDisconnected,
// Was held in the replay buffer due to a disconnect, but there were too many messages ahead of it and so was culled
Lost
}
public enum SentMessageType
{
ChatMessage,
Whisper
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace KfChatDotNetBot.Models;
public class WinnaBetModel
{
[JsonPropertyName("id")]
public required string Id { get; set; }
[JsonPropertyName("currency")]
public required string Currency { get; set; }
[JsonPropertyName("userName")]
public required string Username { get; set; }
[JsonPropertyName("isVip")]
public required bool IsVip { get; set; }
[JsonPropertyName("tier")]
public string? Tier { get; set; }
[JsonPropertyName("level")]
public required int Level { get; set; }
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; set; }
[JsonPropertyName("multiplier")]
public required float Multiplier { get; set; }
[JsonPropertyName("betAmount")]
public required float BetAmount { get; set; }
[JsonPropertyName("payout")]
public required float Payout { get; set; }
[JsonPropertyName("gameName")]
public required string GameName { get; set; }
[JsonPropertyName("amounts")]
public required Dictionary<string, WinnaCurrencyModel> Amounts { get; set; }
}
public class WinnaCurrencyModel
{
[JsonPropertyName("betAmount")]
public required float BetAmount { get; set; }
[JsonPropertyName("payout")]
public required float Payout { get; set; }
}

View File

@@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
namespace KfChatDotNetBot.Models;
public class YouTubeApiModels
{
public class ContentDetailsRoot
{
[JsonPropertyName("kind")]
public required string Kind { get; set; }
[JsonPropertyName("items")]
public required List<ItemModel> Items { get; set; }
}
public class SnippetModel
{
[JsonPropertyName("publishedAt")]
public required DateTime PublishedAt { get; set; }
[JsonPropertyName("channelId")]
public required string ChannelId { get; set; }
[JsonPropertyName("title")]
public required string Title { get; set; }
[JsonPropertyName("description")]
public required string Description { get; set; }
[JsonPropertyName("channelTitle")]
public required string ChannelTitle { get; set; }
// "none", "live", "upcoming"
[JsonPropertyName("liveBroadcastContent")]
public required string LiveBroadcastContent { get; set; }
}
public class ItemModel
{
[JsonPropertyName("kind")]
public required string Kind { get; set; }
[JsonPropertyName("id")]
public required string Id { get; set; }
[JsonPropertyName("snippet")]
public required SnippetModel Snippet { get; set; }
}
}

View File

@@ -1,4 +1,25 @@
using System.Text;
/*
KfChatDotNetBot - Sneedchat bot for the Keno Kasino
Copyright (C) 2025 barelyprofessional
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
The above copyright notice, this permission notice and the word "NIGGER"
shall be included in all copies or substantial portions of the Software.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
using System.Text;
using KfChatDotNetBot.Settings;
using Microsoft.EntityFrameworkCore;
using NLog;

View File

@@ -11,8 +11,8 @@ public class AlmanacShill(ChatBot kfChatBot) : IDisposable
private async Task AlmanacShillTask()
{
var interval = await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotAlmanacInterval);
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Convert.ToInt32(interval.Value)));
var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotAlmanacInterval)).ToType<int>();
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(interval));
while (await timer.WaitForNextTickAsync(_almanacShillCts.Token))
{
_logger.Info("Time to shill the almanac in chat");

View File

@@ -1,7 +1,9 @@
using System.Net;
using System.Net.WebSockets;
using System.Text.Json;
using FlareSolverrSharp;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Settings;
using NLog;
using Websocket.Client;
@@ -10,21 +12,23 @@ namespace KfChatDotNetBot.Services;
public class BetBolt : IDisposable
{
private Logger _logger = LogManager.GetCurrentClassLogger();
private WebsocketClient _wsClient;
private WebsocketClient? _wsClient;
private Uri _wsUri = new("wss://betbolt.com/api/ws");
// Pings every 5 seconds so 15 seconds should be reasonable
private int _reconnectTimeout = 15;
private string? _proxy;
public delegate void OnBetBoltBetEventHandler(object sender, BetBoltBetModel bet);
public delegate void OnWsDisconnectionEventHandler(object sender, DisconnectionInfo e);
public event OnBetBoltBetEventHandler OnBetBoltBet;
public event OnWsDisconnectionEventHandler OnWsDisconnection;
private CancellationToken _cancellationToken = CancellationToken.None;
public BetBolt(string? proxy = null, CancellationToken? cancellationToken = null)
public event OnBetBoltBetEventHandler? OnBetBoltBet;
public event OnWsDisconnectionEventHandler? OnWsDisconnection;
private CancellationToken _cancellationToken;
private IEnumerable<string>? _cookies;
private string? _userAgent;
public BetBolt(string? proxy = null, CancellationToken cancellationToken = default)
{
_proxy = proxy;
if (cancellationToken != null) _cancellationToken = cancellationToken.Value;
_logger.Info("Clash.gg WebSocket client created");
_cancellationToken = cancellationToken;
_logger.Info("BetBolt WebSocket client created");
}
public async Task StartWsClient()
{
@@ -38,7 +42,8 @@ public class BetBolt : IDisposable
{
var clientWs = new ClientWebSocket();
clientWs.Options.SetRequestHeader("Origin", "https://betbolt.com");
clientWs.Options.SetRequestHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0");
clientWs.Options.SetRequestHeader("User-Agent", _userAgent);
clientWs.Options.SetRequestHeader("Cookie", string.Join("; ", _cookies!));
if (_proxy == null) return clientWs;
_logger.Debug($"Using proxy address {_proxy}");
clientWs.Options.Proxy = new WebProxy(_proxy);
@@ -81,7 +86,7 @@ public class BetBolt : IDisposable
if (reconnectionInfo.Type == ReconnectionType.Initial)
{
_logger.Info("Sending subscribe payload to BetBolt");
_wsClient.Send("{\"topic\":\"system/EN\",\"action\":\"subscribe\"}");
_wsClient?.Send("{\"topic\":\"system/EN\",\"action\":\"subscribe\"}");
}
}
@@ -153,9 +158,35 @@ public class BetBolt : IDisposable
}
}
public async Task RefreshCookies()
{
_logger.Info("Refreshing cookies for BetBolt");
var settings =
await SettingsProvider.GetMultipleValuesAsync([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 client = new HttpClient(handler);
// BetBolt seems to have relaxed CF settings since the .io move so should be no checkbox now
var getResponse = await client.GetAsync("https://betbolt.com/", _cancellationToken);
_cookies = getResponse.Headers.GetValues("Set-Cookie");
_userAgent = getResponse.RequestMessage!.Headers.UserAgent.ToString();
}
public void Dispose()
{
_wsClient.Dispose();
_wsClient?.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -1,8 +1,12 @@
using System.Text.RegularExpressions;
using Humanizer;
using KfChatDotNetBot.Commands;
using KfChatDotNetBot.Extensions;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;
using KfChatDotNetWsClient.Models.Events;
using Microsoft.EntityFrameworkCore;
using NLog;
namespace KfChatDotNetBot.Services;
@@ -13,13 +17,13 @@ internal class BotCommands
{
private ChatBot _bot;
private Logger _logger = LogManager.GetCurrentClassLogger();
private char CommandPrefix = '!';
private char CommandPrefix = '!';
private IEnumerable<ICommand> Commands;
private CancellationToken _cancellationToken;
internal BotCommands(ChatBot bot, CancellationToken? ctx = null)
internal BotCommands(ChatBot bot, CancellationToken ctx = default)
{
_cancellationToken = ctx ?? CancellationToken.None;
_cancellationToken = ctx;
_bot = bot;
var interfaceType = typeof(ICommand);
Commands =
@@ -32,63 +36,207 @@ internal class BotCommands
{
_logger.Debug($"Found command {command.GetType().Name}");
}
_ = CleanupExpiredRateLimitEntriesTask();
}
internal void ProcessMessage(MessageModel message)
internal void ProcessMessage(BotCommandMessageModel message)
{
if (string.IsNullOrEmpty(message.MessageRaw))
{
return;
}
if (!message.MessageRaw.StartsWith(CommandPrefix))
{
return;
}
var messageTrimmed = message.MessageRaw.TrimStart(CommandPrefix);
foreach (var command in Commands)
{
var noPrefixCommand = HasAttribute<NoPrefixRequired>(command);
if (!noPrefixCommand && !message.MessageRaw.StartsWith(CommandPrefix)) continue;
foreach (var regex in command.Patterns)
{
var match = regex.Match(messageTrimmed);
if (!match.Success) continue;
_logger.Debug($"Message matches {regex}");
if (!command.WhisperCanInvoke && message.IsWhisper) continue;
using var db = new ApplicationDbContext();
var user = db.Users.FirstOrDefault(u => u.KfId == message.Author.Id);
var user = db.Users.AsNoTracking().FirstOrDefault(u => u.KfId == message.Author.Id);
// This should never happen as brand-new users are created upon join
if (user == null) return;
if (user.Ignored) return;
var continueAfterProcess = HasAttribute<AllowAdditionalMatches>(command);
var kasinoCommand = HasAttribute<KasinoCommand>(command);
var wagerCommand = HasAttribute<WagerCommand>(command);
if (kasinoCommand)
{
var kasinoEnabled = SettingsProvider.GetValueAsync(BuiltIn.Keys.MoneyEnabled).Result.ToBoolean();
if (!kasinoEnabled) return;
}
if (kasinoCommand && Money.IsPermanentlyBannedAsync(user.Id, _cancellationToken).Result)
{
if (message.IsWhisper)
{
_ = _bot.SendWhisperAsync(message.Author.Id, $"@{message.Author.Username}, you've been permanently banned from the kasino. Contact support for more information.");
return;
}
_bot.SendChatMessage($"@{message.Author.Username}, you've been permanently banned from the kasino. Contact support for more information.", true);
return;
}
if (wagerCommand)
{
// GetGamblerEntity will only return null if the user is permanbanned
// and we have a check further up the chain for that hence ignoring the null
var gambler = Money.GetGamblerEntityAsync(user.Id, ct: _cancellationToken).Result;
if (gambler != null)
{
var exclusion = Money.GetActiveExclusionAsync(gambler.Id, ct: _cancellationToken).Result;
if (exclusion != null)
{
if (message.IsWhisper)
{
_ = _bot.SendWhisperAsync(message.Author.Id,
$"@{message.Author.Username}, you're self excluded from the kasino for another {(exclusion.Expires - DateTimeOffset.UtcNow).Humanize(precision: 3)}");
return;
}
_bot.SendChatMessage(
$"@{message.Author.Username}, you're self excluded from the kasino for another {(exclusion.Expires - DateTimeOffset.UtcNow).Humanize(precision: 3)}", true);
return;
}
}
}
if (user.UserRight < command.RequiredRight)
{
_bot.SendChatMessage($"@{message.Author.Username}, you do not have access to use this command. Your rank: {user.UserRight.Humanize()}; Required rank: {command.RequiredRight.Humanize()}", true);
if (message.IsWhisper)
{
_ = _bot.SendWhisperAsync(message.Author.Id,
$"@{message.Author.Username}, you do not have access to use this command. Your rank: {user.UserRight.Humanize()}; Required rank: {command.RequiredRight.Humanize()}");
}
else
{
_bot.SendChatMessage($"@{message.Author.Username}, you do not have access to use this command. Your rank: {user.UserRight.Humanize()}; Required rank: {command.RequiredRight.Humanize()}", true);
}
if (continueAfterProcess) continue;
break;
}
if (command.RateLimitOptions != null)
{
var isRateLimited = RateLimitService.IsRateLimited(user, command, message.MessageRawHtmlDecoded);
if (isRateLimited.IsRateLimited)
{
_ = SendCooldownResponse(user, command.RateLimitOptions, isRateLimited.OldestEntryExpires!.Value, command.GetType().Name);
break;
}
RateLimitService.AddEntry(user, command, message.MessageRawHtmlDecoded);
}
_ = ProcessMessageAsync(command, message, user, match.Groups);
if (!continueAfterProcess) break;
}
}
}
private async Task ProcessMessageAsync(ICommand command, MessageModel message, UserDbModel user, GroupCollection arguments)
private async Task ProcessMessageAsync(ICommand command, BotCommandMessageModel message, UserDbModel user, GroupCollection arguments)
{
var task = Task.Run(() => command.RunCommand(_bot, message, user, arguments, _cancellationToken), _cancellationToken);
var cts = new CancellationTokenSource(command.Timeout);
var task = Task.Run(() => command.RunCommand(_bot, message, user, arguments, cts.Token), cts.Token);
try
{
await task.WaitAsync(command.Timeout, _cancellationToken);
await task.WaitAsync(command.Timeout, cts.Token);
}
catch (OperationCanceledException e)
{
_logger.Error($"{command.GetType().Name} invoked by {user.KfUsername} timed out");
_logger.Error(e);
await _bot.SendChatMessageAsync(
$"{user.FormatUsername()}, {command.GetType().Name} failed due to a timeout :(", true,
autoDeleteAfter: TimeSpan.FromSeconds(10));
return;
}
catch (Exception e)
{
_logger.Error("Caught an exception while waiting for the command to complete");
_logger.Error($"{command.GetType().Name} invoked by {user.KfUsername} failed");
_logger.Error(e);
await _bot.SendChatMessageAsync(
$"{user.FormatUsername()}, {command.GetType().Name} failed due to a retarded error :(", true,
autoDeleteAfter: TimeSpan.FromSeconds(10));
return;
}
if (task.IsFaulted)
{
_logger.Error("Command task failed");
_logger.Error($"{command.GetType().Name} invoked by {user.KfUsername} faulted");
_logger.Error(task.Exception);
await _bot.SendChatMessageAsync(
$"{user.FormatUsername()}, {command.GetType().Name} failed due to shitty coding :(", true,
autoDeleteAfter: TimeSpan.FromSeconds(10));
return;
}
try
{
if (!(await SettingsProvider.GetValueAsync(BuiltIn.Keys.MoneyEnabled)).ToBoolean()) return;
_logger.Debug("Money is enabled. Calculating VIP maybe?");
var wagerCommand = HasAttribute<WagerCommand>(command);
if (!wagerCommand) return;
_logger.Debug("It's a wager command");
var gambler = await Money.GetGamblerEntityAsync(user.Id, ct: _cancellationToken);
if (gambler == null) return;
_logger.Debug($"Gambler ID is {gambler.Id}");
if (gambler.TotalWagered < gambler.NextVipLevelWagerRequirement) return;
_logger.Debug("They've met the wager requirement");
// The reason for doing this instead of passing in TotalWagered is that otherwise VIP levels might
// get skipped if the user is a low VIP level but wagering very large amounts
var newLevel = Money.GetNextVipLevel(gambler.NextVipLevelWagerRequirement);
if (newLevel == null)
{
_logger.Info("newLevel is null");
return;
}
_logger.Info($"New level is {newLevel.VipLevel.Name} {newLevel.Tier}");
var payout = await Money.UpgradeVipLevelAsync(gambler.Id, newLevel, _cancellationToken);
_logger.Info($"Payout is {payout:N2}");
await _bot.SendChatMessageAsync(
$"🤑🤑 {user.FormatUsername()} has leveled up to to {newLevel.VipLevel.Icon} {newLevel.VipLevel.Name} Tier {newLevel.Tier} " +
$"and received a bonus of {await payout.FormatKasinoCurrencyAsync()}", true, autoDeleteAfter: TimeSpan.FromSeconds(30));
_logger.Info("Sent notification");
}
catch (Exception e)
{
_logger.Error(e);
}
}
private async Task SendCooldownResponse(UserDbModel user, RateLimitOptionsModel options, DateTimeOffset oldestEntryExpires, string commandName)
{
if (options.Flags.HasFlag(RateLimitFlags.NoResponse))
{
_logger.Info("No response flag set. Ignoring");
return;
}
_logger.Info($"Oldest entry: {oldestEntryExpires:o}");
var timeRemaining = oldestEntryExpires - DateTimeOffset.UtcNow;
TimeSpan? autoDeleteAfter = null;
if (!options.Flags.HasFlag(RateLimitFlags.NoAutoDeleteCooldownResponse))
{
autoDeleteAfter = TimeSpan.FromMilliseconds(
(await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotRateLimitCooldownAutoDeleteDelay)).ToType<int>());
}
await _bot.SendChatMessageAsync(
$"{user.FormatUsername()}, please wait {timeRemaining.Humanize(maxUnit: TimeUnit.Minute, minUnit: TimeUnit.Millisecond, precision: 2)} before attempting to run {commandName} again.",
true, autoDeleteAfter: autoDeleteAfter);
}
private async Task CleanupExpiredRateLimitEntriesTask()
{
while (!_cancellationToken.IsCancellationRequested)
{
var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.BotRateLimitExpiredEntryCleanupInterval))
.ToType<int>();
await Task.Delay(TimeSpan.FromSeconds(interval), _cancellationToken);
_logger.Info("Cleaning up expired rate limit entries");
RateLimitService.CleanupExpiredEntries();
}
}
@@ -105,4 +253,26 @@ internal class BotCommands
/// Keep in mind since commands are executed in a throwaway task and not awaited, they will run concurrently
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
internal class AllowAdditionalMatches : Attribute;
internal class AllowAdditionalMatches : Attribute;
/// <summary>
/// Use this on commands where a wager is taking place.
/// This will cause the bot to check total wagered and see if the gambler has leveled up.
/// It'll also check whether the gambler is currently temp excluded before running the command.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
internal class WagerCommand : Attribute;
/// <summary>
/// Use this on all commands that interact with the gambling / monetary system
/// When used, this will check if the system is globally enabled before running the command.
/// It'll also check whether the user is permanently banned before running the command.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
internal class KasinoCommand : Attribute;
/// <summary>
/// Use this on commands where the Regex should be tested even if there's no command prefix
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
internal class NoPrefixRequired : Attribute;

Some files were not shown because too many files have changed in this diff Show More