Compare commits
551 Commits
old
...
7981f57a34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7981f57a34 | ||
|
|
e725ca5864 | ||
|
|
1778d0d573 | ||
|
|
9acc1172cc | ||
|
|
3ab46bb4c7 | ||
|
|
b4cd21da41 | ||
|
|
3d269716e8 | ||
|
|
1a49fe1976 | ||
|
|
2bce346967 | ||
|
|
39be005d38 | ||
|
|
1abe5974a7 | ||
|
|
1461c1043a | ||
|
|
2c55e94bfd | ||
|
|
7a58976c16 | ||
|
|
d4d499aebd | ||
|
|
9e921d5ff9 | ||
|
|
354b1cfd99 | ||
|
|
64e318ce84 | ||
|
|
f415409a88 | ||
|
|
95707f58ee | ||
|
|
7e131ff1a4 | ||
|
|
b86986f03e | ||
|
|
5e85566577 | ||
|
|
26e0b1f49f | ||
|
|
d97d4f7fad | ||
|
|
4b66658d47 | ||
|
|
f13717fbd0 | ||
|
|
df6db90822 | ||
|
|
b40fdf1a7c | ||
|
|
6c29899454 | ||
|
|
b33311c37b | ||
|
|
6240f7c7f1 | ||
|
|
01a4b26326 | ||
|
|
4cdb04e3c5 | ||
|
|
606e7867d0 | ||
|
|
a6810591de | ||
|
|
377603ca35 | ||
|
|
af7b5e027b | ||
|
|
75addfb185 | ||
|
|
0454a5bbdd | ||
|
|
986027f5c5 | ||
|
|
b4796bbef6 | ||
|
|
c065bf513b | ||
|
|
9f9bdee61d | ||
|
|
945fac3c50 | ||
|
|
67b0a26163 | ||
|
|
829443283f | ||
|
|
8daaf3c304 | ||
|
|
586a89a4cd | ||
|
|
fda98403ae | ||
|
|
b384845b54 | ||
|
|
f792cf4712 | ||
|
|
5058681022 | ||
|
|
d01fbe6ce3 | ||
|
|
c8dcf8e884 | ||
|
|
469e24dde1 | ||
|
|
11c09ea65c | ||
|
|
5fce555007 | ||
|
|
7b1e33da78 | ||
|
|
64b2ce4a8e | ||
|
|
545c880dba | ||
|
|
896477787d | ||
|
|
98369e3d92 | ||
|
|
75774bb62f | ||
|
|
a94a9a11a8 | ||
|
|
251703a427 | ||
|
|
c05d855edd | ||
|
|
ce0efead0b | ||
|
|
28881143be | ||
|
|
abcaa48be4 | ||
|
|
79cf0b9fdf | ||
|
|
6635ebacd0 | ||
|
|
0f7e75ec91 | ||
|
|
7770dc99ed | ||
|
|
82a69f48dd | ||
|
|
c8016b4fc6 | ||
|
|
8a827a17de | ||
|
|
eae5a18d11 | ||
|
|
72e5115548 | ||
|
|
1337db31b3 | ||
|
|
6d4d461aa7 | ||
|
|
7779189cee | ||
|
|
81a6f0fdd5 | ||
|
|
4962472312 | ||
|
|
d3f7d5e374 | ||
|
|
0305f2a35c | ||
|
|
6b6bfe2699 | ||
|
|
66f2af5f52 | ||
|
|
1c2b36b8b2 | ||
|
|
6967a81d73 | ||
|
|
f1ab9cfcdd | ||
|
|
ec11dff3bd | ||
|
|
366311a20a | ||
|
|
60f74894ca | ||
|
|
cda5aca788 | ||
|
|
78e1494a19 | ||
|
|
2c7e2adf48 | ||
|
|
e5f98fe24c | ||
|
|
e4815a2290 | ||
|
|
f1afce7fab | ||
|
|
0dcbb25fe3 | ||
|
|
75e958cd2a | ||
|
|
bc114e9f64 | ||
|
|
30d9f48d2e | ||
|
|
f701cae171 | ||
|
|
dec3a9473a | ||
|
|
9183c45105 | ||
|
|
f0c1e77e5f | ||
|
|
1ce3f0e8e5 | ||
|
|
b43ce3f95c | ||
|
|
d1e95b07d4 | ||
|
|
75630e4053 | ||
|
|
6e2fd0bc35 | ||
|
|
cbf5b628c3 | ||
|
|
384b2ab3ef | ||
|
|
bdb882795f | ||
|
|
259d5c339b | ||
|
|
34b3c5a671 | ||
|
|
3d99cce5fb | ||
|
|
c4995f55f2 | ||
|
|
4441fa178c | ||
|
|
6747389237 | ||
|
|
d71dd304fd | ||
|
|
26d1da3069 | ||
|
|
21c8803eb9 | ||
|
|
b6df015277 | ||
|
|
e1b5970e8b | ||
|
|
7fbebe81ab | ||
|
|
9643126cf8 | ||
|
|
a272e155bd | ||
|
|
20a267c702 | ||
|
|
3385722455 | ||
|
|
e96620381f | ||
|
|
4c8cbc1748 | ||
|
|
1e44dbe6c1 | ||
|
|
d2cc3f04ad | ||
|
|
9334cac344 | ||
|
|
d8a8b7341a | ||
|
|
24e864f8f5 | ||
|
|
2d255198ea | ||
|
|
d0cabbf759 | ||
|
|
e7c309582a | ||
|
|
cdd309fa24 | ||
|
|
d5f04b5228 | ||
|
|
1901507c25 | ||
|
|
2fb8f0bb89 | ||
|
|
670336145d | ||
|
|
6a47d0d25e | ||
|
|
eccbe44acd | ||
|
|
8246b75868 | ||
|
|
54d989f64f | ||
|
|
6c6ed8d09e | ||
|
|
0c61206e08 | ||
|
|
15f68ee99b | ||
|
|
3ec623f6a4 | ||
|
|
0272d79ee1 | ||
|
|
503d0de41b | ||
|
|
dd469c36b3 | ||
|
|
daba3012a4 | ||
|
|
57e1f7eb04 | ||
|
|
7179cf72ce | ||
|
|
a64d4456ab | ||
|
|
4072709ec6 | ||
|
|
12d184ebac | ||
|
|
d726b4f638 | ||
|
|
1890e3606b | ||
|
|
28cc6a2651 | ||
|
|
bf9d3268cd | ||
|
|
696339f359 | ||
|
|
57e1b9c3b9 | ||
|
|
477c121f72 | ||
|
|
42804c90e4 | ||
|
|
32ae015c3b | ||
|
|
80d4f81610 | ||
|
|
b579789860 | ||
|
|
1996b2b638 | ||
|
|
21f2019366 | ||
|
|
4dba9b4133 | ||
|
|
6ba82ff213 | ||
|
|
cac30a24a2 | ||
|
|
18b19ffcef | ||
|
|
2bb56c2388 | ||
|
|
de859e8fad | ||
|
|
ca2e2c7874 | ||
|
|
6209a76e94 | ||
|
|
305082e17f | ||
|
|
96f17c14cf | ||
|
|
051a663c4e | ||
|
|
a4ad3f4b45 | ||
|
|
65b7b19b8a | ||
|
|
9a7762a933 | ||
|
|
2179d59edd | ||
|
|
2709b3054c | ||
|
|
981700e889 | ||
|
|
28a4e71c58 | ||
|
|
b95c27d928 | ||
|
|
071136f910 | ||
|
|
60be8d45d6 | ||
|
|
17ce32a69c | ||
|
|
8be1ec0f41 | ||
|
|
9bee1188e5 | ||
|
|
eb4bb8dc47 | ||
|
|
ee9ae62e39 | ||
|
|
29f2863c9a | ||
|
|
3f4c3e2713 | ||
|
|
295fef20fb | ||
|
|
b873195e79 | ||
|
|
8f0ada8c78 | ||
|
|
128726d5a9 | ||
|
|
cf45a14eff | ||
|
|
56817cf471 | ||
|
|
68d0984b77 | ||
|
|
61e47ad591 | ||
|
|
31023bc960 | ||
|
|
79a1b7a224 | ||
|
|
fa9cbff738 | ||
|
|
7489c7c46a | ||
|
|
d351dc580c | ||
|
|
e4f8085350 | ||
|
|
f14d9281a9 | ||
|
|
2570523c3e | ||
|
|
2e767f00ab | ||
|
|
334a8795e3 | ||
|
|
4a5a573941 | ||
|
|
50fee7c984 | ||
|
|
82da292cd8 | ||
|
|
6cdb7b6702 | ||
|
|
21fd54f83e | ||
|
|
73f933db4a | ||
|
|
d6fe18638a | ||
|
|
fe2c57f5c1 | ||
|
|
6d8caf6430 | ||
|
|
143f282647 | ||
|
|
2a77e760a1 | ||
|
|
6f6359b6da | ||
|
|
bdc84f6476 | ||
|
|
e0d388b2f0 | ||
|
|
7e3ba4e641 | ||
|
|
47771a0f4c | ||
|
|
be669bf951 | ||
|
|
318241a58c | ||
|
|
b7f570beef | ||
|
|
e581cab142 | ||
|
|
00e556d0bb | ||
|
|
2ade21225e | ||
|
|
f0fe22ab12 | ||
|
|
004a3d42b2 | ||
|
|
eb6ec6a628 | ||
|
|
2664e5e0df | ||
|
|
78207b291b | ||
|
|
78d5ba9f40 | ||
|
|
3992ff3119 | ||
|
|
a288f3f4eb | ||
|
|
1c8a2658ca | ||
|
|
9077e629be | ||
|
|
19571d54e7 | ||
|
|
e952179663 | ||
|
|
6a79063b18 | ||
|
|
4ccb6b7865 | ||
|
|
b0473d68ab | ||
|
|
fcd057e980 | ||
|
|
e183414836 | ||
|
|
9bb9ca63a7 | ||
|
|
289d2c91a3 | ||
|
|
df869c6e82 | ||
|
|
77dad18e92 | ||
|
|
9a416eab1c | ||
|
|
5c186f13b1 | ||
|
|
70c4daf750 | ||
|
|
1c1734922e | ||
|
|
6e32ab90dc | ||
|
|
8342a1e63a | ||
|
|
c9e3f91707 | ||
|
|
56249cdbc0 | ||
|
|
1de4d3b475 | ||
|
|
fdd60a86fb | ||
|
|
10fd0a290b | ||
|
|
518a26f9d1 | ||
|
|
943132ac62 | ||
|
|
d2f06d30ed | ||
|
|
a61d930c88 | ||
|
|
6a818aada6 | ||
|
|
69ac495824 | ||
|
|
fe1ab566d1 | ||
|
|
0301a6c2d3 | ||
|
|
4c54e656b4 | ||
|
|
e602391bd7 | ||
|
|
4b58cc9eae | ||
|
|
7aefa17f47 | ||
|
|
6b86b3ae6c | ||
|
|
5212f7cd76 | ||
|
|
8503636a29 | ||
|
|
a88045c63d | ||
|
|
72a162b67a | ||
|
|
71e534a396 | ||
|
|
d83b357ec3 | ||
|
|
9ee114c466 | ||
|
|
1463d991c1 | ||
|
|
9583313316 | ||
|
|
85a5eb4dfd | ||
|
|
061cbaea9e | ||
|
|
956bfd9b54 | ||
|
|
711ce75a8b | ||
|
|
26d2305a6c | ||
|
|
ffb183f57b | ||
|
|
1392502712 | ||
|
|
db3f09c32f | ||
|
|
052918fd28 | ||
|
|
4671bb3d25 | ||
|
|
5af2015d46 | ||
|
|
91878d92b5 | ||
|
|
0c08c19e90 | ||
|
|
f385ff35c6 | ||
|
|
5f709464b2 | ||
|
|
312f93494d | ||
|
|
cfdac05185 | ||
|
|
df5df27be0 | ||
|
|
e86a4d69be | ||
|
|
a2ed1724e2 | ||
|
|
bdeb2acdf8 | ||
|
|
348ae7b9cb | ||
|
|
3c70fea2ba | ||
|
|
24db30b789 | ||
|
|
4bf9308fa7 | ||
|
|
e99434f5df | ||
|
|
e2ae5c20c2 | ||
|
|
60e0c76b72 | ||
|
|
ccf26d24a2 | ||
|
|
3ca9e1278b | ||
|
|
ee40d14fa6 | ||
|
|
7a625f218f | ||
|
|
8904c4eb81 | ||
|
|
02336ebd32 | ||
|
|
36ad9a23b4 | ||
|
|
d53d2f1def | ||
|
|
2699dcf9ca | ||
|
|
3a8df5c76b | ||
|
|
e770136ca9 | ||
|
|
c04763079a | ||
|
|
2507a8cc7d | ||
|
|
32afdc5354 | ||
|
|
3e1a6632f7 | ||
|
|
3d00b4a708 | ||
|
|
819b278b0e | ||
|
|
8e78d626de | ||
|
|
c6fff46310 | ||
|
|
38177a9051 | ||
|
|
02f94128d8 | ||
|
|
92d2770f98 | ||
|
|
99f5421736 | ||
|
|
c990247bb6 | ||
|
|
a8853aef1c | ||
|
|
c05a9d9d15 | ||
|
|
d863c5666d | ||
|
|
7e1a88b6a3 | ||
|
|
4ed42ba41d | ||
|
|
f0cec04781 | ||
|
|
ee9e62f715 | ||
|
|
5197197a39 | ||
|
|
08e46722de | ||
|
|
232e22d332 | ||
|
|
0474477e44 | ||
|
|
f3020c57c5 | ||
|
|
ae44335f0b | ||
|
|
cf688bd61b | ||
|
|
9d36f58a71 | ||
|
|
15364fc339 | ||
|
|
2bd7b0d94a | ||
|
|
f8acba110d | ||
|
|
f65decc560 | ||
|
|
c6560c4e34 | ||
|
|
92215f8cca | ||
|
|
ebadb76204 | ||
|
|
30f7479ad5 | ||
|
|
0971b444fc | ||
|
|
22bf9c74d6 | ||
|
|
889a197b47 | ||
|
|
40d9b00322 | ||
|
|
662a8387f1 | ||
|
|
536d78415c | ||
|
|
26e0413afa | ||
|
|
4c4567d4a9 | ||
|
|
2f47d1b3c0 | ||
|
|
9595409d64 | ||
|
|
c42c77a270 | ||
|
|
9f3da68b85 | ||
|
|
15b5ef250b | ||
|
|
3126442941 | ||
|
|
ed2a110305 | ||
|
|
b1ec4e9b4d | ||
|
|
280220cd1d | ||
|
|
518d001d82 | ||
|
|
aedcf0a4b6 | ||
|
|
1503593cb1 | ||
|
|
5286e8a2b8 | ||
|
|
6177f22ed5 | ||
|
|
a1d98e54cb | ||
|
|
257e218d3d | ||
|
|
fcd82f552b | ||
|
|
56ef81ab73 | ||
|
|
13e14f913d | ||
|
|
777ff73ae5 | ||
|
|
fbd314e806 | ||
|
|
e25c96859f | ||
|
|
2457a042f3 | ||
|
|
09b6bcb063 | ||
|
|
abae8447cb | ||
|
|
c2a7312f12 | ||
|
|
45e297f8bb | ||
|
|
34f62093b5 | ||
|
|
da3fb4a48f | ||
|
|
fced66c428 | ||
|
|
dd4bc1abd1 | ||
|
|
5aba49697e | ||
|
|
d6bac6706d | ||
|
|
bf18fe1de6 | ||
|
|
a781ed2c3d | ||
|
|
f4f8c332b1 | ||
|
|
f78f0b243b | ||
|
|
605190d325 | ||
|
|
a396a1dcff | ||
|
|
9524beb95b | ||
|
|
7fbd99e472 | ||
|
|
d606a9b8f5 | ||
|
|
23568a85c6 | ||
|
|
c6658bae1f | ||
|
|
494b118969 | ||
|
|
3b5f9f0edd | ||
|
|
2ba0bd853b | ||
|
|
f189cb94b8 | ||
|
|
cb7337375d | ||
|
|
115506ba42 | ||
|
|
0e6bed23b3 | ||
|
|
d37401e1cd | ||
|
|
f3781f9c18 | ||
|
|
bca4cf4f3d | ||
|
|
746a33120d | ||
|
|
b33eb5c4a8 | ||
|
|
ff5484c0c9 | ||
|
|
a92d1dc3c1 | ||
|
|
a8f43aac9d | ||
|
|
9692ae8c1d | ||
|
|
69ea0b6b0b | ||
|
|
2a0f74ab18 | ||
|
|
d3e62476d2 | ||
|
|
54fbc1a39e | ||
|
|
cd3b76745c | ||
|
|
40a452b8b7 | ||
|
|
0432d5360a | ||
|
|
5b71c0a1bb | ||
|
|
146abbe885 | ||
|
|
8fca8829f6 | ||
|
|
7356018805 | ||
|
|
933e4c70f6 | ||
|
|
588a0e95fa | ||
|
|
15de60e60b | ||
|
|
d76f427621 | ||
|
|
cbebdb2144 | ||
|
|
2b243bea57 | ||
|
|
07949e1a7d | ||
|
|
2067267027 | ||
|
|
74be702473 | ||
|
|
77f1321b00 | ||
|
|
9d0ee6e091 | ||
|
|
958286a1ea | ||
|
|
13294b4d07 | ||
|
|
2547ea45fb | ||
|
|
f547638b45 | ||
|
|
350e1cf6c6 | ||
|
|
d7e6290d46 | ||
|
|
b3c3734e22 | ||
|
|
aec236f92a | ||
|
|
820eec7d0c | ||
|
|
ff1d83d9f7 | ||
|
|
f9445d407a | ||
|
|
689b7b1cb8 | ||
|
|
43b0b2bb25 | ||
|
|
45a8a1ba86 | ||
|
|
b26807c298 | ||
|
|
a7739278c7 | ||
|
|
848214e90f | ||
|
|
f2daa85c9c | ||
|
|
c82aeaa7d4 | ||
|
|
23611926ab | ||
|
|
1a77b37491 | ||
|
|
9ca4e03058 | ||
|
|
23be73d524 | ||
|
|
2b07a07ac5 | ||
|
|
bbfdf1e9f4 | ||
|
|
624d4dcc41 | ||
|
|
8ae98322a2 | ||
|
|
4c8a7d5dbb | ||
|
|
69386fce61 | ||
|
|
bd89fa74e6 | ||
|
|
af4d6a5de6 | ||
|
|
34e4762ad4 | ||
|
|
78fcac212d | ||
|
|
155c9c2d36 | ||
|
|
00e09d7e7d | ||
|
|
6ca1cf055c | ||
|
|
8d100b013b | ||
|
|
b2ef7df91b | ||
|
|
7889f50486 | ||
|
|
15abb0fc8b | ||
|
|
3ea031963d | ||
|
|
92ed776e31 | ||
|
|
1b29e85342 | ||
|
|
35c3964854 | ||
|
|
f92cba5b49 | ||
|
|
5d853d5b72 | ||
|
|
57cd989ed6 | ||
|
|
b01ba7c0a4 | ||
|
|
d4c49467e3 | ||
|
|
de4e137a48 | ||
|
|
9462048a29 | ||
|
|
58f101bc61 | ||
|
|
28fd41d511 | ||
|
|
a88449ddab | ||
|
|
12cbb1733f | ||
|
|
0c1c75f729 | ||
|
|
8bcba1755a | ||
|
|
be96be9f85 | ||
|
|
56616d713f | ||
|
|
c134a6808d | ||
|
|
c086ed350a | ||
|
|
172295e07b | ||
|
|
407bd41d71 | ||
|
|
72f2c2633f | ||
|
|
f508a8ebc0 | ||
|
|
cc49ffeb4a | ||
|
|
6a7453a44f | ||
|
|
d22138a9f9 | ||
|
|
7171acacfd | ||
|
|
6ef48c7833 | ||
|
|
6373d317db | ||
|
|
429faabb31 | ||
|
|
2ec9cad2f4 | ||
|
|
451b7e625e | ||
|
|
5f189cb9cc | ||
|
|
bcc3bde6c9 | ||
|
|
2088c4d102 | ||
|
|
88fef4466d | ||
|
|
9b677ea23d | ||
|
|
c790b3f9ae | ||
|
|
c7c80bd6e4 | ||
|
|
26aa6f1507 | ||
|
|
145aebbc5a | ||
|
|
22ec1043ff | ||
|
|
2dedf1118c | ||
|
|
cf31cdd796 | ||
|
|
3ce03fa1e7 |
@@ -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
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,12 @@ 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; }
|
||||
}
|
||||
BIN
KfChatDotNetBot/Assets/Default/A.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
KfChatDotNetBot/Assets/Default/B.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
KfChatDotNetBot/Assets/Default/C.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
KfChatDotNetBot/Assets/Default/D.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
KfChatDotNetBot/Assets/Default/E.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
KfChatDotNetBot/Assets/Default/F.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
KfChatDotNetBot/Assets/Default/G.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
KfChatDotNetBot/Assets/Default/H.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
KfChatDotNetBot/Assets/Default/I.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
KfChatDotNetBot/Assets/Default/J.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
KfChatDotNetBot/Assets/Default/K.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
KfChatDotNetBot/Assets/Default/L.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
KfChatDotNetBot/Assets/Default/exp1.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
KfChatDotNetBot/Assets/Default/exp2.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
KfChatDotNetBot/Assets/Default/exp3.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
KfChatDotNetBot/Assets/Default/exp4.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
KfChatDotNetBot/Assets/Default/exp5.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
KfChatDotNetBot/Assets/Default/header.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
KfChatDotNetBot/Assets/bossmancoin-heads-jacky.webp
Normal file
|
After Width: | Height: | Size: 462 KiB |
BIN
KfChatDotNetBot/Assets/bossmancoin-heads.webp
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
KfChatDotNetBot/Assets/bossmancoin-tails-jacky.webp
Normal file
|
After Width: | Height: | Size: 450 KiB |
BIN
KfChatDotNetBot/Assets/bossmancoin-tails.webp
Normal file
|
After Width: | Height: | Size: 109 KiB |
@@ -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,20 +56,28 @@ 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())
|
||||
{
|
||||
@@ -317,24 +562,125 @@ public class ChatBot
|
||||
_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;
|
||||
}
|
||||
|
||||
public SentMessageTrackerModel SendChatMessage(string message, bool bypassSeshDetect = false,
|
||||
LengthLimitBehavior lengthLimitBehavior = LengthLimitBehavior.TruncateNicely, int lengthLimit = 1023)
|
||||
// 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)
|
||||
{
|
||||
return SendChatMessageAsync(message, bypassSeshDetect, lengthLimitBehavior, lengthLimit).Result;
|
||||
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");
|
||||
SentMessages.Add(messageTracker);
|
||||
await KfClient.SendMessageInstantAsync(messageTracker.Message);
|
||||
return messageTracker;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
_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,6 +699,31 @@ 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)
|
||||
@@ -360,6 +731,7 @@ public class ChatBot
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -417,3 +543,60 @@ public class StartAlmanacCommand : ICommand
|
||||
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);
|
||||
}
|
||||
}
|
||||
122
KfChatDotNetBot/Commands/EightballCommand.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
809
KfChatDotNetBot/Commands/Kasino/BlackjackCommand.cs
Normal 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);
|
||||
153
KfChatDotNetBot/Commands/Kasino/CoinflipCommand.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
163
KfChatDotNetBot/Commands/Kasino/DiceCommand.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
108
KfChatDotNetBot/Commands/Kasino/GuessWhatNumberCommand.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
213
KfChatDotNetBot/Commands/Kasino/KasinoAdminCommands.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
373
KfChatDotNetBot/Commands/Kasino/KasinoEventCommands.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
488
KfChatDotNetBot/Commands/Kasino/KasinoUserCommands.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
326
KfChatDotNetBot/Commands/Kasino/KenoCommand.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
107
KfChatDotNetBot/Commands/Kasino/KrashCommand.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
459
KfChatDotNetBot/Commands/Kasino/LambchopCommand.cs
Normal 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];
|
||||
}
|
||||
|
||||
}
|
||||
188
KfChatDotNetBot/Commands/Kasino/LegitCheckCommand.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
192
KfChatDotNetBot/Commands/Kasino/LimboCommand.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
309
KfChatDotNetBot/Commands/Kasino/MinesCommand.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
541
KfChatDotNetBot/Commands/Kasino/PlanesCommand.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
337
KfChatDotNetBot/Commands/Kasino/PlinkoCommand.cs
Normal 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++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
164
KfChatDotNetBot/Commands/Kasino/RainCommand.cs
Normal 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
|
||||
}
|
||||
}
|
||||
1040
KfChatDotNetBot/Commands/Kasino/RouletteCommand.cs
Normal file
729
KfChatDotNetBot/Commands/Kasino/SlotsCommand.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
287
KfChatDotNetBot/Commands/Kasino/WheelCommand.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
@@ -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 SKIN’S 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"))
|
||||
@@ -272,3 +312,21 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -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!",
|
||||
|
||||
274
KfChatDotNetBot/Commands/NoraCommand.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
await botInstance.SendChatMessageAsync($"@{user.KfUsername} is a weirdo who streams. Come check out his channel at https://kick.com/{channel.ChannelSlug}", true);
|
||||
var streamList = streams.Aggregate(string.Empty, (current, stream) => current + $"[br]- {stream.StreamUrl}");
|
||||
|
||||
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);
|
||||
|
||||
1368
KfChatDotNetBot/Commands/ShopCommands.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
375
KfChatDotNetBot/Commands/XeetEmbedCommand.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
51
KfChatDotNetBot/Extensions/MoneyExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
396
KfChatDotNetBot/Migrations/20250720061845_Streams.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
48
KfChatDotNetBot/Migrations/20250720061845_Streams.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
633
KfChatDotNetBot/Migrations/20250820195308_Money.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
194
KfChatDotNetBot/Migrations/20250820195308_Money.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
164
KfChatDotNetBot/Models/BlackjackGameMetaModel.cs
Normal 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()));
|
||||
}
|
||||
}
|
||||
44
KfChatDotNetBot/Models/BotCommandMessageModel.cs
Normal 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; }
|
||||
}
|
||||
@@ -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
|
||||
@@ -13,3 +17,24 @@ public class CourtHearingModel
|
||||
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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
8
KfChatDotNetBot/Models/DLiveModels.cs
Normal 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; }
|
||||
}
|
||||
305
KfChatDotNetBot/Models/DbModels/KasinoShopDbModels.cs
Normal 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
|
||||
}
|
||||
343
KfChatDotNetBot/Models/DbModels/MoneyDbModels.cs
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
67
KfChatDotNetBot/Models/DbModels/StreamDbModel.cs
Normal 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; }
|
||||
}
|
||||
96
KfChatDotNetBot/Models/FxTwitterModels.cs
Normal 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; }
|
||||
}
|
||||
9
KfChatDotNetBot/Models/KasinoShopModels.cs
Normal 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;
|
||||
}
|
||||
40
KfChatDotNetBot/Models/MoneyMetaModels.cs
Normal 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; }
|
||||
}
|
||||
146
KfChatDotNetBot/Models/MoneyModels.cs
Normal 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
|
||||
}
|
||||
34
KfChatDotNetBot/Models/PartiModels.cs
Normal 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; }
|
||||
|
||||
}
|
||||
29
KfChatDotNetBot/Models/PeerTubeModels.cs
Normal 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; }
|
||||
}
|
||||
87
KfChatDotNetBot/Models/RateLimitModels.cs
Normal 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
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
public class SeenMessageMetadataModel
|
||||
{
|
||||
public int MessageId { get; set; }
|
||||
public required string MessageUuid { get; set; }
|
||||
public DateTimeOffset? LastEdited { get; set; }
|
||||
}
|
||||
@@ -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
|
||||
@@ -25,3 +38,9 @@ public enum SentMessageTrackerStatus
|
||||
// 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
|
||||
}
|
||||
39
KfChatDotNetBot/Models/WinnaModels.cs
Normal 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; }
|
||||
}
|
||||
41
KfChatDotNetBot/Models/YouTubeApiModels.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -17,9 +21,9 @@ internal class BotCommands
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,3 +254,25 @@ internal class BotCommands
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
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;
|
||||