Compare commits

...
Sign in to create a new pull request.

43 commits
v6 ... v6

Author SHA1 Message Date
68e736ceb8
.fishlb will now use totalcatches as a tiebreaker 2025-04-01 13:26:17 +13:00
d3c90ab59f
fishlb will now compare unique fish caught, instead of total catches 2025-04-01 13:24:46 +13:00
4a19d91a4f
fixed and improved .nc commands 2025-03-30 19:07:09 +13:00
53af8f940d
fixed bank quest
.nc will now show instructions
2025-03-30 18:38:31 +13:00
6891869046
Fixes, quests are slightly more difficult 2025-03-30 17:37:34 +13:00
aba5c4fbfd
simplified quota system 2025-03-30 16:16:57 +13:00
7d8f61ecea
updated changelog and version 2025-03-30 15:47:15 +13:00
524c97d9bf
fixed quests completion, removed logs 2025-03-30 15:43:43 +13:00
c058d180ae
added prices to .nczoom
.nc resized to 250x125
2025-03-30 15:38:40 +13:00
57a5993064
color typereader fix
delmsgoncmd
guildconfig init
2025-03-30 15:06:56 +13:00
aa06f62258
added .fishlb
improve vote auth
added discord and dbl stat reporting
updated check lb quest to require fishlb too
2025-03-30 13:48:14 +13:00
07df2ed450
vote has no cooldown, fixed some responses, improved voting logic 2025-03-29 21:20:58 +13:00
aaf8522987
fixed voting reward, message, logging 2025-03-29 21:15:27 +13:00
d414ecda2d
configurable vote endpoints 2025-03-29 21:05:43 +13:00
99a8030898
add env vars 2025-03-29 20:55:11 +13:00
28f21bae12
allow origins in votes 2025-03-29 20:53:46 +13:00
177257da12
fix votesapi docker 2025-03-29 20:51:14 +13:00
051daebada
voteapi fixes 2025-03-29 20:49:00 +13:00
d96039d20c
These were meant to be pushed in 0a1797700c but git had other ideas 2025-03-29 20:43:32 +13:00
0a1797700c
.fishop and .finv
You can list items in `.fishop`
Buy with `.fibuy`
See your inventory with `.finv`
Equip with `.fiuse`
Items are defined in items: array at the bottom of fish.yml
Items will show up in your .fili and bonuses will show up when you do .fish
The migrations for quests were meant to be sorted in 4c2b42ab7f but it kind of decided to be very stupid.
2025-03-29 20:33:25 +13:00
e4afa1e385
hangman looks better 2025-03-29 01:18:53 +13:00
69f45cd592
fixed hangman look 2025-03-28 21:21:31 +13:00
ea1c8c56e3
fixed fishconfig reverting captcha every time 2025-03-28 21:20:20 +13:00
4c2b42ab7f
added a .questlog and a quest system 2025-03-28 21:13:53 +13:00
97fe14cf5a
added owner-only .massping for emergencies 2025-03-26 12:17:53 +13:00
b1d28296f0
voting largely re-added. Votesapi will instantly send grpc requests to the bot in order to award the user instantly
vote and timely now a better breakdown of what affects rewards
2025-03-26 12:07:52 +13:00
9fe75d930f
re-adding/reworking voting capabilities 2025-03-25 14:58:02 +13:00
69a02e0e15
.notify #channel nicecatch <message> fully implemented now
notify fixes and improvements
2025-03-24 14:06:49 +13:00
8d08595a9f
.notify has nicecatch event now, not yet implemented 2025-03-24 13:17:34 +13:00
1cbaaed944
.say will no longer reply 2025-03-24 13:13:06 +13:00
a583c7d763
fixed .antispamignore, again 2025-03-24 13:10:06 +13:00
7246c982df
Updated changelog 2025-03-23 15:54:24 +13:00
d26efb3c8c
fixed .shop commands 2025-03-23 15:53:06 +13:00
16025b74e3
added .rolecolor command
updated changelog
2025-03-23 15:48:45 +13:00
55e3a80405
added .linkfix <old> <new> - bot will automatically reply to any messages containing <old> domain with a new one 2025-03-23 15:25:31 +13:00
4d3bdc2481
added mute reason in the logs 2025-03-23 14:27:53 +13:00
a1bf03ad40
added captcha option for .fish in fish.yml 2025-03-23 14:25:41 +13:00
2d3c7de8e7
added captcha option for .fish 2025-03-23 14:00:03 +13:00
9044a04c87
added .editorconfig 2025-03-23 13:58:50 +13:00
b3d2785cec
grpc xpshop api 2025-03-22 12:10:17 +13:00
2f740e96b8
removed global xp leaderbaord as it is no longer used 2025-03-21 19:47:39 +13:00
ae8a63eeac
fix build 2025-03-21 19:44:13 +13:00
4549b1f4e4
.lcha will now let you override an existing channel template even if you're at the limit (which is 1 right now, will be upped very soon) 2025-03-21 19:43:00 +13:00
145 changed files with 6202 additions and 2409 deletions
.editorconfig.gitignoreCHANGELOG.md
src
Ellie.Marmalade
EllieBot.GrpcApiBase/protos
EllieBot.VotesApi
EllieBot
Db
EllieBot.csprojEllieBot.csproj.DotSettings
Migrations
Modules

208
.editorconfig Normal file
View file

@ -0,0 +1,208 @@
[*]
charset = utf-8-bom
end_of_line = crlf
trim_trailing_whitespace = false
insert_final_newline = false
indent_style = space
indent_size = 4
# Microsoft .NET properties
csharp_new_line_before_members_in_object_initializers = false
csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
dotnet_naming_rule.constants_rule.import_to_resharper = True
dotnet_naming_rule.constants_rule.resharper_description = Constant fields (not private)
dotnet_naming_rule.constants_rule.resharper_guid = 669e5282-fb4b-4e90-91e7-07d269d04b60
dotnet_naming_rule.constants_rule.severity = suggestion
dotnet_naming_rule.constants_rule.style = all_upper_style
dotnet_naming_rule.constants_rule.symbols = constants_symbols
dotnet_naming_rule.interfaces_rule.import_to_resharper = True
dotnet_naming_rule.interfaces_rule.resharper_description = Interfaces
dotnet_naming_rule.interfaces_rule.resharper_guid = a7a3339e-4e89-4319-9735-a9dc4cb74cc7
dotnet_naming_rule.interfaces_rule.severity = suggestion
dotnet_naming_rule.interfaces_rule.style = i_upper_camel_case_style
dotnet_naming_rule.interfaces_rule.symbols = interfaces_symbols
dotnet_naming_rule.local_constants_rule.import_to_resharper = True
dotnet_naming_rule.local_constants_rule.resharper_description = Local constants
dotnet_naming_rule.local_constants_rule.resharper_guid = a4f433b8-abcd-4e55-a08f-82e78cef0f0c
dotnet_naming_rule.local_constants_rule.severity = suggestion
dotnet_naming_rule.local_constants_rule.style = all_upper_style
dotnet_naming_rule.local_constants_rule.symbols = local_constants_symbols
dotnet_naming_rule.private_constants_rule.import_to_resharper = True
dotnet_naming_rule.private_constants_rule.resharper_description = Constant fields (private)
dotnet_naming_rule.private_constants_rule.resharper_guid = 236f7aa5-7b06-43ca-bf2a-9b31bfcff09a
dotnet_naming_rule.private_constants_rule.severity = suggestion
dotnet_naming_rule.private_constants_rule.style = all_upper_style
dotnet_naming_rule.private_constants_rule.symbols = private_constants_symbols
dotnet_naming_rule.private_instance_fields_rule.import_to_resharper = True
dotnet_naming_rule.private_instance_fields_rule.resharper_description = Instance fields (private)
dotnet_naming_rule.private_instance_fields_rule.resharper_guid = 4a98fdf6-7d98-4f5a-afeb-ea44ad98c70c
dotnet_naming_rule.private_instance_fields_rule.severity = suggestion
dotnet_naming_rule.private_instance_fields_rule.style = lower_camel_case_style
dotnet_naming_rule.private_instance_fields_rule.symbols = private_instance_fields_symbols
dotnet_naming_rule.private_static_fields_rule.import_to_resharper = True
dotnet_naming_rule.private_static_fields_rule.resharper_description = Static fields (private)
dotnet_naming_rule.private_static_fields_rule.resharper_exclusive_prefixes_suffixes = true
dotnet_naming_rule.private_static_fields_rule.resharper_guid = f9fce829-e6f4-4cb2-80f1-5497c44f51df
dotnet_naming_rule.private_static_fields_rule.severity = suggestion
dotnet_naming_rule.private_static_fields_rule.style = lower_camel_case_style
dotnet_naming_rule.private_static_fields_rule.symbols = private_static_fields_symbols
dotnet_naming_rule.private_static_readonly_rule.import_to_resharper = True
dotnet_naming_rule.private_static_readonly_rule.resharper_description = Static readonly fields (private)
dotnet_naming_rule.private_static_readonly_rule.resharper_guid = 15b5b1f1-457c-4ca6-b278-5615aedc07d3
dotnet_naming_rule.private_static_readonly_rule.severity = suggestion
dotnet_naming_rule.private_static_readonly_rule.style = lower_camel_case_style
dotnet_naming_rule.private_static_readonly_rule.symbols = private_static_readonly_symbols
dotnet_naming_rule.type_parameters_rule.import_to_resharper = True
dotnet_naming_rule.type_parameters_rule.resharper_description = Type parameters
dotnet_naming_rule.type_parameters_rule.resharper_guid = 2c62818f-621b-4425-adc9-78611099bfcb
dotnet_naming_rule.type_parameters_rule.severity = suggestion
dotnet_naming_rule.type_parameters_rule.style = t_upper_camel_case_style
dotnet_naming_rule.type_parameters_rule.symbols = type_parameters_symbols
dotnet_naming_style.all_upper_style.capitalization = all_upper
dotnet_naming_style.all_upper_style.word_separator = _
dotnet_naming_style.i_upper_camel_case_style.capitalization = pascal_case
dotnet_naming_style.i_upper_camel_case_style.required_prefix = I
dotnet_naming_style.lower_camel_case_style.capitalization = camel_case
dotnet_naming_style.lower_camel_case_style.required_prefix = _
dotnet_naming_style.t_upper_camel_case_style.capitalization = pascal_case
dotnet_naming_style.t_upper_camel_case_style.required_prefix = T
dotnet_naming_symbols.constants_symbols.applicable_accessibilities = public,internal,protected,protected_internal,private_protected
dotnet_naming_symbols.constants_symbols.applicable_kinds = field
dotnet_naming_symbols.constants_symbols.required_modifiers = const
dotnet_naming_symbols.constants_symbols.resharper_applicable_kinds = constant_field
dotnet_naming_symbols.constants_symbols.resharper_required_modifiers = any
dotnet_naming_symbols.interfaces_symbols.applicable_accessibilities = *
dotnet_naming_symbols.interfaces_symbols.applicable_kinds = interface
dotnet_naming_symbols.interfaces_symbols.resharper_applicable_kinds = interface
dotnet_naming_symbols.interfaces_symbols.resharper_required_modifiers = any
dotnet_naming_symbols.local_constants_symbols.applicable_accessibilities = *
dotnet_naming_symbols.local_constants_symbols.applicable_kinds = local
dotnet_naming_symbols.local_constants_symbols.required_modifiers = const
dotnet_naming_symbols.local_constants_symbols.resharper_applicable_kinds = local_constant
dotnet_naming_symbols.local_constants_symbols.resharper_required_modifiers = any
dotnet_naming_symbols.private_constants_symbols.applicable_accessibilities = private
dotnet_naming_symbols.private_constants_symbols.applicable_kinds = field
dotnet_naming_symbols.private_constants_symbols.required_modifiers = const
dotnet_naming_symbols.private_constants_symbols.resharper_applicable_kinds = constant_field
dotnet_naming_symbols.private_constants_symbols.resharper_required_modifiers = any
dotnet_naming_symbols.private_instance_fields_symbols.applicable_accessibilities = private
dotnet_naming_symbols.private_instance_fields_symbols.applicable_kinds = field
dotnet_naming_symbols.private_instance_fields_symbols.resharper_applicable_kinds = field,readonly_field
dotnet_naming_symbols.private_instance_fields_symbols.resharper_required_modifiers = instance
dotnet_naming_symbols.private_static_fields_symbols.applicable_accessibilities = private
dotnet_naming_symbols.private_static_fields_symbols.applicable_kinds = field
dotnet_naming_symbols.private_static_fields_symbols.required_modifiers = static
dotnet_naming_symbols.private_static_fields_symbols.resharper_applicable_kinds = field
dotnet_naming_symbols.private_static_fields_symbols.resharper_required_modifiers = static
dotnet_naming_symbols.private_static_readonly_symbols.applicable_accessibilities = private
dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds = field
dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers = readonly,static
dotnet_naming_symbols.private_static_readonly_symbols.resharper_applicable_kinds = readonly_field
dotnet_naming_symbols.private_static_readonly_symbols.resharper_required_modifiers = static
dotnet_naming_symbols.type_parameters_symbols.applicable_accessibilities = *
dotnet_naming_symbols.type_parameters_symbols.applicable_kinds = type_parameter
dotnet_naming_symbols.type_parameters_symbols.resharper_applicable_kinds = type_parameter
dotnet_naming_symbols.type_parameters_symbols.resharper_required_modifiers = any
dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none
dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
dotnet_style_predefined_type_for_member_access = true:suggestion
dotnet_style_qualification_for_event = false:suggestion
dotnet_style_qualification_for_field = false:suggestion
dotnet_style_qualification_for_method = false:suggestion
dotnet_style_qualification_for_property = false:suggestion
# ReSharper properties
resharper_autodetect_indent_settings = true
resharper_csharp_wrap_arguments_style = chop_if_long
resharper_force_attribute_style = join
resharper_formatter_off_tag = @formatter:off
resharper_formatter_on_tag = @formatter:on
resharper_formatter_tags_enabled = true
resharper_keep_existing_embedded_arrangement = false
resharper_keep_existing_expr_member_arrangement = false
resharper_method_or_operator_body = expression_body
resharper_object_creation_when_type_not_evident = target_typed
resharper_place_accessorholder_attribute_on_same_line = false
resharper_place_accessor_attribute_on_same_line = false
resharper_place_expr_method_on_single_line = false
resharper_place_expr_property_on_single_line = false
resharper_place_field_attribute_on_same_line = false
resharper_place_simple_embedded_statement_on_same_line = false
resharper_show_autodetect_configure_formatting_tip = false
resharper_use_indent_from_vs = false
resharper_wrap_before_arrow_with_expressions = true
# ReSharper inspection severities
resharper_arrange_constructor_or_destructor_body_highlighting = hint
resharper_arrange_method_or_operator_body_highlighting = suggestion
resharper_arrange_object_creation_when_type_evident_highlighting = none
resharper_arrange_redundant_parentheses_highlighting = suggestion
resharper_arrange_this_qualifier_highlighting = hint
resharper_arrange_type_member_modifiers_highlighting = none
resharper_built_in_type_reference_style_for_member_access_highlighting = hint
resharper_built_in_type_reference_style_highlighting = hint
resharper_check_namespace_highlighting = none
resharper_class_never_instantiated_global_highlighting = hint
resharper_convert_to_primary_constructor_highlighting = none
resharper_convert_to_using_declaration_highlighting = warning
resharper_convert_type_check_pattern_to_null_check_highlighting = hint
resharper_empty_general_catch_clause_highlighting = none
resharper_function_never_returns_highlighting = suggestion
resharper_inconsistent_naming_highlighting = suggestion
resharper_invert_if_highlighting = none
resharper_lambda_expression_can_be_made_static_highlighting = hint
resharper_mvc_action_not_resolved_highlighting = warning
resharper_mvc_area_not_resolved_highlighting = warning
resharper_mvc_controller_not_resolved_highlighting = warning
resharper_mvc_masterpage_not_resolved_highlighting = warning
resharper_mvc_partial_view_not_resolved_highlighting = warning
resharper_mvc_template_not_resolved_highlighting = warning
resharper_mvc_view_component_not_resolved_highlighting = warning
resharper_mvc_view_component_view_not_resolved_highlighting = warning
resharper_mvc_view_not_resolved_highlighting = warning
resharper_non_readonly_member_in_get_hash_code_highlighting = suggestion
resharper_not_accessed_field_local_highlighting = suggestion
resharper_out_parameter_value_is_always_discarded_local_highlighting = suggestion
resharper_private_field_can_be_converted_to_local_variable_highlighting = suggestion
resharper_razor_assembly_not_resolved_highlighting = warning
resharper_redundant_anonymous_type_property_name_highlighting = suggestion
resharper_redundant_base_qualifier_highlighting = warning
resharper_redundant_pattern_parentheses_highlighting = none
resharper_redundant_record_class_keyword_highlighting = suggestion
resharper_replace_with_single_call_to_first_or_default_highlighting = hint
resharper_suggest_var_or_type_built_in_types_highlighting = hint
resharper_suggest_var_or_type_elsewhere_highlighting = hint
resharper_suggest_var_or_type_simple_types_highlighting = hint
resharper_unused_method_return_value_local_highlighting = suggestion
resharper_use_await_using_highlighting = warning
resharper_web_config_module_not_resolved_highlighting = warning
resharper_web_config_type_not_resolved_highlighting = warning
resharper_web_config_wrong_module_highlighting = warning
[{*.har,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json}]
indent_style = space
indent_size = 2
[{*.yaml,*.yml}]
indent_style = space
indent_size = 2
[{*.bash,*.sh,*.zsh}]
indent_style = space
indent_size = 2
[*.proto]
indent_style = space
indent_size = 2
[*.{appxmanifest,asax,ascx,aspx,axaml,blockshader,build,cg,cginc,compute,cs,cshtml,dtd,fx,fxh,hlsl,hlsli,hlslinc,master,nuspec,paml,razor,resw,resx,shaderFoundry,skin,urtshader,usf,ush,vb,xaml,xamlx,xoml,xsd}]
indent_style = space
indent_size = 4
tab_width = 4

3
.gitignore vendored
View file

@ -371,8 +371,7 @@ site/
.aider.*
PROMPT.md
.aider*
.windsurfrules
.*rules
## Python pip/env files
Pipfile

View file

@ -2,6 +2,62 @@
*a,c,f,r,o*
## [6.1.0] - 30.03.2025
### Added
- Added Quest System!
- Each user gets a couple of daily quests to complete
- There are 10-15 different quests, each day you'll get 3
- Upon completion of all dailies, the user will get a boost to timely and vote
- `.quests` to see your quests
- Added Fishing Items!
- `.fishop` to see a list of all available items for sale
- `.fibuy` to buy an item
- `.finv` to see your inventory
- `.fiuse` to use an item. You can equip one of each item, except potions
- You can equip one of each item
- You can equip any number of potions, but they have limited duration and cant be unequiped
- `.fili` will show your equipped item names, nad `.fish` will show bonuses
- Added `.fishlb` to see the top anglers
- Added `.notify <channel> nicecatch <message>` event
- It will show all rare fish/trash and all max star fish caught on any server
- You can use `.notifyphs nicecatch` to see the list of placeholders you can use while setting a message
- Example: `.notify #fishfeed nicecatch %user% just caught a %event.fish.stars% %event.fish.name% %event.fish.emoji%`
- Added prices to `.nczoom`
- Voting re-added, `.votefeed` to see all votes. Non-trivial setup required, check commits
- owner only `.massping` command for special situations
### Changed
- .notify commands now require Manage Messages permission
- .notify will now let you know if you can't set a notify message due to a missing channel
- `.say` will no longer reply
- `.vote` and `.timely` will now show active bonuses
- `.lcha` (live channel) limit increased to 5
- `.nc` will now show instructions
### Fixed
- Fixed `.antispamignore` restart persistence
- Fixed `.notify` events. Only levelup used to work
- Fixed `.hangman` misalignment
- Fixed bank quest
## [6.0.13] - 23.03.2025
### Added
- Added `.linkfix <old> <new>` command
- If bot sees a message with the old link, it will reply to the message with a fixed (new) link
- ex: `.linkfix twitter.com vxtwitter.com`
- Added `.roleicon role <icon_url / server_emoji>` command to set the icon of a role
- Added a captcha option for `.fish`
### Fixed
- Fixed youtube stream notifications in case invalid channel was provided
- `.lcha` (live channel) will now let you override an existing channel template even if you're at the limit
- Fixed `.shop` commands
### Removed
- removed `.xpglb` as it is no longer used
## [6.0.12] - 20.03.2025
### Fixed

View file

@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Discord.Net.Core" Version="3.17.1" />
<PackageReference Include="Discord.Net.Core" Version="3.17.2" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="YamlDotNet" Version="15.1.6" />
</ItemGroup>

View file

@ -12,6 +12,8 @@ service GrpcXp {
rpc AddReward(AddRewardRequest) returns (AddRewardReply);
rpc DeleteReward(DeleteRewardRequest) returns (DeleteRewardReply);
rpc GetUserXp(GetUserXpRequest) returns (GetUserXpReply);
}
message GetXpLbRequest {
@ -75,4 +77,18 @@ message DeleteRewardRequest {
message DeleteRewardReply {
bool success = 1;
}
message GetUserXpRequest {
uint64 guildId = 1;
uint64 userId = 2;
}
message GetUserXpReply {
int64 xp = 1;
int64 requiredXp = 2;
int64 level = 3;
string club = 4;
string clubIcon = 5;
int32 rank = 6;
}

View file

@ -0,0 +1,71 @@
syntax = "proto3";
option csharp_namespace = "EllieBot.GrpcApi";
package greet;
service GrpcXpShop {
rpc AddXpShopItem (AddXpShopItemRequest) returns (AddXpShopItemReply);
rpc GetShopItems (GetShopItemsRequest) returns (GetShopItemsReply);
rpc UseShopItem (UseShopItemRequest) returns (UseShopItemReply);
rpc BuyShopItem (BuyShopItemRequest) returns (BuyShopItemReply);
}
message UseShopItemRequest {
uint64 userId = 1;
string uniqueName = 2;
GrpcXpShopItemType itemType = 3;
}
message UseShopItemReply {
bool success = 1;
}
message BuyShopItemRequest {
uint64 userId = 1;
string uniqueName = 2;
GrpcXpShopItemType itemType = 3;
}
message BuyShopItemReply {
bool success = 1;
optional BuyShopItemError Error = 2;
}
enum BuyShopItemError {
NotEnough = 0;
AlreadyOwned = 1;
Unknown = 2;
}
message AddXpShopItemRequest {
XpShopItem item = 1;
string uniqueName = 2;
GrpcXpShopItemType itemType = 3;
}
message AddXpShopItemReply {
bool success = 1;
}
message GetShopItemsRequest {
}
message GetShopItemsReply {
repeated XpShopItem bgs = 1;
repeated XpShopItem frames = 2;
}
message XpShopItem {
string Name = 1;
string Description = 2;
int64 Price = 3;
string FullUrl = 4;
string PreviewUrl = 5;
}
enum GrpcXpShopItemType {
Bg = 0;
Frame = 1;
}

View file

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
@ -14,6 +15,7 @@ namespace EllieBot.VotesApi
public const string SchemeName = "AUTHORIZATION_SCHEME";
public const string DiscordsClaim = "DISCORDS_CLAIM";
public const string TopggClaim = "TOPGG_CLAIM";
public const string DiscordbotlistClaim = "DISCORDBOTLIST_CLAIM";
private readonly IConfiguration _conf;
@ -24,17 +26,58 @@ namespace EllieBot.VotesApi
: base(options, logger, encoder)
=> _conf = conf;
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
{
return AuthenticateResult.Fail("Authorization header missing");
}
var authToken = authHeader.ToString().Trim();
if (string.IsNullOrWhiteSpace(authToken))
{
return AuthenticateResult.Fail("Authorization token empty");
}
var claims = new List<Claim>();
if (_conf[ConfKeys.DISCORDS_KEY].Trim() == Request.Headers["Authorization"].ToString().Trim())
claims.Add(new(DiscordsClaim, "true"));
var discsKey = _conf[ConfKeys.DISCORDS_KEY]?.Trim();
var topggKey = _conf[ConfKeys.TOPGG_KEY]?.Trim();
var dblKey = _conf[ConfKeys.DISCORDBOTLIST_KEY]?.Trim();
if (_conf[ConfKeys.TOPGG_KEY] == Request.Headers["Authorization"].ToString().Trim())
if (!string.IsNullOrWhiteSpace(discsKey)
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(discsKey),
Encoding.UTF8.GetBytes(authToken)))
{
claims.Add(new Claim(DiscordsClaim, "true"));
}
if (!string.IsNullOrWhiteSpace(topggKey)
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(topggKey),
Encoding.UTF8.GetBytes(authToken)))
{
claims.Add(new Claim(TopggClaim, "true"));
}
return Task.FromResult(AuthenticateResult.Success(new(new(new ClaimsIdentity(claims)), SchemeName)));
if (!string.IsNullOrWhiteSpace(dblKey)
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(dblKey),
Encoding.UTF8.GetBytes(authToken)))
{
claims.Add(new Claim(DiscordbotlistClaim, "true"));
}
if (claims.Count == 0)
{
return AuthenticateResult.Fail("Invalid authorization token");
}
return AuthenticateResult.Success(
new AuthenticationTicket(
new ClaimsPrincipal(new ClaimsIdentity(claims)),
SchemeName));
}
}
}

View file

@ -4,5 +4,6 @@
{
public const string DISCORDS_KEY = "DiscordsKey";
public const string TOPGG_KEY = "TopGGKey";
public const string DISCORDBOTLIST_KEY = "DiscordbotListKey";
}
}

View file

@ -0,0 +1,20 @@
namespace EllieBot.VotesApi
{
public class DiscordbotlistVoteWebhookModel
{
/// <summary>
/// The avatar hash of the user
/// </summary>
public string Avatar { get; set; }
/// <summary>
/// The username of the user who voted
/// </summary>
public string Username { get; set; }
/// <summary>
/// The ID of the user who voted
/// </summary>
public string Id { get; set; }
}
}

View file

@ -12,12 +12,6 @@
/// </summary>
public string Bot { get; set; }
/// <summary>
/// Contains totalVotes, votesMonth, votes24, hasVoted - a list of IDs of users who have voted this month, and
/// Voted24 - a list of IDs of users who have voted today
/// </summary>
public string Votes { get; set; }
/// <summary>
/// The type of event, whether it is a vote event or test event
/// </summary>

View file

@ -4,5 +4,6 @@
{
public const string DiscordsAuth = "DiscordsAuth";
public const string TopggAuth = "TopggAuth";
public const string DiscordbotlistAuth = "DiscordbotlistAuth";
}
}

View file

@ -1,33 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using EllieBot.VotesApi.Services;
namespace EllieBot.VotesApi.Controllers
{
[ApiController]
[Route("[controller]")]
public class DiscordsController : ControllerBase
{
private readonly ILogger<DiscordsController> _logger;
private readonly IVotesCache _cache;
public DiscordsController(ILogger<DiscordsController> logger, IVotesCache cache)
{
_logger = logger;
_cache = cache;
}
[HttpGet("new")]
[Authorize(Policy = Policies.DiscordsAuth)]
public async Task<IEnumerable<Vote>> New()
{
var votes = await _cache.GetNewDiscordsVotesAsync();
if (votes.Count > 0)
_logger.LogInformation("Sending {NewDiscordsVotes} new discords votes", votes.Count);
return votes;
}
}
}

View file

@ -1,34 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using EllieBot.VotesApi.Services;
namespace EllieBot.VotesApi.Controllers
{
[ApiController]
[Route("[controller]")]
public class TopGgController : ControllerBase
{
private readonly ILogger<TopGgController> _logger;
private readonly IVotesCache _cache;
public TopGgController(ILogger<TopGgController> logger, IVotesCache cache)
{
_logger = logger;
_cache = cache;
}
[HttpGet("new")]
[Authorize(Policy = Policies.TopggAuth)]
public async Task<IEnumerable<Vote>> New()
{
var votes = await _cache.GetNewTopGgVotesAsync();
if (votes.Count > 0)
_logger.LogInformation("Sending {NewTopggVotes} new topgg votes", votes.Count);
return votes;
}
}
}

View file

@ -2,33 +2,32 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using EllieBot.VotesApi.Services;
using EllieBot.GrpcVotesApi;
namespace EllieBot.VotesApi.Controllers
{
[ApiController]
public class WebhookController : ControllerBase
public class WebhookController(ILogger<WebhookController> logger, VoteService.VoteServiceClient client)
: ControllerBase
{
private readonly ILogger<WebhookController> _logger;
private readonly IVotesCache _votesCache;
public WebhookController(ILogger<WebhookController> logger, IVotesCache votesCache)
{
_logger = logger;
_votesCache = votesCache;
}
[HttpPost("/discordswebhook")]
[Authorize(Policy = Policies.DiscordsAuth)]
public async Task<IActionResult> DiscordsWebhook([FromBody] DiscordsVoteWebhookModel data)
{
_logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
if ((data.Type?.Contains("vote") ?? false) == false)
return Ok();
logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
data.User,
data.Bot,
"discords.com");
await _votesCache.AddNewDiscordsVote(data.User);
await client.VoteReceivedAsync(new GrpcVoteData()
{
Type = VoteType.Discords,
UserId = data.User,
});
return Ok();
}
@ -36,12 +35,34 @@ namespace EllieBot.VotesApi.Controllers
[Authorize(Policy = Policies.TopggAuth)]
public async Task<IActionResult> TopggWebhook([FromBody] TopggVoteWebhookModel data)
{
_logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
data.User,
data.Bot,
"top.gg");
await _votesCache.AddNewTopggVote(data.User);
await client.VoteReceivedAsync(new GrpcVoteData()
{
Type = VoteType.Topgg,
UserId = data.User,
});
return Ok();
}
[HttpPost("/discordbotlistwebhook")]
[Authorize(Policy = Policies.DiscordbotlistAuth)]
public async Task<IActionResult> DiscordbotlistWebhook([FromBody] DiscordbotlistVoteWebhookModel data)
{
logger.LogInformation("User {UserId} has voted for Bot on {Platform}",
data.Id,
"discordbotlist.com");
await client.VoteReceivedAsync(new GrpcVoteData()
{
Type = VoteType.Discordbotlist,
UserId = data.Id,
});
return Ok();
}
}

View file

@ -1,13 +1,13 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS base
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["src/EllieBot.VotesApi/EllieBot.VotesApi.csproj", "EllieBot.VotesApi/"]
RUN dotnet restore "src/EllieBot.VotesApi/EllieBot.VotesApi.csproj"
COPY . .
COPY ["EllieBot.VotesApi.csproj", "EllieBot.VotesApi/"]
RUN dotnet restore "EllieBot.VotesApi/EllieBot.VotesApi.csproj"
COPY . "EllieBot.VotesApi/"
WORKDIR "/src/EllieBot.VotesApi"
RUN dotnet build "EllieBot.VotesApi.csproj" -c Release -o /app/build

View file

@ -8,6 +8,11 @@
<ItemGroup>
<PackageReference Include="MorseCode.ITask" Version="2.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.70.0" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos/vote.proto" GrpcServices="Client" />
</ItemGroup>
</Project>

View file

@ -1,9 +1,15 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using EllieBot.VotesApi;
CreateHostBuilder(args).Build().Run();
static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });
static IHostBuilder CreateHostBuilder(string[] args)
=> Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration(c => c.AddEnvironmentVariables())
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder
.UseStartup<Startup>();
});

View file

@ -0,0 +1,24 @@
syntax = "proto3";
package vote;
option csharp_namespace = "EllieBot.GrpcVotesApi";
enum VoteType {
TOPGG = 0;
DISCORDBOTLIST = 1;
DISCORDS = 2;
}
message GrpcVoteData {
string userId = 1;
VoteType type = 2;
}
message GrpcVoteResult {
}
service VoteService {
rpc VoteReceived (GrpcVoteData) returns (GrpcVoteResult);
}

View file

@ -1,100 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using MorseCode.ITask;
namespace EllieBot.VotesApi.Services
{
public class FileVotesCache : IVotesCache
{
// private const string STATS_FILE = "store/stats.json";
private const string TOPGG_FILE = "store/topgg.json";
private const string DISCORDS_FILE = "store/discords.json";
private readonly SemaphoreSlim _locker = new SemaphoreSlim(1, 1);
public FileVotesCache()
{
if (!Directory.Exists("store"))
Directory.CreateDirectory("store");
if (!File.Exists(TOPGG_FILE))
File.WriteAllText(TOPGG_FILE, "[]");
if (!File.Exists(DISCORDS_FILE))
File.WriteAllText(DISCORDS_FILE, "[]");
}
public ITask AddNewTopggVote(string userId)
=> AddNewVote(TOPGG_FILE, userId);
public ITask AddNewDiscordsVote(string userId)
=> AddNewVote(DISCORDS_FILE, userId);
private async ITask AddNewVote(string file, string userId)
{
await _locker.WaitAsync();
try
{
var votes = await GetVotesAsync(file);
votes.Add(userId);
await File.WriteAllTextAsync(file, JsonSerializer.Serialize(votes));
}
finally
{
_locker.Release();
}
}
public async ITask<IList<Vote>> GetNewTopGgVotesAsync()
{
var votes = await EvictTopggVotes();
return votes;
}
public async ITask<IList<Vote>> GetNewDiscordsVotesAsync()
{
var votes = await EvictDiscordsVotes();
return votes;
}
private ITask<List<Vote>> EvictTopggVotes()
=> EvictVotes(TOPGG_FILE);
private ITask<List<Vote>> EvictDiscordsVotes()
=> EvictVotes(DISCORDS_FILE);
private async ITask<List<Vote>> EvictVotes(string file)
{
await _locker.WaitAsync();
try
{
var ids = await GetVotesAsync(file);
await File.WriteAllTextAsync(file, "[]");
return ids?
.Select(x => (Ok: ulong.TryParse(x, out var r), Id: r))
.Where(x => x.Ok)
.Select(x => new Vote
{
UserId = x.Id
})
.ToList();
}
finally
{
_locker.Release();
}
}
private async ITask<IList<string>> GetVotesAsync(string file)
{
await using var fs = File.Open(file, FileMode.Open);
var votes = await JsonSerializer.DeserializeAsync<List<string>>(fs);
return votes;
}
}
}

View file

@ -1,13 +0,0 @@
using System.Collections.Generic;
using MorseCode.ITask;
namespace EllieBot.VotesApi.Services
{
public interface IVotesCache
{
ITask<IList<Vote>> GetNewTopGgVotesAsync();
ITask<IList<Vote>> GetNewDiscordsVotesAsync();
ITask AddNewTopggVote(string userId);
ITask AddNewDiscordsVote(string userId);
}
}

View file

@ -1,11 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using System;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
using EllieBot.VotesApi.Services;
using EllieBot.GrpcVotesApi;
namespace EllieBot.VotesApi
{
@ -21,11 +22,16 @@ namespace EllieBot.VotesApi
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSingleton<IVotesCache, FileVotesCache>();
services.AddSwaggerGen(static c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "EllieBot.VotesApi", Version = "v1" });
});
services.AddGrpcClient<VoteService.VoteServiceClient>(options =>
{
options.Address = new Uri(Configuration["BotGrpcHost"]!);
})
.ConfigureChannel((sp, c) =>
{
c.Credentials = ChannelCredentials.Insecure;
c.ServiceProvider = sp;
});
services
.AddAuthentication(opts =>
@ -40,9 +46,18 @@ namespace EllieBot.VotesApi
opts.DefaultPolicy = new AuthorizationPolicyBuilder(AuthHandler.SchemeName)
.RequireAssertion(static _ => false)
.Build();
opts.AddPolicy(Policies.DiscordsAuth, static policy => policy.RequireClaim(AuthHandler.DiscordsClaim));
opts.AddPolicy(Policies.TopggAuth, static policy => policy.RequireClaim(AuthHandler.TopggClaim));
opts.AddPolicy(Policies.DiscordsAuth,
static policy => policy.RequireClaim(AuthHandler.DiscordsClaim));
opts.AddPolicy(Policies.TopggAuth,
static policy => policy.RequireClaim(AuthHandler.TopggClaim));
opts.AddPolicy(Policies.DiscordbotlistAuth,
static policy => policy.RequireClaim(AuthHandler.DiscordbotlistClaim));
});
services.AddCors(x => x.AddDefaultPolicy(cpb =>
cpb.AllowAnyHeader()
.AllowAnyMethod()
.AllowAnyOrigin()));
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
@ -51,8 +66,6 @@ namespace EllieBot.VotesApi
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(static c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "EllieBot.VotesApi v1"));
}
app.UseHttpsRedirection();

View file

@ -8,5 +8,7 @@
},
"DiscordsKey": "my_discords_key",
"TopGGKey": "my_topgg_key",
"DiscordBotListKey": "my_discordbotlist_key",
"BotGrpcHost": "http://127.0.0.1:59384",
"AllowedHosts": "*"
}

View file

@ -80,7 +80,7 @@ public abstract class EllieContext : DbContext
{
// load all entities from current assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(EllieContext).Assembly);
#region Notify
modelBuilder.Entity<Notify>(e =>
@ -113,8 +113,8 @@ public abstract class EllieContext : DbContext
#region GuildColors
modelBuilder.Entity<GuildColors>()
.HasIndex(x => x.GuildId)
.IsUnique(true);
.HasIndex(x => x.GuildId)
.IsUnique(true);
#endregion
@ -123,7 +123,7 @@ public abstract class EllieContext : DbContext
modelBuilder.Entity<ButtonRole>(br =>
{
br.HasIndex(x => x.GuildId)
.IsUnique(false);
.IsUnique(false);
br.HasAlternateKey(x => new
{
@ -145,27 +145,27 @@ public abstract class EllieContext : DbContext
});
sg.HasMany(x => x.Roles)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Sar>()
.HasAlternateKey(x => new
{
x.GuildId,
x.RoleId
});
.HasAlternateKey(x => new
{
x.GuildId,
x.RoleId
});
modelBuilder.Entity<SarAutoDelete>()
.HasIndex(x => x.GuildId)
.IsUnique();
.HasIndex(x => x.GuildId)
.IsUnique();
#endregion
#region Rakeback
modelBuilder.Entity<Rakeback>()
.HasKey(x => x.UserId);
.HasKey(x => x.UserId);
#endregion
@ -174,14 +174,14 @@ public abstract class EllieContext : DbContext
modelBuilder.Entity<UserBetStats>(ubs =>
{
ubs.HasIndex(x => new
{
x.UserId,
x.Game
})
.IsUnique();
{
x.UserId,
x.Game
})
.IsUnique();
ubs.HasIndex(x => x.MaxWin)
.IsUnique(false);
.IsUnique(false);
});
#endregion
@ -189,22 +189,22 @@ public abstract class EllieContext : DbContext
#region Flag Translate
modelBuilder.Entity<FlagTranslateChannel>()
.HasIndex(x => new
{
x.GuildId,
x.ChannelId
})
.IsUnique();
.HasIndex(x => new
{
x.GuildId,
x.ChannelId
})
.IsUnique();
#endregion
#region NCanvas
modelBuilder.Entity<NCPixel>()
.HasAlternateKey(x => x.Position);
.HasAlternateKey(x => x.Position);
modelBuilder.Entity<NCPixel>()
.HasIndex(x => x.OwnerId);
.HasIndex(x => x.OwnerId);
#endregion
@ -221,10 +221,11 @@ public abstract class EllieContext : DbContext
var configEntity = modelBuilder.Entity<GuildConfig>();
configEntity.HasIndex(c => c.GuildId)
.IsUnique();
.IsUnique();
configEntity.Property(x => x.VerboseErrors)
.HasDefaultValue(true);
.HasDefaultValue(true);
// end shop
modelBuilder.Entity<PlantedCurrency>().HasIndex(x => x.MessageId).IsUnique();
@ -270,19 +271,19 @@ public abstract class EllieContext : DbContext
modelBuilder.Entity<DiscordUser>(du =>
{
du.Property(x => x.IsClubAdmin)
.HasDefaultValue(false);
.HasDefaultValue(false);
du.Property(x => x.TotalXp)
.HasDefaultValue(0);
.HasDefaultValue(0);
du.Property(x => x.CurrencyAmount)
.HasDefaultValue(0);
.HasDefaultValue(0);
du.HasAlternateKey(w => w.UserId);
du.HasOne(x => x.Club)
.WithMany(x => x.Members)
.IsRequired(false)
.OnDelete(DeleteBehavior.NoAction);
.WithMany(x => x.Members)
.IsRequired(false)
.OnDelete(DeleteBehavior.NoAction);
du.HasIndex(x => x.TotalXp);
du.HasIndex(x => x.CurrencyAmount);
@ -308,11 +309,11 @@ public abstract class EllieContext : DbContext
var xps = modelBuilder.Entity<UserXpStats>();
xps.HasIndex(x => new
{
x.UserId,
x.GuildId
})
.IsUnique();
{
x.UserId,
x.GuildId
})
.IsUnique();
xps.HasIndex(x => x.UserId);
xps.HasIndex(x => x.GuildId);
@ -324,49 +325,49 @@ public abstract class EllieContext : DbContext
var ci = modelBuilder.Entity<ClubInfo>();
ci.HasOne(x => x.Owner)
.WithOne()
.HasForeignKey<ClubInfo>(x => x.OwnerId)
.OnDelete(DeleteBehavior.SetNull);
.WithOne()
.HasForeignKey<ClubInfo>(x => x.OwnerId)
.OnDelete(DeleteBehavior.SetNull);
ci.HasIndex(x => new
{
x.Name
})
.IsUnique();
{
x.Name
})
.IsUnique();
#endregion
#region ClubManytoMany
modelBuilder.Entity<ClubApplicants>()
.HasKey(t => new
{
t.ClubId,
t.UserId
});
.HasKey(t => new
{
t.ClubId,
t.UserId
});
modelBuilder.Entity<ClubApplicants>()
.HasOne(pt => pt.User)
.WithMany();
.HasOne(pt => pt.User)
.WithMany();
modelBuilder.Entity<ClubApplicants>()
.HasOne(pt => pt.Club)
.WithMany(x => x.Applicants);
.HasOne(pt => pt.Club)
.WithMany(x => x.Applicants);
modelBuilder.Entity<ClubBans>()
.HasKey(t => new
{
t.ClubId,
t.UserId
});
.HasKey(t => new
{
t.ClubId,
t.UserId
});
modelBuilder.Entity<ClubBans>()
.HasOne(pt => pt.User)
.WithMany();
.HasOne(pt => pt.User)
.WithMany();
modelBuilder.Entity<ClubBans>()
.HasOne(pt => pt.Club)
.WithMany(x => x.Bans);
.HasOne(pt => pt.Club)
.WithMany(x => x.Bans);
#endregion
@ -375,16 +376,16 @@ public abstract class EllieContext : DbContext
modelBuilder.Entity<CurrencyTransaction>(e =>
{
e.HasIndex(x => x.UserId)
.IsUnique(false);
.IsUnique(false);
e.Property(x => x.OtherId)
.HasDefaultValueSql(CurrencyTransactionOtherIdDefaultValue);
.HasDefaultValueSql(CurrencyTransactionOtherIdDefaultValue);
e.Property(x => x.Type)
.IsRequired();
.IsRequired();
e.Property(x => x.Extra)
.IsRequired();
.IsRequired();
});
#endregion
@ -399,21 +400,21 @@ public abstract class EllieContext : DbContext
modelBuilder.Entity<BanTemplate>().HasIndex(x => x.GuildId).IsUnique();
modelBuilder.Entity<BanTemplate>()
.Property(x => x.PruneDays)
.HasDefaultValue(null)
.IsRequired(false);
.Property(x => x.PruneDays)
.HasDefaultValue(null)
.IsRequired(false);
#endregion
#region Perm Override
modelBuilder.Entity<DiscordPermOverride>()
.HasIndex(x => new
{
x.GuildId,
x.Command
})
.IsUnique();
.HasIndex(x => new
{
x.GuildId,
x.Command
})
.IsUnique();
#endregion
@ -430,14 +431,14 @@ public abstract class EllieContext : DbContext
modelBuilder.Entity<ReactionRoleV2>(rr2 =>
{
rr2.HasIndex(x => x.GuildId)
.IsUnique(false);
.IsUnique(false);
rr2.HasIndex(x => new
{
x.MessageId,
x.Emote
})
.IsUnique();
{
x.MessageId,
x.Emote
})
.IsUnique();
});
#endregion
@ -447,18 +448,18 @@ public abstract class EllieContext : DbContext
modelBuilder.Entity<LogSetting>(ls => ls.HasIndex(x => x.GuildId).IsUnique());
modelBuilder.Entity<LogSetting>(ls => ls
.HasMany(x => x.LogIgnores)
.WithOne(x => x.LogSetting)
.OnDelete(DeleteBehavior.Cascade));
.HasMany(x => x.LogIgnores)
.WithOne(x => x.LogSetting)
.OnDelete(DeleteBehavior.Cascade));
modelBuilder.Entity<IgnoredLogItem>(ili => ili
.HasIndex(x => new
{
x.LogSettingId,
x.LogItemId,
x.ItemType
})
.IsUnique());
.HasIndex(x => new
{
x.LogSettingId,
x.LogItemId,
x.ItemType
})
.IsUnique());
#endregion
@ -511,12 +512,12 @@ public abstract class EllieContext : DbContext
{
// user can own only one of each item
x.HasIndex(model => new
{
model.UserId,
model.ItemType,
model.ItemKey
})
.IsUnique();
{
model.UserId,
model.ItemType,
model.ItemKey
})
.IsUnique();
});
#endregion
@ -524,27 +525,27 @@ public abstract class EllieContext : DbContext
#region AutoPublish
modelBuilder.Entity<AutoPublishChannel>(apc => apc
.HasIndex(x => x.GuildId)
.IsUnique());
.HasIndex(x => x.GuildId)
.IsUnique());
#endregion
#region GamblingStats
modelBuilder.Entity<GamblingStats>(gs => gs
.HasIndex(x => x.Feature)
.IsUnique());
.HasIndex(x => x.Feature)
.IsUnique());
#endregion
#region Sticky Roles
modelBuilder.Entity<StickyRole>(sr => sr.HasIndex(x => new
{
x.GuildId,
x.UserId
})
.IsUnique());
{
x.GuildId,
x.UserId
})
.IsUnique());
#endregion
@ -552,35 +553,35 @@ public abstract class EllieContext : DbContext
#region Giveaway
modelBuilder.Entity<GiveawayModel>()
.HasMany(x => x.Participants)
.WithOne()
.HasForeignKey(x => x.GiveawayId)
.OnDelete(DeleteBehavior.Cascade);
.HasMany(x => x.Participants)
.WithOne()
.HasForeignKey(x => x.GiveawayId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GiveawayUser>(gu => gu
.HasIndex(x => new
{
x.GiveawayId,
x.UserId
})
.IsUnique());
.HasIndex(x => new
{
x.GiveawayId,
x.UserId
})
.IsUnique());
#endregion
#region Todo
modelBuilder.Entity<TodoModel>()
.HasKey(x => x.Id);
.HasKey(x => x.Id);
modelBuilder.Entity<TodoModel>()
.HasIndex(x => x.UserId)
.IsUnique(false);
.HasIndex(x => x.UserId)
.IsUnique(false);
modelBuilder.Entity<ArchivedTodoListModel>()
.HasMany(x => x.Items)
.WithOne()
.HasForeignKey(x => x.ArchiveId)
.OnDelete(DeleteBehavior.Cascade);
.HasMany(x => x.Items)
.WithOne()
.HasForeignKey(x => x.ArchiveId)
.OnDelete(DeleteBehavior.Cascade);
#endregion
@ -588,11 +589,11 @@ public abstract class EllieContext : DbContext
modelBuilder
.Entity<GreetSettings>(gs => gs.HasIndex(x => new
{
x.GuildId,
x.GreetType
})
.IsUnique());
{
x.GuildId,
x.GreetType
})
.IsUnique());
modelBuilder.Entity<GreetSettings>(gs =>
{
@ -619,6 +620,6 @@ public abstract class EllieContext : DbContext
#endif
optionsBuilder.ConfigureWarnings(x => x.Log(RelationalEventId.PendingModelChangesWarning)
.Ignore());
.Ignore());
}
}

View file

@ -1,4 +1,3 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System.ComponentModel.DataAnnotations;
@ -31,39 +30,38 @@ public sealed class GuildFilterConfigEntityConfiguration : IEntityTypeConfigurat
public class GuildConfig : DbEntity
{
public ulong GuildId { get; set; }
public string Prefix { get; set; }
public string? Prefix { get; set; } = null;
public bool DeleteMessageOnCommand { get; set; }
public string AutoAssignRoleIds { get; set; }
public bool DeleteMessageOnCommand { get; set; } = false;
public string? AutoAssignRoleIds { get; set; } = null;
public bool VerbosePermissions { get; set; } = true;
public string PermissionRole { get; set; }
public string? PermissionRole { get; set; } = null;
//filtering
public string MuteRoleName { get; set; }
public string? MuteRoleName { get; set; } = null;
// chatterbot
public bool CleverbotEnabled { get; set; }
public bool CleverbotEnabled { get; set; } = false;
// aliases
public bool WarningsInitialized { get; set; }
public bool WarningsInitialized { get; set; } = false;
public ulong? GameVoiceChannel { get; set; }
public ulong? GameVoiceChannel { get; set; } = null;
public bool VerboseErrors { get; set; } = true;
public bool NotifyStreamOffline { get; set; }
public bool DeleteStreamOnlineMessage { get; set; }
public int WarnExpireHours { get; set; }
public bool NotifyStreamOffline { get; set; } = true;
public bool DeleteStreamOnlineMessage { get; set; } = false;
public int WarnExpireHours { get; set; } = 0;
public WarnExpireAction WarnExpireAction { get; set; } = WarnExpireAction.Clear;
public bool DisableGlobalExpressions { get; set; } = false;
public bool StickyRoles { get; set; }
public string TimeZoneId { get; set; }
public string Locale { get; set; }
public bool StickyRoles { get; set; } = false;
public string? TimeZoneId { get; set; } = null;
public string? Locale { get; set; } = null;
public List<Permissionv2> Permissions { get; set; } = [];
}

View file

@ -21,5 +21,6 @@ public enum NotifyType
Protection = 1, Prot = 1,
AddRoleReward = 2,
RemoveRoleReward = 3,
NiceCatch = 4,
// BigWin = 4,
}

View file

@ -4,7 +4,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>true</ImplicitUsings>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<Version>6.0.12</Version>
<Version>6.1.0</Version>
<!-- Output/build -->
<RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory>
@ -25,7 +25,7 @@
</PackageReference>
<PackageReference Include="CodeHollow.FeedReader" Version="1.2.6" />
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Discord.Net" Version="3.17.1" />
<PackageReference Include="Discord.Net" Version="3.17.2" />
<PackageReference Include="CoreCLR-NCalc" Version="3.1.253" />
<PackageReference Include="Google.Apis.Urlshortener.v1" Version="1.41.1.138" />
<PackageReference Include="Google.Apis.YouTube.v3" Version="1.68.0.3653" />
@ -124,6 +124,9 @@
<Link>_common\CoordinatorProtos\coordinator.proto</Link>
<GrpcServices>Client</GrpcServices>
</Protobuf>
<Protobuf Include="../EllieBot.VotesApi/Protos/*.proto">
<GrpcServices>Server</GrpcServices>
</Protobuf>
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'GlobalEllie' ">

View file

@ -1,17 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Canti/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Cbtnrole/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Cclub/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Ccurrency/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Cexpr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Cfilter/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Cgiveaway/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Cncanvas/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Cpunish/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Croles/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Cslowmode/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Csupport/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Ctodo/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Cuntimer/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=db_005Cmodels_005Cxp/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=modules_005Cadministration_005Cnotify_005Cmodels/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -0,0 +1,16 @@
START TRANSACTION;
CREATE TABLE linkfix (
id integer GENERATED BY DEFAULT AS IDENTITY,
guildid numeric(20,0) NOT NULL,
olddomain text NOT NULL,
newdomain text NOT NULL,
CONSTRAINT pk_linkfix PRIMARY KEY (id)
);
CREATE UNIQUE INDEX ix_linkfix_guildid_olddomain ON linkfix (guildid, olddomain);
INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
VALUES ('20250323021916_linkfixer', '9.0.1');
COMMIT;

View file

@ -0,0 +1,20 @@
START TRANSACTION;
CREATE TABLE userquest (
id integer GENERATED BY DEFAULT AS IDENTITY,
questnumber integer NOT NULL,
userid numeric(20,0) NOT NULL,
questid integer NOT NULL,
progress bigint NOT NULL,
iscompleted boolean NOT NULL,
dateassigned timestamp without time zone NOT NULL,
CONSTRAINT pk_userquest PRIMARY KEY (id)
);
CREATE INDEX ix_userquest_userid ON userquest (userid);
CREATE UNIQUE INDEX ix_userquest_userid_questnumber_dateassigned ON userquest (userid, questnumber, dateassigned);
INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
VALUES ('20250324230804_quests', '9.0.1');
COMMIT;

View file

@ -0,0 +1,25 @@
START TRANSACTION;
ALTER TABLE userfishstats DROP COLUMN bait;
ALTER TABLE userfishstats DROP COLUMN pole;
ALTER TABLE userquest ALTER COLUMN progress TYPE integer;
CREATE TABLE userfishitem (
id integer GENERATED BY DEFAULT AS IDENTITY,
userid numeric(20,0) NOT NULL,
itemtype integer NOT NULL,
itemid integer NOT NULL,
isequipped boolean NOT NULL,
usesleft integer,
expiresat timestamp without time zone,
CONSTRAINT pk_userfishitem PRIMARY KEY (id)
);
CREATE INDEX ix_userfishitem_userid ON userfishitem (userid);
INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
VALUES ('20250327001838_fishitems', '9.0.1');
COMMIT;

View file

@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace EllieBot.Migrations.PostgreSql
{
[DbContext(typeof(PostgreSqlContext))]
[Migration("20250319010930_init")]
[Migration("20250327001912_init")]
partial class init
{
/// <inheritdoc />
@ -867,57 +867,6 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("discorduser", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowTarget")
.HasColumnType("boolean")
.HasColumnName("allowtarget");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("boolean")
.HasColumnName("autodeletetrigger");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("boolean")
.HasColumnName("containsanywhere");
b.Property<DateTime?>("DateAdded")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateadded");
b.Property<bool>("DmResponse")
.HasColumnType("boolean")
.HasColumnName("dmresponse");
b.Property<decimal?>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<string>("Reactions")
.HasColumnType("text")
.HasColumnName("reactions");
b.Property<string>("Response")
.HasColumnType("text")
.HasColumnName("response");
b.Property<string>("Trigger")
.HasColumnType("text")
.HasColumnName("trigger");
b.HasKey("Id")
.HasName("pk_expressions");
b.ToTable("expressions", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b =>
{
b.Property<int>("Id")
@ -1541,6 +1490,39 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("imageonlychannels", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.LinkFix", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<string>("NewDomain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("newdomain");
b.Property<string>("OldDomain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("olddomain");
b.HasKey("Id")
.HasName("pk_linkfix");
b.HasIndex("GuildId", "OldDomain")
.IsUnique()
.HasDatabaseName("ix_linkfix_guildid_olddomain");
b.ToTable("linkfix", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b =>
{
b.Property<int>("Id")
@ -1829,6 +1811,57 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("ncpixel", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowTarget")
.HasColumnType("boolean")
.HasColumnName("allowtarget");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("boolean")
.HasColumnName("autodeletetrigger");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("boolean")
.HasColumnName("containsanywhere");
b.Property<DateTime?>("DateAdded")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateadded");
b.Property<bool>("DmResponse")
.HasColumnType("boolean")
.HasColumnName("dmresponse");
b.Property<decimal?>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<string>("Reactions")
.HasColumnType("text")
.HasColumnName("reactions");
b.Property<string>("Response")
.HasColumnType("text")
.HasColumnName("response");
b.Property<string>("Trigger")
.HasColumnType("text")
.HasColumnName("trigger");
b.HasKey("Id")
.HasName("pk_expressions");
b.ToTable("expressions", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.Notify", b =>
{
b.Property<int>("Id")
@ -2955,6 +2988,52 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("unroletimer", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.UserQuest", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("DateAssigned")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateassigned");
b.Property<bool>("IsCompleted")
.HasColumnType("boolean")
.HasColumnName("iscompleted");
b.Property<int>("Progress")
.HasColumnType("integer")
.HasColumnName("progress");
b.Property<int>("QuestId")
.HasColumnType("integer")
.HasColumnName("questid");
b.Property<int>("QuestNumber")
.HasColumnType("integer")
.HasColumnName("questnumber");
b.Property<decimal>("UserId")
.HasColumnType("numeric(20,0)")
.HasColumnName("userid");
b.HasKey("Id")
.HasName("pk_userquest");
b.HasIndex("UserId")
.HasDatabaseName("ix_userquest_userid");
b.HasIndex("UserId", "QuestNumber", "DateAssigned")
.IsUnique()
.HasDatabaseName("ix_userquest_userid_questnumber_dateassigned");
b.ToTable("userquest", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.UserXpStats", b =>
{
b.Property<int>("Id")
@ -3437,6 +3516,48 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("xpshopowneditem", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Games.Fish.Db.UserFishItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp without time zone")
.HasColumnName("expiresat");
b.Property<bool>("IsEquipped")
.HasColumnType("boolean")
.HasColumnName("isequipped");
b.Property<int>("ItemId")
.HasColumnType("integer")
.HasColumnName("itemid");
b.Property<int>("ItemType")
.HasColumnType("integer")
.HasColumnName("itemtype");
b.Property<decimal>("UserId")
.HasColumnType("numeric(20,0)")
.HasColumnName("userid");
b.Property<int?>("UsesLeft")
.HasColumnType("integer")
.HasColumnName("usesleft");
b.HasKey("Id")
.HasName("pk_userfishitem");
b.HasIndex("UserId")
.HasDatabaseName("ix_userfishitem_userid");
b.ToTable("userfishitem", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Games.FishCatch", b =>
{
b.Property<int>("Id")
@ -3480,14 +3601,6 @@ namespace EllieBot.Migrations.PostgreSql
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("Bait")
.HasColumnType("integer")
.HasColumnName("bait");
b.Property<int?>("Pole")
.HasColumnType("integer")
.HasColumnName("pole");
b.Property<int>("Skill")
.HasColumnType("integer")
.HasColumnName("skill");
@ -4151,4 +4264,4 @@ namespace EllieBot.Migrations.PostgreSql
#pragma warning restore 612, 618
}
}
}
}

View file

@ -550,6 +550,21 @@ namespace EllieBot.Migrations.PostgreSql
table.PrimaryKey("pk_imageonlychannels", x => x.id);
});
migrationBuilder.CreateTable(
name: "linkfix",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
guildid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
olddomain = table.Column<string>(type: "text", nullable: false),
newdomain = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_linkfix", x => x.id);
});
migrationBuilder.CreateTable(
name: "livechannelconfig",
columns: table => new
@ -1094,6 +1109,24 @@ namespace EllieBot.Migrations.PostgreSql
table.PrimaryKey("pk_userbetstats", x => x.id);
});
migrationBuilder.CreateTable(
name: "userfishitem",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
userid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
itemtype = table.Column<int>(type: "integer", nullable: false),
itemid = table.Column<int>(type: "integer", nullable: false),
isequipped = table.Column<bool>(type: "boolean", nullable: false),
usesleft = table.Column<int>(type: "integer", nullable: true),
expiresat = table.Column<DateTime>(type: "timestamp without time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_userfishitem", x => x.id);
});
migrationBuilder.CreateTable(
name: "userfishstats",
columns: table => new
@ -1101,15 +1134,31 @@ namespace EllieBot.Migrations.PostgreSql
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
userid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
skill = table.Column<int>(type: "integer", nullable: false),
pole = table.Column<int>(type: "integer", nullable: true),
bait = table.Column<int>(type: "integer", nullable: true)
skill = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_userfishstats", x => x.id);
});
migrationBuilder.CreateTable(
name: "userquest",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
questnumber = table.Column<int>(type: "integer", nullable: false),
userid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
questid = table.Column<int>(type: "integer", nullable: false),
progress = table.Column<int>(type: "integer", nullable: false),
iscompleted = table.Column<bool>(type: "boolean", nullable: false),
dateassigned = table.Column<DateTime>(type: "timestamp without time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_userquest", x => x.id);
});
migrationBuilder.CreateTable(
name: "userrole",
columns: table => new
@ -1999,6 +2048,12 @@ namespace EllieBot.Migrations.PostgreSql
column: "channelid",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_linkfix_guildid_olddomain",
table: "linkfix",
columns: new[] { "guildid", "olddomain" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_livechannelconfig_guildid",
table: "livechannelconfig",
@ -2212,12 +2267,28 @@ namespace EllieBot.Migrations.PostgreSql
columns: new[] { "userid", "game" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_userfishitem_userid",
table: "userfishitem",
column: "userid");
migrationBuilder.CreateIndex(
name: "ix_userfishstats_userid",
table: "userfishstats",
column: "userid",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_userquest_userid",
table: "userquest",
column: "userid");
migrationBuilder.CreateIndex(
name: "ix_userquest_userid_questnumber_dateassigned",
table: "userquest",
columns: new[] { "userid", "questnumber", "dateassigned" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_userrole_guildid",
table: "userrole",
@ -2501,6 +2572,9 @@ namespace EllieBot.Migrations.PostgreSql
migrationBuilder.DropTable(
name: "imageonlychannels");
migrationBuilder.DropTable(
name: "linkfix");
migrationBuilder.DropTable(
name: "livechannelconfig");
@ -2597,9 +2671,15 @@ namespace EllieBot.Migrations.PostgreSql
migrationBuilder.DropTable(
name: "userbetstats");
migrationBuilder.DropTable(
name: "userfishitem");
migrationBuilder.DropTable(
name: "userfishstats");
migrationBuilder.DropTable(
name: "userquest");
migrationBuilder.DropTable(
name: "userrole");
@ -2679,4 +2759,4 @@ namespace EllieBot.Migrations.PostgreSql
name: "discorduser");
}
}
}
}

View file

@ -864,57 +864,6 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("discorduser", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowTarget")
.HasColumnType("boolean")
.HasColumnName("allowtarget");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("boolean")
.HasColumnName("autodeletetrigger");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("boolean")
.HasColumnName("containsanywhere");
b.Property<DateTime?>("DateAdded")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateadded");
b.Property<bool>("DmResponse")
.HasColumnType("boolean")
.HasColumnName("dmresponse");
b.Property<decimal?>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<string>("Reactions")
.HasColumnType("text")
.HasColumnName("reactions");
b.Property<string>("Response")
.HasColumnType("text")
.HasColumnName("response");
b.Property<string>("Trigger")
.HasColumnType("text")
.HasColumnName("trigger");
b.HasKey("Id")
.HasName("pk_expressions");
b.ToTable("expressions", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b =>
{
b.Property<int>("Id")
@ -1538,6 +1487,39 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("imageonlychannels", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.LinkFix", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<string>("NewDomain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("newdomain");
b.Property<string>("OldDomain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("olddomain");
b.HasKey("Id")
.HasName("pk_linkfix");
b.HasIndex("GuildId", "OldDomain")
.IsUnique()
.HasDatabaseName("ix_linkfix_guildid_olddomain");
b.ToTable("linkfix", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b =>
{
b.Property<int>("Id")
@ -1826,6 +1808,57 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("ncpixel", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowTarget")
.HasColumnType("boolean")
.HasColumnName("allowtarget");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("boolean")
.HasColumnName("autodeletetrigger");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("boolean")
.HasColumnName("containsanywhere");
b.Property<DateTime?>("DateAdded")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateadded");
b.Property<bool>("DmResponse")
.HasColumnType("boolean")
.HasColumnName("dmresponse");
b.Property<decimal?>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<string>("Reactions")
.HasColumnType("text")
.HasColumnName("reactions");
b.Property<string>("Response")
.HasColumnType("text")
.HasColumnName("response");
b.Property<string>("Trigger")
.HasColumnType("text")
.HasColumnName("trigger");
b.HasKey("Id")
.HasName("pk_expressions");
b.ToTable("expressions", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.Notify", b =>
{
b.Property<int>("Id")
@ -2952,6 +2985,52 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("unroletimer", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.UserQuest", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("DateAssigned")
.HasColumnType("timestamp without time zone")
.HasColumnName("dateassigned");
b.Property<bool>("IsCompleted")
.HasColumnType("boolean")
.HasColumnName("iscompleted");
b.Property<int>("Progress")
.HasColumnType("integer")
.HasColumnName("progress");
b.Property<int>("QuestId")
.HasColumnType("integer")
.HasColumnName("questid");
b.Property<int>("QuestNumber")
.HasColumnType("integer")
.HasColumnName("questnumber");
b.Property<decimal>("UserId")
.HasColumnType("numeric(20,0)")
.HasColumnName("userid");
b.HasKey("Id")
.HasName("pk_userquest");
b.HasIndex("UserId")
.HasDatabaseName("ix_userquest_userid");
b.HasIndex("UserId", "QuestNumber", "DateAssigned")
.IsUnique()
.HasDatabaseName("ix_userquest_userid_questnumber_dateassigned");
b.ToTable("userquest", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.UserXpStats", b =>
{
b.Property<int>("Id")
@ -3434,6 +3513,48 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("xpshopowneditem", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Games.Fish.Db.UserFishItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp without time zone")
.HasColumnName("expiresat");
b.Property<bool>("IsEquipped")
.HasColumnType("boolean")
.HasColumnName("isequipped");
b.Property<int>("ItemId")
.HasColumnType("integer")
.HasColumnName("itemid");
b.Property<int>("ItemType")
.HasColumnType("integer")
.HasColumnName("itemtype");
b.Property<decimal>("UserId")
.HasColumnType("numeric(20,0)")
.HasColumnName("userid");
b.Property<int?>("UsesLeft")
.HasColumnType("integer")
.HasColumnName("usesleft");
b.HasKey("Id")
.HasName("pk_userfishitem");
b.HasIndex("UserId")
.HasDatabaseName("ix_userfishitem_userid");
b.ToTable("userfishitem", (string)null);
});
modelBuilder.Entity("EllieBot.Modules.Games.FishCatch", b =>
{
b.Property<int>("Id")
@ -3477,14 +3598,6 @@ namespace EllieBot.Migrations.PostgreSql
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("Bait")
.HasColumnType("integer")
.HasColumnName("bait");
b.Property<int?>("Pole")
.HasColumnType("integer")
.HasColumnName("pole");
b.Property<int>("Skill")
.HasColumnType("integer")
.HasColumnName("skill");
@ -4148,4 +4261,4 @@ namespace EllieBot.Migrations.PostgreSql
#pragma warning restore 612, 618
}
}
}
}

View file

@ -0,0 +1,15 @@
BEGIN TRANSACTION;
CREATE TABLE "LinkFix" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_LinkFix" PRIMARY KEY AUTOINCREMENT,
"GuildId" INTEGER NOT NULL,
"OldDomain" TEXT NOT NULL,
"NewDomain" TEXT NOT NULL
);
CREATE UNIQUE INDEX "IX_LinkFix_GuildId_OldDomain" ON "LinkFix" ("GuildId", "OldDomain");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250323021857_linkfixer', '9.0.1');
COMMIT;

View file

@ -0,0 +1,19 @@
BEGIN TRANSACTION;
CREATE TABLE "UserQuest" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_UserQuest" PRIMARY KEY AUTOINCREMENT,
"QuestNumber" INTEGER NOT NULL,
"UserId" INTEGER NOT NULL,
"QuestId" INTEGER NOT NULL,
"Progress" INTEGER NOT NULL,
"IsCompleted" INTEGER NOT NULL,
"DateAssigned" TEXT NOT NULL
);
CREATE INDEX "IX_UserQuest_UserId" ON "UserQuest" ("UserId");
CREATE UNIQUE INDEX "IX_UserQuest_UserId_QuestNumber_DateAssigned" ON "UserQuest" ("UserId", "QuestNumber", "DateAssigned");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250324230801_quests', '9.0.1');
COMMIT;

View file

@ -0,0 +1,43 @@
BEGIN TRANSACTION;
CREATE TABLE "UserFishItem" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_UserFishItem" PRIMARY KEY AUTOINCREMENT,
"UserId" INTEGER NOT NULL,
"ItemType" INTEGER NOT NULL,
"ItemId" INTEGER NOT NULL,
"IsEquipped" INTEGER NOT NULL,
"UsesLeft" INTEGER NULL,
"ExpiresAt" TEXT NULL
);
CREATE INDEX "IX_UserFishItem_UserId" ON "UserFishItem" ("UserId");
CREATE TABLE "ef_temp_UserFishStats" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_UserFishStats" PRIMARY KEY AUTOINCREMENT,
"Skill" INTEGER NOT NULL,
"UserId" INTEGER NOT NULL
);
INSERT INTO "ef_temp_UserFishStats" ("Id", "Skill", "UserId")
SELECT "Id", "Skill", "UserId"
FROM "UserFishStats";
COMMIT;
PRAGMA foreign_keys = 0;
BEGIN TRANSACTION;
DROP TABLE "UserFishStats";
ALTER TABLE "ef_temp_UserFishStats" RENAME TO "UserFishStats";
COMMIT;
PRAGMA foreign_keys = 1;
BEGIN TRANSACTION;
CREATE UNIQUE INDEX "IX_UserFishStats_UserId" ON "UserFishStats" ("UserId");
COMMIT;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250327001835_fishitems', '9.0.1');

View file

@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace EllieBot.Migrations.Sqlite
{
[DbContext(typeof(SqliteContext))]
[Migration("20250319010920_init")]
[Migration("20250327001909_init")]
partial class init
{
/// <inheritdoc />
@ -649,44 +649,6 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("DiscordUser");
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowTarget")
.HasColumnType("INTEGER");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("INTEGER");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("DmResponse")
.HasColumnType("INTEGER");
b.Property<ulong?>("GuildId")
.HasColumnType("INTEGER");
b.Property<string>("Reactions")
.HasColumnType("TEXT");
b.Property<string>("Response")
.HasColumnType("TEXT");
b.Property<string>("Trigger")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Expressions");
});
modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b =>
{
b.Property<int>("Id")
@ -1151,6 +1113,31 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("ImageOnlyChannels");
});
modelBuilder.Entity("EllieBot.Db.Models.LinkFix", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<string>("NewDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OldDomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GuildId", "OldDomain")
.IsUnique();
b.ToTable("LinkFix");
});
modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b =>
{
b.Property<int>("Id")
@ -1366,6 +1353,44 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("NCPixel");
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowTarget")
.HasColumnType("INTEGER");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("INTEGER");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("DmResponse")
.HasColumnType("INTEGER");
b.Property<ulong?>("GuildId")
.HasColumnType("INTEGER");
b.Property<string>("Reactions")
.HasColumnType("TEXT");
b.Property<string>("Response")
.HasColumnType("TEXT");
b.Property<string>("Trigger")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Expressions");
});
modelBuilder.Entity("EllieBot.Db.Models.Notify", b =>
{
b.Property<int>("Id")
@ -2202,6 +2227,40 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("UnroleTimer");
});
modelBuilder.Entity("EllieBot.Db.Models.UserQuest", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAssigned")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<int>("Progress")
.HasColumnType("INTEGER");
b.Property<int>("QuestId")
.HasColumnType("INTEGER");
b.Property<int>("QuestNumber")
.HasColumnType("INTEGER");
b.Property<ulong>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("UserId", "QuestNumber", "DateAssigned")
.IsUnique();
b.ToTable("UserQuest");
});
modelBuilder.Entity("EllieBot.Db.Models.UserXpStats", b =>
{
b.Property<int>("Id")
@ -2557,6 +2616,37 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("XpShopOwnedItem");
});
modelBuilder.Entity("EllieBot.Modules.Games.Fish.Db.UserFishItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsEquipped")
.HasColumnType("INTEGER");
b.Property<int>("ItemId")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<ulong>("UserId")
.HasColumnType("INTEGER");
b.Property<int?>("UsesLeft")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserFishItem");
});
modelBuilder.Entity("EllieBot.Modules.Games.FishCatch", b =>
{
b.Property<int>("Id")
@ -2588,12 +2678,6 @@ namespace EllieBot.Migrations.Sqlite
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("Bait")
.HasColumnType("INTEGER");
b.Property<int?>("Pole")
.HasColumnType("INTEGER");
b.Property<int>("Skill")
.HasColumnType("INTEGER");
@ -3156,4 +3240,4 @@ namespace EllieBot.Migrations.Sqlite
#pragma warning restore 612, 618
}
}
}
}

View file

@ -550,6 +550,21 @@ namespace EllieBot.Migrations.Sqlite
table.PrimaryKey("PK_ImageOnlyChannels", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LinkFix",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GuildId = table.Column<ulong>(type: "INTEGER", nullable: false),
OldDomain = table.Column<string>(type: "TEXT", nullable: false),
NewDomain = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LinkFix", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LiveChannelConfig",
columns: table => new
@ -1096,6 +1111,24 @@ namespace EllieBot.Migrations.Sqlite
table.PrimaryKey("PK_UserBetStats", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserFishItem",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<ulong>(type: "INTEGER", nullable: false),
ItemType = table.Column<int>(type: "INTEGER", nullable: false),
ItemId = table.Column<int>(type: "INTEGER", nullable: false),
IsEquipped = table.Column<bool>(type: "INTEGER", nullable: false),
UsesLeft = table.Column<int>(type: "INTEGER", nullable: true),
ExpiresAt = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserFishItem", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserFishStats",
columns: table => new
@ -1103,15 +1136,31 @@ namespace EllieBot.Migrations.Sqlite
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<ulong>(type: "INTEGER", nullable: false),
Skill = table.Column<int>(type: "INTEGER", nullable: false),
Pole = table.Column<int>(type: "INTEGER", nullable: true),
Bait = table.Column<int>(type: "INTEGER", nullable: true)
Skill = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserFishStats", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserQuest",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
QuestNumber = table.Column<int>(type: "INTEGER", nullable: false),
UserId = table.Column<ulong>(type: "INTEGER", nullable: false),
QuestId = table.Column<int>(type: "INTEGER", nullable: false),
Progress = table.Column<int>(type: "INTEGER", nullable: false),
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false),
DateAssigned = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserQuest", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserRole",
columns: table => new
@ -2001,6 +2050,12 @@ namespace EllieBot.Migrations.Sqlite
column: "ChannelId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LinkFix_GuildId_OldDomain",
table: "LinkFix",
columns: new[] { "GuildId", "OldDomain" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LiveChannelConfig_GuildId",
table: "LiveChannelConfig",
@ -2214,12 +2269,28 @@ namespace EllieBot.Migrations.Sqlite
columns: new[] { "UserId", "Game" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserFishItem_UserId",
table: "UserFishItem",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserFishStats_UserId",
table: "UserFishStats",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserQuest_UserId",
table: "UserQuest",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserQuest_UserId_QuestNumber_DateAssigned",
table: "UserQuest",
columns: new[] { "UserId", "QuestNumber", "DateAssigned" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserRole_GuildId",
table: "UserRole",
@ -2503,6 +2574,9 @@ namespace EllieBot.Migrations.Sqlite
migrationBuilder.DropTable(
name: "ImageOnlyChannels");
migrationBuilder.DropTable(
name: "LinkFix");
migrationBuilder.DropTable(
name: "LiveChannelConfig");
@ -2599,9 +2673,15 @@ namespace EllieBot.Migrations.Sqlite
migrationBuilder.DropTable(
name: "UserBetStats");
migrationBuilder.DropTable(
name: "UserFishItem");
migrationBuilder.DropTable(
name: "UserFishStats");
migrationBuilder.DropTable(
name: "UserQuest");
migrationBuilder.DropTable(
name: "UserRole");
@ -2681,4 +2761,4 @@ namespace EllieBot.Migrations.Sqlite
name: "DiscordUser");
}
}
}
}

View file

@ -646,44 +646,6 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("DiscordUser");
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowTarget")
.HasColumnType("INTEGER");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("INTEGER");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("DmResponse")
.HasColumnType("INTEGER");
b.Property<ulong?>("GuildId")
.HasColumnType("INTEGER");
b.Property<string>("Reactions")
.HasColumnType("TEXT");
b.Property<string>("Response")
.HasColumnType("TEXT");
b.Property<string>("Trigger")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Expressions");
});
modelBuilder.Entity("EllieBot.Db.Models.FeedSub", b =>
{
b.Property<int>("Id")
@ -1148,6 +1110,31 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("ImageOnlyChannels");
});
modelBuilder.Entity("EllieBot.Db.Models.LinkFix", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<string>("NewDomain")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OldDomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GuildId", "OldDomain")
.IsUnique();
b.ToTable("LinkFix");
});
modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b =>
{
b.Property<int>("Id")
@ -1363,6 +1350,44 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("NCPixel");
});
modelBuilder.Entity("EllieBot.Db.Models.EllieExpression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowTarget")
.HasColumnType("INTEGER");
b.Property<bool>("AutoDeleteTrigger")
.HasColumnType("INTEGER");
b.Property<bool>("ContainsAnywhere")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("DmResponse")
.HasColumnType("INTEGER");
b.Property<ulong?>("GuildId")
.HasColumnType("INTEGER");
b.Property<string>("Reactions")
.HasColumnType("TEXT");
b.Property<string>("Response")
.HasColumnType("TEXT");
b.Property<string>("Trigger")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Expressions");
});
modelBuilder.Entity("EllieBot.Db.Models.Notify", b =>
{
b.Property<int>("Id")
@ -2199,6 +2224,40 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("UnroleTimer");
});
modelBuilder.Entity("EllieBot.Db.Models.UserQuest", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAssigned")
.HasColumnType("TEXT");
b.Property<bool>("IsCompleted")
.HasColumnType("INTEGER");
b.Property<int>("Progress")
.HasColumnType("INTEGER");
b.Property<int>("QuestId")
.HasColumnType("INTEGER");
b.Property<int>("QuestNumber")
.HasColumnType("INTEGER");
b.Property<ulong>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("UserId", "QuestNumber", "DateAssigned")
.IsUnique();
b.ToTable("UserQuest");
});
modelBuilder.Entity("EllieBot.Db.Models.UserXpStats", b =>
{
b.Property<int>("Id")
@ -2554,6 +2613,37 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("XpShopOwnedItem");
});
modelBuilder.Entity("EllieBot.Modules.Games.Fish.Db.UserFishItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("IsEquipped")
.HasColumnType("INTEGER");
b.Property<int>("ItemId")
.HasColumnType("INTEGER");
b.Property<int>("ItemType")
.HasColumnType("INTEGER");
b.Property<ulong>("UserId")
.HasColumnType("INTEGER");
b.Property<int?>("UsesLeft")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserFishItem");
});
modelBuilder.Entity("EllieBot.Modules.Games.FishCatch", b =>
{
b.Property<int>("Id")
@ -2585,12 +2675,6 @@ namespace EllieBot.Migrations.Sqlite
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("Bait")
.HasColumnType("INTEGER");
b.Property<int?>("Pole")
.HasColumnType("INTEGER");
b.Property<int>("Skill")
.HasColumnType("INTEGER");
@ -3153,4 +3237,4 @@ namespace EllieBot.Migrations.Sqlite
#pragma warning restore 612, 618
}
}
}
}

View file

@ -124,7 +124,7 @@ public partial class Administration : EllieModule<AdministrationService>
[Priority(1)]
public async Task Delmsgoncmd(Server _ = Server.Server)
{
var enabled = await _service.ToggleDeleteMessageOnCommand(ctx.Guild.Id);
var enabled = await _service.ToggleDelMsgOnCmd(ctx.Guild.Id);
if (enabled)
{
await Response().Confirm(strs.delmsg_on).SendAsync();

View file

@ -1,7 +1,5 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using EllieBot.Modules.Administration._common.results;
@ -10,8 +8,8 @@ namespace EllieBot.Modules.Administration;
public class AdministrationService : IEService, IReadyExecutor
{
private ConcurrentHashSet<ulong> deleteMessagesOnCommand;
private ConcurrentDictionary<ulong, bool> delMsgOnCmdChannels;
private ConcurrentHashSet<ulong> _deleteMessagesOnCommand;
private ConcurrentDictionary<ulong, bool> _delMsgOnCmdChannels;
private readonly DbService _db;
private readonly IReplacementService _repSvc;
@ -39,31 +37,32 @@ public class AdministrationService : IEService, IReadyExecutor
public async Task OnReadyAsync()
{
await using var uow = _db.GetDbContext();
deleteMessagesOnCommand = new(await uow.GetTable<GuildConfig>()
.Where(x => Queries.GuildOnShard(x.GuildId, _shardData.TotalShards, _shardData.ShardId) && x.DeleteMessageOnCommand)
.Select(x => x.GuildId)
.ToListAsyncLinqToDB());
_deleteMessagesOnCommand = new(await uow.GetTable<GuildConfig>()
.Where(x => Queries.GuildOnShard(x.GuildId, _shardData.TotalShards, _shardData.ShardId) &&
x.DeleteMessageOnCommand)
.Select(x => x.GuildId)
.ToListAsyncLinqToDB());
delMsgOnCmdChannels = (await uow.GetTable<DelMsgOnCmdChannel>()
.Where(x => deleteMessagesOnCommand.Contains(x.GuildId))
.ToDictionaryAsyncLinqToDB(x => x.ChannelId, x => x.State))
.ToConcurrent();
_delMsgOnCmdChannels = (await uow.GetTable<DelMsgOnCmdChannel>()
.Where(x => _deleteMessagesOnCommand.Contains(x.GuildId))
.ToDictionaryAsyncLinqToDB(x => x.ChannelId, x => x.State))
.ToConcurrent();
_cmdHandler.CommandExecuted += DelMsgOnCmd_Handler;
}
public async Task<(bool DelMsgOnCmd, IEnumerable<DelMsgOnCmdChannel> channels)> GetDelMsgOnCmdData(ulong guildId)
{
using var uow = _db.GetDbContext();
await using var uow = _db.GetDbContext();
var conf = await uow.GetTable<GuildConfig>()
.Where(x => x.GuildId == guildId)
.Select(x => x.DeleteMessageOnCommand)
.FirstOrDefaultAsyncLinqToDB();
.Where(x => x.GuildId == guildId)
.Select(x => x.DeleteMessageOnCommand)
.FirstOrDefaultAsyncLinqToDB();
var channels = await uow.GetTable<DelMsgOnCmdChannel>()
.Where(x => x.GuildId == guildId)
.ToListAsyncLinqToDB();
.Where(x => x.GuildId == guildId)
.ToListAsyncLinqToDB();
return (conf, channels);
}
@ -76,50 +75,50 @@ public class AdministrationService : IEService, IReadyExecutor
_ = Task.Run(async () =>
{
//wat ?!
if (delMsgOnCmdChannels.TryGetValue(channel.Id, out var state))
if (_delMsgOnCmdChannels.TryGetValue(channel.Id, out var state))
{
if (state && cmd.Name != "prune" && cmd.Name != "pick")
{
_logService.AddDeleteIgnore(msg.Id);
try
{ await msg.DeleteAsync(); }
catch { }
{
await msg.DeleteAsync();
}
catch
{
}
}
//if state is false, that means do not do it
}
else if (deleteMessagesOnCommand.Contains(channel.Guild.Id) && cmd.Name != "prune" && cmd.Name != "pick")
else if (_deleteMessagesOnCommand.Contains(channel.Guild.Id) && cmd.Name != "prune" && cmd.Name != "pick")
{
_logService.AddDeleteIgnore(msg.Id);
try
{ await msg.DeleteAsync(); }
catch { }
{
await msg.DeleteAsync();
}
catch
{
}
}
});
return Task.CompletedTask;
}
public async Task<bool> ToggleDeleteMessageOnCommand(ulong guildId)
public async Task<bool> ToggleDelMsgOnCmd(ulong guildId)
{
using var uow = _db.GetDbContext();
await using var uow = _db.GetDbContext();
var conf = await uow.GetTable<GuildConfig>()
.Where(x => x.GuildId == guildId)
.UpdateWithOutputAsync(x => new()
{
DeleteMessageOnCommand = !x.DeleteMessageOnCommand
}, (old, newVal) => newVal);
var gc = uow.GuildConfigsForId(guildId);
gc.DeleteMessageOnCommand = !gc.DeleteMessageOnCommand;
if (conf.Length == 0)
return false;
var val = conf[0].DeleteMessageOnCommand;
if (val)
deleteMessagesOnCommand.Add(guildId);
if (gc.DeleteMessageOnCommand)
_deleteMessagesOnCommand.Add(guildId);
else
deleteMessagesOnCommand.TryRemove(guildId);
_deleteMessagesOnCommand.TryRemove(guildId);
return val;
await uow.SaveChangesAsync();
return gc.DeleteMessageOnCommand;
}
public async Task SetDelMsgOnCmdState(ulong guildId, ulong chId, Administration.State newState)
@ -151,7 +150,7 @@ public class AdministrationService : IEService, IReadyExecutor
}
old.State = newState == Administration.State.Enable;
delMsgOnCmdChannels[chId] = newState == Administration.State.Enable;
_delMsgOnCmdChannels[chId] = newState == Administration.State.Enable;
}
await uow.SaveChangesAsync();
@ -162,11 +161,11 @@ public class AdministrationService : IEService, IReadyExecutor
}
else if (newState == Administration.State.Enable)
{
delMsgOnCmdChannels[chId] = true;
_delMsgOnCmdChannels[chId] = true;
}
else
{
delMsgOnCmdChannels.TryRemove(chId, out _);
_delMsgOnCmdChannels.TryRemove(chId, out _);
}
}
@ -249,5 +248,6 @@ public class AdministrationService : IEService, IReadyExecutor
return SetServerIconResult.Success;
}
private bool IsValidUri(string img) => !string.IsNullOrWhiteSpace(img) && Uri.IsWellFormedUriString(img, UriKind.Absolute);
private bool IsValidUri(string img)
=> !string.IsNullOrWhiteSpace(img) && Uri.IsWellFormedUriString(img, UriKind.Absolute);
}

View file

@ -0,0 +1,42 @@
#nullable disable
using EllieBot.Db.Models;
using EllieBot.Modules.Games;
namespace EllieBot.Modules.Administration.Services;
public record struct NiceCatchNotifyModel(
ulong UserId,
FishData Fish,
string Stars
) : INotifyModel<NiceCatchNotifyModel>
{
public static string KeyName
=> "notify.nicecatch";
public static NotifyType NotifyType
=> NotifyType.NiceCatch;
public const string PH_EMOJI = "fish.emoji";
public const string PH_IMAGE = "fish.image";
public const string PH_NAME = "fish.name";
public const string PH_STARS = "fish.stars";
public const string PH_FLUFF = "fish.fluff";
public bool TryGetUserId(out ulong userId)
{
userId = UserId;
return true;
}
public static IReadOnlyList<NotifyModelPlaceholderData<NiceCatchNotifyModel>> GetReplacements()
{
return
[
new(PH_EMOJI, static (data, _) => data.Fish.Emoji),
new(PH_IMAGE, static (data, _) => data.Fish.Image),
new(PH_NAME, static (data, _) => data.Fish.Name),
new(PH_STARS, static (data, _) => data.Stars),
new(PH_FLUFF, static (data, _) => data.Fish.Fluff),
];
}
}

View file

@ -8,7 +8,7 @@ public partial class Administration
public class NotifyCommands : EllieModule<NotifyService>
{
[Cmd]
[UserPerm(GuildPerm.Administrator)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task Notify()
{
await Response()
@ -38,11 +38,12 @@ public partial class Administration
NotifyType.Protection => strs.notify_desc_protection,
NotifyType.AddRoleReward => strs.notify_desc_addrolerew,
NotifyType.RemoveRoleReward => strs.notify_desc_removerolerew,
NotifyType.NiceCatch => strs.notify_desc_nicecatch,
_ => strs.notify_desc_not_found
};
[Cmd]
[UserPerm(GuildPerm.Administrator)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task Notify(NotifyType nType)
{
// show msg
@ -76,12 +77,12 @@ public partial class Administration
}
[Cmd]
[UserPerm(GuildPerm.Administrator)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task Notify(NotifyType nType, [Leftover] string message)
=> await NotifyInternalAsync(nType, null, message);
[Cmd]
[UserPerm(GuildPerm.Administrator)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task Notify(NotifyType nType, IMessageChannel channel, [Leftover] string message)
=> await NotifyInternalAsync(nType, channel, message);
@ -89,6 +90,14 @@ public partial class Administration
{
var result = await _service.EnableAsync(ctx.Guild.Id, channel?.Id, nType, message);
if(!result)
{
await Response()
.Error(strs.notify_cant_set)
.SendAsync();
return;
}
var outChannel = channel is null ? "origin" : $"<#{channel.Id}>";
await Response()
.Confirm(strs.notify_on(outChannel, Format.Bold(nType.ToString())))
@ -96,7 +105,7 @@ public partial class Administration
}
[Cmd]
[UserPerm(GuildPerm.Administrator)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task NotifyPhs(NotifyType nType)
{
var data = _service.GetRegisteredModel(nType);
@ -111,7 +120,7 @@ public partial class Administration
}
[Cmd]
[UserPerm(GuildPerm.Administrator)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task NotifyList(int page = 1)
{
if (--page < 0)
@ -139,7 +148,7 @@ public partial class Administration
}
[Cmd]
[UserPerm(GuildPerm.Administrator)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task NotifyClear(NotifyType nType)
{
await _service.DisableAsync(ctx.Guild.Id, nType);

View file

@ -42,12 +42,11 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
RegisterModel<ProtectionNotifyModel>();
RegisterModel<AddRoleRewardNotifyModel>();
RegisterModel<RemoveRoleRewardNotifyModel>();
RegisterModel<NiceCatchNotifyModel>();
}
public async Task OnReadyAsync()
{
RegisterModels();
await using var uow = _db.GetDbContext();
_events = (await uow.GetTable<Notify>()
.Where(x => Queries.GuildOnShard(x.GuildId,
@ -57,9 +56,8 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
.GroupBy(x => x.Type)
.ToDictionary(x => x.Key, x => x.ToDictionary(x => x.GuildId).ToConcurrent())
.ToConcurrent();
await SubscribeToEvent<LevelUpNotifyModel>();
RegisterModels();
}
private async Task SubscribeToEvent<T>()
@ -75,7 +73,7 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
{
if (isShardLocal)
{
await OnEvent(data);
_ = Task.Run(async () => await OnEvent(data));
return;
}
@ -160,7 +158,15 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
IUser? user = null;
if (model.TryGetUserId(out var userId))
{
user = guild.GetUser(userId) ?? _client.GetUser(userId);
try
{
user = guild.GetUser(userId)
?? await _client.GetUserAsync(userId);
}
catch
{
user = null;
}
}
var rctx = new ReplacementContext(guild: guild, channel: channel, user: user);
@ -283,7 +289,10 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
var data = new NotifyModelData(T.NotifyType,
T.SupportsOriginTarget,
T.GetReplacements().Map(x => x.Name));
_models[T.NotifyType] = data;
_pubSub.Sub<T>(new(T.KeyName), async (data) => await OnEvent(data));
}
public NotifyModelData GetRegisteredModel(NotifyType nType)

View file

@ -391,7 +391,7 @@ public class ProtectionService : IReadyExecutor, IEService
{
var obj = new AntiSpamIgnore
{
ChannelId = channelId
ChannelId = channelId,
};
await using var uow = _db.GetDbContext();
@ -529,16 +529,18 @@ public class ProtectionService : IReadyExecutor, IEService
};
}
var spamConfigs = await uow.GetTable<AntiSpamSetting>()
var spamConfigs = await uow.Set<AntiSpamSetting>()
.AsNoTracking()
.Where(x => Queries.GuildOnShard(x.GuildId, _shardData.TotalShards, _shardData.ShardId))
.ToListAsyncLinqToDB();
.Where(x => gids.Contains(x.GuildId))
.Include(x => x.IgnoredChannels)
.ToListAsyncEF();
foreach (var config in spamConfigs)
{
_antiSpamGuilds[config.GuildId] = new()
{
AntiSpamSettings = config,
UserStats = new()
};
}

View file

@ -40,7 +40,9 @@ public partial class Administration
var progressMsg = await Response().Pending(strs.prune_progress(0, 100)).SendAsync();
var progress = GetProgressTracker(progressMsg);
var result = await _service.PruneWhere(ctx.Channel,
var result = await _service.PruneWhere(
ctx.User.Id,
ctx.Channel,
100,
x => x.Author.Id == ctx.Client.CurrentUser.Id,
progress);
@ -66,13 +68,17 @@ public partial class Administration
PruneResult result;
if (opts.Safe)
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
result = await _service.PruneWhere(
ctx.User.Id,
(ITextChannel)ctx.Channel,
100,
x => x.Author.Id == user.Id && !x.IsPinned,
progress,
opts.After);
else
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
result = await _service.PruneWhere(
ctx.User.Id,
(ITextChannel)ctx.Channel,
100,
x => x.Author.Id == user.Id,
progress,
@ -107,13 +113,17 @@ public partial class Administration
PruneResult result;
if (opts.Safe)
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
result = await _service.PruneWhere(
ctx.User.Id,
ctx.Channel,
count,
x => !x.IsPinned && x.Id != progressMsg.Id,
progress,
opts.After);
else
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
result = await _service.PruneWhere(
ctx.User.Id,
ctx.Channel,
count,
x => x.Id != progressMsg.Id,
progress,
@ -133,13 +143,14 @@ public partial class Administration
await progressMsg.ModifyAsync(props =>
{
props.Embed = CreateEmbed()
.WithPendingColor()
.WithDescription(GetText(strs.prune_progress(deleted, total)))
.Build();
.WithPendingColor()
.WithDescription(GetText(strs.prune_progress(deleted, total)))
.Build();
});
}
catch
{
// ignored
}
});
@ -182,7 +193,9 @@ public partial class Administration
PruneResult result;
if (opts.Safe)
{
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
result = await _service.PruneWhere(
ctx.User.Id,
ctx.Channel,
count,
m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks && !m.IsPinned,
progress,
@ -191,7 +204,9 @@ public partial class Administration
}
else
{
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
result = await _service.PruneWhere(
ctx.User.Id,
ctx.Channel,
count,
m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks,
progress,
@ -233,7 +248,7 @@ public partial class Administration
msg.DeleteAfter(5);
break;
case PruneResult.FeatureLimit:
var msg2 = await Response().Pending(strs.feature_limit_reached_owner).SendAsync();
var msg2 = await Response().Pending(strs.prune_patron).SendAsync();
msg2.DeleteAfter(10);
break;
default:

View file

@ -1,23 +1,13 @@
#nullable disable
using EllieBot.Modules.Patronage;
namespace EllieBot.Modules.Administration.Services;
public class PruneService : IEService
public class PruneService(ILogCommandService logService) : IEService
{
//channelids where prunes are currently occuring
private readonly ConcurrentDictionary<ulong, CancellationTokenSource> _pruningGuilds = new();
private readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14);
private readonly ILogCommandService _logService;
private readonly IPatronageService _ps;
public PruneService(ILogCommandService logService, IPatronageService ps)
{
_logService = logService;
_ps = ps;
}
public async Task<PruneResult> PruneWhere(
ulong runningUserId,
IMessageChannel channel,
int amount,
Func<IMessage, bool> predicate,
@ -37,11 +27,6 @@ public class PruneService : IEService
try
{
if (channel is ITextChannel tc && !await _ps.LimitHitAsync(LimitedFeatureName.Prune, tc.Guild.OwnerId))
{
return PruneResult.FeatureLimit;
}
var now = DateTime.UtcNow;
IMessage[] msgs;
IMessage lastMessage = null;
@ -67,7 +52,7 @@ public class PruneService : IEService
var singleDeletable = new List<IMessage>();
foreach (var x in msgs)
{
_logService.AddDeleteIgnore(x.Id);
logService.AddDeleteIgnore(x.Id);
if (now - x.CreatedAt < _twoWeeks)
bulkDeletable.Add(x);

View file

@ -57,8 +57,7 @@ public partial class Administration
_ => ctx.OkAsync(),
async fl =>
{
_ = msg.RemoveReactionAsync(emote, ctx.Client.CurrentUser);
await Response().Pending(strs.feature_limit_reached_owner).SendAsync();
await msg.RemoveReactionAsync(emote, ctx.Client.CurrentUser);
});
}

View file

@ -48,9 +48,9 @@ public partial class Administration
});
await Response()
.Confirm(strs.setrole(Format.Bold(roleToAdd.Name),
Format.Bold(targetUser.ToString())))
.SendAsync();
.Confirm(strs.setrole(Format.Bold(roleToAdd.Name),
Format.Bold(targetUser.ToString())))
.SendAsync();
}
catch (Exception ex)
{
@ -73,9 +73,9 @@ public partial class Administration
{
await targetUser.RemoveRoleAsync(roleToRemove);
await Response()
.Confirm(strs.remrole(Format.Bold(roleToRemove.Name),
Format.Bold(targetUser.ToString())))
.SendAsync();
.Confirm(strs.remrole(Format.Bold(roleToRemove.Name),
Format.Bold(targetUser.ToString())))
.SendAsync();
}
catch
{
@ -226,8 +226,8 @@ public partial class Administration
if (!await CheckRoleHierarchy(role))
{
await Response()
.Error(strs.hierarchy)
.SendAsync();
.Error(strs.hierarchy)
.SendAsync();
return;
}
@ -236,10 +236,83 @@ public partial class Administration
await Response()
.Confirm(strs.temp_role_added(user.Mention,
Format.Bold(role.Name),
TimestampTag.FromDateTime(DateTime.UtcNow.Add(timespan.Time), TimestampTagStyles.Relative)))
.SendAsync();
.Confirm(strs.temp_role_added(user.Mention,
Format.Bold(role.Name),
TimestampTag.FromDateTime(DateTime.UtcNow.Add(timespan.Time), TimestampTagStyles.Relative)))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public Task RoleIcon(IRole role, Emote emote)
=> RoleIcon(role, emote.Url);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task RoleIcon(IRole role, [Leftover] string iconUrl)
{
if (!await CheckRoleHierarchy(role))
return;
if (string.IsNullOrWhiteSpace(iconUrl))
{
await Response().Error(strs.userrole_icon_invalid).SendAsync();
return;
}
// Validate the URL format
if (!Uri.TryCreate(iconUrl, UriKind.Absolute, out var uri))
{
await Response().Error(strs.userrole_icon_invalid).SendAsync();
return;
}
// Download the image
using var httpClient = new HttpClient();
using var response = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
// Check if the response is successful
if (!response.IsSuccessStatusCode)
{
await Response().Error(strs.userrole_icon_fail).SendAsync();
return;
}
// Check content type - must be image/png or image/jpeg
var contentType = response.Content.Headers.ContentType?.MediaType?.ToLower();
if (contentType != "image/png"
&& contentType != "image/jpeg"
&& contentType != "image/webp")
{
await Response().Error(strs.userrole_icon_fail).SendAsync();
return;
}
// Check file size - Discord limit is 256KB
var contentLength = response.Content.Headers.ContentLength;
if (contentLength is > 256 * 1024)
{
await Response().Error(strs.userrole_icon_fail).SendAsync();
return;
}
// Save the image to a memory stream
await using var stream = await response.Content.ReadAsStreamAsync();
await using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
// Create Discord image from stream
using var discordImage = new Image(memoryStream);
// Upload the image to Discord
await role.ModifyAsync(r => r.Icon = discordImage);
await Response().Confirm(strs.userrole_icon_success(Format.Bold(role.Name))).SendAsync();
}
}
}

View file

@ -494,6 +494,9 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
.WithFooter(CurrentTime(usr.Guild))
.WithOkColor();
if (!string.IsNullOrWhiteSpace(reason))
embed.WithDescription(reason);
await _sender.Response(logChannel).Embed(embed).SendAsync();
}
catch

View file

@ -1,6 +1,7 @@
#nullable disable
using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
using EllieBot.Modules.Games.Common;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Gambling.Common.AnimalRacing;
@ -35,12 +36,18 @@ public sealed class AnimalRace : IDisposable
private readonly ICurrencyService _currency;
private readonly RaceOptions _options;
private readonly Queue<RaceAnimal> _animalsQueue;
private readonly QuestService _quests;
public AnimalRace(RaceOptions options, ICurrencyService currency, IEnumerable<RaceAnimal> availableAnimals)
public AnimalRace(
RaceOptions options,
ICurrencyService currency,
IEnumerable<RaceAnimal> availableAnimals,
QuestService quests)
{
_currency = currency;
_options = options;
_animalsQueue = new(availableAnimals);
_quests = quests;
MaxUsers = _animalsQueue.Count;
if (_animalsQueue.Count == 0)
@ -60,7 +67,10 @@ public sealed class AnimalRace : IDisposable
await Start();
}
finally { _locker.Release(); }
finally
{
_locker.Release();
}
});
public async Task<AnimalRacingUser> JoinRace(ulong userId, string userName, long bet = 0)
@ -93,7 +103,10 @@ public sealed class AnimalRace : IDisposable
return user;
}
finally { _locker.Release(); }
finally
{
_locker.Release();
}
}
private async Task Start()
@ -104,7 +117,9 @@ public sealed class AnimalRace : IDisposable
foreach (var user in _users)
{
if (user.Bet > 0)
await _currency.AddAsync(user.UserId, (long)(user.Bet + BASE_MULTIPLIER), new("animalrace", "refund"));
await _currency.AddAsync(user.UserId,
(long)(user.Bet * BASE_MULTIPLIER),
new("animalrace", "refund"));
}
_ = OnStartingFailed?.Invoke(this);
@ -112,6 +127,11 @@ public sealed class AnimalRace : IDisposable
return;
}
foreach (var user in _users)
{
await _quests.ReportActionAsync(user.UserId, QuestEventType.RaceJoined);
}
_ = OnStarted?.Invoke(this);
_ = Task.Run(async () =>
{

View file

@ -4,6 +4,7 @@ using EllieBot.Modules.Gambling.Common;
using EllieBot.Modules.Gambling.Common.AnimalRacing;
using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
using EllieBot.Modules.Gambling.Services;
using EllieBot.Modules.Games.Quests;
using EllieBot.Modules.Games.Services;
namespace EllieBot.Modules.Gambling;
@ -17,6 +18,7 @@ public partial class Gambling
private readonly ICurrencyService _cs;
private readonly DiscordSocketClient _client;
private readonly GamesConfigService _gamesConf;
private readonly QuestService _quests;
private IUserMessage raceMessage;
@ -24,12 +26,14 @@ public partial class Gambling
ICurrencyService cs,
DiscordSocketClient client,
GamblingConfigService gamblingConf,
GamesConfigService gamesConf)
GamesConfigService gamesConf,
QuestService quests)
: base(gamblingConf)
{
_cs = cs;
_client = client;
_gamesConf = gamesConf;
_quests = quests;
}
[Cmd]
@ -39,11 +43,11 @@ public partial class Gambling
{
var (options, _) = OptionsParser.ParseFrom(new RaceOptions(), args);
var ar = new AnimalRace(options, _cs, _gamesConf.Data.RaceAnimals.Shuffle());
var ar = new AnimalRace(options, _cs, _gamesConf.Data.RaceAnimals.Shuffle(), _quests);
if (!_service.AnimalRaces.TryAdd(ctx.Guild.Id, ar))
return Response()
.Error(GetText(strs.animal_race), GetText(strs.animal_race_already_started))
.SendAsync();
.Error(GetText(strs.animal_race), GetText(strs.animal_race_already_started))
.SendAsync();
ar.Initialize();
@ -61,7 +65,9 @@ public partial class Gambling
raceMessage = null;
}
}
catch { }
catch
{
}
});
return Task.CompletedTask;
}
@ -74,22 +80,22 @@ public partial class Gambling
if (race.FinishedUsers[0].Bet > 0)
{
return Response()
.Embed(CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.animal_race))
.WithDescription(GetText(strs.animal_race_won_money(
Format.Bold(winner.Username),
winner.Animal.Icon,
N(race.FinishedUsers[0].Bet * race.Multi))))
.WithFooter($"x{race.Multi:F2}"))
.SendAsync();
.Embed(CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.animal_race))
.WithDescription(GetText(strs.animal_race_won_money(
Format.Bold(winner.Username),
winner.Animal.Icon,
N(race.FinishedUsers[0].Bet * race.Multi))))
.WithFooter($"x{race.Multi:F2}"))
.SendAsync();
}
ar.Dispose();
return Response()
.Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon)))
.SendAsync();
.Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon)))
.SendAsync();
}
ar.OnStartingFailed += Ar_OnStartingFailed;
@ -99,10 +105,10 @@ public partial class Gambling
_client.MessageReceived += ClientMessageReceived;
return Response()
.Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_starting(options.StartTime)),
footer: GetText(strs.animal_race_join_instr(prefix)))
.SendAsync();
.Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_starting(options.StartTime)),
footer: GetText(strs.animal_race_join_instr(prefix)))
.SendAsync();
}
private Task Ar_OnStarted(AnimalRace race)
@ -110,9 +116,9 @@ public partial class Gambling
if (race.Users.Count == race.MaxUsers)
return Response().Confirm(GetText(strs.animal_race), GetText(strs.animal_race_full)).SendAsync();
return Response()
.Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_starting_with_x(race.Users.Count)))
.SendAsync();
.Confirm(GetText(strs.animal_race),
GetText(strs.animal_race_starting_with_x(race.Users.Count)))
.SendAsync();
}
private async Task Ar_OnStateUpdate(AnimalRace race)
@ -133,10 +139,10 @@ public partial class Gambling
else
{
await msg.ModifyAsync(x => x.Embed = CreateEmbed()
.WithTitle(GetText(strs.animal_race))
.WithDescription(text)
.WithOkColor()
.Build());
.WithTitle(GetText(strs.animal_race))
.WithDescription(text)
.WithOkColor()
.Build());
}
}
@ -166,15 +172,15 @@ public partial class Gambling
if (amount > 0)
{
await Response()
.Confirm(GetText(strs.animal_race_join_bet(ctx.User.Mention,
user.Animal.Icon,
amount + CurrencySign)))
.SendAsync();
.Confirm(GetText(strs.animal_race_join_bet(ctx.User.Mention,
user.Animal.Icon,
amount + CurrencySign)))
.SendAsync();
}
else
await Response()
.Confirm(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon))
.SendAsync();
.Confirm(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon))
.SendAsync();
}
catch (ArgumentOutOfRangeException)
{

View file

@ -1,20 +1,15 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Db.Models;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Gambling.Bank;
public sealed class BankService : IBankService, IEService
public sealed class BankService(
ICurrencyService _cur,
DbService _db,
QuestService quests) : IBankService, IEService
{
private readonly ICurrencyService _cur;
private readonly DbService _db;
public BankService(ICurrencyService cur, DbService db)
{
_cur = cur;
_db = db;
}
public async Task<bool> AwardAsync(ulong userId, long amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
@ -37,7 +32,7 @@ public sealed class BankService : IBankService, IEService
return true;
}
public async Task<bool> TakeAsync(ulong userId, long amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
@ -50,7 +45,7 @@ public sealed class BankService : IBankService, IEService
{
Balance = old.Balance - amount
});
return rows > 0;
}
@ -63,20 +58,28 @@ public sealed class BankService : IBankService, IEService
await using var ctx = _db.GetDbContext();
await ctx.Set<BankUser>()
.ToLinqToDBTable()
.InsertOrUpdateAsync(() => new()
{
UserId = userId,
Balance = amount
},
(old) => new()
{
Balance = old.Balance + amount
},
() => new()
{
UserId = userId
});
.ToLinqToDBTable()
.InsertOrUpdateAsync(() => new()
{
UserId = userId,
Balance = amount
},
(old) => new()
{
Balance = old.Balance + amount
},
() => new()
{
UserId = userId
});
await quests.ReportActionAsync(userId,
QuestEventType.BankAction,
new()
{
{ "type", "deposit" },
{ "amount", amount.ToString() }
});
return true;
}
@ -87,16 +90,23 @@ public sealed class BankService : IBankService, IEService
await using var ctx = _db.GetDbContext();
var rows = await ctx.Set<BankUser>()
.ToLinqToDBTable()
.Where(x => x.UserId == userId && x.Balance >= amount)
.UpdateAsync((old) => new()
{
Balance = old.Balance - amount
});
.ToLinqToDBTable()
.Where(x => x.UserId == userId && x.Balance >= amount)
.UpdateAsync((old) => new()
{
Balance = old.Balance - amount
});
if (rows > 0)
{
await _cur.AddAsync(userId, amount, new("bank", "withdraw"));
await quests.ReportActionAsync(userId,
QuestEventType.BankAction,
new()
{
{ "type", "withdraw" },
{ "amount", amount.ToString() }
});
return true;
}
@ -106,10 +116,18 @@ public sealed class BankService : IBankService, IEService
public async Task<long> GetBalanceAsync(ulong userId)
{
await using var ctx = _db.GetDbContext();
return (await ctx.Set<BankUser>()
.ToLinqToDBTable()
.FirstOrDefaultAsync(x => x.UserId == userId))
?.Balance
?? 0;
var res = (await ctx.Set<BankUser>()
.ToLinqToDBTable()
.FirstOrDefaultAsync(x => x.UserId == userId))
?.Balance
?? 0;
await quests.ReportActionAsync(userId,
QuestEventType.BankAction,
new()
{
{ "type", "balance" }
});
return res;
}
}

View file

@ -14,6 +14,7 @@ using System.Text;
using EllieBot.Modules.Gambling.Rps;
using EllieBot.Common.TypeReaders;
using EllieBot.Modules.Games;
using EllieBot.Modules.Games.Quests;
using EllieBot.Modules.Patronage;
namespace EllieBot.Modules.Gambling;
@ -35,6 +36,8 @@ public partial class Gambling : GamblingModule<GamblingService>
private readonly RakebackService _rb;
private readonly IBotCache _cache;
private readonly CaptchaService _captchaService;
private readonly VoteRewardService _vrs;
private readonly QuestService _quests;
public Gambling(
IGamblingService gs,
@ -50,7 +53,9 @@ public partial class Gambling : GamblingModule<GamblingService>
GamblingTxTracker gamblingTxTracker,
RakebackService rb,
IBotCache cache,
CaptchaService captchaService)
CaptchaService captchaService,
VoteRewardService vrs,
QuestService quests)
: base(configService)
{
_gs = gs;
@ -65,6 +70,8 @@ public partial class Gambling : GamblingModule<GamblingService>
_captchaService = captchaService;
_ps = patronage;
_rng = new EllieRandom();
_vrs = vrs;
_quests = quests;
_enUsCulture = new CultureInfo("en-US", false).NumberFormat;
_enUsCulture.NumberDecimalDigits = 0;
@ -131,6 +138,67 @@ public partial class Gambling : GamblingModule<GamblingService>
await ClaimTimely();
});
[Cmd]
public async Task Vote()
{
var reward = Config.VoteReward;
if (reward <= 0)
{
if (Config.Timely.Amount > 0 && Config.Timely.Cooldown > 0)
{
await Timely();
}
return;
}
var (amount, msg) = await _service.GetAmountAndMessage(ctx.User.Id, reward);
var prepend = GetText(strs.vote_suggest(Format.Bold(N(amount))));
msg = prepend + "\n\n" + msg;
var inter = CreateRemindMeInteraction(12) as EllieButtonInteractionHandler;
var eb = CreateEmbed()
.WithOkColor()
.WithDescription(msg);
var cb = new ComponentBuilder();
// Add vote platform buttons if any are configured
if (Config.VotePlatforms.Length > 0)
{
var row = new ActionRowBuilder();
// Loop through each vote platform and create a URL button for it
foreach (var platform in Config.VotePlatforms)
{
// Create a URL button for each platform
// The platform string should be in format "Label|URL"
var parts = platform.Split('|', 2);
if (parts.Length == 2)
{
var label = parts[0];
var url = parts[1];
// Add a URL button to the component builder
row.WithButton(label, style: ButtonStyle.Link, url: url);
}
}
cb.AddRow(row);
}
if (!_service.UserHasTimelyReminder(ctx.User.Id))
{
var secondRow = new ActionRowBuilder();
secondRow.WithButton(inter.Button);
cb.AddRow(secondRow);
var sent = await ctx.Channel.SendMessageAsync(embed: eb.Build(), components: cb?.Build());
await inter.RunAsync(sent);
}
else
{
await ctx.Channel.SendMessageAsync(embed: eb.Build(), components: cb?.Build());
}
}
[Cmd]
public async Task Timely()
{
@ -138,10 +206,17 @@ public partial class Gambling : GamblingModule<GamblingService>
var period = Config.Timely.Cooldown;
if (val <= 0 || period <= 0)
{
if (Config.VoteReward > 0)
{
await Vote();
return;
}
await Response().Error(strs.timely_none).SendAsync();
return;
}
await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim"));
if (Config.Timely.ProtType == TimelyProt.Button)
{
var interaction = CreateTimelyInteraction();
@ -149,7 +224,8 @@ public partial class Gambling : GamblingModule<GamblingService>
await msg.DeleteAsync();
return;
}
else if (Config.Timely.ProtType == TimelyProt.Captcha)
if (Config.Timely.ProtType == TimelyProt.Captcha)
{
var password = await _captchaService.GetUserCaptcha(ctx.User.Id);
@ -209,56 +285,19 @@ public partial class Gambling : GamblingModule<GamblingService>
var val = Config.Timely.Amount;
var boostGuilds = Config.BoostBonus.GuildIds ?? new();
var guildUsers = await boostGuilds
.Select(async gid =>
{
try
{
var guild = await _client.Rest.GetGuildAsync(gid, false);
var user = await _client.Rest.GetGuildUserAsync(gid, ctx.User.Id);
return (guild, user);
}
catch
{
return default;
}
})
.WhenAll();
var userInfo = guildUsers.FirstOrDefault(x => x.user?.PremiumSince is not null);
var booster = userInfo != default;
if (booster)
val += Config.BoostBonus.BaseTimelyBonus;
var patron = await _ps.GetPatronAsync(ctx.User.Id);
var percentBonus = (_ps.PercentBonus(patron) / 100f);
val += (int)(val * percentBonus);
var inter = CreateRemindMeInteraction(period);
await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim"));
var prepend = GetText(strs.timely(N(val), period));
var (newVal, msg) = await _service.GetAmountAndMessage(ctx.User.Id, val);
var msg = GetText(strs.timely(N(val), period));
if (booster || percentBonus > float.Epsilon)
{
msg += "\n\n";
if (booster)
msg += $"*+{N(Config.BoostBonus.BaseTimelyBonus)} bonus for boosting {userInfo.guild}!*\n";
msg = prepend + "\n\n" + msg;
if (percentBonus > float.Epsilon)
msg +=
$"*+{percentBonus:P0} bonus for the [Patreon](https://patreon.com/elliebot) pledge! <:hart:746995901758832712>*";
await _cs.AddAsync(ctx.User.Id, newVal, new("timely", "claim"));
await Response().Confirm(msg).Interaction(inter).SendAsync();
}
else
await Response().Confirm(strs.timely(N(val), period)).Interaction(inter).SendAsync();
await Response().Confirm(msg).Interaction(inter).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task TimelyReset()
@ -911,7 +950,6 @@ public partial class Gambling : GamblingModule<GamblingService>
await Response().Embed(eb).SendAsync();
}
public enum GambleTestTarget
{
Slot,

View file

@ -11,7 +11,7 @@ namespace EllieBot.Modules.Gambling.Common;
public sealed partial class GamblingConfig : ICloneable<GamblingConfig>
{
[Comment("""DO NOT CHANGE""")]
public int Version { get; set; } = 12;
public int Version { get; set; } = 13;
[Comment("""Currency settings""")]
public CurrencyConfig Currency { get; set; }
@ -63,6 +63,20 @@ public sealed partial class GamblingConfig : ICloneable<GamblingConfig>
This will work only if you've set up VotesApi and correct credentials for topgg and/or discords voting
""")]
public long VoteReward { get; set; } = 100;
[Comment("""
Id of the channel to send a message to after a user votes
""")]
public ulong? VoteFeedChannelId { get; set; }
[Comment("""
List of platforms for which the bot will give currency rewards.
Format: PLATFORM|URL
Supported platforms: topgg, discords, discordbotlist
You will have to have VotesApi running on the same machine.
Format example: Top.gg|https://top.gg/bot/YOUR_BOT_ID/vote
""")]
public string[] VotePlatforms { get; set; } = [];
[Comment("""Slot config""")]
public SlotsConfig Slots { get; set; }

View file

@ -12,12 +12,6 @@ public sealed class GamblingConfigService : ConfigServiceBase<GamblingConfig>
public override string Name
=> "gambling";
private readonly IEnumerable<WaifuItemModel> _antiGiftSeed = new[]
{
new WaifuItemModel("🥀", 100, "WiltedRose", true), new WaifuItemModel("✂️", 1000, "Haircut", true),
new WaifuItemModel("🧻", 10000, "ToiletPaper", true)
};
public GamblingConfigService(IConfigSeria serializer, IPubSub pubSub)
: base(FILE_PATH, serializer, pubSub, _changeKey)
{
@ -154,51 +148,12 @@ public sealed class GamblingConfigService : ConfigServiceBase<GamblingConfig>
public void Migrate()
{
if (data.Version < 2)
if (data.Version < 13)
{
ModifyConfig(c =>
{
c.Waifu.Items = c.Waifu.Items.Concat(_antiGiftSeed).ToList();
c.Version = 2;
});
}
if (data.Version < 3)
{
ModifyConfig(c =>
{
c.Version = 3;
c.VoteReward = 100;
});
}
if (data.Version < 7)
{
ModifyConfig(c =>
{
c.Version = 7;
});
}
if (data.Version < 8)
{
ModifyConfig(c =>
{
c.Version = 8;
c.Waifu.Decay.UnclaimedDecayPercent = 0;
});
}
if (data.Version < 12)
{
ModifyConfig(c =>
{
c.Version = 12;
if (c.BetRoll.Pairs.Length == 3 && c.BetRoll.Pairs[2].WhenAbove == 66)
{
c.BetRoll.Pairs[2].WhenAbove = 65;
}
c.Version = 13;
c.VotePlatforms = [];
});
}
}

View file

@ -1,10 +1,13 @@
#nullable disable
using System.Globalization;
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using EllieBot.Modules.Gambling.Common;
using EllieBot.Modules.Gambling.Common.Connect4;
using EllieBot.Modules.Games.Quests;
using EllieBot.Modules.Patronage;
namespace EllieBot.Modules.Gambling.Services;
@ -15,7 +18,9 @@ public class GamblingService : IEService, IReadyExecutor
private readonly DbService _db;
private readonly DiscordSocketClient _client;
private readonly IBotCache _cache;
private readonly GamblingConfigService _gss;
private readonly GamblingConfigService _gcs;
private readonly IPatronageService _ps;
private readonly QuestService _quests;
private readonly EllieRandom _rng;
private static readonly TypedKey<long> _curDecayKey = new("currency:last_decay");
@ -24,12 +29,16 @@ public class GamblingService : IEService, IReadyExecutor
DbService db,
DiscordSocketClient client,
IBotCache cache,
GamblingConfigService gss)
GamblingConfigService gcs,
IPatronageService ps,
QuestService quests)
{
_db = db;
_client = client;
_cache = cache;
_gss = gss;
_gcs = gcs;
_ps = ps;
_quests = quests;
_rng = new EllieRandom();
}
@ -53,7 +62,7 @@ public class GamblingService : IEService, IReadyExecutor
{
try
{
var lifetime = _gss.Data.Currency.TransactionsLifetime;
var lifetime = _gcs.Data.Currency.TransactionsLifetime;
if (lifetime <= 0)
continue;
@ -61,7 +70,7 @@ public class GamblingService : IEService, IReadyExecutor
var days = TimeSpan.FromDays(lifetime);
await using var uow = _db.GetDbContext();
await uow.Set<CurrencyTransaction>()
.DeleteAsync(ct => ct.DateAdded == null || now - ct.DateAdded < days);
.DeleteAsync(ct => ct.DateAdded == null || now - ct.DateAdded < days);
}
catch (Exception ex)
{
@ -82,7 +91,7 @@ public class GamblingService : IEService, IReadyExecutor
{
try
{
var config = _gss.Data;
var config = _gcs.Data;
var maxDecay = config.Decay.MaxDecay;
if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0)
continue;
@ -113,14 +122,14 @@ public class GamblingService : IEService, IReadyExecutor
var decay = (double)config.Decay.Percent;
await uow.Set<DiscordUser>()
.Where(x => x.CurrencyAmount > config.Decay.MinThreshold && x.UserId != _client.CurrentUser.Id)
.UpdateAsync(old => new()
{
CurrencyAmount =
maxDecay > Sql.Round((old.CurrencyAmount * decay) - 0.5)
? (long)(old.CurrencyAmount - Sql.Round((old.CurrencyAmount * decay) - 0.5))
: old.CurrencyAmount - maxDecay
});
.Where(x => x.CurrencyAmount > config.Decay.MinThreshold && x.UserId != _client.CurrentUser.Id)
.UpdateAsync(old => new()
{
CurrencyAmount =
maxDecay > Sql.Round((old.CurrencyAmount * decay) - 0.5)
? (long)(old.CurrencyAmount - Sql.Round((old.CurrencyAmount * decay) - 0.5))
: old.CurrencyAmount - maxDecay
});
await uow.SaveChangesAsync();
@ -142,6 +151,7 @@ public class GamblingService : IEService, IReadyExecutor
private static TypedKey<Dictionary<ulong, long>> _timelyKey
= new("timely:claims");
public async Task<TimeSpan?> ClaimTimelyAsync(ulong userId, int period)
{
if (period == 0)
@ -190,8 +200,77 @@ public class GamblingService : IEService, IReadyExecutor
return db.GetTable<Reminder>()
.Any(x => x.UserId == userId
&& x.Type == ReminderType.Timely);
}
}
public async Task RemoveAllTimelyClaimsAsync()
=> await _cache.RemoveAsync(_timelyKey);
private string N(long amount)
=> CurrencyHelper.N(amount, CultureInfo.InvariantCulture, _gcs.Data.Currency.Sign);
public async Task<(long val, string msg)> GetAmountAndMessage(ulong userId, long originalAmount)
{
var gcsData = _gcs.Data;
var boostGuilds = gcsData.BoostBonus.GuildIds ?? [];
var guildUsers = await boostGuilds
.Select(async gid =>
{
try
{
var guild = _client.GetGuild(gid) as IGuild ?? await _client.Rest.GetGuildAsync(gid, false);
var user = await guild.GetUserAsync(gid) ?? await _client.Rest.GetGuildUserAsync(gid, userId);
return (guild, user);
}
catch
{
return default;
}
})
.WhenAll();
var userInfo = guildUsers.FirstOrDefault(x => x.user?.PremiumSince is not null);
var booster = userInfo != default;
if (booster)
originalAmount += gcsData.BoostBonus.BaseTimelyBonus;
var hasCompletedDailies = await _quests.UserCompletedDailies(userId);
if (hasCompletedDailies)
originalAmount = (long)(1.5 * originalAmount);
var patron = await _ps.GetPatronAsync(userId);
var percentBonus = (_ps.PercentBonus(patron) / 100f);
originalAmount += (long)(originalAmount * percentBonus);
var msg = $"**{N(originalAmount)}** base reward\n\n";
if (boostGuilds.Count > 0)
{
if (booster)
msg += $"✅ *+{N(gcsData.BoostBonus.BaseTimelyBonus)} bonus for boosting {userInfo.guild}!*\n";
else
msg += $"❌ *+0 bonus for boosting {userInfo.guild}*\n";
}
if (_ps.GetConfig().IsEnabled)
{
if (percentBonus > float.Epsilon)
msg +=
$"✅ *+{percentBonus:P0} bonus for the [Patreon](https://patreon.com/elliebot) pledge! <:hart:746995901758832712>*\n";
else
msg += $"❌ *+0 bonus for the [Patreon](https://patreon.com/elliebot) pledge*\n";
}
if (hasCompletedDailies)
{
msg += $"✅ *+50% bonus for completing daily quests*\n";
}
else
{
msg += $"❌ *+0 bonus for completing daily quests*\n";
}
return (originalAmount, msg);
}
}

View file

@ -4,6 +4,7 @@ using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using EllieBot.Modules.Games.Quests;
using SixLabors.Fonts;
using SixLabors.Fonts.Unicode;
using SixLabors.ImageSharp;
@ -15,67 +16,47 @@ using Image = SixLabors.ImageSharp.Image;
namespace EllieBot.Modules.Gambling.Services;
public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
public class PlantPickService(
DbService db,
IBotStrings strings,
IImageCache images,
FontProvider fonts,
ICurrencyService cs,
CommandHandler cmdHandler,
DiscordSocketClient client,
GamblingConfigService gss,
GamblingService gs,
QuestService quests) : IEService, IExecNoCommand, IReadyExecutor
{
//channelId/last generation
public ConcurrentDictionary<ulong, long> LastGenerations { get; } = new();
private readonly DbService _db;
private readonly IBotStrings _strings;
private readonly IImageCache _images;
private readonly FontProvider _fonts;
private readonly ICurrencyService _cs;
private readonly CommandHandler _cmdHandler;
private readonly DiscordSocketClient _client;
private readonly GamblingConfigService _gss;
private readonly GamblingService _gs;
private ConcurrentHashSet<ulong> _generationChannels = [];
public PlantPickService(
DbService db,
IBotStrings strings,
IImageCache images,
FontProvider fonts,
ICurrencyService cs,
CommandHandler cmdHandler,
DiscordSocketClient client,
GamblingConfigService gss,
GamblingService gs)
{
_db = db;
_strings = strings;
_images = images;
_fonts = fonts;
_cs = cs;
_cmdHandler = cmdHandler;
_client = client;
_gss = gss;
_gs = gs;
}
public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
=> PotentialFlowerGeneration(msg);
private string GetText(ulong gid, LocStr str)
=> _strings.GetText(str, gid);
=> strings.GetText(str, gid);
public async Task<bool> ToggleCurrencyGeneration(ulong gid, ulong cid)
{
bool enabled;
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
if (_generationChannels.Add(cid))
{
await uow.GetTable<GCChannelId>()
.InsertOrUpdateAsync(() => new()
.InsertOrUpdateAsync(() => new()
{
ChannelId = cid,
GuildId = gid
}, (x) => new()
},
(x) => new()
{
ChannelId = cid,
GuildId = gid
}, () => new()
},
() => new()
{
ChannelId = cid,
GuildId = gid
@ -87,8 +68,8 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
else
{
await uow.GetTable<GCChannelId>()
.Where(x => x.ChannelId == cid && x.GuildId == gid)
.DeleteAsync();
.Where(x => x.ChannelId == cid && x.GuildId == gid)
.DeleteAsync();
_generationChannels.TryRemove(cid);
enabled = false;
@ -99,9 +80,9 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
public async Task<IReadOnlyCollection<GCChannelId>> GetAllGeneratingChannels()
{
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
return await uow.GetTable<GCChannelId>()
.ToListAsyncLinqToDB();
.ToListAsyncLinqToDB();
}
/// <summary>
@ -111,7 +92,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
/// <returns>Stream of the currency image</returns>
public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass)
{
var curImg = await _images.GetCurrencyImageAsync();
var curImg = await images.GetCurrencyImageAsync();
if (curImg is null)
return (new MemoryStream(), null);
@ -142,7 +123,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
pass = pass.TrimTo(10, true).ToLowerInvariant();
using var img = Image.Load<Rgba32>(curImg);
// choose font size based on the image height, so that it's visible
var font = _fonts.NotoSans.CreateFont(img.Height / 11.0f, FontStyle.Bold);
var font = fonts.NotoSans.CreateFont(img.Height / 11.0f, FontStyle.Bold);
img.Mutate(x =>
{
// measure the size of the text to be drawing
@ -170,13 +151,13 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
// draw the password over the background
x.DrawText(new RichTextOptions(font)
{
Origin = new(0, 0),
TextRuns =
{
Origin = new(0, 0),
TextRuns =
[
strikeoutRun
]
},
},
pass,
new SolidBrush(Color.White));
});
@ -200,7 +181,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
{
try
{
var config = _gss.Data;
var config = gss.Data;
var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue.ToBinary());
var rng = new EllieRandom();
@ -219,7 +200,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
if (dropAmount > 0)
{
var prefix = _cmdHandler.GetPrefix(channel.Guild.Id);
var prefix = cmdHandler.GetPrefix(channel.Guild.Id);
var toSend = dropAmount == 1
? GetText(channel.GuildId, strs.curgen_sn(config.Currency.Sign))
+ " "
@ -228,7 +209,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
+ " "
+ GetText(channel.GuildId, strs.pick_pl(prefix));
var pw = config.Generation.HasPassword ? _gs.GeneratePassword().ToUpperInvariant() : null;
var pw = config.Generation.HasPassword ? gs.GeneratePassword().ToUpperInvariant() : null;
IUserMessage sent;
var (stream, ext) = await GetRandomCurrencyImageAsync(pw);
@ -238,7 +219,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
var res = await AddPlantToDatabase(channel.GuildId,
channel.Id,
_client.CurrentUser.Id,
client.CurrentUser.Id,
sent.Id,
dropAmount,
pw,
@ -261,12 +242,12 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
public async Task<long> PickAsync(
ulong gid,
ITextChannel ch,
ulong uid,
ulong userId,
string pass)
{
long amount;
ulong[] ids;
await using (var uow = _db.GetDbContext())
await using (var uow = db.GetDbContext())
{
// this method will sum all plants with that password,
// remove them, and get messageids of the removed plants
@ -274,8 +255,8 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
pass = pass?.Trim().TrimTo(10, true)?.ToUpperInvariant();
// gets all plants in this channel with the same password
var entries = await uow.GetTable<PlantedCurrency>()
.Where(x => x.ChannelId == ch.Id && pass == x.Password)
.DeleteWithOutputAsync();
.Where(x => x.ChannelId == ch.Id && pass == x.Password)
.DeleteWithOutputAsync();
if (!entries.Any())
return 0;
@ -285,14 +266,24 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
}
if (amount > 0)
await _cs.AddAsync(uid, amount, new("currency", "collect"));
{
await cs.AddAsync(userId, amount, new("currency", "collect"));
await quests.ReportActionAsync(userId,
QuestEventType.PlantOrPick,
new()
{
{ "type", "pick" },
});
}
try
{
_ = ch.DeleteMessagesAsync(ids);
}
catch { }
catch
{
}
// return the amount of currency the user picked
return amount;
@ -308,8 +299,8 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
try
{
// get the text
var prefix = _cmdHandler.GetPrefix(gid);
var msgToSend = GetText(gid, strs.planted(Format.Bold(user), amount + _gss.Data.Currency.Sign));
var prefix = cmdHandler.GetPrefix(gid);
var msgToSend = GetText(gid, strs.planted(Format.Bold(user), amount + gss.Data.Currency.Sign));
if (amount > 1)
msgToSend += " " + GetText(gid, strs.pick_pl(prefix));
@ -337,7 +328,7 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
public async Task<bool> PlantAsync(
ulong gid,
ITextChannel ch,
ulong uid,
ulong userId,
string user,
long amount,
string pass)
@ -349,19 +340,20 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
return false;
// remove currency from the user who's planting
if (await _cs.RemoveAsync(uid, amount, new("put/collect", "put")))
if (await cs.RemoveAsync(userId, amount, new("put/collect", "put")))
{
// try to send the message with the currency image
var msgId = await SendPlantMessageAsync(gid, ch, user, amount, pass);
if (msgId is null)
{
// if it fails it will return null, if it returns null, refund
await _cs.AddAsync(uid, amount, new("put/collect", "refund"));
await cs.AddAsync(userId, amount, new("put/collect", "refund"));
return false;
}
// if it doesn't fail, put the plant in the database for other people to pick
await AddPlantToDatabase(gid, ch.Id, uid, msgId.Value, amount, pass);
await AddPlantToDatabase(gid, ch.Id, userId, msgId.Value, amount, pass);
await quests.ReportActionAsync(userId, QuestEventType.PlantOrPick, new() { { "type", "plant" } });
return true;
}
@ -379,43 +371,42 @@ public class PlantPickService : IEService, IExecNoCommand, IReadyExecutor
string pass,
bool auto = false)
{
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
PlantedCurrency[] deleted = [];
if (!string.IsNullOrWhiteSpace(pass) && auto)
{
deleted = await uow.GetTable<PlantedCurrency>()
.Where(x => x.GuildId == gid
&& x.ChannelId == cid
&& x.Password != null
&& x.Password.Length == pass.Length)
.DeleteWithOutputAsync();
.Where(x => x.GuildId == gid
&& x.ChannelId == cid
&& x.Password != null
&& x.Password.Length == pass.Length)
.DeleteWithOutputAsync();
}
var totalDeletedAmount = deleted.Length == 0 ? 0 : deleted.Sum(x => x.Amount);
await uow.GetTable<PlantedCurrency>()
.InsertAsync(() => new()
{
Amount = totalDeletedAmount + amount,
GuildId = gid,
ChannelId = cid,
Password = pass,
UserId = uid,
MessageId = mid,
});
.InsertAsync(() => new()
{
Amount = totalDeletedAmount + amount,
GuildId = gid,
ChannelId = cid,
Password = pass,
UserId = uid,
MessageId = mid,
});
return (totalDeletedAmount + amount, deleted.Select(x => x.MessageId).ToArray());
}
public async Task OnReadyAsync()
{
await using var uow = _db.GetDbContext();
await using var uow = db.GetDbContext();
_generationChannels = (await uow.GetTable<GCChannelId>()
.Select(x => x.ChannelId)
.ToListAsyncLinqToDB())
.Select(x => x.ChannelId)
.ToListAsyncLinqToDB())
.ToHashSet()
.ToConcurrentSet();
}
}

View file

@ -332,17 +332,18 @@ public partial class Gambling
Type = ShopEntryType.Role,
AuthorId = ctx.User.Id,
RoleId = role.Id,
RoleName = role.Name
RoleName = role.Name,
GuildId = ctx.Guild.Id,
};
await using (var uow = _db.GetDbContext())
{
var entries = new IndexedCollection<ShopEntry>(await uow.Set<ShopEntry>()
.Where(x => x.GuildId == ctx.Guild.Id)
.Include(x => x.Items)
.ToListAsyncEF())
{
entry
};
.ToListAsyncEF());
entries.Add(entry);
uow.Add(entry);
await uow.SaveChangesAsync();
}
@ -363,7 +364,8 @@ public partial class Gambling
Price = price,
Type = ShopEntryType.List,
AuthorId = ctx.User.Id,
Items = new()
Items = new(),
GuildId = ctx.Guild.Id
};
await using (var uow = _db.GetDbContext())
{
@ -440,7 +442,7 @@ public partial class Gambling
var items = await uow.Set<ShopEntry>()
.Where(x => x.GuildId == ctx.Guild.Id)
.Include(x => x.Items)
.ToListAsyncLinqToDB();
.ToListAsyncEF();
var entries = new IndexedCollection<ShopEntry>(items);
removed = entries.ElementAtOrDefault(index);

View file

@ -1,106 +1,202 @@
#nullable disable
using System.Globalization;
using System.Net.Http.Json;
using Grpc.Core;
using EllieBot.Common.ModuleBehaviors;
using System.Text.Json;
using System.Text.Json.Serialization;
using EllieBot.GrpcVotesApi;
namespace EllieBot.Modules.Gambling.Services;
public class VoteModel
public sealed class ServerCountRewardService(
IBotCreds creds,
IHttpClientFactory httpFactory,
DiscordSocketClient client,
ShardData shardData
)
: IEService, IReadyExecutor
{
[JsonPropertyName("userId")]
public ulong UserId { get; set; }
private Task dblTask = Task.CompletedTask;
private Task discordsTask = Task.CompletedTask;
public Task OnReadyAsync()
{
if (creds.Votes is null)
return Task.CompletedTask;
if (!string.IsNullOrWhiteSpace(creds.Votes.DblApiKey))
{
dblTask = Task.Run(async () =>
{
var dblApiKey = creds.Votes.DblApiKey;
while (true)
{
try
{
using var httpClient = httpFactory.CreateClient();
httpClient.DefaultRequestHeaders.Clear();
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", dblApiKey);
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json");
await httpClient.PostAsJsonAsync(
$"https://discordbotlist.com/api/v1/bots/{608119997713350679}/stats",
new
{
users = client.Guilds.Sum(x => x.MemberCount),
shard_id = shardData.ShardId,
guilds = client.Guilds.Count,
voice_connections = 0
});
}
catch (Exception ex)
{
Log.Warning(ex, "Unable to send server count to DBL");
}
await Task.Delay(TimeSpan.FromHours(12));
}
});
if (shardData.ShardId != 0)
return Task.CompletedTask;
if (!string.IsNullOrWhiteSpace(creds.Votes.DiscordsApiKey))
{
discordsTask = Task.Run(async () =>
{
var discordsApiKey = creds.Votes.DiscordsApiKey;
while (true)
{
try
{
using var httpClient = httpFactory.CreateClient();
httpClient.DefaultRequestHeaders.Clear();
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", discordsApiKey);
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type",
"application/json");
await httpClient.PostAsJsonAsync(
$"https://discords.com/bots/api/bot/{client.CurrentUser.Id}/setservers",
new
{
server_count = client.Guilds.Count * shardData.TotalShards,
});
}
catch (Exception ex)
{
Log.Warning(ex, "Unable to send server count to Discords");
}
await Task.Delay(TimeSpan.FromHours(12));
}
});
}
}
return Task.CompletedTask;
}
}
public class VoteRewardService : IEService, IReadyExecutor
public class VoteRewardService(
ShardData shardData,
GamblingConfigService gcs,
GamblingService gs,
CurrencyService cs,
DiscordSocketClient client,
IMessageSenderService sender,
IBotCreds creds
) : IEService, IReadyExecutor
{
private readonly DiscordSocketClient _client;
private readonly IBotCreds _creds;
private readonly ICurrencyService _currencyService;
private readonly GamblingConfigService _gamb;
public VoteRewardService(
DiscordSocketClient client,
IBotCreds creds,
ICurrencyService currencyService,
GamblingConfigService gamb)
{
_client = client;
_creds = creds;
_currencyService = currencyService;
_gamb = gamb;
}
private Server? _app;
private IMessageChannel? _voteFeedChannel;
public async Task OnReadyAsync()
{
if (_client.ShardId != 0)
if (shardData.ShardId != 0)
return;
using var http = new HttpClient(new HttpClientHandler
if (creds.Votes is null || creds.Votes.Host is null || creds.Votes.Port == 0)
return;
var serverCreds = ServerCredentials.Insecure;
var ssd = VoteService.BindService(new VotesGrpcService(this));
_app = new()
{
AllowAutoRedirect = false,
ServerCertificateCustomValidationCallback = delegate { return true; }
});
Ports =
{
new(creds.Votes.Host, creds.Votes.Port, serverCreds),
}
};
while (true)
_app.Services.Add(ssd);
_app.Start();
if (gcs.Data.VoteFeedChannelId is ulong cid)
{
await Task.Delay(30000);
var topggKey = _creds.Votes?.TopggKey;
var topggServiceUrl = _creds.Votes?.TopggServiceUrl;
try
{
if (!string.IsNullOrWhiteSpace(topggKey) && !string.IsNullOrWhiteSpace(topggServiceUrl))
{
http.DefaultRequestHeaders.Authorization = new(topggKey);
var uri = new Uri(new(topggServiceUrl), "topgg/new");
var res = await http.GetStringAsync(uri);
var data = JsonSerializer.Deserialize<List<VoteModel>>(res);
if (data is { Count: > 0 })
{
var ids = data.Select(x => x.UserId).ToList();
await _currencyService.AddBulkAsync(ids,
_gamb.Data.VoteReward,
new("vote", "top.gg", "top.gg vote reward"));
Log.Information("Rewarding {Count} top.gg voters", ids.Count());
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Critical error loading top.gg vote rewards");
}
var discordsKey = _creds.Votes?.DiscordsKey;
var discordsServiceUrl = _creds.Votes?.DiscordsServiceUrl;
try
{
if (!string.IsNullOrWhiteSpace(discordsKey) && !string.IsNullOrWhiteSpace(discordsServiceUrl))
{
http.DefaultRequestHeaders.Authorization = new(discordsKey);
var res = await http.GetStringAsync(new Uri(new(discordsServiceUrl), "discords/new"));
var data = JsonSerializer.Deserialize<List<VoteModel>>(res);
if (data is { Count: > 0 })
{
var ids = data.Select(x => x.UserId).ToList();
await _currencyService.AddBulkAsync(ids,
_gamb.Data.VoteReward,
new("vote", "discords", "discords.com vote reward"));
Log.Information("Rewarding {Count} discords.com voters", ids.Count());
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Critical error loading discords.com vote rewards");
}
_voteFeedChannel = await client.GetChannelAsync(cid) as IMessageChannel;
}
}
public void SetVoiceChannel(IMessageChannel? channel)
{
gcs.ModifyConfig(c => { c.VoteFeedChannelId = channel?.Id; });
_voteFeedChannel = channel;
}
public async Task UserVotedAsync(ulong userId, VoteType requestType)
{
var gcsData = gcs.Data;
var reward = gcsData.VoteReward;
if (reward <= 0)
return;
(reward, var msg) = await gs.GetAmountAndMessage(userId, reward);
await cs.AddAsync(userId, reward, new("vote", requestType.ToString()));
_ = Task.Run(async () =>
{
try
{
var user = await client.GetUserAsync(userId);
await sender
.Response(user)
.Confirm($"You've received{N(reward)} for voting!\n\n{msg}")
.SendAsync();
}
catch (Exception ex)
{
Log.Warning(ex, "Unable to send vote confirmation message to user {UserId}", userId);
}
});
_ = Task.Run(async () =>
{
if (_voteFeedChannel is not null)
{
try
{
var user = await client.GetUserAsync(userId);
await _voteFeedChannel.SendMessageAsync(
$"**{user}** just received **{N(reward)}** for voting!",
allowedMentions: AllowedMentions.None);
}
catch (Exception ex)
{
Log.Warning(ex, "Unable to send vote reward message to user {UserId}", userId);
}
}
});
}
private string N(long amount)
=> CurrencyHelper.N(amount, CultureInfo.InvariantCulture, gcs.Data.Currency.Sign);
}
public sealed class VotesGrpcService(VoteRewardService vrs)
: VoteService.VoteServiceBase, IEService
{
public override async Task<GrpcVoteResult> VoteReceived(GrpcVoteData request, ServerCallContext context)
{
await vrs.UserVotedAsync(ulong.Parse(request.UserId), request.Type);
return new();
}
}

View file

@ -6,6 +6,7 @@ using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using EllieBot.Modules.Gambling.Common;
using EllieBot.Modules.Gambling.Common.Waifu;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Gambling.Services;
@ -15,23 +16,23 @@ public class WaifuService : IEService, IReadyExecutor
private readonly ICurrencyService _cs;
private readonly IBotCache _cache;
private readonly GamblingConfigService _gss;
private readonly IBotCreds _creds;
private readonly DiscordSocketClient _client;
private readonly QuestService _quests;
public WaifuService(
DbService db,
ICurrencyService cs,
IBotCache cache,
GamblingConfigService gss,
IBotCreds creds,
DiscordSocketClient client)
DiscordSocketClient client,
QuestService quests)
{
_db = db;
_cs = cs;
_cache = cache;
_gss = gss;
_creds = creds;
_client = client;
_quests = quests;
}
public async Task<bool> WaifuTransfer(IUser owner, ulong waifuId, IUser newOwner)
@ -411,6 +412,8 @@ public class WaifuService : IEService, IReadyExecutor
w.Price += (long)(totalValue * _gss.Data.Waifu.Multipliers.GiftEffect);
else
w.Price += totalValue / 2;
await _quests.ReportActionAsync(from.Id, QuestEventType.WaifuGiftSent);
}
else
{

View file

@ -145,7 +145,7 @@ public class ChatterBotService : IExecOnMessage, IReadyExecutor
if (!res.IsAllowed)
return false;
if (!await _ps.LimitHitAsync(LimitedFeatureName.ChatBot, usrMsg.Author.Id, 2048 / 2))
if (!await _ps.LimitHitAsync("ai", guild.OwnerId, 1))
{
// limit exceeded
return false;
@ -156,14 +156,6 @@ public class ChatterBotService : IExecOnMessage, IReadyExecutor
if (response.TryPickT0(out var result, out var error))
{
// calculate the diff in case we overestimated user's usage
var inTokens = (result.TokensIn - 2048) / 2;
// add the output tokens to the limit
await _ps.LimitForceHit(LimitedFeatureName.ChatBot,
usrMsg.Author.Id,
(inTokens) + (result.TokensOut / 2 * 3));
await _sender.Response(channel)
.Confirm(result.Text)
.SendAsync();

View file

@ -0,0 +1,70 @@
using System;
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using EllieBot.Db;
using EllieBot.Modules.Games.Fish;
namespace EllieBot.Modules.Games.Fish.Db;
/// <summary>
/// Represents a fishing item owned by a user.
/// </summary>
public class UserFishItem
{
/// <summary>
/// The unique identifier for this user fish item.
/// </summary>
[Key]
public int Id { get; set; }
/// <summary>
/// The ID of the user who owns this item.
/// </summary>
public ulong UserId { get; set; }
/// <summary>
/// The type of the fishing item.
/// </summary>
public FishItemType ItemType { get; set; }
/// <summary>
/// The ID of the fishing item.
/// </summary>
public int ItemId { get; set; }
/// <summary>
/// Indicates whether the item is currently equipped by the user.
/// </summary>
public bool IsEquipped { get; set; }
/// <summary>
/// The number of uses left for this item. Null means unlimited uses.
/// </summary>
public int? UsesLeft { get; set; }
/// <summary>
/// The date and time when this item expires. Null means the item doesn't expire.
/// </summary>
public DateTime? ExpiresAt { get; set; }
public int? ExpiryFromNowInMinutes()
{
if (ExpiresAt is null)
return null;
return (int)(ExpiresAt.Value - DateTime.UtcNow).TotalMinutes;
}
}
/// <summary>
/// Entity configuration for UserFishItem.
/// </summary>
public class UserFishItemConfiguration : IEntityTypeConfiguration<UserFishItem>
{
public void Configure(EntityTypeBuilder<UserFishItem> builder)
{
builder.HasIndex(x => new { x.UserId });
}
}

View file

@ -9,20 +9,4 @@ public sealed class UserFishStats
public ulong UserId { get; set; }
public int Skill { get; set; }
public int? Pole { get; set; }
public int? Bait { get; set; }
}
// public sealed class FishingPole
// {
// [Key]
// public int Id { get; set; }
// public string Name { get; set; } = string.Empty;
// public long Price { get; set; }
// public string Emoji { get; set; } = string.Empty;
// }
}

View file

@ -1,276 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.Text;
using EllieBot.Modules.Games.Fish;
using Format = Discord.Format;
namespace EllieBot.Modules.Games;
public partial class Games
{
public class FishCommands(
FishService fs,
FishConfigService fcs,
IBotCache cache,
CaptchaService captchaService) : EllieModule
{
private static readonly EllieRandom _rng = new();
private TypedKey<bool> FishingWhitelistKey(ulong userId)
=> new($"fishingwhitelist:{userId}");
[Cmd]
public async Task Fish()
{
var cRes = await cache.GetAsync(FishingWhitelistKey(ctx.User.Id));
if (cRes.TryPickT1(out _, out _))
{
var password = await captchaService.GetUserCaptcha(ctx.User.Id);
if (password is not null)
{
var img = captchaService.GetPasswordImage(password);
using var stream = await img.ToStreamAsync();
var toSend = Response()
.File(stream, "timely.png");
#if GLOBAL_ELLIE
if (_rng.Next(0, 8) == 0)
toSend = toSend
.Text("*[Sub on Patreon](https://patreon.com/elliebot) to remove captcha.*");
#endif
var captcha = await toSend.SendAsync();
try
{
var userInput = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
if (userInput?.ToLowerInvariant() != password?.ToLowerInvariant())
{
return;
}
// whitelist the user for 30 minutes
await cache.AddAsync(FishingWhitelistKey(ctx.User.Id), true, TimeSpan.FromMinutes(30));
// reset the password
await captchaService.ClearUserCaptcha(ctx.User.Id);
}
finally
{
_ = captcha.DeleteAsync();
}
}
}
var fishResult = await fs.FishAsync(ctx.User.Id, ctx.Channel.Id);
if (fishResult.TryPickT1(out _, out var fishTask))
{
return;
}
var currentWeather = fs.GetCurrentWeather();
var currentTod = fs.GetTime();
var spot = fs.GetSpot(ctx.Channel.Id);
var msg = await Response()
.Embed(CreateEmbed()
.WithPendingColor()
.WithAuthor(ctx.User)
.WithDescription(GetText(strs.fish_waiting))
.AddField(GetText(strs.fish_spot), GetSpotEmoji(spot) + " " + spot.ToString(), true)
.AddField(GetText(strs.fish_weather),
GetWeatherEmoji(currentWeather) + " " + currentWeather,
true)
.AddField(GetText(strs.fish_tod), GetTodEmoji(currentTod) + " " + currentTod, true))
.SendAsync();
var res = await fishTask;
if (res is null)
{
await Response().Error(strs.fish_nothing).SendAsync();
return;
}
var desc = GetText(strs.fish_caught(res.Fish.Emoji + " " + Format.Bold(res.Fish.Name)));
if (res.IsSkillUp)
{
desc += "\n" + GetText(strs.fish_skill_up(res.Skill, res.MaxSkill));
}
await Response()
.Embed(CreateEmbed()
.WithOkColor()
.WithAuthor(ctx.User)
.WithDescription(desc)
.AddField(GetText(strs.fish_quality), GetStarText(res.Stars, res.Fish.Stars), true)
.AddField(GetText(strs.desc), res.Fish.Fluff, true)
.WithThumbnailUrl(res.Fish.Image))
.SendAsync();
await msg.DeleteAsync();
}
[Cmd]
public async Task FishSpot()
{
var ws = fs.GetWeatherForPeriods(7);
var spot = fs.GetSpot(ctx.Channel.Id);
var time = fs.GetTime();
await Response()
.Embed(CreateEmbed()
.WithOkColor()
.WithDescription(GetText(strs.fish_weather_duration(fs.GetWeatherPeriodDuration())))
.AddField(GetText(strs.fish_spot), GetSpotEmoji(spot) + " " + spot, true)
.AddField(GetText(strs.fish_tod), GetTodEmoji(time) + " " + time, true)
.AddField(GetText(strs.fish_weather_forecast),
ws.Select(x => GetWeatherEmoji(x)).Join(""),
true))
.SendAsync();
}
[Cmd]
public async Task Fishlist(int page = 1)
{
if (--page < 0)
return;
var fishes = await fs.GetAllFish();
var catches = await fs.GetUserCatches(ctx.User.Id);
var (skill, maxSkill) = await fs.GetSkill(ctx.User.Id);
var catchDict = catches.ToDictionary(x => x.FishId, x => x);
await Response()
.Paginated()
.Items(fishes)
.PageSize(9)
.CurrentPage(page)
.Page((fs, i) =>
{
var eb = CreateEmbed()
.WithDescription($"🧠 **Skill:** {skill} / {maxSkill}")
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.fish_list_title))
.WithOkColor();
foreach (var f in fs)
{
if (catchDict.TryGetValue(f.Id, out var c))
{
eb.AddField(f.Name,
GetFishEmoji(f, c.Count)
+ " "
+ GetSpotEmoji(f.Spot)
+ GetTodEmoji(f.Time)
+ GetWeatherEmoji(f.Weather)
+ "\n"
+ GetStarText(c.MaxStars, f.Stars)
+ "\n"
+ Format.Italics(f.Fluff),
true);
}
else
{
eb.AddField("?", GetFishEmoji(null, 0) + "\n" + GetStarText(0, f.Stars), true);
}
}
return eb;
})
.SendAsync();
}
private string GetFishEmoji(FishData? fish, int count)
{
if (fish is null)
return "";
return fish.Emoji + " x" + count;
}
private string GetSpotEmoji(FishingSpot? spot)
{
if (spot is not FishingSpot fs)
return string.Empty;
var conf = fcs.Data;
return conf.SpotEmojis[(int)fs];
}
private string GetTodEmoji(FishingTime? fishTod)
{
return fishTod switch
{
FishingTime.Night => "🌑",
FishingTime.Dawn => "🌅",
FishingTime.Dusk => "🌆",
FishingTime.Day => "☀️",
_ => ""
};
}
private string GetWeatherEmoji(FishingWeather? w)
=> w switch
{
FishingWeather.Rain => "🌧️",
FishingWeather.Snow => "❄️",
FishingWeather.Storm => "⛈️",
FishingWeather.Clear => "☀️",
_ => ""
};
private string GetStarText(int resStars, int fishStars)
{
if (resStars == fishStars)
{
return MultiplyStars(fcs.Data.StarEmojis[^1], fishStars);
}
var c = fcs.Data;
var starsp1 = MultiplyStars(c.StarEmojis[resStars], resStars);
var starsp2 = MultiplyStars(c.StarEmojis[0], fishStars - resStars);
return starsp1 + starsp2;
}
private string MultiplyStars(string starEmoji, int count)
{
var sb = new StringBuilder();
for (var i = 0; i < count; i++)
{
sb.Append(starEmoji);
}
return sb.ToString();
}
}
}
public enum FishingSpot
{
Ocean,
River,
Lake,
Swamp,
Reef
}
public enum FishingTime
{
Night,
Dawn,
Day,
Dusk
}
public enum FishingWeather
{
Clear,
Rain,
Storm,
Snow
}

View file

@ -7,49 +7,15 @@ namespace EllieBot.Modules.Games;
public sealed partial class FishConfig : ICloneable<FishConfig>
{
[Comment("DO NOT CHANGE")]
public int Version { get; set; } = 1;
public int Version { get; set; } = 2;
public string WeatherSeed { get; set; } = string.Empty;
public bool RequireCaptcha { get; set; } = true;
public List<string> StarEmojis { get; set; } = new();
public List<string> SpotEmojis { get; set; } = new();
public FishChance Chance { get; set; } = new FishChance();
// public List<FishBait> Baits { get; set; } = new();
// public List<FishingPole> Poles { get; set; } = new();
public List<FishData> Fish { get; set; } = new();
public List<FishData> Trash { get; set; } = new();
}
// public sealed class FishBait : ICloneable<FishBait>
// {
// public int Id { get; set; }
// public string Name { get; set; } = string.Empty;
// public long Price { get; set; }
// public string Emoji { get; set; } = string.Empty;
// public int StackSize { get; set; } = 100;
//
// public string? OnlyWeather { get; set; }
// public string? OnlySpot { get; set; }
// public string? OnlyTime { get; set; }
//
// public double FishMulti { get; set; } = 1;
// public double TrashMulti { get; set; } = 1;
// public double NothingMulti { get; set; } = 1;
//
// public double RareFishMulti { get; set; } = 1;
// public double RareTrashMulti { get; set; } = 1;
//
// public double MaxStarMulti { get; set; } = 1;
// }
//
// public sealed class FishingPole : ICloneable<FishingPole>
// {
// public int Id { get; set; }
// public string Name { get; set; } = string.Empty;
// public long Price { get; set; }
// public string Emoji { get; set; } = string.Empty;
// public string Img { get; set; } = string.Empty;
//
// public double FishMulti { get; set; } = 1;
// public double TrashMulti { get; set; } = 1;
// public double NothingMulti { get; set; } = 1;
// }
public List<FishItem> Items { get; set; } = new();
}

View file

@ -15,5 +15,18 @@ public sealed class FishConfigService : ConfigServiceBase<FishConfig>
IPubSub pubSub)
: base(FILE_PATH, serializer, pubSub, _changeKey)
{
Migrate();
}
private void Migrate()
{
if (data.Version < 2)
{
ModifyConfig(c =>
{
c.Version = 2;
c.RequireCaptcha = true;
});
}
}
}

View file

@ -0,0 +1,98 @@
namespace EllieBot.Modules.Games;
/// <summary>
/// Represents an item used in the fishing game.
/// </summary>
public class FishItem
{
/// <summary>
/// Unique identifier for the item.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Type of the fishing item (pole, bait, boat, potion).
/// </summary>
public FishItemType ItemType { get; set; }
/// <summary>
/// Name of the item.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Item Emoji
/// </summary>
public string Emoji { get; set; } = string.Empty;
/// <summary>
/// Description of the item.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Price of the item.
/// </summary>
public int Price { get; set; }
/// <summary>
/// Number of times the item can be used. Null means unlimited uses.
/// </summary>
public int? Uses { get; set; }
/// <summary>
/// Duration of the item's effect in minutes. Null means permanent effect.
/// </summary>
public int? DurationMinutes { get; set; }
/// <summary>
/// Multiplier affecting the fish catch rate.
/// </summary>
public double? FishMultiplier { get; set; }
/// <summary>
/// Multiplier affecting the trash catch rate.
/// </summary>
public double? TrashMultiplier { get; set; }
/// <summary>
/// Multiplier affecting the maximum star rating of caught fish.
/// </summary>
public double? MaxStarMultiplier { get; set; }
/// <summary>
/// Multiplier affecting the chance of catching rare fish.
/// </summary>
public double? RareMultiplier { get; set; }
/// <summary>
/// Multiplier affecting the fishing speed.
/// </summary>
public double? FishingSpeedMultiplier { get; set; }
}
/// <summary>
/// Defines the types of items available in the fishing game.
/// </summary>
public enum FishItemType
{
/// <summary>
/// Fishing pole used to catch fish.
/// </summary>
Pole,
/// <summary>
/// Bait used to attract fish.
/// </summary>
Bait,
/// <summary>
/// Boat used for fishing.
/// </summary>
Boat,
/// <summary>
/// Potion that provides temporary effects.
/// </summary>
Potion
}

View file

@ -0,0 +1,240 @@
using EllieBot.Modules.Games.Fish.Db;
namespace EllieBot.Modules.Games;
public partial class Games
{
public class FishItemCommands(FishItemService fis, ICurrencyProvider cp) : EllieModule
{
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishShop()
{
var items = fis.GetItems();
await Response()
.Paginated()
.Items(items)
.PageSize(9)
.CurrentPage(0)
.Page((pageItems, i) =>
{
var eb = CreateEmbed()
.WithTitle(GetText(strs.fish_items_title))
.WithFooter("`.fibuy <id>` to by an item")
.WithOkColor();
foreach (var item in pageItems)
{
var description = GetItemDescription(item);
eb.AddField($"{item.Id}",
$"""
{description}
""",
true);
}
return eb;
})
.AddFooter(false)
.SendAsync();
}
private string GetItemDescription(FishItem item, UserFishItem? userItem = null)
{
var multiplierInfo = GetMultiplierInfo(item);
var priceText = userItem is null
? $"【 **{CurrencyHelper.N(item.Price, Culture, cp.GetCurrencySign())}** 】"
: "";
return $"""
**{item.Name}**
{GetEmoji(item.ItemType)} `{item.ItemType.ToString().ToLower()}` {priceText}
{item.Description}
{GetItemNotes(item, userItem)}
{multiplierInfo}
""";
}
private string GetItemNotes(FishItem item, UserFishItem? userItem)
{
var stats = new List<string>();
if (item.Uses.HasValue)
stats.Add($"**Uses:** {userItem?.UsesLeft ?? item.Uses}");
if (item.DurationMinutes.HasValue)
stats.Add($"**Duration:** {userItem?.ExpiryFromNowInMinutes() ?? item.DurationMinutes}m");
var toReturn = stats.Count > 0 ? string.Join(" | ", stats) + "\n" : "\n";
return "\n" + toReturn;
}
public static string GetEmoji(FishItemType itemType)
=> itemType switch
{
FishItemType.Pole => @"\🎣",
FishItemType.Boat => @"\⛵",
FishItemType.Bait => @"\🍥",
FishItemType.Potion => @"\🍷",
_ => ""
};
private string GetMultiplierInfo(FishItem item)
{
var multipliers = new FishMultipliers()
{
FishMultiplier = item.FishMultiplier ?? 1,
TrashMultiplier = item.TrashMultiplier ?? 1,
RareMultiplier = item.RareMultiplier ?? 1,
StarMultiplier = item.MaxStarMultiplier ?? 1,
FishingSpeedMultiplier = item.FishingSpeedMultiplier ?? 1
};
return GetMultiplierInfo(multipliers);
}
public static string GetMultiplierInfo(FishMultipliers item)
{
var multipliers = new List<string>();
if (item.FishMultiplier is not 1.0d)
multipliers.Add($"{AsPercent(item.FishMultiplier)} chance to catch fish");
if (item.TrashMultiplier is not 1.0d)
multipliers.Add($"{AsPercent(item.TrashMultiplier)} chance to catch trash");
if (item.RareMultiplier is not 1.0d)
multipliers.Add($"{AsPercent(item.RareMultiplier)} chance to catch rare fish");
if (item.StarMultiplier is not 1.0d)
multipliers.Add($"{AsPercent(item.StarMultiplier)} to max star rating");
if (item.FishingSpeedMultiplier is not 1.0d)
multipliers.Add($"{AsPercent(item.FishingSpeedMultiplier)} fishing speed");
return multipliers.Count > 0
? $"{string.Join("\n", multipliers)}\n"
: "";
}
private static string AsPercent(double multiplier)
{
var percentage = (int)((multiplier - 1.0f) * 100);
return percentage >= 0 ? $"**+{percentage}%**" : $"**{percentage}%**";
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishBuy(int itemId)
{
var res = await fis.BuyItemAsync(ctx.User.Id, itemId);
if (res.TryPickT1(out var err, out var eqItem))
{
if (err == BuyResult.InsufficientFunds)
await Response().Error(strs.not_enough(cp.GetCurrencySign())).SendAsync();
else
await Response().Error(strs.fish_item_not_found).SendAsync();
return;
}
var embed = CreateEmbed()
.WithDescription(GetText(strs.fish_buy_success))
.AddField(eqItem.Name, GetMultiplierInfo(eqItem));
await Response()
.Embed(embed)
.Interaction(_inter.Create(ctx.User.Id,
new ButtonBuilder("Inventory", Guid.NewGuid().ToString(), ButtonStyle.Secondary),
(smc) => FishInv()))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishUse(int index)
{
var eqItem = await fis.EquipItemAsync(ctx.User.Id, index);
if (eqItem is null)
{
await Response().Error(strs.fish_item_not_found).SendAsync();
return;
}
var embed = CreateEmbed()
.WithDescription(GetText(strs.fish_use_success))
.AddField(eqItem.Name, GetMultiplierInfo(eqItem));
await Response().Embed(embed).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishUnequip(FishItemType itemType)
{
var res = await fis.UnequipItemAsync(ctx.User.Id, itemType);
if (res == UnequipResult.Success)
await Response().Confirm(strs.fish_unequip_success).SendAsync();
else if (res == UnequipResult.NotFound)
await Response().Error(strs.fish_item_not_found).SendAsync();
else
await Response().Error(strs.fish_cant_uneq_potion).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishInv()
{
var userItems = await fis.GetUserItemsAsync(ctx.User.Id);
await Response()
.Paginated()
.Items(userItems)
.PageSize(9)
.Page((items, page) =>
{
page += 1;
var eb = CreateEmbed()
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.fish_inv_title))
.WithFooter($"`.fiuse <num>` to use/equip an item")
.WithOkColor();
for (var i = 0; i < items.Count; i++)
{
var (userItem, item) = items[i];
var isEquipped = userItem.IsEquipped;
if (item is null)
{
eb.AddField($"{(page * 9) + i + 1} | Item not found", $"ID: {userItem.Id}", true);
continue;
}
var description = GetItemDescription(item, userItem);
if (isEquipped)
description = "🫴 **IN USE**\n" + description;
eb.AddField($"{i + 1} | {item.Name} ",
$"""
{description}
""",
true);
}
return eb;
})
.AddFooter(false)
.SendAsync();
}
}
}

View file

@ -0,0 +1,276 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Modules.Games.Fish.Db;
namespace EllieBot.Modules.Games;
/// <summary>
/// Service for managing fish items that users can buy, equip, and use.
/// </summary>
public sealed class FishItemService(
DbService db,
ICurrencyService cs,
FishConfigService fcs) : IEService
{
private IReadOnlyList<FishItem> _items
=> fcs.Data.Items;
/// <summary>
/// Gets all available fish items.
/// </summary>
public IReadOnlyList<FishItem> GetItems()
=> _items;
/// <summary>
/// Gets a specific fish item by ID.
/// </summary>
public FishItem? GetItem(int id)
=> _items.FirstOrDefault(i => i.Id == id);
/// <summary>
/// Gets all items of a specific type.
/// </summary>
public List<FishItem> GetItemsByType(FishItemType type)
=> _items.Where(i => i.ItemType == type).ToList();
/// <summary>
/// Gets all items owned by a user.
/// </summary>
public async Task<List<(UserFishItem UserItem, FishItem? Item)>> GetUserItemsAsync(ulong userId)
{
await using var ctx = db.GetDbContext();
var userItems = await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId)
.ToListAsyncLinqToDB();
return userItems
.Select(ui => (ui, GetItem(ui.ItemId)))
.Where(x => x.Item2 != null)
.ToList();
}
/// <summary>
/// Gets all equipped items for a user.
/// </summary>
public async Task<List<(UserFishItem UserItem, FishItem Item)>> GetEquippedItemsAsync(ulong userId)
{
await CheckExpiredItemsAsync(userId);
await using var ctx = db.GetDbContext();
var items = await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId && x.IsEquipped)
.ToListAsyncLinqToDB();
var output = new List<(UserFishItem, FishItem)>();
foreach (var item in items)
{
var fishItem = GetItem(item.ItemId);
if (fishItem is not null)
output.Add((item, fishItem));
}
return output;
}
/// <summary>
/// Buys an item for a user.
/// </summary>
public async Task<OneOf.OneOf<FishItem, BuyResult>> BuyItemAsync(ulong userId, int itemId)
{
var item = GetItem(itemId);
if (item is null)
return BuyResult.NotFound;
await using var ctx = db.GetDbContext();
var removed = await cs.RemoveAsync(userId, item.Price, new("fish_item_purchase", item.Name));
if (!removed)
return BuyResult.InsufficientFunds;
// Add item to user's inventory
await ctx.GetTable<UserFishItem>()
.InsertAsync(() => new UserFishItem
{
UserId = userId,
ItemId = itemId,
ItemType = item.ItemType,
UsesLeft = item.Uses,
IsEquipped = false,
});
return item;
}
/// <summary>
/// Equips an item for a user.
/// </summary>
public async Task<FishItem?> EquipItemAsync(ulong userId, int index)
{
await using var ctx = db.GetDbContext();
await using var tr = await ctx.Database.BeginTransactionAsync();
try
{
var userItem = await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId)
.Skip(index - 1)
.Take(1)
.FirstOrDefaultAsync();
if (userItem is null)
return null;
var fishItem = GetItem(userItem.ItemId);
if (fishItem is null)
return null;
if (userItem.ItemType == FishItemType.Potion)
{
var query = ctx.GetTable<UserFishItem>()
.Where(x => x.Id == userItem.Id && !x.IsEquipped)
.Set(x => x.IsEquipped, true);
if (fishItem.DurationMinutes is { } dur)
query = query
.Set(x => x.ExpiresAt, DateTime.UtcNow.AddMinutes(dur));
await query.UpdateAsync();
await tr.CommitAsync();
return fishItem;
}
// UnEquip any currently equipped item of the same type
// and equip current one
await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId && x.ItemType == userItem.ItemType)
.Set(x => x.IsEquipped, x => x.Id == userItem.Id)
.UpdateAsync();
await tr.CommitAsync();
return fishItem;
}
catch
{
await tr.RollbackAsync();
return null;
}
}
/// <summary>
/// Unequips an item for a user.
/// </summary>
public async Task<UnequipResult> UnequipItemAsync(ulong userId, FishItemType itemType)
{
// can't unequip potions
if (itemType == FishItemType.Potion)
return UnequipResult.Potion;
await using var ctx = db.GetDbContext();
var affected = await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId && x.ItemType == itemType && x.IsEquipped)
.Set(x => x.IsEquipped, false)
.UpdateAsync();
if (affected > 0)
return UnequipResult.Success;
else
return UnequipResult.NotFound;
}
/// <summary>
/// Gets the multipliers from a user's equipped items.
/// </summary>
public async Task<FishMultipliers> GetUserMultipliersAsync(ulong userId)
{
var equippedItems = await GetEquippedItemsAsync(userId);
var multipliers = new FishMultipliers();
foreach (var (_, item) in equippedItems)
{
multipliers.FishMultiplier *= item.FishMultiplier ?? 1;
multipliers.TrashMultiplier *= item.TrashMultiplier ?? 1;
multipliers.StarMultiplier *= item.MaxStarMultiplier ?? 1;
multipliers.RareMultiplier *= item.RareMultiplier ?? 1;
multipliers.FishingSpeedMultiplier *= item.FishingSpeedMultiplier ?? 1;
}
return multipliers;
}
/// <summary>
/// Uses a bait item (reduces uses left) when fishing.
/// </summary>
public async Task<bool> UseBaitAsync(ulong userId)
{
await using var ctx = db.GetDbContext();
var updated = await ctx.GetTable<UserFishItem>()
.Where(x =>
x.UserId == userId &&
x.ItemType == FishItemType.Bait &&
x.IsEquipped)
.Set(x => x.UsesLeft, x => x.UsesLeft - 1)
.UpdateWithOutputAsync((o, n) => n);
if (updated.Length == 0)
return false;
if (updated[0].UsesLeft <= 0)
{
await ctx.GetTable<UserFishItem>()
.DeleteAsync(x => x.Id == updated[0].Id);
}
return true;
}
/// <summary>
/// Checks and removes expired items.
/// </summary>
public async Task CheckExpiredItemsAsync(ulong userId)
{
await using var ctx = db.GetDbContext();
var now = DateTime.UtcNow;
await ctx.GetTable<UserFishItem>()
.Where(x => x.UserId == userId && x.ExpiresAt.HasValue && x.ExpiresAt < now)
.DeleteAsync();
}
}
/// <summary>
/// Represents the result of a buy operation.
/// </summary>
public enum BuyResult
{
NotFound,
InsufficientFunds
}
/// <summary>
/// Represents the result of an equip operation.
/// </summary>
public enum UnequipResult
{
Success,
NotFound,
Potion
}
/// <summary>
/// Contains multipliers applied to fishing based on equipped items.
/// </summary>
public class FishMultipliers
{
public double FishMultiplier { get; set; } = 1.0;
public double TrashMultiplier { get; set; } = 1.0;
public double StarMultiplier { get; set; } = 1.0;
public double RareMultiplier { get; set; } = 1.0;
public double FishingSpeedMultiplier { get; set; } = 1.0;
}

View file

@ -7,6 +7,12 @@ public sealed class FishResult
public bool IsSkillUp { get; set; }
public int Skill { get; set; }
public int MaxSkill { get; set; }
public bool IsMaxStar()
=> Stars == Fish.Stars;
public bool IsRare()
=> Fish.Chance <= 15;
}
public readonly record struct AlreadyFishing;

View file

@ -1,10 +1,23 @@
using System.Security.Cryptography;
using System.Text;
using AngleSharp.Common;
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Modules.Administration;
using EllieBot.Modules.Administration.Services;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Games.Fish;
public sealed class FishService(FishConfigService fcs, IBotCache cache, DbService db) : IEService
public sealed class FishService(
FishConfigService fcs,
IBotCache cache,
DbService db,
INotifySubscriber notify,
QuestService quests,
FishItemService itemService
)
: IEService
{
private const double MAX_SKILL = 100;
@ -13,19 +26,24 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
private static TypedKey<bool> FishingKey(ulong userId)
=> new($"fishing:{userId}");
public async Task<OneOf.OneOf<Task<FishResult?>, AlreadyFishing>> FishAsync(ulong userId, ulong channelId)
public async Task<OneOf.OneOf<Task<FishResult?>, AlreadyFishing>> FishAsync(ulong userId, ulong channelId,
FishMultipliers multipliers)
{
var duration = _rng.Next(5, 9);
var duration = _rng.Next(3, 6) / multipliers.FishingSpeedMultiplier;
if (!await cache.AddAsync(FishingKey(userId), true, TimeSpan.FromSeconds(duration), overwrite: false))
{
return new AlreadyFishing();
}
return TryFishAsync(userId, channelId, duration);
return TryFishAsync(userId, channelId, duration, multipliers);
}
private async Task<FishResult?> TryFishAsync(ulong userId, ulong channelId, int duration)
private async Task<FishResult?> TryFishAsync(
ulong userId,
ulong channelId,
double duration,
FishMultipliers multipliers)
{
var conf = fcs.Data;
await Task.Delay(TimeSpan.FromSeconds(duration));
@ -35,8 +53,8 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
var trashChanceMultiplier = Math.Clamp(((2 * MAX_SKILL) - playerSkill) / MAX_SKILL, 1, 2);
var nothingChance = conf.Chance.Nothing;
var fishChance = conf.Chance.Fish * fishChanceMultiplier;
var trashChance = conf.Chance.Trash * trashChanceMultiplier;
var fishChance = conf.Chance.Fish * fishChanceMultiplier * multipliers.FishMultiplier;
var trashChance = conf.Chance.Trash * trashChanceMultiplier * multipliers.TrashMultiplier;
// first roll whether it's fish, trash or nothing
var totalChance = fishChance + trashChance + conf.Chance.Nothing;
@ -48,13 +66,21 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
return null;
}
var items = typeRoll < nothingChance + fishChance
var isFish = typeRoll < nothingChance + fishChance;
var items = isFish
? conf.Fish
: conf.Trash;
var result = await FishAsyncInternal(userId, channelId, items, multipliers);
// use bait
if (result is not null)
{
await itemService.UseBaitAsync(userId);
}
var result = await FishAsyncInternal(userId, channelId, items);
// skill
if (result is not null)
{
var isSkillUp = await TrySkillUpAsync(userId, playerSkill);
@ -69,6 +95,27 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
}
}
// notification system
if (result is not null)
{
if (result.IsMaxStar() || result.IsRare())
{
await notify.NotifyAsync(new NiceCatchNotifyModel(
userId,
result.Fish,
GetStarText(result.Stars, result.Fish.Stars)
));
}
await quests.ReportActionAsync(userId,
QuestEventType.FishCaught,
new()
{
{ "fish", result.Fish.Name },
{ "type", typeRoll < nothingChance + fishChance ? "fish" : "trash" },
{ "stars", result.Stars.ToString() }
});
}
return result;
}
@ -85,21 +132,21 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
var maxSkill = (int)MAX_SKILL;
await ctx.GetTable<UserFishStats>()
.InsertOrUpdateAsync(() => new()
{
UserId = userId,
Skill = 1,
},
(old) => new()
{
UserId = userId,
Skill = old.Skill > maxSkill ? maxSkill : old.Skill + 1
},
() => new()
{
UserId = userId,
Skill = playerSkill
});
.InsertOrUpdateAsync(() => new()
{
UserId = userId,
Skill = 1,
},
(old) => new()
{
UserId = userId,
Skill = old.Skill > maxSkill ? maxSkill : old.Skill + 1
},
() => new()
{
UserId = userId,
Skill = playerSkill
});
return true;
}
@ -123,14 +170,18 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
await using var ctx = db.GetDbContext();
var skill = await ctx.GetTable<UserFishStats>()
.Where(x => x.UserId == userId)
.Select(x => x.Skill)
.FirstOrDefaultAsyncLinqToDB();
.Where(x => x.UserId == userId)
.Select(x => x.Skill)
.FirstOrDefaultAsyncLinqToDB();
return (skill, (int)MAX_SKILL);
}
private async Task<FishResult?> FishAsyncInternal(ulong userId, ulong channelId, List<FishData> items)
private async Task<FishResult?> FishAsyncInternal(
ulong userId,
ulong channelId,
List<FishData> items,
FishMultipliers multipliers)
{
var filteredItems = new List<FishData>();
@ -160,7 +211,20 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
filteredItems.Add(item);
}
var maxSum = filteredItems.Sum(x => x.Chance * 100);
var maxSum = filteredItems
.Select(x => (x.Id, x.Chance, x.Stars))
.Select(x =>
{
if (x.Chance <= 15)
return x with
{
Chance = x.Chance *= multipliers.RareMultiplier
};
return x;
})
.Sum(x => { return x.Chance * 100; });
var roll = _rng.NextDouble() * maxSum;
@ -177,7 +241,7 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
caught = new FishResult()
{
Fish = i,
Stars = GetRandomStars(i.Stars),
Stars = GetRandomStars(i.Stars, multipliers),
};
break;
}
@ -188,23 +252,23 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
await using var uow = db.GetDbContext();
await uow.GetTable<FishCatch>()
.InsertOrUpdateAsync(() => new FishCatch()
{
UserId = userId,
FishId = caught.Fish.Id,
MaxStars = caught.Stars,
Count = 1
},
(old) => new FishCatch()
{
Count = old.Count + 1,
MaxStars = Math.Max(old.MaxStars, caught.Stars),
},
() => new()
{
FishId = caught.Fish.Id,
UserId = userId
});
.InsertOrUpdateAsync(() => new FishCatch()
{
UserId = userId,
FishId = caught.Fish.Id,
MaxStars = caught.Stars,
Count = 1
},
(old) => new FishCatch()
{
Count = old.Count + 1,
MaxStars = Math.Max(old.MaxStars, caught.Stars),
},
() => new()
{
FishId = caught.Fish.Id,
UserId = userId
});
return caught;
}
@ -321,25 +385,30 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
/// if maxStars == 5, returns 1 (40%) or 2 (30%) or 3 (15%) or 4 (10%) or 5 (5%)
/// </summary>
/// <param name="maxStars">Max Number of stars to generate</param>
/// <param name="multipliers"></param>
/// <returns>Random number of stars</returns>
private int GetRandomStars(int maxStars)
private int GetRandomStars(int maxStars, FishMultipliers multipliers)
{
if (maxStars == 1)
return 1;
var maxStarMulti = multipliers.StarMultiplier;
double baseChance;
if (maxStars == 2)
{
// 15% chance of 1 star, 85% chance of 2 stars
return _rng.NextDouble() < 0.85 ? 1 : 2;
baseChance = Math.Clamp(0.15 * multipliers.StarMultiplier, 0, 1);
return _rng.NextDouble() < (1 - baseChance) ? 1 : 2;
}
if (maxStars == 3)
{
// 65% chance of 1 star, 30% chance of 2 stars, 5% chance of 3 stars
baseChance = 0.05 * multipliers.StarMultiplier;
var r = _rng.NextDouble();
if (r < 0.65)
if (r < (1 - baseChance - 0.3))
return 1;
if (r < 0.95)
if (r < (1 - baseChance))
return 2;
return 3;
}
@ -349,26 +418,28 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
// this should never happen
// 50% chance of 1 star, 25% chance of 2 stars, 18% chance of 3 stars, 7% chance of 4 stars
var r = _rng.NextDouble();
if (r < 0.55)
baseChance = 0.02 * multipliers.StarMultiplier;
if (r < (1 - baseChance - 0.45))
return 1;
if (r < 0.80)
if (r < (1 - baseChance - 0.15))
return 2;
if (r < 0.98)
if (r < (1 - baseChance))
return 3;
return 4;
}
if (maxStars == 5)
{
// 40% chance of 1 star, 30% chance of 2 stars, 15% chance of 3 stars, 10% chance of 4 stars, 5% chance of 5 stars
// 40% chance of 1 star, 30% chance of 2 stars, 15% chance of 3 stars, 10% chance of 4 stars, 2% chance of 5 stars
var r = _rng.NextDouble();
if (r < 0.4)
baseChance = 0.02 * multipliers.StarMultiplier;
if (r < (1 - baseChance - 0.6))
return 1;
if (r < 0.7)
if (r < (1 - baseChance - 0.3))
return 2;
if (r < 0.9)
if (r < (1 - baseChance - 0.1))
return 3;
if (r < 0.98)
if (r < (1 - baseChance))
return 4;
return 5;
}
@ -392,9 +463,62 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
await using var ctx = db.GetDbContext();
var catches = await ctx.GetTable<FishCatch>()
.Where(x => x.UserId == userId)
.ToListAsyncLinqToDB();
.Where(x => x.UserId == userId)
.ToListAsyncLinqToDB();
return catches;
}
public async Task<IReadOnlyCollection<(ulong UserId, int Catches, int Unique)>> GetFishLbAsync(int page)
{
await using var ctx = db.GetDbContext();
var result = await ctx.GetTable<FishCatch>()
.GroupBy(x => x.UserId)
.OrderByDescending(x => x.Count()).ThenByDescending(x => x.Sum(x => x.Count))
.Skip(page * 10)
.Take(10)
.Select(x => new
{
UserId = x.Key,
Catches = x.Sum(x => x.Count),
Unique = x.Count()
})
.ToListAsyncLinqToDB()
.Fmap(x => x.Map(y => (y.UserId, y.Catches, y.Unique)).ToList());
return result;
}
public string GetStarText(int resStars, int fishStars)
{
if (resStars == fishStars)
{
return MultiplyStars(fcs.Data.StarEmojis[^1], fishStars);
}
var c = fcs.Data;
var starsp1 = MultiplyStars(c.StarEmojis[resStars], resStars);
var starsp2 = MultiplyStars(c.StarEmojis[0], fishStars - resStars);
return starsp1 + starsp2;
}
private string MultiplyStars(string starEmoji, int count)
{
var sb = new StringBuilder();
for (var i = 0; i < count; i++)
{
sb.Append(starEmoji);
}
return sb.ToString();
}
}
public sealed class IUserFishCatch
{
public ulong UserId { get; set; }
public int Count { get; set; }
}

View file

@ -0,0 +1,320 @@
using EllieBot.Modules.Games.Fish;
using EllieBot.Modules.Xp.Services;
using Format = Discord.Format;
namespace EllieBot.Modules.Games;
public partial class Games
{
public class FishingCommands(
FishService fs,
FishItemService fis,
FishConfigService fcs,
IBotCache cache,
UserService us,
CaptchaService captchaService) : EllieModule
{
private static readonly EllieRandom _rng = new();
private TypedKey<bool> FishingWhitelistKey(ulong userId)
=> new($"fishingwhitelist:{userId}");
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Fish()
{
var cRes = await cache.GetAsync(FishingWhitelistKey(ctx.User.Id));
if (cRes.TryPickT1(out _, out _))
{
string? password = null;
if (fcs.Data.RequireCaptcha)
password = await captchaService.GetUserCaptcha(ctx.User.Id);
if (password is not null)
{
var img = captchaService.GetPasswordImage(password);
using var stream = await img.ToStreamAsync();
var toSend = Response()
.File(stream, "timely.png")
.Embed(CreateEmbed()
.WithFooter("captcha: type the text from the image")
.WithImageUrl("attachment://timely.png"));
#if GLOBAL_ELLIE
if (_rng.Next(0, 8) == 0)
toSend = toSend
.Text("*[Sub on Patreon](https://patreon.com/elliebot) to remove captcha.*");
#endif
var captcha = await toSend.SendAsync();
try
{
var userInput = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
if (userInput?.ToLowerInvariant() != password?.ToLowerInvariant())
{
return;
}
// whitelist the user for 30 minutes
await cache.AddAsync(FishingWhitelistKey(ctx.User.Id), true, TimeSpan.FromMinutes(30));
// reset the password
await captchaService.ClearUserCaptcha(ctx.User.Id);
}
finally
{
_ = captcha.DeleteAsync();
}
}
}
var multis = await fis.GetUserMultipliersAsync(ctx.User.Id);
var fishResult = await fs.FishAsync(ctx.User.Id, ctx.Channel.Id, multis);
if (fishResult.TryPickT1(out _, out var fishTask))
{
return;
}
var currentWeather = fs.GetCurrentWeather();
var currentTod = fs.GetTime();
var spot = fs.GetSpot(ctx.Channel.Id);
var msg = await Response()
.Embed(CreateEmbed()
.WithPendingColor()
.WithAuthor(ctx.User)
.WithDescription($"""
{GetText(strs.fish_waiting)}
{FishItemCommands.GetMultiplierInfo(multis)}
""")
.AddField(GetText(strs.fish_spot), GetSpotEmoji(spot) + " " + spot.ToString(), true)
.AddField(GetText(strs.fish_weather),
GetWeatherEmoji(currentWeather) + " " + currentWeather,
true)
.AddField(GetText(strs.fish_tod), GetTodEmoji(currentTod) + " " + currentTod, true))
.SendAsync();
var res = await fishTask;
if (res is null)
{
await Response().Error(strs.fish_nothing).SendAsync();
return;
}
var desc = GetText(strs.fish_caught(res.Fish.Emoji + " " + Format.Bold(res.Fish.Name)));
if (res.IsSkillUp)
{
desc += "\n" + GetText(strs.fish_skill_up(res.Skill, res.MaxSkill));
}
await Response()
.Embed(CreateEmbed()
.WithOkColor()
.WithAuthor(ctx.User)
.WithDescription(desc)
.AddField(GetText(strs.fish_quality), fs.GetStarText(res.Stars, res.Fish.Stars), true)
.AddField(GetText(strs.desc), res.Fish.Fluff, true)
.WithThumbnailUrl(res.Fish.Image))
.SendAsync();
await msg.DeleteAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishSpot()
{
var ws = fs.GetWeatherForPeriods(7);
var spot = fs.GetSpot(ctx.Channel.Id);
var time = fs.GetTime();
await Response()
.Embed(CreateEmbed()
.WithOkColor()
.WithDescription(GetText(strs.fish_weather_duration(fs.GetWeatherPeriodDuration())))
.AddField(GetText(strs.fish_spot), GetSpotEmoji(spot) + " " + spot, true)
.AddField(GetText(strs.fish_tod), GetTodEmoji(time) + " " + time, true)
.AddField(GetText(strs.fish_weather_forecast),
ws.Select(x => GetWeatherEmoji(x)).Join(""),
true))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishList(int page = 1)
{
if (--page < 0)
return;
var fishes = await fs.GetAllFish();
var catches = await fs.GetUserCatches(ctx.User.Id);
var (skill, maxSkill) = await fs.GetSkill(ctx.User.Id);
var catchDict = catches.ToDictionary(x => x.FishId, x => x);
var items = await fis.GetEquippedItemsAsync(ctx.User.Id);
var desc = $"""
🧠 {skill} / {maxSkill}
""";
foreach (var itemType in Enum.GetValues<FishItemType>())
{
var i = items.Where(x => x.Item.ItemType == itemType).ToArray();
desc += " · " + FishItemCommands.GetEmoji(itemType) + " " +
(i.Any() ? string.Join(", ", i.Select(x => x.Item.Name)) : "None");
}
await Response()
.Paginated()
.Items(fishes)
.PageSize(9)
.CurrentPage(page)
.Page((pageFish, i) =>
{
var eb = CreateEmbed()
.WithDescription(desc)
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.fish_list_title))
.WithOkColor();
foreach (var f in pageFish)
{
if (catchDict.TryGetValue(f.Id, out var c))
{
eb.AddField(f.Name,
GetFishEmoji(f, c.Count)
+ " "
+ GetSpotEmoji(f.Spot)
+ GetTodEmoji(f.Time)
+ GetWeatherEmoji(f.Weather)
+ "\n"
+ fs.GetStarText(c.MaxStars, f.Stars)
+ "\n"
+ Format.Italics(f.Fluff),
true);
}
else
{
eb.AddField("?", GetFishEmoji(null, 0) + "\n" + fs.GetStarText(0, f.Stars), true);
}
}
return eb;
})
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FishLb(int page = 1)
{
if (--page < 0)
return;
await Response()
.Paginated()
.PageItems(async p => await fs.GetFishLbAsync(p))
.PageSize(9)
.Page(async (items, page) =>
{
var users = await us.GetUsersAsync(items.Select(x => x.UserId).ToArray());
var eb = CreateEmbed()
.WithTitle(GetText(strs.fish_lb_title))
.WithOkColor();
for (var i = 0; i < items.Count; i++)
{
var data = items[i];
var user = users.TryGetValue(data.UserId, out var ud)
? ud.ToString()
: data.UserId.ToString();
var text =
$"""
{GetText(strs.fish_unique(Format.Bold(data.Unique.ToString())))}
*{GetText(strs.fish_catches(data.Catches))}*
""";
eb.AddField("#" + (page * 9 + i + 1) + " | " + user,
text,
false);
}
return eb;
})
.SendAsync();
}
private string GetFishEmoji(FishData? fish, int count)
{
if (fish is null)
return "";
return fish.Emoji + " x" + count;
}
private string GetSpotEmoji(FishingSpot? spot)
{
if (spot is not FishingSpot fs)
return string.Empty;
var conf = fcs.Data;
return conf.SpotEmojis[(int)fs];
}
private string GetTodEmoji(FishingTime? fishTod)
{
return fishTod switch
{
FishingTime.Night => "🌑",
FishingTime.Dawn => "🌅",
FishingTime.Dusk => "🌆",
FishingTime.Day => "☀️",
_ => ""
};
}
private string GetWeatherEmoji(FishingWeather? w)
=> w switch
{
FishingWeather.Rain => "🌧️",
FishingWeather.Snow => "❄️",
FishingWeather.Storm => "⛈️",
FishingWeather.Clear => "☀️",
_ => ""
};
}
}
public enum FishingSpot
{
Ocean,
River,
Lake,
Swamp,
Reef
}
public enum FishingTime
{
Night,
Dawn,
Day,
Dusk
}
public enum FishingWeather
{
Clear,
Rain,
Storm,
Snow
}

View file

@ -0,0 +1,21 @@
{
"fish_items_title": "Available Fishing Items",
"fish_buy_success": "Item purchased successfully!",
"fish_buy_not_found": "Item not found.",
"fish_buy_already_owned": "You already own this item.",
"fish_buy_insufficient_funds": "You don't have enough currency to buy this item.",
"fish_buy_error": "An error occurred while trying to buy the item.",
"fish_use_success": "Item equipped successfully!",
"fish_use_not_found": "Item not found.",
"fish_use_not_owned": "You don't own this item.",
"fish_use_expired": "This item has expired.",
"fish_use_no_uses": "This item has no uses left.",
"fish_use_error": "An error occurred while trying to use the item.",
"fish_unequip_success": "Item unequipped successfully!",
"fish_unequip_error": "Could not unequip item.",
"fish_inv_title": "{0}'s Fishing Inventory",
"fish_gift_self": "You can't gift items to yourself.",
"fish_gift_not_owned": "You don't own this item.",
"fish_gift_equipped": "You can't gift equipped items. Unequip it first.",
"fish_gift_success": "Item successfully gifted to {0}!"
}

View file

@ -10,44 +10,54 @@ public partial class Games
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Hangmanlist()
=> await Response().Confirm(GetText(strs.hangman_types(prefix)), _service.GetHangmanTypes().Join('\n')).SendAsync();
=> await Response().Confirm(GetText(strs.hangman_types(prefix)), _service.GetHangmanTypes().Join('\n'))
.SendAsync();
private static string Draw(HangmanGame.State state)
=> $"""
.
................
................
.{(state.Errors > 0 ? ".............😲" : "")}
.{(state.Errors > 1 ? "............./" : "")} {(state.Errors > 2 ? "|" : "")} {(state.Errors > 3 ? "\\" : "")}
.{(state.Errors > 4 ? "............../" : "")} {(state.Errors > 5 ? "\\" : "")}
/-\
""";
{
var head = state.Errors >= 1 ? "O" : " ";
var torso = state.Errors >= 2 ? "|" : " ";
var leftArm = state.Errors >= 3 ? "/" : " ";
var rightArm = state.Errors >= 4 ? "\\" : " ";
var leftLeg = state.Errors >= 5 ? "/" : " ";
var rightLeg = state.Errors >= 6 ? "\\" : " ";
return $"""
```
{head}
{leftArm}{torso}{rightArm}
{leftLeg} {rightLeg}
```
""";
}
public static EmbedBuilder GetEmbed(IMessageSenderService sender, HangmanGame.State state)
{
if (state.Phase == HangmanGame.Phase.Running)
{
return sender.CreateEmbed()
.WithOkColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
.WithOkColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
}
if (state.Phase == HangmanGame.Phase.Ended && state.Failed)
{
return sender.CreateEmbed()
.WithErrorColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
.WithErrorColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
}
return sender.CreateEmbed()
.WithOkColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
.WithOkColor()
.AddField("Hangman", Draw(state))
.AddField("Guess", Format.Code(state.Word))
.WithFooter(state.MissedLetters.Join(' '));
}
[Cmd]

View file

@ -2,6 +2,7 @@
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Modules.Games.Services;
using System.Diagnostics.CodeAnalysis;
using EllieBot.Modules.Games.Quests;
namespace EllieBot.Modules.Games.Hangman;
@ -13,6 +14,7 @@ public sealed class HangmanService : IHangmanService, IExecNoCommand
private readonly GamesConfigService _gcs;
private readonly ICurrencyService _cs;
private readonly IMemoryCache _cdCache;
private readonly QuestService _quests;
private readonly object _locker = new();
public HangmanService(
@ -20,13 +22,15 @@ public sealed class HangmanService : IHangmanService, IExecNoCommand
IMessageSenderService sender,
GamesConfigService gcs,
ICurrencyService cs,
IMemoryCache cdCache)
IMemoryCache cdCache,
QuestService quests)
{
_source = source;
_sender = sender;
_gcs = gcs;
_cs = cs;
_cdCache = cdCache;
_quests = quests;
}
public bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? state)
@ -104,6 +108,9 @@ public sealed class HangmanService : IHangmanService, IExecNoCommand
if (rew > 0)
await _cs.AddAsync(msg.Author, rew, new("hangman", "win"));
if (state.GuessResult == HangmanGame.GuessResult.Win)
await _quests.ReportActionAsync(msg.Author.Id, QuestEventType.GameWon, new() { { "game", "hangman" } });
await SendState((ITextChannel)msg.Channel, msg.Author, msg.Content, state);
}

View file

@ -5,12 +5,14 @@ using SixLabors.ImageSharp.Advanced;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Color = SixLabors.ImageSharp.Color;
using Image = SixLabors.ImageSharp.Image;
namespace EllieBot.Modules.Games;
public partial class Games
{
[RequireContext(ContextType.Guild)]
public sealed class NCanvasCommands : EllieModule
{
private readonly INCanvasService _service;
@ -30,37 +32,61 @@ public partial class Games
_gcs = gcs;
}
[Cmd]
public async Task NCanvas(kwum x)
=> await NcPixel(x);
[Cmd]
public async Task NCanvas()
{
var pixels = await _service.GetCanvas();
var image = new Image<Rgba32>(_service.GetWidth(), _service.GetHeight());
var w = _service.GetWidth();
var h = _service.GetHeight();
var image = new Image<Rgba32>(w * 2, h * 2);
Parallel.For(0,
image.Height,
h * 2,
y =>
{
var pixelAccessor = image.DangerousGetPixelRowMemory(y);
var row = pixelAccessor.Span;
for (int x = 0; x < image.Width; x++)
for (var x = 0; x < image.Width; x += 2)
{
row[x] = new Rgba32(pixels[(y * image.Width) + x]);
var pi = pixels[(y / 2 * w) + x / 2];
row[x] = new Rgba32(pi);
row[x + 1] = new Rgba32(pi);
}
});
await using var stream = await image.ToStreamAsync();
var hint = GetText(strs.nc_hint(prefix, _service.GetWidth(), _service.GetHeight()));
var rng = new EllieRandom();
var inter = _inter.Create(ctx.User.Id,
new ButtonBuilder("Zoom to a Random Spot",
Guid.NewGuid().ToString(),
ButtonStyle.Secondary),
(_) => NCzoom(rng.Next(0, w), rng.Next(0, h)));
await Response()
.File(stream, "ncanvas.png")
.Embed(CreateEmbed()
.WithOkColor()
.File(stream, "ncanvas.png")
.Interaction(inter)
.Embed(CreateEmbed()
.WithDescription("""
Draw pixels on the canvas!
`.ncz x y` to zoom to a particular coordinates
`.ncs CODE color` to set color
`.nc` to see the whole canvas
""")
.WithOkColor()
#if GLOBAL_ELLIE
.WithDescription("This is not available yet.")
#endif
.WithFooter(hint)
.WithImageUrl("attachment://ncanvas.png"))
.SendAsync();
.WithFooter(hint)
.WithImageUrl("attachment://ncanvas.png"))
.SendAsync();
}
[Cmd]
@ -81,7 +107,20 @@ public partial class Games
using var img = await GetZoomImage(position);
await using var stream = await img.ToStreamAsync();
await ctx.Channel.SendFileAsync(stream, $"zoom_{position}.png");
var eb = CreateEmbed()
.WithOkColor()
.WithImageUrl($"attachment://zoom_{position}.png")
.WithFooter($"`.ncs code color` to set | ex: `.ncs {position} pink`");
await Response()
.Embed(eb)
.Interaction(_inter.Create(ctx.User.Id,
new ButtonBuilder("See Canvas",
Guid.NewGuid().ToString(),
ButtonStyle.Secondary),
async (smc) => await NCanvas()))
.File(stream, $"zoom_{position}.png")
.SendAsync();
}
private async Task<Image<Rgba32>> GetZoomImage(kwum position)
@ -97,11 +136,13 @@ public partial class Games
const float fontSize = 30;
var posFont = _fonts.NotoSans.CreateFont(fontSize, FontStyle.Bold);
var priceFont = _fonts.Symbola.CreateFont(35, FontStyle.Regular);
var size = TextMeasurer.MeasureSize("wwww", new TextOptions(posFont));
var scale = 100f / size.Width;
if (scale < 1)
posFont = _fonts.NotoSans.CreateFont(fontSize * scale, FontStyle.Bold);
var outlinePen = new SolidPen(SixLabors.ImageSharp.Color.Black, 1f);
var outlinePen = new SolidPen(SixLabors.ImageSharp.Color.Black, 0.5f);
Parallel.For(0,
pixels.Length,
@ -119,14 +160,26 @@ public partial class Games
image.Mutate(x =>
{
x.DrawText(new RichTextOptions(posFont)
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Origin = new(startX + 50, startY + 50)
},
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Origin = new(startX + 50, startY + 30)
},
((kwum)pix.Position).ToString().PadLeft(2, '2'),
Brushes.Solid(SixLabors.ImageSharp.Color.White),
outlinePen);
x.DrawText(new RichTextOptions(priceFont)
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Origin = new(startX + 50, startY + 80)
},
// "", Brushes.Solid(SixLabors.ImageSharp.Color.White), outlinePen);
pix.Price + "💵",
// CurrencyHelper.N(pix.Price, Culture, _gcs.Data.Currency.Sign),
Brushes.Solid(SixLabors.ImageSharp.Color.White),
outlinePen);
});
});
@ -135,7 +188,7 @@ public partial class Games
}
[Cmd]
public async Task NcSetPixel(kwum position, string colorHex, [Leftover] string text = "")
public async Task NcSetPixel(kwum position, Rgba32 color, [Leftover] string text = "")
{
if (position < 0 || position >= _service.GetWidth() * _service.GetHeight())
{
@ -143,15 +196,6 @@ public partial class Games
return;
}
if (colorHex.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
colorHex = colorHex[2..];
if (!Rgba32.TryParseHex(colorHex, out var clr))
{
await Response().Error(strs.invalid_color).SendAsync();
return;
}
var pixel = await _service.GetPixel(position);
if (pixel is null)
{
@ -165,13 +209,13 @@ public partial class Games
_gcs.Data.Currency.Sign))));
if (!await PromptUserConfirmAsync(CreateEmbed()
.WithPendingColor()
.WithDescription(prompt)))
.WithPendingColor()
.WithDescription(prompt)))
{
return;
}
var result = await _service.SetPixel(position, clr.PackedValue, text, ctx.User.Id, pixel.Price);
var result = await _service.SetPixel(position, color.PackedValue, text, ctx.User.Id, pixel.Price);
if (result == SetPixelResult.NotEnoughMoney)
{
@ -193,12 +237,17 @@ public partial class Games
await using var stream = await img.ToStreamAsync();
await Response()
.Embed(CreateEmbed()
.WithOkColor()
.WithDescription(GetText(strs.nc_pixel_set(Format.Code(position.ToString()))))
.WithImageUrl($"attachment://zoom_{position}.png"))
.File(stream, $"zoom_{position}.png")
.SendAsync();
.Embed(CreateEmbed()
.WithOkColor()
.WithDescription(GetText(strs.nc_pixel_set(Format.Code(position.ToString()))))
.WithImageUrl($"attachment://zoom_{position}.png"))
.Interaction(_inter.Create(ctx.User.Id,
new ButtonBuilder("See Canvas",
Guid.NewGuid().ToString(),
ButtonStyle.Secondary),
async (smc) => await NCanvas()))
.File(stream, $"zoom_{position}.png")
.SendAsync();
}
[Cmd]
@ -230,18 +279,18 @@ public partial class Games
var pos = new kwum(pixel.Position);
await Response()
.File(stream, $"{pixel.Position}.png")
.Embed(CreateEmbed()
.WithOkColor()
.WithDescription(string.IsNullOrWhiteSpace(pixel.Text) ? string.Empty : pixel.Text)
.WithTitle(GetText(strs.nc_pixel(pos)))
.AddField(GetText(strs.nc_position),
$"{pixel.Position % _service.GetWidth()} {pixel.Position / _service.GetWidth()}",
true)
.AddField(GetText(strs.price), pixel.Price.ToString(), true)
.AddField(GetText(strs.color), "#" + new Rgba32(pixel.Color).ToHex())
.WithImageUrl($"attachment://{pixel.Position}.png"))
.SendAsync();
.File(stream, $"{pixel.Position}.png")
.Embed(CreateEmbed()
.WithOkColor()
.WithDescription(string.IsNullOrWhiteSpace(pixel.Text) ? string.Empty : pixel.Text)
.WithTitle(GetText(strs.nc_pixel(pos)))
.AddField(GetText(strs.nc_position),
$"{pixel.Position % _service.GetWidth()} {pixel.Position / _service.GetWidth()}",
true)
.AddField(GetText(strs.price), pixel.Price.ToString(), true)
.AddField(GetText(strs.color), "#" + new Rgba32(pixel.Color).ToHex())
.WithImageUrl($"attachment://{pixel.Position}.png"))
.SendAsync();
}
[Cmd]
@ -264,9 +313,9 @@ public partial class Games
}
if (!await PromptUserConfirmAsync(CreateEmbed()
.WithDescription(
"This will reset the canvas to the specified image. All prices, text and colors will be reset.\n\n"
+ "Are you sure you want to continue?")))
.WithDescription(
"This will reset the canvas to the specified image. All prices, text and colors will be reset.\n\n"
+ "Are you sure you want to continue?")))
return;
using var http = _http.CreateClient();
@ -294,9 +343,9 @@ public partial class Games
await _service.ResetAsync();
if (!await PromptUserConfirmAsync(CreateEmbed()
.WithDescription(
"This will delete all pixels and reset the canvas.\n\n"
+ "Are you sure you want to continue?")))
.WithDescription(
"This will delete all pixels and reset the canvas.\n\n"
+ "Are you sure you want to continue?")))
return;
await ctx.OkAsync();

View file

@ -3,9 +3,9 @@ using LinqToDB.Data;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using SixLabors.ImageSharp.ColorSpaces;
using SixLabors.ImageSharp.ColorSpaces.Conversion;
using EllieBot.Modules.Games.Quests;
using SixLabors.ImageSharp.PixelFormats;
using Color = SixLabors.ImageSharp.Color;
namespace EllieBot.Modules.Games;
@ -17,21 +17,24 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
private readonly IBotCache _cache;
private readonly DiscordSocketClient _client;
private readonly ICurrencyService _cs;
private readonly QuestService _quests;
public const int CANVAS_WIDTH = 500;
public const int CANVAS_HEIGHT = 350;
public const int CANVAS_WIDTH = 200;
public const int CANVAS_HEIGHT = 100;
public const int INITIAL_PRICE = 3;
public NCanvasService(
DbService db,
IBotCache cache,
DiscordSocketClient client,
ICurrencyService cs)
ICurrencyService cs,
QuestService quests)
{
_db = db;
_cache = cache;
_client = client;
_cs = cs;
_quests = quests;
}
public async Task OnReadyAsync()
@ -41,9 +44,10 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
await using var uow = _db.GetDbContext();
if (await uow.GetTable<NCPixel>().CountAsyncLinqToDB() > 0)
var count = await uow.GetTable<NCPixel>().CountAsync();
if (count == CANVAS_WIDTH * CANVAS_HEIGHT)
return;
await ResetAsync();
}
@ -59,23 +63,20 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
}
await uow.GetTable<NCPixel>()
.BulkCopyAsync(toAdd.Select(x =>
{
var clr = ColorSpaceConverter.ToRgb(new Hsv(((float)Random.Shared.NextDouble() * 360),
(float)(0.5 + (Random.Shared.NextDouble() * 0.49)),
(float)(0.4 + (Random.Shared.NextDouble() / 5 + (x % 100 * 0.2)))))
.ToVector3();
.BulkCopyAsync(toAdd.Select(x =>
{
var clr = Color.Black;
var packed = new Rgba32(clr).PackedValue;
return new NCPixel()
{
Color = packed,
Price = 1,
Position = x,
Text = "",
OwnerId = 0
};
}));
var packed = ((Rgba32)clr).PackedValue;
return new NCPixel()
{
Color = packed,
Price = 1,
Position = x,
Text = "",
OwnerId = 0
};
}));
}
@ -83,9 +84,9 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
{
await using var uow = _db.GetDbContext();
var colors = await uow.GetTable<NCPixel>()
.OrderBy(x => x.Position)
.Select(x => x.Color)
.ToArrayAsyncLinqToDB();
.OrderBy(x => x.Position)
.Select(x => x.Color)
.ToArrayAsyncLinqToDB();
return colors;
}
@ -121,15 +122,15 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
{
await using var uow = _db.GetDbContext();
var updates = await uow.GetTable<NCPixel>()
.Where(x => x.Position == position && x.Price <= price)
.UpdateAsync(old => new NCPixel()
{
Position = position,
Color = color,
Text = text,
OwnerId = userId,
Price = price + 1
});
.Where(x => x.Position == position && x.Price <= price)
.UpdateAsync(old => new NCPixel()
{
Position = position,
Color = color,
Text = text,
OwnerId = userId,
Price = price + 1
});
success = updates > 0;
}
catch
@ -140,6 +141,10 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
{
await wallet.Add(price, new("canvas", "pixel-refund", $"Refund pixel {new kwum(position)} purchase"));
}
else
{
await _quests.ReportActionAsync(userId, QuestEventType.PixelSet);
}
return success ? SetPixelResult.Success : SetPixelResult.InsufficientPayment;
}
@ -152,14 +157,14 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
await using var uow = _db.GetDbContext();
await uow.GetTable<NCPixel>().DeleteAsync();
await uow.GetTable<NCPixel>()
.BulkCopyAsync(colors.Select((x, i) => new NCPixel()
{
Color = x,
Price = INITIAL_PRICE,
Position = i,
Text = "",
OwnerId = 0
}));
.BulkCopyAsync(colors.Select((x, i) => new NCPixel()
{
Color = x,
Price = INITIAL_PRICE,
Position = i,
Text = "",
OwnerId = 0
}));
return true;
}
@ -190,12 +195,12 @@ public sealed class NCanvasService : INCanvasService, IReadyExecutor, IEService
await using var uow = _db.GetDbContext();
return await uow.GetTable<NCPixel>()
.Where(x => x.Position % CANVAS_WIDTH >= (position % CANVAS_WIDTH) - 2
&& x.Position % CANVAS_WIDTH <= (position % CANVAS_WIDTH) + 2
&& x.Position / CANVAS_WIDTH >= (position / CANVAS_WIDTH) - 2
&& x.Position / CANVAS_WIDTH <= (position / CANVAS_WIDTH) + 2)
.OrderBy(x => x.Position)
.ToArrayAsyncLinqToDB();
.Where(x => x.Position % CANVAS_WIDTH >= (position % CANVAS_WIDTH) - 2
&& x.Position % CANVAS_WIDTH <= (position % CANVAS_WIDTH) + 2
&& x.Position / CANVAS_WIDTH >= (position / CANVAS_WIDTH) - 2
&& x.Position / CANVAS_WIDTH <= (position / CANVAS_WIDTH) + 2)
.OrderBy(x => x.Position)
.ToArrayAsyncLinqToDB();
}
public int GetHeight()

View file

@ -0,0 +1,9 @@
namespace EllieBot.Modules.Games.Quests;
public record class Quest(
QuestIds Id,
string Name,
string Description,
QuestEventType TriggerEvent,
int RequiredAmount
);

View file

@ -0,0 +1,38 @@
namespace EllieBot.Modules.Games.Quests;
public class QuestCommands : EllieModule<QuestService>
{
[Cmd]
public async Task QuestLog()
{
var now = DateTime.UtcNow;
var quests = await _service.GetUserQuestsAsync(ctx.User.Id, now);
var embed = CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.quest_log));
var allDone = quests.All(x => x.UserQuest.IsCompleted);
var tmrw = now.AddDays(1).Date;
var desc = GetText(strs.dailies_reset(TimestampTag.FromDateTime(tmrw, TimestampTagStyles.Relative)));
if (allDone)
desc = GetText(strs.dailies_done) + "\n" + desc;
embed.WithDescription(desc);
foreach (var res in quests)
{
if (res.Quest is null)
continue;
embed.AddField(
(res.UserQuest.IsCompleted ? IQuest.COMPLETED : IQuest.INCOMPLETE) + " " + res.Quest.Name,
$"{res.Quest.Desc}\n\n" +
res.Quest.ToString(res.UserQuest.Progress),
true);
}
await Response().Embed(embed).SendAsync();
}
}

View file

@ -0,0 +1,15 @@
namespace EllieBot.Modules.Games.Quests;
public class QuestEvent
{
public QuestEventType EventType { get; }
public ulong UserId { get; }
public Dictionary<string, string> Metadata { get; }
public QuestEvent(QuestEventType eventType, ulong userId, Dictionary<string, string>? metadata = null)
{
EventType = eventType;
UserId = userId;
Metadata = metadata ?? new Dictionary<string, string>();
}
}

View file

@ -0,0 +1,15 @@
namespace EllieBot.Modules.Games.Quests;
public enum QuestEventType
{
CommandUsed,
GameWon,
BetPlaced,
FishCaught,
PixelSet,
RaceJoined,
BankAction,
PlantOrPick,
Give,
WaifuGiftSent
}

View file

@ -0,0 +1,16 @@
namespace EllieBot.Modules.Games.Quests;
public enum QuestIds
{
HangmanWin,
Bet,
WaifuGift,
CatchFish,
SetPixels,
JoinAnimalRace,
BankDeposit,
CheckBetting,
PlantPick,
GiveFlowers,
WellInformed
}

View file

@ -0,0 +1,65 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class BankerQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.BankDeposit;
public string Name
=> "Banker";
public string Desc
=> "Perform bank actions";
public string ProgDesc
=> "";
public QuestEventType EventType
=> QuestEventType.BankAction;
public long RequiredAmount
=> 0b111;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("type", out var type))
return oldProgress;
var progress = oldProgress;
if (type == "balance")
progress |= 0b001;
else if (type == "deposit")
progress |= 0b010;
else if (type == "withdraw")
progress |= 0b100;
return progress;
}
public string ToString(long progress)
{
var msg = "";
var emoji = IQuest.INCOMPLETE;
if ((progress & 0b001) == 0b001)
emoji = IQuest.COMPLETED;
msg += emoji + " checked bank balance";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b010) == 0b010)
emoji = IQuest.COMPLETED;
msg += "\n" + emoji + " made a deposit";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b100) == 0b100)
emoji = IQuest.COMPLETED;
msg += "\n" + emoji + " made a withdrawal";
return msg;
}
}

View file

@ -0,0 +1,31 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class BetFlowersQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.Bet;
public string Name
=> "Flower Gambler";
public string Desc
=> "Bet 300 flowers";
public string ProgDesc
=> "flowers bet";
public QuestEventType EventType
=> QuestEventType.BetPlaced;
public long RequiredAmount
=> 300;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("amount", out var amountStr)
|| !long.TryParse(amountStr, out var amount))
return oldProgress;
return oldProgress + amount;
}
}

View file

@ -0,0 +1,27 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class BetQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.Bet;
public string Name
=> "High Roller";
public string Desc
=> "Place 20 bets";
public string ProgDesc
=> "bets placed";
public QuestEventType EventType
=> QuestEventType.BetPlaced;
public long RequiredAmount
=> 20;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
return oldProgress + 1;
}
}

View file

@ -0,0 +1,30 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class CatchFishQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.CatchFish;
public string Name
=> "Fisherman";
public string Desc
=> "Catch 10 fish";
public string ProgDesc
=> "fish caught";
public QuestEventType EventType
=> QuestEventType.FishCaught;
public long RequiredAmount
=> 10;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (metadata.TryGetValue("type", out var type) && type == "fish")
return oldProgress + 1;
return oldProgress;
}
}

View file

@ -0,0 +1,32 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class CatchQualityQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.CatchFish;
public string Name
=> "Master Angler";
public string Desc
=> "Catch a fish or an item rated 3 stars or above.";
public string ProgDesc
=> "3+ star fish caught";
public QuestEventType EventType
=> QuestEventType.FishCaught;
public long RequiredAmount
=> 1;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (metadata.TryGetValue("stars", out var quality)
&& int.TryParse(quality, out var q)
&& q >= 3)
return oldProgress + 1;
return oldProgress;
}
}

View file

@ -0,0 +1,30 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class CatchTrashQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.CatchFish;
public string Name
=> "Environmentalist";
public string Desc
=> "Catch 10 trash items while fishing";
public string ProgDesc
=> "items caught";
public QuestEventType EventType
=> QuestEventType.FishCaught;
public long RequiredAmount
=> 10;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (metadata.TryGetValue("type", out var type) && type == "trash")
return oldProgress + 1;
return oldProgress;
}
}

View file

@ -0,0 +1,72 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class CheckLeaderboardsQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.CheckBetting;
public string Name
=> "Leaderboard Enthusiast";
public string Desc
=> "Check lb, xplb, fishlb and waifulb";
public string ProgDesc
=> "";
public QuestEventType EventType
=> QuestEventType.CommandUsed;
public long RequiredAmount
=> 0b1111;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("name", out var name))
return oldProgress;
var progress = oldProgress;
if (name == "leaderboard")
progress |= 0b0001;
else if (name == "xpleaderboard")
progress |= 0b0010;
else if (name == "waifulb")
progress |= 0b0100;
else if (name == "fishlb")
progress |= 0b1000;
return progress;
}
public string ToString(long progress)
{
var msg = "";
var emoji = IQuest.INCOMPLETE;
if ((progress & 0b0001) == 0b0001)
emoji = IQuest.COMPLETED;
msg += emoji + " flower lb seen\n";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b0010) == 0b0010)
emoji = IQuest.COMPLETED;
msg += emoji + " xp lb seen\n";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b0100) == 0b0100)
emoji = IQuest.COMPLETED;
msg += emoji + " waifu lb seen";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b1000) == 0b1000)
emoji = IQuest.COMPLETED;
msg += "\n" + emoji + " fish lb seen";
return msg;
}
}

View file

@ -0,0 +1,27 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class GiftWaifuQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.WaifuGift;
public string Name
=> "Generous Gifter";
public string Desc
=> "Gift a waifu 2 times";
public string ProgDesc
=> "waifus gifted";
public QuestEventType EventType
=> QuestEventType.WaifuGiftSent;
public long RequiredAmount
=> 2;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
return oldProgress + 1;
}
}

View file

@ -0,0 +1,31 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class GiveFlowersQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.GiveFlowers;
public string Name
=> "Sharing is Caring";
public string Desc
=> "Give 20 flowers to someone";
public string ProgDesc
=> "flowers given";
public QuestEventType EventType
=> QuestEventType.Give;
public long RequiredAmount
=> 20;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("amount", out var amountStr)
|| !long.TryParse(amountStr, out var amount))
return oldProgress;
return oldProgress + amount;
}
}

View file

@ -0,0 +1,30 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class HangmanWinQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.HangmanWin;
public string Name
=> "Hangman Champion";
public string Desc
=> "Win 2 games of Hangman";
public string ProgDesc
=> "hangman games won";
public QuestEventType EventType
=> QuestEventType.GameWon;
public long RequiredAmount
=> 2;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("game", out var value))
return oldProgress;
return value == "hangman" ? oldProgress + 1 : oldProgress;
}
}

View file

@ -0,0 +1,33 @@
using EllieBot.Db.Models;
namespace EllieBot.Modules.Games.Quests;
public interface IQuest
{
QuestIds QuestId { get; }
string Name { get; }
string Desc { get; }
string ProgDesc { get; }
QuestEventType EventType { get; }
long RequiredAmount { get; }
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress);
public virtual string ToString(long progress)
=> GetEmoji(progress, RequiredAmount) + $" [{progress}/{RequiredAmount}] " + ProgDesc;
public static string GetEmoji(long progress, long requiredAmount)
=> progress >= requiredAmount
? COMPLETED
: INCOMPLETE;
/// <summary>
/// Completed Emoji
/// </summary>
public const string COMPLETED = "✅";
/// <summary>
/// Incomplete Emoji
/// </summary>
public const string INCOMPLETE = "❌";
}

View file

@ -0,0 +1,27 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class JoinAnimalRaceQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.JoinAnimalRace;
public string Name
=> "Race Participant";
public string Desc
=> "Join an animal race";
public string ProgDesc
=> "races joined";
public QuestEventType EventType
=> QuestEventType.RaceJoined;
public long RequiredAmount
=> 1;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
return oldProgress + 1;
}
}

View file

@ -0,0 +1,61 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class PlantPickQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.PlantPick;
public string Name
=> "Gardener";
public string Desc
=> "pick and plant";
public string ProgDesc
=> "";
public QuestEventType EventType
=> QuestEventType.PlantOrPick;
public long RequiredAmount
=> 0b11;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("type", out var val))
return oldProgress;
if (val == "plant")
{
oldProgress |= 0b10;
return oldProgress;
}
if (val == "pick")
{
oldProgress |= 0b01;
return oldProgress;
}
return oldProgress;
}
public string ToString(long progress)
{
var msg = "";
var emoji = IQuest.INCOMPLETE;
if ((progress & 0b01) == 0b01)
emoji = IQuest.COMPLETED;
msg += emoji + " picked flowers\n";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b10) == 0b10)
emoji = IQuest.COMPLETED;
msg += emoji + " planted flowers";
return msg;
}
}

View file

@ -0,0 +1,27 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class SetPixelsQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.SetPixels;
public string Name
=> "Pixel Artist";
public string Desc
=> "Set 3 pixels";
public string ProgDesc
=> "pixels set";
public QuestEventType EventType
=> QuestEventType.PixelSet;
public long RequiredAmount
=> 3;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
return oldProgress + 1;
}
}

View file

@ -0,0 +1,64 @@
namespace EllieBot.Modules.Games.Quests;
public sealed class WellInformedQuest : IQuest
{
public QuestIds QuestId
=> QuestIds.WellInformed;
public string Name
=> "Well Informed";
public string Desc
=> "Check your flower stats";
public string ProgDesc
=> "";
public QuestEventType EventType
=> QuestEventType.CommandUsed;
public long RequiredAmount
=> 0b111;
public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
{
if (!metadata.TryGetValue("name", out var type))
return oldProgress;
var progress = oldProgress;
if (type == "cash")
progress |= 0b001;
else if (type == "rakeback")
progress |= 0b010;
else if (type == "betstats")
progress |= 0b100;
return progress;
}
public string ToString(long progress)
{
var msg = "";
var emoji = IQuest.INCOMPLETE;
if ((progress & 0b001) == 0b001)
emoji = IQuest.COMPLETED;
msg += emoji + " checked cash\n";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b010) == 0b010)
emoji = IQuest.COMPLETED;
msg += emoji + " checked rakeback\n";
emoji = IQuest.INCOMPLETE;
if ((progress & 0b100) == 0b100)
emoji = IQuest.COMPLETED;
msg += emoji + " checked bet stats";
return msg;
}
}

View file

@ -0,0 +1,199 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Games.Quests;
public sealed class QuestService(
DbService db,
IBotCache botCache,
IMessageSenderService sender,
DiscordSocketClient client
) : IEService, IExecPreCommand
{
private readonly IQuest[] _availableQuests =
[
new HangmanWinQuest(),
new PlantPickQuest(),
new BetQuest(),
new BetFlowersQuest(),
new GiftWaifuQuest(),
new CatchFishQuest(),
new SetPixelsQuest(),
new JoinAnimalRaceQuest(),
new BankerQuest(),
new CheckLeaderboardsQuest(),
new WellInformedQuest(),
];
private const int MAX_QUESTS_PER_DAY = 3;
private TypedKey<bool> UserHasQuestsKey(ulong userId)
=> new($"daily:generated:{userId}");
private TypedKey<bool> UserCompletedDailiesKey(ulong userId)
=> new($"daily:completed:{userId}");
public Task ReportActionAsync(
ulong userId,
QuestEventType eventType,
Dictionary<string, string>? metadata = null)
{
// don't block any caller
_ = Task.Run(async () =>
{
metadata ??= new();
var now = DateTime.UtcNow;
var alreadyDone = await botCache.GetAsync(UserCompletedDailiesKey(userId));
if (alreadyDone.IsT0)
return;
var userQuests = await GetUserQuestsAsync(userId, now);
foreach (var (q, uq) in userQuests)
{
// deleted quest
if (q is null)
continue;
// user already completed or incorrect event
if (uq.IsCompleted || q.EventType != eventType)
continue;
var newProgress = q.TryUpdateProgress(metadata, uq.Progress);
// user already did that part of the quest
if (newProgress == uq.Progress)
continue;
var isCompleted = newProgress >= q.RequiredAmount;
await using var uow = db.GetDbContext();
await uow.GetTable<UserQuest>()
.Where(x => x.UserId == userId && x.QuestId == q.QuestId && x.QuestNumber == uq.QuestNumber)
.Set(x => x.Progress, newProgress)
.Set(x => x.IsCompleted, isCompleted)
.UpdateAsync();
uq.IsCompleted = isCompleted;
if (userQuests.All(x => x.UserQuest.IsCompleted))
{
var timeUntilTomorrow = now.Date.AddDays(1) - DateTime.UtcNow;
if (!await botCache.AddAsync(
UserCompletedDailiesKey(userId),
true,
expiry: timeUntilTomorrow))
return;
try
{
var user = await client.GetUserAsync(userId);
await sender
.Response(user)
.Confirm(strs.dailies_done)
.SendAsync();
}
catch
{
// we don't really care if the user receives it
}
break;
}
}
});
return Task.CompletedTask;
}
public async Task<IReadOnlyList<(IQuest? Quest, UserQuest UserQuest)>> GetUserQuestsAsync(
ulong userId,
DateTime now)
{
var today = now.Date;
await EnsureUserDailiesAsync(userId, today);
await using var uow = db.GetDbContext();
var quests = await uow.GetTable<UserQuest>()
.Where(x => x.UserId == userId && x.DateAssigned == today)
.ToListAsync();
return quests
.Select(x => (_availableQuests.FirstOrDefault(q => q.QuestId == x.QuestId), x))
.Select(x => x!)
.ToList();
}
private async Task EnsureUserDailiesAsync(ulong userId, DateTime date)
{
var today = date.Date;
var timeUntilTomorrow = today.AddDays(1) - DateTime.UtcNow;
if (!await botCache.AddAsync(UserHasQuestsKey(userId), true, expiry: timeUntilTomorrow, overwrite: false))
return;
await using var uow = db.GetDbContext();
var newQuests = GenerateDailyQuestsAsync();
for (var i = 0; i < MAX_QUESTS_PER_DAY; i++)
{
await uow.GetTable<UserQuest>()
.InsertOrUpdateAsync(() => new()
{
UserId = userId,
QuestNumber = i,
DateAssigned = today,
IsCompleted = false,
QuestId = newQuests[i].QuestId,
Progress = 0,
},
old => new()
{
},
() => new()
{
UserId = userId,
QuestNumber = i,
DateAssigned = today
});
}
}
private IReadOnlyList<IQuest> GenerateDailyQuestsAsync()
{
return _availableQuests
.ToList()
.Shuffle()
.Take(MAX_QUESTS_PER_DAY)
.ToList();
}
public int Priority
=> int.MinValue;
public async Task<bool> ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command)
{
var cmdName = command.Name.ToLowerInvariant();
await ReportActionAsync(
context.User.Id,
QuestEventType.CommandUsed,
new()
{
{ "name", cmdName }
});
return false;
}
public async Task<bool> UserCompletedDailies(ulong userId)
{
var result = await botCache.GetAsync(UserCompletedDailiesKey(userId));
return result.IsT0;
}
}

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