diff --git a/src/Ellie/.editorconfig b/src/Ellie/.editorconfig new file mode 100644 index 0000000..9bb0934 --- /dev/null +++ b/src/Ellie/.editorconfig @@ -0,0 +1,359 @@ +root = true +# Remove the line below if you want to inherit .editorconfig settings from higher directories + +[obj/**] +generated_code = true + +# C# files +[*.cs] + + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false + +# this. and Me. preferences +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = always:error + +# Expression-level preferences +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = false:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:error +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true:suggestion + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:warning + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = true +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_expression_bodied_constructors = when_on_single_line:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = true:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:error +csharp_style_pattern_matching_over_is_with_cast_check = true:error +csharp_style_prefer_not_pattern = true:error +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:error + +# Modifier preferences +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async + +# Code-block preferences +csharp_prefer_braces = when_multiline:warning +csharp_prefer_simple_using_statement = true + +# Expression-level preferences +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true:error +csharp_style_inlined_variable_declaration = true:warning +csharp_style_pattern_local_over_anonymous_function = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_range_operator = true +csharp_style_throw_expression = true:error +csharp_style_unused_value_assignment_preference = discard_variable:warning +csharp_style_unused_value_expression_statement_preference = discard_variable + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:error + +# Enforce file-scoped namespaces +csharp_style_namespace_declarations = file_scoped:error + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false +csharp_style_allow_embedded_statements_on_same_line_experimental = false + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.private_readonly_field.symbols = private_readonly_field +dotnet_naming_rule.private_readonly_field.style = begins_with_underscore +dotnet_naming_rule.private_readonly_field.severity = warning + +dotnet_naming_rule.private_field.symbols = private_field +dotnet_naming_rule.private_field.style = camel_case +dotnet_naming_rule.private_field.severity = warning + +dotnet_naming_rule.const_fields.symbols = const_fields +dotnet_naming_rule.const_fields.style = all_upper +dotnet_naming_rule.const_fields.severity = warning + +# dotnet_naming_rule.class_should_be_pascal_case.severity = error +# dotnet_naming_rule.class_should_be_pascal_case.symbols = class +# dotnet_naming_rule.class_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.struct_should_be_pascal_case.severity = error +dotnet_naming_rule.struct_should_be_pascal_case.symbols = struct +dotnet_naming_rule.struct_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = error +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +# dotnet_naming_rule.types_should_be_pascal_case.severity = error +# dotnet_naming_rule.types_should_be_pascal_case.symbols = types +# dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +# dotnet_naming_rule.enum_should_be_pascal_case.severity = error +# dotnet_naming_rule.enum_should_be_pascal_case.symbols = enum +# dotnet_naming_rule.enum_should_be_pascal_case.style = pascal_case + +# dotnet_naming_rule.property_should_be_pascal_case.severity = error +# dotnet_naming_rule.property_should_be_pascal_case.symbols = property +# dotnet_naming_rule.property_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.method_should_be_pascal_case.severity = error +dotnet_naming_rule.method_should_be_pascal_case.symbols = method +dotnet_naming_rule.method_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.async_method_should_be_ends_with_async.severity = error +dotnet_naming_rule.async_method_should_be_ends_with_async.symbols = async_method +dotnet_naming_rule.async_method_should_be_ends_with_async.style = ends_with_async + +# dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = error +# dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +# dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.local_variable_should_be_camel_case.severity = error +dotnet_naming_rule.local_variable_should_be_camel_case.symbols = local_variable +dotnet_naming_rule.local_variable_should_be_camel_case.style = camel_case + +# Symbol specifications + +dotnet_naming_symbols.const_fields.required_modifiers = const +dotnet_naming_symbols.const_fields.applicable_kinds = field + +dotnet_naming_symbols.class.applicable_kinds = class +dotnet_naming_symbols.class.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.class.required_modifiers = + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.struct.applicable_kinds = struct +dotnet_naming_symbols.struct.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.struct.required_modifiers = + +dotnet_naming_symbols.enum.applicable_kinds = enum +dotnet_naming_symbols.enum.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enum.required_modifiers = + +dotnet_naming_symbols.method.applicable_kinds = method +dotnet_naming_symbols.method.applicable_accessibilities = public +dotnet_naming_symbols.method.required_modifiers = + +dotnet_naming_symbols.property.applicable_kinds = property +dotnet_naming_symbols.property.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.property.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.private_readonly_field.applicable_kinds = field +dotnet_naming_symbols.private_readonly_field.applicable_accessibilities = private, protected +dotnet_naming_symbols.private_readonly_field.required_modifiers = readonly + +dotnet_naming_symbols.private_field.applicable_kinds = field +dotnet_naming_symbols.private_field.applicable_accessibilities = private, protected +dotnet_naming_symbols.private_field.required_modifiers = + +dotnet_naming_symbols.async_method.applicable_kinds = method, local_function +dotnet_naming_symbols.async_method.applicable_accessibilities = * +dotnet_naming_symbols.async_method.required_modifiers = async + +dotnet_naming_symbols.local_variable.applicable_kinds = parameter, local +dotnet_naming_symbols.local_variable.applicable_accessibilities = local +dotnet_naming_symbols.local_variable.required_modifiers = + +# Naming styles + + +dotnet_naming_style.all_upper.capitalization = all_upper + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.begins_with_underscore.required_prefix = _ +dotnet_naming_style.begins_with_underscore.required_suffix = +dotnet_naming_style.begins_with_underscore.word_separator = +dotnet_naming_style.begins_with_underscore.capitalization = camel_case + +dotnet_naming_style.ends_with_async.required_prefix = +# dotnet_naming_style.ends_with_async.required_suffix = Async +dotnet_naming_style.ends_with_async.word_separator = +dotnet_naming_style.ends_with_async.capitalization = pascal_case + +dotnet_naming_style.camel_case.required_prefix = +dotnet_naming_style.camel_case.required_suffix = +dotnet_naming_style.camel_case.word_separator = +dotnet_naming_style.camel_case.capitalization = camel_case + +# CA1822: Mark members as static +dotnet_diagnostic.ca1822.severity = suggestion + +# IDE0004: Cast is redundant +dotnet_diagnostic.ide0004.severity = warning + +# IDE0058: Expression value is never used +dotnet_diagnostic.ide0058.severity = none + +# # IDE0011: Add braces to 'if'/'else' statement +# dotnet_diagnostic.ide0011.severity = none + +resharper_wrap_after_invocation_lpar = false +resharper_wrap_before_invocation_rpar = false + +# ReSharper properties +resharper_align_multiline_calls_chain = true +resharper_csharp_wrap_after_declaration_lpar = true +resharper_csharp_wrap_after_invocation_lpar = false +resharper_csharp_wrap_before_binary_opsign = true +resharper_csharp_wrap_before_invocation_rpar = false +resharper_csharp_wrap_parameters_style = chop_if_long +resharper_force_chop_compound_if_expression = false +resharper_keep_existing_linebreaks = true +resharper_keep_user_linebreaks = true +resharper_max_formal_parameters_on_line = 3 +resharper_place_simple_embedded_statement_on_same_line = false +resharper_wrap_chained_binary_expressions = chop_if_long +resharper_wrap_chained_binary_patterns = chop_if_long +resharper_wrap_chained_method_calls = chop_if_long +resharper_wrap_object_and_collection_initializer_style = chop_always + +resharper_csharp_wrap_before_first_type_parameter_constraint = true +resharper_csharp_place_type_constraints_on_same_line = false +resharper_csharp_wrap_before_extends_colon = true +resharper_csharp_place_constructor_initializer_on_same_line = false +resharper_force_attribute_style = separate +resharper_csharp_braces_for_ifelse = required_for_multiline_statement +resharper_csharp_braces_for_foreach = required_for_multiline +resharper_csharp_braces_for_while = required_for_multiline +resharper_csharp_braces_for_for = required_for_multiline +resharper_arrange_redundant_parentheses_highlighting = hint + +# IDE0011: Add braces +dotnet_diagnostic.IDE0011.severity = warning \ No newline at end of file diff --git a/src/Ellie/Bot.cs b/src/Ellie/Bot.cs new file mode 100644 index 0000000..1183e61 --- /dev/null +++ b/src/Ellie/Bot.cs @@ -0,0 +1,412 @@ +#nullable disable +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Ellie.Common.Configs; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Modules.Administration; +using Ellie.Modules.Gambling; +using Ellie.Modules.Help; +using Ellie.Modules.Music; +using Ellie.Modules.EllieExpressions; +using Ellie.Modules.Patronage; +using Ellie.Modules.Permissions; +using Ellie.Modules.Searches; +using Ellie.Modules.Utility; +using Ellie.Modules.Xp; +using Ellie.Services.Database; +using Ellie.Services.Database.Models; +using Ninject; +using Ninject.Planning; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Reflection; +using RunMode = Discord.Commands.RunMode; + + +namespace Ellie; + +public class Bot : IBot +{ + public event Func JoinedGuild = delegate { return Task.CompletedTask; }; + + public DiscordSocketClient Client { get; set; } + public IReadOnlyCollection AllGuildConfigs { get; private set; } + + private IKernel Services { get; set; } + + // todo remove + public string Mention { get; private set; } + public bool IsReady { get; private set; } + public int ShardId { get; set; } + + private readonly IBotCredentials _creds; + private readonly CommandService _commandService; + private readonly DbService _db; + + private readonly IBotCredsProvider _credsProvider; + + private readonly Assembly[] _loadedAssemblies; + // private readonly InteractionService _interactionService; + + public Bot(int shardId, int? totalShards, string credPath = null) + { + if (shardId < 0) + throw new ArgumentOutOfRangeException(nameof(shardId)); + + ShardId = shardId; + _credsProvider = new BotCredsProvider(totalShards, credPath); + _creds = _credsProvider.GetCreds(); + + _db = new EllieDbService(_credsProvider); + + var messageCacheSize = +#if GLOBAL_ELLIE + 0; +#else + 50; +#endif + + if (!_creds.UsePrivilegedIntents) + Log.Warning("You are not using privileged intents. Some features will not work properly"); + + Client = new(new() + { + MessageCacheSize = messageCacheSize, + LogLevel = LogSeverity.Warning, + ConnectionTimeout = int.MaxValue, + TotalShards = _creds.TotalShards, + ShardId = shardId, + AlwaysDownloadUsers = false, + AlwaysResolveStickers = false, + AlwaysDownloadDefaultStickers = false, + GatewayIntents = _creds.UsePrivilegedIntents + ? GatewayIntents.All + : GatewayIntents.AllUnprivileged, + LogGatewayIntentWarnings = false, + FormatUsersInBidirectionalUnicode = false, + DefaultRetryMode = RetryMode.Retry502 + }); + + _commandService = new(new() + { + CaseSensitiveCommands = false, + DefaultRunMode = RunMode.Sync, + }); + + // _interactionService = new(Client.Rest); + + Client.Log += Client_Log; + _loadedAssemblies = new[] + { + typeof(Bot).Assembly, // bot + typeof(Creds).Assembly, // bot.common + + // modules + typeof(EllieExpression).Assembly, typeof(Administration).Assembly, typeof(Gambling).Assembly, + typeof(Help).Assembly, typeof(Music).Assembly, typeof(Patronage).Assembly, typeof(Permissions).Assembly, + typeof(Searches).Assembly, typeof(Utility).Assembly, typeof(Xp).Assembly, + }; + } + + public IReadOnlyList GetCurrentGuildIds() + => Client.Guilds.Select(x => x.Id).ToList(); + + private void AddServices() + { + var startingGuildIdList = GetCurrentGuildIds(); + var sw = Stopwatch.StartNew(); + var bot = Client.CurrentUser; + + using (var uow = _db.GetDbContext()) + { + uow.EnsureUserCreated(bot.Id, bot.Username, bot.Discriminator, bot.AvatarId); + AllGuildConfigs = uow.Set().GetAllGuildConfigs(startingGuildIdList).ToImmutableArray(); + } + + var svcs = new StandardKernel(new NinjectSettings() + { + ThrowOnGetServiceNotFound = true, + ActivationCacheDisabled = true, + }); + + // this is required in order for medusa unloading to work + svcs.Components.Remove(); + svcs.Components.Add(); + + svcs.AddSingleton(_ => _credsProvider.GetCreds()); + svcs.AddSingleton(_db); + svcs.AddSingleton(_credsProvider); + svcs.AddSingleton(Client); + svcs.AddSingleton(_commandService); + svcs.AddSingleton(this); + svcs.AddSingleton(this); + + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(new MemoryCache(new MemoryCacheOptions())); + svcs.AddSingleton(); + + + foreach (var a in _loadedAssemblies) + { + svcs.AddConfigServices(a) + .AddConfigMigrators(a) + .AddLifetimeServices(a); + } + + svcs.AddMusic() + .AddCache(_creds) + .AddHttpClients(); + + if (Environment.GetEnvironmentVariable("NADEKOBOT_IS_COORDINATED") != "1") + { + svcs.AddSingleton(); + } + else + { + svcs.AddSingleton(); + svcs.AddSingleton(_ => svcs.GetRequiredService()); + svcs.AddSingleton(_ => svcs.GetRequiredService()); + } + + svcs.AddSingleton(svcs); + + //initialize Services + Services = svcs; + Services.GetRequiredService().Initialize(); + + if (Client.ShardId == 0) + ApplyConfigMigrations(); + + foreach (var a in _loadedAssemblies) + { + LoadTypeReaders(a); + } + + sw.Stop(); + Log.Information("All services loaded in {ServiceLoadTime:F2}s", sw.Elapsed.TotalSeconds); + } + + private void ApplyConfigMigrations() + { + // execute all migrators + var migrators = Services.GetServices(); + foreach (var migrator in migrators) + migrator.EnsureMigrated(); + } + + private void LoadTypeReaders(Assembly assembly) + { + var filteredTypes = assembly.GetTypes() + .Where(x => x.IsSubclassOf(typeof(TypeReader)) + && x.BaseType?.GetGenericArguments().Length > 0 + && !x.IsAbstract); + + foreach (var ft in filteredTypes) + { + var baseType = ft.BaseType; + if (baseType is null) + continue; + + var typeReader = (TypeReader)ActivatorUtilities.CreateInstance(Services, ft); + var typeArgs = baseType.GetGenericArguments(); + _commandService.AddTypeReader(typeArgs[0], typeReader); + } + } + + private async Task LoginAsync(string token) + { + var clientReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + async Task SetClientReady() + { + clientReady.TrySetResult(true); + try + { + foreach (var chan in await Client.GetDMChannelsAsync()) + await chan.CloseAsync(); + } + catch + { + // ignored + } + } + + //connect + Log.Information("Shard {ShardId} logging in ...", Client.ShardId); + try + { + Client.Ready += SetClientReady; + + await Client.LoginAsync(TokenType.Bot, token); + await Client.StartAsync(); + } + catch (HttpException ex) + { + LoginErrorHandler.Handle(ex); + Helpers.ReadErrorAndExit(3); + } + catch (Exception ex) + { + LoginErrorHandler.Handle(ex); + Helpers.ReadErrorAndExit(4); + } + + await clientReady.Task.ConfigureAwait(false); + Client.Ready -= SetClientReady; + + Client.JoinedGuild += Client_JoinedGuild; + Client.LeftGuild += Client_LeftGuild; + + // _ = Client.SetStatusAsync(UserStatus.Online); + Log.Information("Shard {ShardId} logged in", Client.ShardId); + } + + private Task Client_LeftGuild(SocketGuild arg) + { + Log.Information("Left server: {GuildName} [{GuildId}]", arg?.Name, arg?.Id); + return Task.CompletedTask; + } + + private Task Client_JoinedGuild(SocketGuild arg) + { + Log.Information("Joined server: {GuildName} [{GuildId}]", arg.Name, arg.Id); + _ = Task.Run(async () => + { + GuildConfig gc; + await using (var uow = _db.GetDbContext()) + { + gc = uow.GuildConfigsForId(arg.Id, null); + } + + await JoinedGuild.Invoke(gc); + }); + return Task.CompletedTask; + } + + public async Task RunAsync() + { + if (ShardId == 0) + await _db.SetupAsync(); + + var sw = Stopwatch.StartNew(); + + await LoginAsync(_creds.Token); + + Mention = Client.CurrentUser.Mention; + Log.Information("Shard {ShardId} loading services...", Client.ShardId); + try + { + AddServices(); + } + catch (Exception ex) + { + Log.Error(ex, "Error adding services"); + Helpers.ReadErrorAndExit(9); + } + + sw.Stop(); + Log.Information("Shard {ShardId} connected in {Elapsed:F2}s", Client.ShardId, sw.Elapsed.TotalSeconds); + var commandHandler = Services.GetRequiredService(); + + // start handling messages received in commandhandler + await commandHandler.StartHandling(); + + foreach (var a in _loadedAssemblies) + { + await _commandService.AddModulesAsync(a, Services); + } + + // await _interactionService.AddModulesAsync(typeof(Bot).Assembly, Services); + IsReady = true; + + await EnsureBotOwnershipAsync(); + _ = Task.Run(ExecuteReadySubscriptions); + Log.Information("Shard {ShardId} ready", Client.ShardId); + } + + private async ValueTask EnsureBotOwnershipAsync() + { + try + { + if (_creds.OwnerIds.Count != 0) + return; + + Log.Information("Initializing Owner Id..."); + var info = await Client.GetApplicationInfoAsync(); + _credsProvider.ModifyCredsFile(x => x.OwnerIds = new[] { info.Owner.Id }); + } + catch (Exception ex) + { + Log.Warning("Getting application info failed: {ErrorMessage}", ex.Message); + } + } + + private Task ExecuteReadySubscriptions() + { + var readyExecutors = Services.GetServices(); + var tasks = readyExecutors.Select(async toExec => + { + try + { + await toExec.OnReadyAsync(); + } + catch (Exception ex) + { + Log.Error(ex, + "Failed running OnReadyAsync method on {Type} type: {Message}", + toExec.GetType().Name, + ex.Message); + } + }); + + return tasks.WhenAll(); + } + + private Task Client_Log(LogMessage arg) + { + if (arg.Message?.Contains("unknown dispatch", StringComparison.InvariantCultureIgnoreCase) ?? false) + return Task.CompletedTask; + + if (arg.Exception is { InnerException: WebSocketClosedException { CloseCode: 4014 } }) + { + Log.Error(""" + Login failed. + + *** Please enable privileged intents *** + + Certain Ellie features require Discord's privileged gateway intents. + These include greeting and goodbye messages, as well as creating the Owner message channels for DM forwarding. + + How to enable privileged intents: + 1. Head over to the Discord Developer Portal https://discord.com/developers/applications/ + 2. Select your Application. + 3. Click on `Bot` in the left side navigation panel, and scroll down to the intents section. + 4. Enable all intents. + 5. Restart your bot. + + Read this only if your bot is in 100 or more servers: + + You'll need to apply to use the intents with Discord, but for small selfhosts, all that is required is enabling the intents in the developer portal. + Yes, this is a new thing from Discord, as of October 2020. No, there's nothing we can do about it. Yes, we're aware it worked before. + While waiting for your bot to be accepted, you can change the 'usePrivilegedIntents' inside your creds.yml to 'false', although this will break many of the ellie's features + """); + return Task.CompletedTask; + } + +#if GLOBAL_ELLIE || DEBUG + if (arg.Exception is not null) + Log.Warning(arg.Exception, "{ErrorSource} | {ErrorMessage}", arg.Source, arg.Message); + else + Log.Warning("{ErrorSource} | {ErrorMessage}", arg.Source, arg.Message); +#endif + return Task.CompletedTask; + } + + public async Task RunAndBlockAsync() + { + await RunAsync(); + await Task.Delay(-1); + } +} \ No newline at end of file diff --git a/src/Ellie/Ellie.csproj b/src/Ellie/Ellie.csproj index 96f97c9..bb0e4ce 100644 --- a/src/Ellie/Ellie.csproj +++ b/src/Ellie/Ellie.csproj @@ -97,6 +97,7 @@ + @@ -129,10 +130,11 @@ Always - - - - - + + + 5.0.0 + $(VersionPrefix).$(VersionSuffix) + $(VersionPrefix) + diff --git a/src/Ellie/GlobalUsings.cs b/src/Ellie/GlobalUsings.cs new file mode 100644 index 0000000..653d973 --- /dev/null +++ b/src/Ellie/GlobalUsings.cs @@ -0,0 +1,31 @@ +// global using System.Collections.Concurrent; +global using NonBlocking; + +// packages +global using Serilog; +global using Humanizer; + +// ellie +global using Ellie; +global using Ellie.Services; +global using Ellise.Common; // new project +global using Ellie.Common; // old + ellie specific things +global using Ellie.Common.Attributes; +global using Ellie.Extensions; +global using Ellie.Marmalade; + +// discord +global using Discord; +global using Discord.Commands; +global using Discord.Net; +global using Discord.WebSocket; + +// aliases +global using GuildPerm = Discord.GuildPermission; +global using ChannelPerm = Discord.ChannelPermission; +global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute; +global using LeftoverAttribute = Discord.Commands.RemainderAttribute; +global using TypeReaderResult = Ellie.Common.TypeReaders.TypeReaderResult; + +// non-essential +global using JetBrains.Annotations; \ No newline at end of file diff --git a/src/Ellie/Modules/Gambling/CurrencyProvider.cs b/src/Ellie/Modules/Gambling/CurrencyProvider.cs new file mode 100644 index 0000000..1e68124 --- /dev/null +++ b/src/Ellie/Modules/Gambling/CurrencyProvider.cs @@ -0,0 +1,18 @@ +using Ellie.Bot.Common; +using Ellie.Modules.Gambling.Services; + +namespace Ellie.Modules.Gambling; + +// todo do we need both currencyprovider and currencyservice +public sealed class CurrencyProvider : ICurrencyProvider, IEService +{ + private readonly GamblingConfigService _cs; + + public CurrencyProvider(GamblingConfigService cs) + { + _cs = cs; + } + + public string GetCurrencySign() + => _cs.Data.Currency.Sign; +} \ No newline at end of file diff --git a/src/Ellie/Modules/Marmalades/IMarmaladesRepositoryService.cs b/src/Ellie/Modules/Marmalades/IMarmaladesRepositoryService.cs new file mode 100644 index 0000000..6fa71f5 --- /dev/null +++ b/src/Ellie/Modules/Marmalades/IMarmaladesRepositoryService.cs @@ -0,0 +1,6 @@ +namespace Ellie.Modules; + +public interface IMarmaladesRepositoryService +{ + Task> GetModuleItemsAsync(); +} \ No newline at end of file diff --git a/src/Ellie/Modules/Marmalades/Marmalade.cs b/src/Ellie/Modules/Marmalades/Marmalade.cs new file mode 100644 index 0000000..4868d49 --- /dev/null +++ b/src/Ellie/Modules/Marmalades/Marmalade.cs @@ -0,0 +1,229 @@ +using Ellie.Marmalade; + +namespace Ellie.Modules; + +[OwnerOnly] +public partial class Marmalade : EllieModule +{ + private readonly IMarmaladesRepositoryService _repo; + + public Marmalade(IMarmaladesRepositoryService repo) + { + _repo = repo; + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeLoad(string? name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + var loaded = _service.GetLoadedMarmalades() + .Select(x => x.Name) + .ToHashSet(); + + var unloaded = _service.GetAllMarmalades() + .Where(x => !loaded.Contains(x)) + .Select(x => Format.Code(x.ToString())) + .ToArray(); + + if (unloaded.Length == 0) + { + await ReplyPendingLocalizedAsync(strs.no_marmalade_available); + return; + } + + await ctx.SendPaginatedConfirmAsync(0, + page => + { + return _eb.Create(ctx) + .WithOkColor() + .WithTitle(GetText(strs.list_of_unloaded)) + .WithDescription(unloaded.Skip(10 * page).Take(10).Join('\n')); + }, + unloaded.Length, + 10); + return; + } + + var res = await _service.LoadMarmaladeAsync(name); + if (res == MarmaladeLoadResult.Success) + await ReplyConfirmLocalizedAsync(strs.marmalade_loaded(Format.Code(name))); + else + { + var locStr = res switch + { + MarmaladeLoadResult.Empty => strs.marmalade_empty, + MarmaladeLoadResult.AlreadyLoaded => strs.marmalade_already_loaded(Format.Code(name)), + MarmaladeLoadResult.NotFound => strs.marmalade_invalid_not_found, + MarmaladeLoadResult.UnknownError => strs.error_occured, + _ => strs.error_occured + }; + + await ReplyErrorLocalizedAsync(locStr); + } + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeUnload(string? name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + var loaded = _service.GetLoadedMarmalades(); + if (loaded.Count == 0) + { + await ReplyPendingLocalizedAsync(strs.no_marmalade_loaded); + return; + } + + await ctx.Channel.EmbedAsync(_eb.Create(ctx) + .WithOkColor() + .WithTitle(GetText(strs.loaded_marmalades)) + .WithDescription(loaded.Select(x => x.Name) + .Join("\n"))); + + return; + } + + var res = await _service.UnloadMarmaladeAsync(name); + if (res == MarmaladeUnloadResult.Success) + await ReplyConfirmLocalizedAsync(strs.marmalade_unloaded(Format.Code(name))); + else + { + var locStr = res switch + { + MarmaladeUnloadResult.NotFound => strs.marmalade_not_loaded, + MarmaladeUnloadResult.PossiblyUnable => strs.marmalade_possibly_cant_unload, + _ => strs.error_occured + }; + + await ReplyErrorLocalizedAsync(locStr); + } + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeList() + { + var all = _service.GetAllMarmalades(); + + if (all.Count == 0) + { + await ReplyPendingLocalizedAsync(strs.no_marmalade_available); + return; + } + + var loaded = _service.GetLoadedMarmalades() + .Select(x => x.Name) + .ToHashSet(); + + var output = all + .Select(m => + { + var emoji = loaded.Contains(m) ? "`✅`" : "`🔴`"; + return $"{emoji} `{m}`"; + }) + .ToArray(); + + + await ctx.SendPaginatedConfirmAsync(0, + page => _eb.Create(ctx) + .WithOkColor() + .WithTitle(GetText(strs.list_of_marmalades)) + .WithDescription(output.Skip(page * 10).Take(10).Join('\n')), + output.Length, + 10); + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeInfo(string? name = null) + { + var marmalades = _service.GetLoadedMarmalades(); + + if (name is not null) + { + var found = marmalades.FirstOrDefault(x => string.Equals(x.Name, + name, + StringComparison.InvariantCultureIgnoreCase)); + + if (found is null) + { + await ReplyErrorLocalizedAsync(strs.marmalade_name_not_found); + return; + } + + var cmdCount = found.Canaries.Sum(x => x.Commands.Count); + var cmdNames = found.Canaries + .SelectMany(x => Format.Code(string.IsNullOrWhiteSpace(x.Prefix) + ? x.Name + : $"{x.Prefix} {x.Name}")) + .Join("\n"); + + var eb = _eb.Create(ctx) + .WithOkColor() + .WithAuthor(GetText(strs.marmalade_info)) + .WithTitle(found.Name) + .WithDescription(found.Description) + .AddField(GetText(strs.sneks_count(found.Canaries.Count)), + found.Canaries.Count == 0 + ? "-" + : found.Canaries.Select(x => x.Name).Join('\n'), + true) + .AddField(GetText(strs.commands_count(cmdCount)), + string.IsNullOrWhiteSpace(cmdNames) + ? "-" + : cmdNames, + true); + + await ctx.Channel.EmbedAsync(eb); + return; + } + + if (marmalades.Count == 0) + { + await ReplyPendingLocalizedAsync(strs.no_marmalade_loaded); + return; + } + + await ctx.SendPaginatedConfirmAsync(0, + page => + { + var eb = _eb.Create(ctx) + .WithOkColor(); + + foreach (var marmalade in marmalades.Skip(page * 9).Take(9)) + { + eb.AddField(marmalade.Name, + $""" + `Canaries:` {marmalade.Canaries.Count} + `Commands:` {marmalade.Canaries.Sum(x => x.Commands.Count)} + -- + {marmalade.Description} + """); + } + + return eb; + }, marmalades.Count, 9); + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeSearch() + { + var eb = _eb.Create() + .WithTitle(GetText(strs.list_of_marmalades)) + .WithOkColor(); + + foreach (var item in await _repo.GetModuleItemsAsync()) + { + eb.AddField(item.Name, $""" + {item.Description} + `{item.Command}` + """, true); + } + + await ctx.Channel.EmbedAsync(eb); + } +} \ No newline at end of file diff --git a/src/Ellie/Modules/Marmalades/MarmaladeItem.cs b/src/Ellie/Modules/Marmalades/MarmaladeItem.cs new file mode 100644 index 0000000..a2ba039 --- /dev/null +++ b/src/Ellie/Modules/Marmalades/MarmaladeItem.cs @@ -0,0 +1,8 @@ +namespace Ellie.Modules; + +public sealed class ModuleItem +{ + public required string Name { get; init; } + public required string Description { get; init; } + public required string Command { get; init; } +} \ No newline at end of file diff --git a/src/Ellie/Modules/Marmalades/MarmaladesRepositoryService.cs b/src/Ellie/Modules/Marmalades/MarmaladesRepositoryService.cs new file mode 100644 index 0000000..4dd1ac8 --- /dev/null +++ b/src/Ellie/Modules/Marmalades/MarmaladesRepositoryService.cs @@ -0,0 +1,22 @@ +namespace Ellie.Modules; + +public class MarmaladesRepositoryService : IMarmaladesRepositoryService, IEService +{ + public async Task> GetModuleItemsAsync() + { + // Simulate retrieving data from a database or API + await Task.Delay(100); + return new List + { + new ModuleItem { Name = "RSS Reader", Description = "Keep up to date with your favorite websites", Command = ".mainstall rss" }, + new ModuleItem { Name = "Password Manager", Description = "Safely store and manage all your passwords", Command = ".mainstall passwordmanager" }, + new ModuleItem { Name = "Browser Extension", Description = "Enhance your browsing experience with useful tools", Command = ".mainstall browserextension" }, + new ModuleItem { Name = "Video Downloader", Description = "Download videos from popular websites", Command = ".mainstall videodownloader" }, + new ModuleItem { Name = "Virtual Private Network", Description = "Securely browse the web and protect your privacy", Command = ".mainstall vpn" }, + new ModuleItem { Name = "Ad Blocker", Description = "Block annoying ads and improve page load times", Command = ".mainstall adblocker" }, + new ModuleItem { Name = "Cloud Storage", Description = "Store and share your files online", Command = ".mainstall cloudstorage" }, + new ModuleItem { Name = "Social Media Manager", Description = "Manage all your social media accounts in one place", Command = ".mainstall socialmediamanager" }, + new ModuleItem { Name = "Code Editor", Description = "Write and edit code online", Command = ".mainstall codeeditor" } + }; + } +} \ No newline at end of file diff --git a/src/Ellie/PermissionChecker.cs b/src/Ellie/PermissionChecker.cs new file mode 100644 index 0000000..4f65e06 --- /dev/null +++ b/src/Ellie/PermissionChecker.cs @@ -0,0 +1,80 @@ +using Ellie.Bot.Common; +using Ellie.Modules.Permissions.Common; +using Ellie.Modules.Permissions.Services; +using OneOf; +using OneOf.Types; + +namespace Ellie; + +public sealed class PermissionChecker : IPermissionChecker, IEService +{ + private readonly PermissionService _perms; + private readonly GlobalPermissionService _gperm; + private readonly CmdCdService _cmdCds; + + public PermissionChecker(PermissionService perms, GlobalPermissionService gperm, CmdCdService cmdCds) + { + _perms = perms; + _gperm = gperm; + _cmdCds = cmdCds; + } + + public async Task>> CheckAsync( + IGuild guild, + IMessageChannel channel, + IUser author, + string module, + string? cmd) + { + module = module.ToLowerInvariant(); + cmd = cmd?.ToLowerInvariant(); + // todo add proper string + if (cmd is not null && await _cmdCds.TryBlock(guild, author, cmd)) + return new Error(new()); + + try + { + if (_gperm.BlockedModules.Contains(module)) + { + Log.Information("u:{UserId} tried to use module {Module} which is globally disabled.", + author.Id, + module + ); + + return new Success(); + } + + if (guild is SocketGuild sg) + { + var pc = _perms.GetCacheFor(guild.Id); + if (!pc.Permissions.CheckPermissions(author, channel, cmd, "ACTUALEXPRESSIONS", out var index)) + { + if (pc.Verbose) + { + // todo fix + // var permissionMessage = strs.perm_prevent(index + 1, + // Format.Bold(pc.Permissions[index].GetCommand(_cmd.GetPrefix(guild), sg))); + // + // try + // { + // await msg.Channel.SendErrorAsync(_eb, permissionMessage); + // } + // catch + // { + // } + // + // Log.Information("{PermissionMessage}", permissionMessage); + } + + // todo add proper string + return new Error(new()); + } + } + } + catch + { + } + + return new Success(); + } +} \ No newline at end of file diff --git a/src/Ellie/Program.cs b/src/Ellie/Program.cs new file mode 100644 index 0000000..1b5a5b8 --- /dev/null +++ b/src/Ellie/Program.cs @@ -0,0 +1,28 @@ +var pid = Environment.ProcessId; + +var shardId = 0; +int? totalShards = null; // 0 to read from creds.yml +if (args.Length > 0 && args[0] != "run") +{ + if (!int.TryParse(args[0], out shardId)) + { + Console.Error.WriteLine("Invalid first argument (shard id): {0}", args[0]); + return; + } + + if (args.Length > 1) + { + if (!int.TryParse(args[1], out var shardCount)) + { + Console.Error.WriteLine("Invalid second argument (total shards): {0}", args[1]); + return; + } + + totalShards = shardCount; + } +} + +LogSetup.SetupLogger(shardId); +Log.Information("Pid: {ProcessId}", pid); + +await new Bot(shardId, totalShards, Environment.GetEnvironmentVariable("Ellie__creds")).RunAndBlockAsync(); \ No newline at end of file diff --git a/src/Ellie/_common/ServiceCollectionExtensions.cs b/src/Ellie/_common/ServiceCollectionExtensions.cs index e142f09..171b8be 100644 --- a/src/Ellie/_common/ServiceCollectionExtensions.cs +++ b/src/Ellie/_common/ServiceCollectionExtensions.cs @@ -133,7 +133,7 @@ public static class ServiceCollectionExtensions { scan.From(a) .SelectAllClasses() - .Where(c => (c.IsAssignableTo(typeof(INService)) + .Where(c => (c.IsAssignableTo(typeof(IEService)) || c.IsAssignableTo(typeof(IExecOnMessage)) || c.IsAssignableTo(typeof(IInputTransformer)) || c.IsAssignableTo(typeof(IExecPreCommand)) diff --git a/src/Ellie/creds_example.yml b/src/Ellie/creds_example.yml new file mode 100644 index 0000000..3246d3a --- /dev/null +++ b/src/Ellie/creds_example.yml @@ -0,0 +1,115 @@ +# DO NOT CHANGE +version: 7 +# Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/ +token: '' +# List of Ids of the users who have bot owner permissions +# **DO NOT ADD PEOPLE YOU DON'T TRUST** +ownerIds: [] +# Keep this on 'true' unless you're sure your bot shouldn't use privileged intents or you're waiting to be accepted +usePrivilegedIntents: true +# The number of shards that the bot will be running on. +# Leave at 1 if you don't know what you're doing. +# +# note: If you are planning to have more than one shard, then you must change botCache to 'redis'. +# Also, in that case you should be using Ellie.Coordinator to start the bot, and it will correctly override this value. +totalShards: 1 +# Login to https://console.cloud.google.com, create a new project, go to APIs & Services -> Library -> YouTube Data API and enable it. +# Then, go to APIs and Services -> Credentials and click Create credentials -> API key. +# Used only for Youtube Data Api (at the moment). +googleApiKey: '' +# Create a new custom search here https://programmablesearchengine.google.com/cse/create/new +# Enable SafeSearch +# Remove all Sites to Search +# Enable Search the entire web +# Copy the 'Search Engine ID' to the SearchId field +# +# Do all steps again but enable image search for the ImageSearchId +google: + searchId: + imageSearchId: +# Settings for voting system for discordbots. Meant for use on global Nadeko. +votes: + # top.gg votes service url + # This is the url of your instance of the Ellie.Votes api + # Example: https://votes.my.cool.bot.com + topggServiceUrl: '' + # Authorization header value sent to the TopGG service url with each request + # This should be equivalent to the TopggKey in your Ellie.Votes api appsettings.json file + topggKey: '' + # discords.com votes service url + # This is the url of your instance of the Ellie.Votes api + # Example: https://votes.my.cool.bot.com + discordsServiceUrl: '' + # Authorization header value sent to the Discords service url with each request + # This should be equivalent to the DiscordsKey in your Ellie.Votes api appsettings.json file + discordsKey: '' +# Patreon auto reward system settings. +# go to https://www.patreon.com/portal -> my clients -> create client +patreon: + clientId: + accessToken: '' + refreshToken: '' + clientSecret: '' + # Campaign ID of your patreon page. Go to your patreon page (make sure you're logged in) and type "prompt('Campaign ID', window.patreon.bootstrap.creator.data.id);" in the console. (ctrl + shift + i) + campaignId: '' +# Api key for sending stats to DiscordBotList. +botListToken: '' +# Official cleverbot api key. +cleverbotApiKey: '' +# Official GPT-3 api key. +gpt3ApiKey: '' +# Which cache implementation should bot use. +# 'memory' - Cache will be in memory of the bot's process itself. Only use this on bots with a single shard. When the bot is restarted the cache is reset. +# 'redis' - Uses redis (which needs to be separately downloaded and installed). The cache will persist through bot restarts. You can configure connection string in creds.yml +botCache: Memory +# Redis connection string. Don't change if you don't know what you're doing. +# Only used if botCache is set to 'redis' +redisOptions: localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password= +# Database options. Don't change if you don't know what you're doing. Leave null for default values +db: + # Database type. "sqlite", "mysql" and "postgresql" are supported. + # Default is "sqlite" + type: sqlite + # Database connection string. + # You MUST change this if you're not using "sqlite" type. + # Default is "Data Source=data/Ellie.db" + # Example for mysql: "Server=localhost;Port=3306;Uid=root;Pwd=my_super_secret_mysql_password;Database=nadeko" + # Example for postgresql: "Server=localhost;Port=5432;User Id=postgres;Password=my_super_secret_postgres_password;Database=nadeko;" + connectionString: Data Source=data/Ellie.db +# Address and port of the coordinator endpoint. Leave empty for default. +# Change only if you've changed the coordinator address or port. +coordinatorUrl: http://localhost:3442 +# Api key obtained on https://rapidapi.com (go to MyApps -> Add New App -> Enter Name -> Application key) +rapidApiKey: +# https://locationiq.com api key (register and you will receive the token in the email). +# Used only for .time command. +locationIqApiKey: +# https://timezonedb.com api key (register and you will receive the token in the email). +# Used only for .time command +timezoneDbApiKey: +# https://pro.coinmarketcap.com/account/ api key. There is a free plan for personal use. +# Used for cryptocurrency related commands. +coinmarketcapApiKey: +# Api key used for Osu related commands. Obtain this key at https://osu.ppy.sh/p/api +osuApiKey: +# Optional Trovo client id. +# You should use this if Trovo stream notifications stopped working or you're getting ratelimit errors. +trovoClientId: +# Obtain by creating an application at https://dev.twitch.tv/console/apps +twitchClientId: +# Obtain by creating an application at https://dev.twitch.tv/console/apps +twitchClientSecret: +# Command and args which will be used to restart the bot. +# Only used if bot is executed directly (NOT through the coordinator) +# placeholders: +# {0} -> shard id +# {1} -> total shards +# Linux default +# cmd: dotnet +# args: "Ellie.dll -- {0}" +# Windows default +# cmd: Ellie.exe +# args: "{0}" +restartCommand: + cmd: + args: diff --git a/src/Ellie/ellie_icon.ico b/src/Ellie/ellie_icon.ico new file mode 100644 index 0000000..f35e7f2 Binary files /dev/null and b/src/Ellie/ellie_icon.ico differ