diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..adbec5a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,1766 @@ +[*] +charset = utf-8 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +# Microsoft .NET properties +csharp_indent_braces = false +csharp_indent_switch_labels = true +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = false +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true +csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:warning +csharp_prefer_braces = true:warning +csharp_preserve_single_line_blocks = true +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_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 +csharp_style_expression_bodied_accessors = false:warning +csharp_style_expression_bodied_constructors = false:warning +csharp_style_expression_bodied_methods = false:warning +csharp_style_expression_bodied_properties = true:warning +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_prefer_utf8_string_literals = true:warning +csharp_style_var_elsewhere = true:warning +csharp_style_var_for_built_in_types = true:warning +csharp_style_var_when_type_is_apparent = true:warning +csharp_using_directive_placement = outside_namespace:warning +dotnet_diagnostic.bc40000.severity = warning +dotnet_diagnostic.bc400005.severity = warning +dotnet_diagnostic.bc40008.severity = warning +dotnet_diagnostic.bc40056.severity = warning +dotnet_diagnostic.bc42016.severity = warning +dotnet_diagnostic.bc42024.severity = warning +dotnet_diagnostic.bc42025.severity = warning +dotnet_diagnostic.bc42104.severity = warning +dotnet_diagnostic.bc42105.severity = warning +dotnet_diagnostic.bc42106.severity = warning +dotnet_diagnostic.bc42107.severity = warning +dotnet_diagnostic.bc42304.severity = warning +dotnet_diagnostic.bc42309.severity = warning +dotnet_diagnostic.bc42322.severity = warning +dotnet_diagnostic.bc42349.severity = warning +dotnet_diagnostic.bc42353.severity = warning +dotnet_diagnostic.bc42354.severity = warning +dotnet_diagnostic.bc42355.severity = warning +dotnet_diagnostic.bc42356.severity = warning +dotnet_diagnostic.bc42358.severity = warning +dotnet_diagnostic.bc42504.severity = warning +dotnet_diagnostic.bc42505.severity = warning +dotnet_diagnostic.ca2252.severity = error +dotnet_diagnostic.cs0067.severity = warning +dotnet_diagnostic.cs0078.severity = warning +dotnet_diagnostic.cs0108.severity = warning +dotnet_diagnostic.cs0109.severity = warning +dotnet_diagnostic.cs0114.severity = warning +dotnet_diagnostic.cs0162.severity = warning +dotnet_diagnostic.cs0164.severity = warning +dotnet_diagnostic.cs0168.severity = warning +dotnet_diagnostic.cs0169.severity = warning +dotnet_diagnostic.cs0183.severity = warning +dotnet_diagnostic.cs0184.severity = warning +dotnet_diagnostic.cs0197.severity = warning +dotnet_diagnostic.cs0219.severity = warning +dotnet_diagnostic.cs0252.severity = warning +dotnet_diagnostic.cs0253.severity = warning +dotnet_diagnostic.cs0282.severity = warning +dotnet_diagnostic.cs0414.severity = warning +dotnet_diagnostic.cs0420.severity = warning +dotnet_diagnostic.cs0458.severity = warning +dotnet_diagnostic.cs0464.severity = warning +dotnet_diagnostic.cs0465.severity = warning +dotnet_diagnostic.cs0469.severity = warning +dotnet_diagnostic.cs0472.severity = warning +dotnet_diagnostic.cs0612.severity = warning +dotnet_diagnostic.cs0618.severity = warning +dotnet_diagnostic.cs0628.severity = warning +dotnet_diagnostic.cs0642.severity = warning +dotnet_diagnostic.cs0649.severity = warning +dotnet_diagnostic.cs0652.severity = warning +dotnet_diagnostic.cs0657.severity = warning +dotnet_diagnostic.cs0658.severity = warning +dotnet_diagnostic.cs0659.severity = warning +dotnet_diagnostic.cs0660.severity = warning +dotnet_diagnostic.cs0661.severity = warning +dotnet_diagnostic.cs0665.severity = warning +dotnet_diagnostic.cs0672.severity = warning +dotnet_diagnostic.cs0675.severity = warning +dotnet_diagnostic.cs0693.severity = warning +dotnet_diagnostic.cs1030.severity = warning +dotnet_diagnostic.cs1058.severity = warning +dotnet_diagnostic.cs1066.severity = warning +dotnet_diagnostic.cs1522.severity = warning +dotnet_diagnostic.cs1570.severity = warning +dotnet_diagnostic.cs1571.severity = warning +dotnet_diagnostic.cs1572.severity = warning +dotnet_diagnostic.cs1573.severity = warning +dotnet_diagnostic.cs1574.severity = warning +dotnet_diagnostic.cs1580.severity = warning +dotnet_diagnostic.cs1581.severity = warning +dotnet_diagnostic.cs1584.severity = warning +dotnet_diagnostic.cs1587.severity = warning +dotnet_diagnostic.cs1589.severity = warning +dotnet_diagnostic.cs1590.severity = warning +dotnet_diagnostic.cs1591.severity = warning +dotnet_diagnostic.cs1592.severity = warning +dotnet_diagnostic.cs1710.severity = warning +dotnet_diagnostic.cs1711.severity = warning +dotnet_diagnostic.cs1712.severity = warning +dotnet_diagnostic.cs1717.severity = warning +dotnet_diagnostic.cs1723.severity = warning +dotnet_diagnostic.cs1911.severity = warning +dotnet_diagnostic.cs1957.severity = warning +dotnet_diagnostic.cs1981.severity = warning +dotnet_diagnostic.cs1998.severity = warning +dotnet_diagnostic.cs4014.severity = warning +dotnet_diagnostic.cs7022.severity = warning +dotnet_diagnostic.cs7023.severity = warning +dotnet_diagnostic.cs7095.severity = warning +dotnet_diagnostic.cs8073.severity = warning +dotnet_diagnostic.cs8094.severity = warning +dotnet_diagnostic.cs8123.severity = warning +dotnet_diagnostic.cs8321.severity = warning +dotnet_diagnostic.cs8383.severity = warning +dotnet_diagnostic.cs8416.severity = warning +dotnet_diagnostic.cs8417.severity = warning +dotnet_diagnostic.cs8424.severity = warning +dotnet_diagnostic.cs8425.severity = warning +dotnet_diagnostic.cs8500.severity = warning +dotnet_diagnostic.cs8509.severity = warning +dotnet_diagnostic.cs8519.severity = warning +dotnet_diagnostic.cs8520.severity = warning +dotnet_diagnostic.cs8524.severity = warning +dotnet_diagnostic.cs8597.severity = warning +dotnet_diagnostic.cs8600.severity = warning +dotnet_diagnostic.cs8601.severity = warning +dotnet_diagnostic.cs8602.severity = warning +dotnet_diagnostic.cs8603.severity = warning +dotnet_diagnostic.cs8604.severity = warning +dotnet_diagnostic.cs8605.severity = warning +dotnet_diagnostic.cs8607.severity = warning +dotnet_diagnostic.cs8608.severity = warning +dotnet_diagnostic.cs8609.severity = warning +dotnet_diagnostic.cs8610.severity = warning +dotnet_diagnostic.cs8611.severity = warning +dotnet_diagnostic.cs8612.severity = warning +dotnet_diagnostic.cs8613.severity = warning +dotnet_diagnostic.cs8614.severity = warning +dotnet_diagnostic.cs8615.severity = warning +dotnet_diagnostic.cs8616.severity = warning +dotnet_diagnostic.cs8617.severity = warning +dotnet_diagnostic.cs8618.severity = warning +dotnet_diagnostic.cs8619.severity = warning +dotnet_diagnostic.cs8620.severity = warning +dotnet_diagnostic.cs8621.severity = warning +dotnet_diagnostic.cs8622.severity = warning +dotnet_diagnostic.cs8624.severity = warning +dotnet_diagnostic.cs8625.severity = warning +dotnet_diagnostic.cs8629.severity = warning +dotnet_diagnostic.cs8631.severity = warning +dotnet_diagnostic.cs8632.severity = warning +dotnet_diagnostic.cs8633.severity = warning +dotnet_diagnostic.cs8634.severity = warning +dotnet_diagnostic.cs8643.severity = warning +dotnet_diagnostic.cs8644.severity = warning +dotnet_diagnostic.cs8645.severity = warning +dotnet_diagnostic.cs8655.severity = warning +dotnet_diagnostic.cs8656.severity = warning +dotnet_diagnostic.cs8667.severity = warning +dotnet_diagnostic.cs8669.severity = warning +dotnet_diagnostic.cs8670.severity = warning +dotnet_diagnostic.cs8714.severity = warning +dotnet_diagnostic.cs8762.severity = warning +dotnet_diagnostic.cs8763.severity = warning +dotnet_diagnostic.cs8764.severity = warning +dotnet_diagnostic.cs8765.severity = warning +dotnet_diagnostic.cs8766.severity = warning +dotnet_diagnostic.cs8767.severity = warning +dotnet_diagnostic.cs8768.severity = warning +dotnet_diagnostic.cs8769.severity = warning +dotnet_diagnostic.cs8770.severity = warning +dotnet_diagnostic.cs8774.severity = warning +dotnet_diagnostic.cs8775.severity = warning +dotnet_diagnostic.cs8776.severity = warning +dotnet_diagnostic.cs8777.severity = warning +dotnet_diagnostic.cs8794.severity = warning +dotnet_diagnostic.cs8819.severity = warning +dotnet_diagnostic.cs8824.severity = warning +dotnet_diagnostic.cs8825.severity = warning +dotnet_diagnostic.cs8846.severity = warning +dotnet_diagnostic.cs8847.severity = warning +dotnet_diagnostic.cs8851.severity = warning +dotnet_diagnostic.cs8860.severity = warning +dotnet_diagnostic.cs8892.severity = warning +dotnet_diagnostic.cs8907.severity = warning +dotnet_diagnostic.cs8947.severity = warning +dotnet_diagnostic.cs8960.severity = warning +dotnet_diagnostic.cs8961.severity = warning +dotnet_diagnostic.cs8962.severity = warning +dotnet_diagnostic.cs8963.severity = warning +dotnet_diagnostic.cs8965.severity = warning +dotnet_diagnostic.cs8966.severity = warning +dotnet_diagnostic.cs8971.severity = warning +dotnet_diagnostic.cs8974.severity = warning +dotnet_diagnostic.cs8981.severity = warning +dotnet_diagnostic.cs9042.severity = warning +dotnet_diagnostic.cs9073.severity = warning +dotnet_diagnostic.cs9074.severity = warning +dotnet_diagnostic.cs9080.severity = warning +dotnet_diagnostic.cs9081.severity = warning +dotnet_diagnostic.cs9082.severity = warning +dotnet_diagnostic.cs9083.severity = warning +dotnet_diagnostic.cs9084.severity = warning +dotnet_diagnostic.cs9085.severity = warning +dotnet_diagnostic.cs9086.severity = warning +dotnet_diagnostic.cs9087.severity = warning +dotnet_diagnostic.cs9088.severity = warning +dotnet_diagnostic.cs9089.severity = warning +dotnet_diagnostic.cs9090.severity = warning +dotnet_diagnostic.cs9091.severity = warning +dotnet_diagnostic.cs9092.severity = warning +dotnet_diagnostic.cs9093.severity = warning +dotnet_diagnostic.cs9094.severity = warning +dotnet_diagnostic.cs9095.severity = warning +dotnet_diagnostic.cs9097.severity = warning +dotnet_diagnostic.cs9099.severity = warning +dotnet_diagnostic.cs9100.severity = warning +dotnet_diagnostic.cs9113.severity = warning +dotnet_diagnostic.wme006.severity = warning +dotnet_naming_rule.constants_rule.import_to_resharper = as_predefined +dotnet_naming_rule.constants_rule.severity = warning +dotnet_naming_rule.constants_rule.style = upper_camel_case_style +dotnet_naming_rule.constants_rule.symbols = constants_symbols +dotnet_naming_rule.event_rule.import_to_resharper = as_predefined +dotnet_naming_rule.event_rule.severity = warning +dotnet_naming_rule.event_rule.style = upper_camel_case_style +dotnet_naming_rule.event_rule.symbols = event_symbols +dotnet_naming_rule.interfaces_rule.import_to_resharper = as_predefined +dotnet_naming_rule.interfaces_rule.severity = warning +dotnet_naming_rule.interfaces_rule.style = i_upper_camel_case_style +dotnet_naming_rule.interfaces_rule.symbols = interfaces_symbols +dotnet_naming_rule.locals_rule.import_to_resharper = as_predefined +dotnet_naming_rule.locals_rule.severity = warning +dotnet_naming_rule.locals_rule.style = lower_camel_case_style_1 +dotnet_naming_rule.locals_rule.symbols = locals_symbols +dotnet_naming_rule.local_constants_rule.import_to_resharper = as_predefined +dotnet_naming_rule.local_constants_rule.severity = warning +dotnet_naming_rule.local_constants_rule.style = lower_camel_case_style_1 +dotnet_naming_rule.local_constants_rule.symbols = local_constants_symbols +dotnet_naming_rule.local_functions_rule.import_to_resharper = as_predefined +dotnet_naming_rule.local_functions_rule.severity = warning +dotnet_naming_rule.local_functions_rule.style = upper_camel_case_style +dotnet_naming_rule.local_functions_rule.symbols = local_functions_symbols +dotnet_naming_rule.method_rule.import_to_resharper = as_predefined +dotnet_naming_rule.method_rule.severity = warning +dotnet_naming_rule.method_rule.style = upper_camel_case_style +dotnet_naming_rule.method_rule.symbols = method_symbols +dotnet_naming_rule.parameters_rule.import_to_resharper = as_predefined +dotnet_naming_rule.parameters_rule.severity = warning +dotnet_naming_rule.parameters_rule.style = lower_camel_case_style_1 +dotnet_naming_rule.parameters_rule.symbols = parameters_symbols +dotnet_naming_rule.private_constants_rule.import_to_resharper = as_predefined +dotnet_naming_rule.private_constants_rule.severity = warning +dotnet_naming_rule.private_constants_rule.style = upper_camel_case_style +dotnet_naming_rule.private_constants_rule.symbols = private_constants_symbols +dotnet_naming_rule.private_instance_fields_rule.import_to_resharper = as_predefined +dotnet_naming_rule.private_instance_fields_rule.severity = warning +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 = as_predefined +dotnet_naming_rule.private_static_fields_rule.severity = warning +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 = as_predefined +dotnet_naming_rule.private_static_readonly_rule.severity = warning +dotnet_naming_rule.private_static_readonly_rule.style = upper_camel_case_style +dotnet_naming_rule.private_static_readonly_rule.symbols = private_static_readonly_symbols +dotnet_naming_rule.property_rule.import_to_resharper = as_predefined +dotnet_naming_rule.property_rule.severity = warning +dotnet_naming_rule.property_rule.style = upper_camel_case_style +dotnet_naming_rule.property_rule.symbols = property_symbols +dotnet_naming_rule.public_fields_rule.import_to_resharper = as_predefined +dotnet_naming_rule.public_fields_rule.severity = warning +dotnet_naming_rule.public_fields_rule.style = upper_camel_case_style +dotnet_naming_rule.public_fields_rule.symbols = public_fields_symbols +dotnet_naming_rule.static_readonly_rule.import_to_resharper = as_predefined +dotnet_naming_rule.static_readonly_rule.severity = warning +dotnet_naming_rule.static_readonly_rule.style = upper_camel_case_style +dotnet_naming_rule.static_readonly_rule.symbols = static_readonly_symbols +dotnet_naming_rule.types_and_namespaces_rule.import_to_resharper = as_predefined +dotnet_naming_rule.types_and_namespaces_rule.severity = warning +dotnet_naming_rule.types_and_namespaces_rule.style = upper_camel_case_style +dotnet_naming_rule.types_and_namespaces_rule.symbols = types_and_namespaces_symbols +dotnet_naming_rule.type_parameters_rule.import_to_resharper = as_predefined +dotnet_naming_rule.type_parameters_rule.severity = warning +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.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.lower_camel_case_style_1.capitalization = camel_case +dotnet_naming_style.t_upper_camel_case_style.capitalization = pascal_case +dotnet_naming_style.t_upper_camel_case_style.required_prefix = T +dotnet_naming_style.upper_camel_case_style.capitalization = pascal_case +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.event_symbols.applicable_accessibilities = * +dotnet_naming_symbols.event_symbols.applicable_kinds = event +dotnet_naming_symbols.interfaces_symbols.applicable_accessibilities = * +dotnet_naming_symbols.interfaces_symbols.applicable_kinds = interface +dotnet_naming_symbols.locals_symbols.applicable_accessibilities = * +dotnet_naming_symbols.locals_symbols.applicable_kinds = local +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_functions_symbols.applicable_accessibilities = * +dotnet_naming_symbols.local_functions_symbols.applicable_kinds = local_function +dotnet_naming_symbols.method_symbols.applicable_accessibilities = * +dotnet_naming_symbols.method_symbols.applicable_kinds = method +dotnet_naming_symbols.parameters_symbols.applicable_accessibilities = * +dotnet_naming_symbols.parameters_symbols.applicable_kinds = parameter +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_instance_fields_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_instance_fields_symbols.applicable_kinds = field +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_readonly_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers = static, readonly +dotnet_naming_symbols.property_symbols.applicable_accessibilities = * +dotnet_naming_symbols.property_symbols.applicable_kinds = property +dotnet_naming_symbols.public_fields_symbols.applicable_accessibilities = public, internal, protected, protected_internal, private_protected +dotnet_naming_symbols.public_fields_symbols.applicable_kinds = field +dotnet_naming_symbols.static_readonly_symbols.applicable_accessibilities = public, internal, protected, protected_internal, private_protected +dotnet_naming_symbols.static_readonly_symbols.applicable_kinds = field +dotnet_naming_symbols.static_readonly_symbols.required_modifiers = static, readonly +dotnet_naming_symbols.types_and_namespaces_symbols.applicable_accessibilities = * +dotnet_naming_symbols.types_and_namespaces_symbols.applicable_kinds = namespace, class, struct, enum, delegate +dotnet_naming_symbols.type_parameters_symbols.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters_symbols.applicable_kinds = type_parameter +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:warning +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:warning +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning +dotnet_style_qualification_for_event = false:warning +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning + +# ReSharper properties +resharper_alignment_tab_fill_style = use_spaces +resharper_align_first_arg_by_paren = false +resharper_align_linq_query = false +resharper_align_multiline_array_and_object_initializer = false +resharper_align_multiline_array_initializer = true +resharper_align_multiline_binary_patterns = false +resharper_align_multiline_comments = true +resharper_align_multiline_ctor_init = true +resharper_align_multiline_expression_braces = false +resharper_align_multiline_implements_list = true +resharper_align_multiline_list_pattern = false +resharper_align_multiline_property_pattern = false +resharper_align_multiline_statement_conditions = true +resharper_align_multiline_switch_expression = false +resharper_align_multiline_type_argument = true +resharper_align_multiline_type_parameter = true +resharper_align_multline_type_parameter_constrains = false +resharper_align_multline_type_parameter_list = false +resharper_align_ternary = align_not_nested +resharper_align_tuple_components = false +resharper_allow_alias = true +resharper_allow_comment_after_lbrace = false +resharper_allow_far_alignment = false +resharper_always_use_end_of_line_brace_style = false +resharper_apply_auto_detected_rules = true +resharper_apply_on_completion = false +resharper_arguments_anonymous_function = positional +resharper_arguments_literal = positional +resharper_arguments_named = positional +resharper_arguments_other = positional +resharper_arguments_skip_single = true +resharper_arguments_string_literal = positional +resharper_attribute_style = do_not_touch +resharper_autodetect_indent_settings = true +resharper_blank_lines_after_access_specifier = 0 +resharper_blank_lines_after_block_statements = 1 +resharper_blank_lines_after_case = 0 +resharper_blank_lines_after_control_transfer_statements = 0 +resharper_blank_lines_after_file_scoped_namespace_directive = 1 +resharper_blank_lines_after_imports = 1 +resharper_blank_lines_after_multiline_statements = 0 +resharper_blank_lines_after_options = 1 +resharper_blank_lines_after_start_comment = 1 +resharper_blank_lines_after_using_list = 1 +resharper_blank_lines_around_accessor = 0 +resharper_blank_lines_around_auto_property = 1 +resharper_blank_lines_around_block_case_section = 0 +resharper_blank_lines_around_class_definition = 1 +resharper_blank_lines_around_field = 1 +resharper_blank_lines_around_function_declaration = 0 +resharper_blank_lines_around_function_definition = 1 +resharper_blank_lines_around_global_attribute = 0 +resharper_blank_lines_around_invocable = 1 +resharper_blank_lines_around_local_method = 1 +resharper_blank_lines_around_multiline_case_section = 0 +resharper_blank_lines_around_namespace = 1 +resharper_blank_lines_around_other_declaration = 0 +resharper_blank_lines_around_property = 1 +resharper_blank_lines_around_razor_functions = 1 +resharper_blank_lines_around_razor_helpers = 1 +resharper_blank_lines_around_razor_sections = 1 +resharper_blank_lines_around_region = 1 +resharper_blank_lines_around_single_line_accessor = 0 +resharper_blank_lines_around_single_line_auto_property = 0 +resharper_blank_lines_around_single_line_field = 0 +resharper_blank_lines_around_single_line_function_definition = 0 +resharper_blank_lines_around_single_line_invocable = 0 +resharper_blank_lines_around_single_line_local_method = 0 +resharper_blank_lines_around_single_line_property = 0 +resharper_blank_lines_around_single_line_type = 1 +resharper_blank_lines_around_type = 1 +resharper_blank_lines_before_access_specifier = 1 +resharper_blank_lines_before_block_statements = 0 +resharper_blank_lines_before_case = 0 +resharper_blank_lines_before_control_transfer_statements = 0 +resharper_blank_lines_before_multiline_statements = 0 +resharper_blank_lines_before_single_line_comment = 0 +resharper_blank_lines_inside_namespace = 0 +resharper_blank_lines_inside_region = 1 +resharper_blank_lines_inside_type = 0 +resharper_blank_line_after_pi = true +resharper_braces_redundant = false +resharper_break_template_declaration = line_break +resharper_builtin_type_apply_to_native_integer = false +resharper_can_use_global_alias = true +resharper_configure_await_analysis_mode = disabled +resharper_continuous_indent_multiplier = 1 +resharper_continuous_line_indent = single +resharper_csharp_align_multiline_argument = false +resharper_csharp_align_multiline_binary_expressions_chain = true +resharper_csharp_align_multiline_calls_chain = false +resharper_csharp_align_multiline_expression = false +resharper_csharp_align_multiline_extends_list = false +resharper_csharp_align_multiline_for_stmt = false +resharper_csharp_align_multiline_parameter = false +resharper_csharp_align_multiple_declaration = false +resharper_csharp_insert_final_newline = true +resharper_csharp_keep_blank_lines_in_code = 1 +resharper_csharp_max_line_length = 120 +resharper_csharp_naming_rule.enum_member = AaBb +resharper_csharp_naming_rule.method_property_event = AaBb +resharper_csharp_naming_rule.other = AaBb +resharper_csharp_new_line_before_while = false +resharper_csharp_prefer_qualified_reference = false +resharper_csharp_space_after_unary_operator = false +resharper_csharp_wrap_before_first_type_parameter_constraint = true +resharper_csharp_wrap_lines = true +resharper_default_exception_variable_name = e +resharper_default_value_when_type_evident = default_literal +resharper_default_value_when_type_not_evident = default_literal +resharper_delete_quotes_from_solid_values = false +resharper_disable_blank_line_changes = false +resharper_disable_formatter = false +resharper_disable_indenter = false +resharper_disable_int_align = false +resharper_disable_line_break_changes = false +resharper_disable_line_break_removal = false +resharper_disable_space_changes = false +resharper_disable_space_changes_before_trailing_comment = false +resharper_dont_remove_extra_blank_lines = false +resharper_empty_block_style = multiline +resharper_enable_wrapping = false +resharper_enforce_line_ending_style = false +resharper_event_handler_pattern_long = $object$On$event$ +resharper_event_handler_pattern_short = On$event$ +resharper_export_declaration_braces = next_line +resharper_expression_braces = inside +resharper_expression_pars = inside +resharper_extra_spaces = remove_all +resharper_force_attribute_style = separate +resharper_force_chop_compound_do_expression = false +resharper_force_chop_compound_if_expression = false +resharper_force_chop_compound_while_expression = false +resharper_formatter_off_tag = @formatter:off +resharper_formatter_on_tag = @formatter:on +resharper_formatter_tags_accept_regexp = false +resharper_formatter_tags_enabled = true +resharper_format_leading_spaces_decl = false +resharper_free_block_braces = next_line +resharper_function_declaration_return_type_style = do_not_change +resharper_function_definition_return_type_style = do_not_change +resharper_generator_mode = false +resharper_ignore_space_preservation = false +resharper_include_prefix_comment_in_indent = false +resharper_indent_access_specifiers_from_class = false +resharper_indent_aligned_ternary = true +resharper_indent_anonymous_method_block = false +resharper_indent_braces_inside_statement_conditions = true +resharper_indent_case_from_select = true +resharper_indent_child_elements = OneIndent +resharper_indent_class_members_from_access_specifiers = false +resharper_indent_comment = true +resharper_indent_export_declaration_members = true +resharper_indent_inside_namespace = true +resharper_indent_invocation_pars = inside +resharper_indent_member_initializer_list = true +resharper_indent_method_decl_pars = inside +resharper_indent_nested_fixed_stmt = false +resharper_indent_nested_foreach_stmt = false +resharper_indent_nested_for_stmt = false +resharper_indent_nested_lock_stmt = false +resharper_indent_nested_usings_stmt = false +resharper_indent_nested_while_stmt = false +resharper_indent_pars = inside +resharper_indent_preprocessor_directives = none +resharper_indent_preprocessor_if = no_indent +resharper_indent_preprocessor_other = no_indent +resharper_indent_preprocessor_region = usual_indent +resharper_indent_raw_literal_string = align +resharper_indent_statement_pars = inside +resharper_indent_text = OneIndent +resharper_indent_typearg_angles = inside +resharper_indent_typeparam_angles = inside +resharper_indent_type_constraints = true +resharper_indent_wrapped_function_names = false +resharper_instance_members_qualify_declared_in = this_class, base_class +resharper_int_align = false +resharper_int_align_bitfield_sizes = false +resharper_int_align_comments = false +resharper_int_align_declaration_names = false +resharper_int_align_enum_initializers = false +resharper_int_align_eq = false +resharper_int_align_fix_in_adjacent = true +resharper_keep_blank_lines_in_declarations = 2 +resharper_keep_existing_arrangement = true +resharper_keep_nontrivial_alias = true +resharper_keep_user_linebreaks = true +resharper_keep_user_wrapping = true +resharper_linebreaks_around_razor_statements = true +resharper_linebreaks_inside_tags_for_elements_longer_than = 2147483647 +resharper_linebreaks_inside_tags_for_elements_with_child_elements = true +resharper_linebreaks_inside_tags_for_multiline_elements = true +resharper_linebreak_before_all_elements = false +resharper_linebreak_before_multiline_elements = true +resharper_linebreak_before_singleline_elements = false +resharper_line_break_after_colon_in_member_initializer_lists = do_not_change +resharper_line_break_after_comma_in_member_initializer_lists = false +resharper_line_break_after_init_statement = do_not_change +resharper_line_break_before_comma_in_member_initializer_lists = false +resharper_line_break_before_requires_clause = do_not_change +resharper_linkage_specification_braces = end_of_line +resharper_linkage_specification_indentation = none +resharper_local_function_body = expression_body +resharper_macro_block_begin = +resharper_macro_block_end = +resharper_max_array_initializer_elements_on_line = 10000 +resharper_max_attribute_length_for_same_line = 38 +resharper_max_enum_members_on_line = 3 +resharper_max_formal_parameters_on_line = 10000 +resharper_max_initializer_elements_on_line = 4 +resharper_max_invocation_arguments_on_line = 10000 +resharper_member_initializer_list_style = do_not_change +resharper_namespace_declaration_braces = next_line +resharper_namespace_indentation = all +resharper_nested_ternary_style = autodetect +resharper_new_line_before_catch = true +resharper_new_line_before_else = true +resharper_new_line_before_enumerators = true +resharper_normalize_tag_names = false +resharper_no_indent_inside_elements = html, body, thead, tbody, tfoot +resharper_no_indent_inside_if_element_longer_than = 200 +resharper_null_checking_pattern_style = not_null_pattern +resharper_object_creation_when_type_evident = target_typed +resharper_object_creation_when_type_not_evident = explicitly_typed +resharper_old_engine = false +resharper_outdent_binary_ops = false +resharper_outdent_binary_pattern_ops = false +resharper_outdent_commas = false +resharper_outdent_dots = false +resharper_outdent_namespace_member = false +resharper_outdent_statement_labels = false +resharper_outdent_ternary_ops = false +resharper_parentheses_non_obvious_operations = none, shift, bitwise_and, bitwise_exclusive_or, bitwise_inclusive_or, bitwise +resharper_parentheses_redundancy_style = remove_if_not_clarifies_precedence +resharper_parentheses_same_type_operations = false +resharper_pi_attributes_indent = align_by_first_attribute +resharper_place_attribute_on_same_line = false +resharper_place_comments_at_first_column = false +resharper_place_constructor_initializer_on_same_line = true +resharper_place_event_attribute_on_same_line = false +resharper_place_expr_accessor_on_single_line = true +resharper_place_expr_method_on_single_line = true +resharper_place_expr_property_on_single_line = true +resharper_place_linq_into_on_new_line = true +resharper_place_namespace_definitions_on_same_line = false +resharper_place_property_attribute_on_same_line = false +resharper_place_simple_case_statement_on_same_line = false +resharper_place_simple_embedded_statement_on_same_line = true +resharper_place_simple_initializer_on_single_line = true +resharper_place_simple_list_pattern_on_single_line = true +resharper_place_simple_property_pattern_on_single_line = true +resharper_place_simple_switch_expression_on_single_line = true +resharper_place_type_constraints_on_same_line = true +resharper_prefer_explicit_discard_declaration = false +resharper_prefer_separate_deconstructed_variables_declaration = true +resharper_preserve_spaces_inside_tags = pre, textarea +resharper_qualified_using_at_nested_scope = false +resharper_quote_style = doublequoted +resharper_razor_prefer_qualified_reference = true +resharper_remove_blank_lines_near_braces = false +resharper_remove_blank_lines_near_braces_in_code = true +resharper_remove_blank_lines_near_braces_in_declarations = true +resharper_remove_this_qualifier = true +resharper_requires_expression_braces = next_line +resharper_resx_attribute_indent = single_indent +resharper_resx_insert_final_newline = true +resharper_resx_linebreak_before_elements = +resharper_resx_max_blank_lines_between_tags = 0 +resharper_resx_max_line_length = 2147483647 +resharper_resx_pi_attribute_style = do_not_touch +resharper_resx_space_before_self_closing = false +resharper_resx_wrap_lines = false +resharper_resx_wrap_tags_and_pi = false +resharper_resx_wrap_text = false +resharper_show_autodetect_configure_formatting_tip = false +resharper_simple_block_style = do_not_change +resharper_simple_case_statement_style = do_not_change +resharper_simple_embedded_statement_style = do_not_change +resharper_sort_attributes = false +resharper_sort_class_selectors = false +resharper_sort_usings = true +resharper_sort_usings_lowercase_first = false +resharper_spaces_around_eq_in_attribute = false +resharper_spaces_around_eq_in_pi_attribute = false +resharper_spaces_inside_tags = false +resharper_space_after_attributes = true +resharper_space_after_attribute_target_colon = true +resharper_space_after_cast = false +resharper_space_after_colon = true +resharper_space_after_colon_in_bitfield_declarator = true +resharper_space_after_colon_in_case = true +resharper_space_after_colon_in_inheritance_clause = true +resharper_space_after_comma = true +resharper_space_after_ellipsis_in_parameter_pack = true +resharper_space_after_for_colon = true +resharper_space_after_keywords_in_control_flow_statements = true +resharper_space_after_last_attribute = false +resharper_space_after_last_pi_attribute = false +resharper_space_after_operator_keyword = true +resharper_space_after_operator_not = false +resharper_space_after_ptr_in_data_member = true +resharper_space_after_ptr_in_data_members = false +resharper_space_after_ptr_in_method = true +resharper_space_after_ptr_in_nested_declarator = false +resharper_space_after_ref_in_data_member = true +resharper_space_after_ref_in_data_members = false +resharper_space_after_ref_in_method = true +resharper_space_after_semicolon_in_for_statement = true +resharper_space_after_ternary_colon = true +resharper_space_after_ternary_quest = true +resharper_space_after_triple_slash = true +resharper_space_after_type_parameter_constraint_colon = true +resharper_space_around_additive_op = true +resharper_space_around_alias_eq = true +resharper_space_around_assignment_op = true +resharper_space_around_assignment_operator = true +resharper_space_around_deref_in_trailing_return_type = true +resharper_space_around_lambda_arrow = true +resharper_space_around_member_access_operator = false +resharper_space_around_relational_op = true +resharper_space_around_shift_op = true +resharper_space_around_stmt_colon = true +resharper_space_around_ternary_operator = true +resharper_space_before_array_rank_parentheses = false +resharper_space_before_attribute_target_colon = false +resharper_space_before_checked_parentheses = false +resharper_space_before_colon = false +resharper_space_before_colon_in_bitfield_declarator = true +resharper_space_before_colon_in_case = false +resharper_space_before_colon_in_inheritance_clause = true +resharper_space_before_comma = false +resharper_space_before_default_parentheses = false +resharper_space_before_ellipsis_in_parameter_pack = false +resharper_space_before_empty_invocation_parentheses = false +resharper_space_before_empty_method_parentheses = false +resharper_space_before_for_colon = true +resharper_space_before_initializer_braces = false +resharper_space_before_invocation_parentheses = false +resharper_space_before_label_colon = false +resharper_space_before_lambda_parentheses = false +resharper_space_before_method_parentheses = false +resharper_space_before_nameof_parentheses = false +resharper_space_before_new_parentheses = false +resharper_space_before_nullable_mark = false +resharper_space_before_open_square_brackets = false +resharper_space_before_pointer_asterik_declaration = false +resharper_space_before_postfix_operator = false +resharper_space_before_ptr_in_abstract_decl = false +resharper_space_before_ptr_in_data_member = false +resharper_space_before_ptr_in_data_members = true +resharper_space_before_ptr_in_method = false +resharper_space_before_ref_in_abstract_decl = false +resharper_space_before_ref_in_data_member = false +resharper_space_before_ref_in_data_members = true +resharper_space_before_ref_in_method = false +resharper_space_before_semicolon = false +resharper_space_before_semicolon_in_for_statement = false +resharper_space_before_singleline_accessorholder = true +resharper_space_before_sizeof_parentheses = false +resharper_space_before_template_args = false +resharper_space_before_template_params = true +resharper_space_before_ternary_colon = true +resharper_space_before_ternary_quest = true +resharper_space_before_trailing_comment = true +resharper_space_before_typeof_parentheses = false +resharper_space_before_type_argument_angle = false +resharper_space_before_type_parameter_angle = false +resharper_space_before_type_parameter_constraint_colon = true +resharper_space_before_type_parameter_parentheses = true +resharper_space_between_accessors_in_singleline_property = true +resharper_space_between_attribute_sections = true +resharper_space_between_closing_angle_brackets_in_template_args = false +resharper_space_between_keyword_and_expression = true +resharper_space_between_keyword_and_type = true +resharper_space_between_method_call_empty_parameter_list_parentheses = false +resharper_space_between_method_call_name_and_opening_parenthesis = false +resharper_space_between_method_call_parameter_list_parentheses = false +resharper_space_between_method_declaration_empty_parameter_list_parentheses = false +resharper_space_between_method_declaration_name_and_open_parenthesis = false +resharper_space_between_method_declaration_parameter_list_parentheses = false +resharper_space_between_parentheses_of_control_flow_statements = false +resharper_space_between_square_brackets = false +resharper_space_between_typecast_parentheses = false +resharper_space_in_singleline_accessorholder = true +resharper_space_in_singleline_anonymous_method = true +resharper_space_in_singleline_method = true +resharper_space_near_postfix_and_prefix_op = false +resharper_space_within_array_initialization_braces = false +resharper_space_within_array_rank_empty_parentheses = false +resharper_space_within_array_rank_parentheses = false +resharper_space_within_attribute_angles = false +resharper_space_within_checked_parentheses = false +resharper_space_within_declaration_parentheses = false +resharper_space_within_default_parentheses = false +resharper_space_within_empty_braces = true +resharper_space_within_empty_initializer_braces = false +resharper_space_within_empty_invocation_parentheses = false +resharper_space_within_empty_method_parentheses = false +resharper_space_within_empty_template_params = false +resharper_space_within_expression_parentheses = false +resharper_space_within_initializer_braces = false +resharper_space_within_invocation_parentheses = false +resharper_space_within_method_parentheses = false +resharper_space_within_nameof_parentheses = false +resharper_space_within_new_parentheses = false +resharper_space_within_parentheses = false +resharper_space_within_single_line_array_initializer_braces = true +resharper_space_within_sizeof_parentheses = false +resharper_space_within_slice_pattern = true +resharper_space_within_template_args = false +resharper_space_within_template_params = false +resharper_space_within_tuple_parentheses = false +resharper_space_within_typeof_parentheses = false +resharper_space_within_type_argument_angles = false +resharper_space_within_type_parameter_angles = false +resharper_space_within_type_parameter_parentheses = false +resharper_special_else_if_treatment = true +resharper_static_members_qualify_members = none +resharper_static_members_qualify_with = declared_type +resharper_stick_comment = true +resharper_support_vs_event_naming_pattern = true +resharper_toplevel_function_declaration_return_type_style = do_not_change +resharper_toplevel_function_definition_return_type_style = do_not_change +resharper_trailing_comma_in_multiline_lists = false +resharper_trailing_comma_in_singleline_lists = false +resharper_use_continuous_indent_inside_initializer_braces = true +resharper_use_continuous_indent_inside_parens = true +resharper_use_continuous_line_indent_in_expression_braces = false +resharper_use_continuous_line_indent_in_method_pars = false +resharper_use_indents_from_main_language_in_file = true +resharper_use_indent_from_previous_element = true +resharper_use_indent_from_vs = false +resharper_use_roslyn_logic_for_evident_types = false +resharper_wrap_after_binary_opsign = true +resharper_wrap_after_declaration_lpar = false +resharper_wrap_after_dot = false +resharper_wrap_after_dot_in_method_calls = false +resharper_wrap_after_expression_lbrace = true +resharper_wrap_after_invocation_lpar = false +resharper_wrap_after_property_in_chained_method_calls = false +resharper_wrap_arguments_style = wrap_if_long +resharper_wrap_around_elements = true +resharper_wrap_array_initializer_style = wrap_if_long +resharper_wrap_base_clause_style = wrap_if_long +resharper_wrap_before_arrow_with_expressions = false +resharper_wrap_before_binary_opsign = false +resharper_wrap_before_binary_pattern_op = true +resharper_wrap_before_colon = false +resharper_wrap_before_comma = false +resharper_wrap_before_comma_in_base_clause = false +resharper_wrap_before_declaration_lpar = false +resharper_wrap_before_declaration_rpar = false +resharper_wrap_before_eq = false +resharper_wrap_before_expression_rbrace = true +resharper_wrap_before_extends_colon = false +resharper_wrap_before_first_method_call = false +resharper_wrap_before_invocation_lpar = false +resharper_wrap_before_invocation_rpar = false +resharper_wrap_before_linq_expression = false +resharper_wrap_before_ternary_opsigns = true +resharper_wrap_before_type_parameter_langle = false +resharper_wrap_braced_init_list_style = wrap_if_long +resharper_wrap_chained_binary_expressions = wrap_if_long +resharper_wrap_chained_binary_patterns = wrap_if_long +resharper_wrap_chained_method_calls = wrap_if_long +resharper_wrap_ctor_initializer_style = wrap_if_long +resharper_wrap_enumeration_style = chop_if_long +resharper_wrap_enum_declaration = chop_always +resharper_wrap_extends_list_style = wrap_if_long +resharper_wrap_for_stmt_header_style = chop_if_long +resharper_wrap_list_pattern = wrap_if_long +resharper_wrap_multiple_declaration_style = chop_if_long +resharper_wrap_multiple_type_parameter_constraints_style = chop_if_long +resharper_wrap_object_and_collection_initializer_style = chop_always +resharper_wrap_parameters_style = wrap_if_long +resharper_wrap_property_pattern = chop_if_long +resharper_wrap_switch_expression = chop_always +resharper_wrap_ternary_expr_style = chop_if_long +resharper_wrap_verbatim_interpolated_strings = no_wrap +resharper_xmldoc_attribute_indent = single_indent +resharper_xmldoc_insert_final_newline = true +resharper_xmldoc_linebreak_before_elements = summary, remarks, example, returns, param, typeparam, value, para +resharper_xmldoc_max_blank_lines_between_tags = 0 +resharper_xmldoc_max_line_length = 120 +resharper_xmldoc_pi_attribute_style = do_not_touch +resharper_xmldoc_space_before_self_closing = true +resharper_xmldoc_wrap_lines = true +resharper_xmldoc_wrap_tags_and_pi = true +resharper_xmldoc_wrap_text = true + +# ReSharper inspection severities +resharper_access_rights_in_text_highlighting = warning +resharper_access_to_disposed_closure_highlighting = warning +resharper_access_to_for_each_variable_in_closure_highlighting = warning +resharper_access_to_modified_closure_highlighting = warning +resharper_access_to_static_member_via_derived_type_highlighting = warning +resharper_address_of_marshal_by_ref_object_highlighting = warning +resharper_all_underscore_local_parameter_name_highlighting = warning +resharper_angular_html_banana_highlighting = warning +resharper_annotate_can_be_null_parameter_highlighting = warning +resharper_annotate_can_be_null_type_member_highlighting = warning +resharper_annotate_not_null_parameter_highlighting = warning +resharper_annotate_not_null_type_member_highlighting = warning +resharper_annotation_conflict_in_hierarchy_highlighting = warning +resharper_annotation_redundancy_at_value_type_highlighting = warning +resharper_annotation_redundancy_in_hierarchy_highlighting = warning +resharper_anonymous_object_destructuring_problem_highlighting = warning +resharper_arguments_style_anonymous_function_highlighting = warning +resharper_arguments_style_literal_highlighting = warning +resharper_arguments_style_named_expression_highlighting = warning +resharper_arguments_style_other_highlighting = warning +resharper_arguments_style_string_literal_highlighting = warning +resharper_arrange_attributes_highlighting = warning +resharper_arrange_default_value_when_type_evident_highlighting = warning +resharper_arrange_default_value_when_type_not_evident_highlighting = warning +resharper_arrange_local_function_body_highlighting = warning +resharper_arrange_null_checking_pattern_highlighting = warning +resharper_arrange_object_creation_when_type_evident_highlighting = warning +resharper_arrange_object_creation_when_type_not_evident_highlighting = warning +resharper_arrange_static_member_qualifier_highlighting = warning +resharper_arrange_trailing_comma_in_multiline_lists_highlighting = warning +resharper_arrange_trailing_comma_in_singleline_lists_highlighting = warning +resharper_arrange_var_keywords_in_deconstructing_declaration_highlighting = warning +resharper_asp_content_placeholder_not_resolved_highlighting = error +resharper_asp_custom_page_parser_filter_type_highlighting = warning +resharper_asp_dead_code_highlighting = warning +resharper_asp_entity_highlighting = warning +resharper_asp_image_highlighting = warning +resharper_asp_invalid_control_type_highlighting = error +resharper_asp_not_resolved_highlighting = error +resharper_asp_ods_method_reference_resolve_error_highlighting = error +resharper_asp_resolve_warning_highlighting = warning +resharper_asp_skin_not_resolved_highlighting = error +resharper_asp_tag_attribute_with_optional_value_highlighting = warning +resharper_asp_theme_not_resolved_highlighting = error +resharper_asp_unused_register_directive_highlighting_highlighting = warning +resharper_asp_warning_highlighting = warning +resharper_assignment_instead_of_discard_highlighting = warning +resharper_assignment_in_conditional_expression_highlighting = warning +resharper_assignment_is_fully_discarded_highlighting = warning +resharper_assign_null_to_not_null_attribute_highlighting = warning +resharper_asxx_path_error_highlighting = warning +resharper_async_iterator_invocation_without_await_foreach_highlighting = warning +resharper_async_void_lambda_highlighting = warning +resharper_async_void_method_highlighting = warning +resharper_auto_property_can_be_made_get_only_global_highlighting = warning +resharper_auto_property_can_be_made_get_only_local_highlighting = warning +resharper_bad_attribute_brackets_spaces_highlighting = warning +resharper_bad_braces_spaces_highlighting = warning +resharper_bad_child_statement_indent_highlighting = warning +resharper_bad_colon_spaces_highlighting = warning +resharper_bad_comma_spaces_highlighting = warning +resharper_bad_control_braces_indent_highlighting = warning +resharper_bad_control_braces_line_breaks_highlighting = warning +resharper_bad_declaration_braces_indent_highlighting = warning +resharper_bad_declaration_braces_line_breaks_highlighting = warning +resharper_bad_empty_braces_line_breaks_highlighting = warning +resharper_bad_expression_braces_indent_highlighting = warning +resharper_bad_expression_braces_line_breaks_highlighting = warning +resharper_bad_generic_brackets_spaces_highlighting = warning +resharper_bad_indent_highlighting = warning +resharper_bad_linq_line_breaks_highlighting = warning +resharper_bad_list_line_breaks_highlighting = warning +resharper_bad_member_access_spaces_highlighting = warning +resharper_bad_namespace_braces_indent_highlighting = warning +resharper_bad_parens_line_breaks_highlighting = warning +resharper_bad_parens_spaces_highlighting = warning +resharper_bad_preprocessor_indent_highlighting = warning +resharper_bad_semicolon_spaces_highlighting = warning +resharper_bad_spaces_after_keyword_highlighting = warning +resharper_bad_square_brackets_spaces_highlighting = warning +resharper_bad_switch_braces_indent_highlighting = warning +resharper_bad_symbol_spaces_highlighting = warning +resharper_base_member_has_params_highlighting = warning +resharper_base_method_call_with_default_parameter_highlighting = warning +resharper_base_object_equals_is_object_equals_highlighting = warning +resharper_base_object_get_hash_code_call_in_get_hash_code_highlighting = warning +resharper_bitwise_operator_on_enum_without_flags_highlighting = warning +resharper_blazor_editor_required_highlighting = warning +resharper_by_ref_argument_is_volatile_field_highlighting = warning +resharper_cannot_apply_equality_operator_to_type_highlighting = warning +resharper_can_simplify_dictionary_lookup_with_try_add_highlighting = warning +resharper_can_simplify_dictionary_lookup_with_try_get_value_highlighting = warning +resharper_center_tag_is_obsolete_highlighting = warning +resharper_check_for_reference_equality_instead_1_highlighting = warning +resharper_check_for_reference_equality_instead_2_highlighting = warning +resharper_check_for_reference_equality_instead_3_highlighting = warning +resharper_check_for_reference_equality_instead_4_highlighting = warning +resharper_check_namespace_highlighting = warning +resharper_class_cannot_be_instantiated_highlighting = warning +resharper_class_can_be_sealed_global_highlighting = warning +resharper_class_can_be_sealed_local_highlighting = warning +resharper_class_never_instantiated_global_highlighting = warning +resharper_class_never_instantiated_local_highlighting = warning +resharper_class_with_virtual_members_never_inherited_global_highlighting = warning +resharper_class_with_virtual_members_never_inherited_local_highlighting = warning +resharper_clear_attribute_is_obsolete_all_highlighting = warning +resharper_clear_attribute_is_obsolete_highlighting = warning +resharper_cognitive_complexity_highlighting = warning +resharper_collection_never_queried_global_highlighting = warning +resharper_collection_never_queried_local_highlighting = warning +resharper_collection_never_updated_global_highlighting = warning +resharper_collection_never_updated_local_highlighting = warning +resharper_comment_typo_highlighting = none +resharper_compare_non_constrained_generic_with_null_highlighting = warning +resharper_compare_of_floats_by_equality_operator_highlighting = warning +resharper_complex_object_destructuring_problem_highlighting = warning +resharper_complex_object_in_context_destructuring_problem_highlighting = warning +resharper_conditional_access_qualifier_is_non_nullable_according_to_api_contract_highlighting = warning +resharper_conditional_ternary_equal_branch_highlighting = warning +resharper_condition_is_always_true_or_false_according_to_nullable_api_contract_highlighting = warning +resharper_condition_is_always_true_or_false_highlighting = warning +resharper_confusing_char_as_integer_in_constructor_highlighting = warning +resharper_constant_conditional_access_qualifier_highlighting = warning +resharper_constant_null_coalescing_condition_highlighting = warning +resharper_consteval_if_is_always_constant_highlighting = warning +resharper_constructor_initializer_loop_highlighting = warning +resharper_container_annotation_redundancy_highlighting = warning +resharper_contextual_logger_problem_highlighting = warning +resharper_context_value_is_provided_highlighting = warning +resharper_contract_annotation_not_parsed_highlighting = warning +resharper_convert_closure_to_method_group_highlighting = warning +resharper_convert_conditional_ternary_expression_to_switch_expression_highlighting = warning +resharper_convert_constructor_to_member_initializers_highlighting = warning +resharper_convert_if_do_to_while_highlighting = warning +resharper_convert_if_statement_to_conditional_ternary_expression_highlighting = warning +resharper_convert_if_statement_to_null_coalescing_assignment_highlighting = warning +resharper_convert_if_statement_to_null_coalescing_expression_highlighting = warning +resharper_convert_if_statement_to_return_statement_highlighting = warning +resharper_convert_if_statement_to_switch_statement_highlighting = hint +resharper_convert_if_to_or_expression_highlighting = warning +resharper_convert_nullable_to_short_form_highlighting = warning +resharper_convert_switch_statement_to_switch_expression_highlighting = warning +resharper_convert_to_auto_property_highlighting = warning +resharper_convert_to_auto_property_when_possible_highlighting = warning +resharper_convert_to_auto_property_with_private_setter_highlighting = warning +resharper_convert_to_compound_assignment_highlighting = warning +resharper_convert_to_constant_global_highlighting = warning +resharper_convert_to_constant_local_highlighting = warning +resharper_convert_to_lambda_expression_highlighting = warning +resharper_convert_to_local_function_highlighting = warning +resharper_convert_to_null_coalescing_compound_assignment_highlighting = warning +resharper_convert_to_primary_constructor_highlighting = none +resharper_convert_to_static_class_highlighting = warning +resharper_convert_to_using_declaration_highlighting = warning +resharper_convert_type_check_pattern_to_null_check_highlighting = warning +resharper_convert_type_check_to_null_check_highlighting = warning +resharper_co_variant_array_conversion_highlighting = warning +resharper_c_sharp_build_cs_invalid_module_name_highlighting = warning +resharper_c_sharp_missing_plugin_dependency_highlighting = warning +resharper_default_value_attribute_for_optional_parameter_highlighting = warning +resharper_double_negation_in_pattern_highlighting = warning +resharper_double_negation_operator_highlighting = warning +resharper_duplicate_resource_highlighting = warning +resharper_empty_constructor_highlighting = warning +resharper_empty_destructor_highlighting = warning +resharper_empty_embedded_statement_highlighting = warning +resharper_empty_for_statement_highlighting = warning +resharper_empty_general_catch_clause_highlighting = warning +resharper_empty_namespace_highlighting = warning +resharper_empty_region_highlighting = warning +resharper_empty_statement_highlighting = warning +resharper_empty_title_tag_highlighting = warning +resharper_entity_name_captured_only_global_highlighting = warning +resharper_entity_name_captured_only_local_highlighting = warning +resharper_enumerable_sum_in_explicit_unchecked_context_highlighting = warning +resharper_enum_underlying_type_is_int_highlighting = warning +resharper_equal_expression_comparison_highlighting = warning +resharper_event_never_invoked_global_highlighting = warning +resharper_event_never_subscribed_to_global_highlighting = warning +resharper_event_never_subscribed_to_local_highlighting = warning +resharper_event_unsubscription_via_anonymous_delegate_highlighting = warning +resharper_exception_passed_as_template_argument_problem_highlighting = warning +resharper_explicit_caller_info_argument_highlighting = warning +resharper_expression_is_always_null_highlighting = warning +resharper_extract_common_property_pattern_highlighting = warning +resharper_field_can_be_made_read_only_global_highlighting = warning +resharper_field_can_be_made_read_only_local_highlighting = warning +resharper_field_hides_interface_property_with_default_implementation_highlighting = warning +resharper_foreach_can_be_converted_to_query_using_another_get_enumerator_highlighting = warning +resharper_foreach_can_be_partly_converted_to_query_using_another_get_enumerator_highlighting = warning +resharper_format_string_placeholders_mismatch_highlighting = warning +resharper_format_string_problem_highlighting = warning +resharper_for_can_be_converted_to_foreach_highlighting = warning +resharper_for_statement_condition_is_true_highlighting = warning +resharper_function_complexity_overflow_highlighting = warning +resharper_function_never_returns_highlighting = warning +resharper_function_recursive_on_all_paths_highlighting = warning +resharper_gc_suppress_finalize_for_type_without_destructor_highlighting = warning +resharper_generic_enumerator_not_disposed_highlighting = warning +resharper_heap_view_boxing_allocation_highlighting = hint +resharper_heap_view_can_avoid_closure_highlighting = suggestion +resharper_heap_view_closure_allocation_highlighting = hint +resharper_heap_view_delegate_allocation_highlighting = hint +resharper_heap_view_implicit_capture_highlighting = hint +resharper_heap_view_object_allocation_evident_highlighting = hint +resharper_heap_view_object_allocation_highlighting = hint +resharper_heap_view_object_allocation_possible_highlighting = hint +resharper_heap_view_possible_boxing_allocation_highlighting = hint +resharper_heuristic_unreachable_code_highlighting = warning +resharper_html_attributes_quotes_highlighting = warning +resharper_html_attribute_not_resolved_highlighting = warning +resharper_html_attribute_value_not_resolved_highlighting = warning +resharper_html_dead_code_highlighting = warning +resharper_html_event_not_resolved_highlighting = warning +resharper_html_id_duplication_highlighting = warning +resharper_html_id_not_resolved_highlighting = warning +resharper_html_obsolete_highlighting = warning +resharper_html_path_error_highlighting = warning +resharper_html_tag_not_closed_highlighting = error +resharper_html_tag_not_resolved_highlighting = warning +resharper_html_tag_should_be_self_closed_highlighting = warning +resharper_html_tag_should_not_be_self_closed_highlighting = warning +resharper_html_warning_highlighting = warning +resharper_identifier_typo_highlighting = none +resharper_if_std_is_constant_evaluated_can_be_replaced_highlighting = warning +resharper_inactive_preprocessor_branch_highlighting = warning +resharper_inconsistently_synchronized_field_highlighting = warning +resharper_inconsistent_context_log_property_naming_highlighting = warning +resharper_inconsistent_log_property_naming_highlighting = warning +resharper_inconsistent_naming_highlighting = warning +resharper_inconsistent_order_of_locks_highlighting = warning +resharper_incorrect_blank_lines_near_braces_highlighting = warning +resharper_indexing_by_invalid_range_highlighting = warning +resharper_inheritdoc_consider_usage_highlighting = none +resharper_inheritdoc_invalid_usage_highlighting = warning +resharper_inline_out_variable_declaration_highlighting = warning +resharper_inline_temporary_variable_highlighting = warning +resharper_internal_or_private_member_not_documented_highlighting = warning +resharper_interpolated_string_expression_is_not_i_formattable_highlighting = warning +resharper_introduce_optional_parameters_global_highlighting = warning +resharper_introduce_optional_parameters_local_highlighting = warning +resharper_int_division_by_zero_highlighting = warning +resharper_int_variable_overflow_highlighting = warning +resharper_int_variable_overflow_in_checked_context_highlighting = warning +resharper_int_variable_overflow_in_unchecked_context_highlighting = warning +resharper_invalid_value_type_highlighting = warning +resharper_invalid_xml_doc_comment_highlighting = warning +resharper_invert_condition_1_highlighting = warning +resharper_invert_if_highlighting = hint +resharper_invocation_is_skipped_highlighting = warning +resharper_invoke_as_extension_method_highlighting = warning +resharper_is_expression_always_false_highlighting = warning +resharper_is_expression_always_true_highlighting = warning +resharper_iterator_method_result_is_ignored_highlighting = warning +resharper_iterator_never_returns_highlighting = warning +resharper_join_declaration_and_initializer_highlighting = warning +resharper_join_null_check_with_usage_highlighting = warning +resharper_lambda_expression_can_be_made_static_highlighting = none +resharper_lambda_expression_must_be_static_highlighting = warning +resharper_lambda_should_not_capture_context_highlighting = warning +resharper_localizable_element_highlighting = warning +resharper_local_function_can_be_made_static_highlighting = warning +resharper_local_function_hides_method_highlighting = warning +resharper_local_variable_hides_member_highlighting = warning +resharper_log_message_is_sentence_problem_highlighting = warning +resharper_long_literal_ending_lower_l_highlighting = warning +resharper_loop_can_be_converted_to_query_highlighting = warning +resharper_loop_can_be_partly_converted_to_query_highlighting = warning +resharper_loop_variable_is_never_changed_inside_loop_highlighting = warning +resharper_markup_attribute_typo_highlighting = none +resharper_markup_text_typo_highlighting = none +resharper_math_abs_method_is_redundant_highlighting = warning +resharper_math_clamp_min_greater_than_max_highlighting = warning +resharper_meaningless_default_parameter_value_highlighting = warning +resharper_member_can_be_file_local_highlighting = warning +resharper_member_can_be_internal_highlighting = none +resharper_member_can_be_made_static_global_highlighting = warning +resharper_member_can_be_made_static_local_highlighting = warning +resharper_member_can_be_private_global_highlighting = warning +resharper_member_can_be_private_local_highlighting = warning +resharper_member_can_be_protected_global_highlighting = warning +resharper_member_can_be_protected_local_highlighting = warning +resharper_member_hides_interface_member_with_default_implementation_highlighting = warning +resharper_member_hides_static_from_outer_class_highlighting = warning +resharper_member_initializer_value_ignored_highlighting = warning +resharper_merge_and_pattern_highlighting = warning +resharper_merge_cast_with_type_check_highlighting = warning +resharper_merge_conditional_expression_highlighting = warning +resharper_merge_into_logical_pattern_highlighting = warning +resharper_merge_into_negated_pattern_highlighting = warning +resharper_merge_into_pattern_highlighting = warning +resharper_merge_nested_property_patterns_highlighting = warning +resharper_merge_sequential_checks_highlighting = warning +resharper_method_has_async_overload_highlighting = warning +resharper_method_has_async_overload_with_cancellation_highlighting = warning +resharper_method_overload_with_optional_parameter_highlighting = warning +resharper_method_supports_cancellation_highlighting = warning +resharper_missing_alt_attribute_in_img_tag_highlighting = warning +resharper_missing_blank_lines_highlighting = warning +resharper_missing_body_tag_highlighting = warning +resharper_missing_head_and_body_tags_highlighting = warning +resharper_missing_head_tag_highlighting = warning +resharper_missing_indent_highlighting = warning +resharper_missing_linebreak_highlighting = warning +resharper_missing_space_highlighting = warning +resharper_more_specific_foreach_variable_type_available_highlighting = warning +resharper_move_local_function_after_jump_statement_highlighting = warning +resharper_move_to_existing_positional_deconstruction_pattern_highlighting = warning +resharper_move_variable_declaration_inside_loop_condition_highlighting = warning +resharper_multiple_nullable_attributes_usage_highlighting = warning +resharper_multiple_order_by_highlighting = warning +resharper_multiple_resolve_candidates_in_text_highlighting = warning +resharper_multiple_spaces_highlighting = warning +resharper_multiple_statements_on_one_line_highlighting = warning +resharper_multiple_type_members_on_one_line_highlighting = warning +resharper_must_use_return_value_highlighting = warning +resharper_mvc_action_not_resolved_highlighting = error +resharper_mvc_area_not_resolved_highlighting = error +resharper_mvc_controller_not_resolved_highlighting = error +resharper_mvc_invalid_model_type_highlighting = error +resharper_mvc_masterpage_not_resolved_highlighting = error +resharper_mvc_partial_view_not_resolved_highlighting = error +resharper_mvc_template_not_resolved_highlighting = error +resharper_mvc_view_component_not_resolved_highlighting = error +resharper_mvc_view_component_view_not_resolved_highlighting = error +resharper_mvc_view_not_resolved_highlighting = error +resharper_negation_of_relational_pattern_highlighting = warning +resharper_negative_equality_expression_highlighting = warning +resharper_negative_index_highlighting = warning +resharper_nested_string_interpolation_highlighting = warning +resharper_non_atomic_compound_operator_highlighting = warning +resharper_non_constant_equality_expression_has_constant_result_highlighting = warning +resharper_non_parsable_element_highlighting = warning +resharper_non_readonly_member_in_get_hash_code_highlighting = warning +resharper_non_volatile_field_in_double_check_locking_highlighting = warning +resharper_not_accessed_field_global_highlighting = warning +resharper_not_accessed_field_local_highlighting = warning +resharper_not_accessed_out_parameter_variable_highlighting = warning +resharper_not_accessed_positional_property_global_highlighting = warning +resharper_not_accessed_positional_property_local_highlighting = warning +resharper_not_accessed_variable_highlighting = warning +resharper_not_assigned_out_parameter_highlighting = warning +resharper_not_declared_in_parent_culture_highlighting = warning +resharper_not_null_or_required_member_is_not_initialized_highlighting = warning +resharper_not_observable_annotation_redundancy_highlighting = warning +resharper_not_overridden_in_specific_culture_highlighting = warning +resharper_not_resolved_in_text_highlighting = warning +resharper_nullable_warning_suppression_is_used_highlighting = warning +resharper_nullness_annotation_conflict_with_jet_brains_annotations_highlighting = warning +resharper_null_coalescing_condition_is_always_not_null_according_to_api_contract_highlighting = warning +resharper_n_unit_async_method_must_be_task_highlighting = warning +resharper_n_unit_attribute_produces_too_many_tests_highlighting = warning +resharper_n_unit_auto_fixture_incorrect_argument_type_highlighting = warning +resharper_n_unit_auto_fixture_missed_test_attribute_highlighting = warning +resharper_n_unit_auto_fixture_missed_test_or_test_fixture_attribute_highlighting = warning +resharper_n_unit_auto_fixture_redundant_argument_in_inline_auto_data_attribute_highlighting = warning +resharper_n_unit_duplicate_values_highlighting = warning +resharper_n_unit_ignored_parameter_attribute_highlighting = warning +resharper_n_unit_implicit_unspecified_null_values_highlighting = warning +resharper_n_unit_incorrect_argument_type_highlighting = warning +resharper_n_unit_incorrect_expected_result_type_highlighting = warning +resharper_n_unit_incorrect_range_bounds_highlighting = warning +resharper_n_unit_method_with_parameters_and_test_attribute_highlighting = warning +resharper_n_unit_missing_arguments_in_test_case_attribute_highlighting = warning +resharper_n_unit_non_public_method_with_test_attribute_highlighting = warning +resharper_n_unit_no_values_provided_highlighting = warning +resharper_n_unit_parameter_type_is_not_compatible_with_attribute_highlighting = warning +resharper_n_unit_range_attribute_bounds_are_out_of_range_highlighting = warning +resharper_n_unit_range_step_sign_mismatch_highlighting = warning +resharper_n_unit_range_step_value_must_not_be_zero_highlighting = warning +resharper_n_unit_range_to_value_is_not_reachable_highlighting = warning +resharper_n_unit_redundant_argument_instead_of_expected_result_highlighting = warning +resharper_n_unit_redundant_argument_in_test_case_attribute_highlighting = warning +resharper_n_unit_redundant_expected_result_in_test_case_attribute_highlighting = warning +resharper_n_unit_test_case_attribute_requires_expected_result_highlighting = warning +resharper_n_unit_test_case_result_property_duplicates_expected_result_highlighting = warning +resharper_n_unit_test_case_result_property_is_obsolete_highlighting = warning +resharper_n_unit_test_case_source_cannot_be_resolved_highlighting = warning +resharper_n_unit_test_case_source_must_be_field_property_method_highlighting = warning +resharper_n_unit_test_case_source_must_be_static_highlighting = warning +resharper_n_unit_test_case_source_should_implement_i_enumerable_highlighting = warning +resharper_object_creation_as_statement_highlighting = warning +resharper_obsolete_element_error_highlighting = error +resharper_obsolete_element_highlighting = warning +resharper_one_way_operation_contract_with_return_type_highlighting = warning +resharper_operation_contract_without_service_contract_highlighting = warning +resharper_operator_is_can_be_used_highlighting = warning +resharper_operator_without_matched_checked_operator_highlighting = warning +resharper_optional_parameter_hierarchy_mismatch_highlighting = warning +resharper_optional_parameter_ref_out_highlighting = warning +resharper_other_tags_inside_script1_highlighting = error +resharper_other_tags_inside_script2_highlighting = error +resharper_other_tags_inside_unclosed_script_highlighting = error +resharper_outdent_is_off_prev_level_highlighting = warning +resharper_out_parameter_value_is_always_discarded_global_highlighting = warning +resharper_out_parameter_value_is_always_discarded_local_highlighting = warning +resharper_overridden_with_empty_value_highlighting = warning +resharper_overridden_with_same_value_highlighting = warning +resharper_parameter_hides_member_highlighting = warning +resharper_parameter_only_used_for_precondition_check_global_highlighting = warning +resharper_parameter_only_used_for_precondition_check_local_highlighting = warning +resharper_parameter_type_can_be_enumerable_global_highlighting = warning +resharper_parameter_type_can_be_enumerable_local_highlighting = warning +resharper_partial_method_parameter_name_mismatch_highlighting = warning +resharper_partial_method_with_single_part_highlighting = warning +resharper_partial_type_with_single_part_highlighting = warning +resharper_pass_string_interpolation_highlighting = warning +resharper_pattern_always_matches_highlighting = warning +resharper_pattern_is_always_true_or_false_highlighting = warning +resharper_pattern_is_redundant_highlighting = warning +resharper_pattern_never_matches_highlighting = warning +resharper_place_assignment_expression_into_block_highlighting = warning +resharper_polymorphic_field_like_event_invocation_highlighting = warning +resharper_positional_property_used_problem_highlighting = warning +resharper_possible_infinite_inheritance_highlighting = warning +resharper_possible_intended_rethrow_highlighting = warning +resharper_possible_interface_member_ambiguity_highlighting = warning +resharper_possible_invalid_cast_exception_highlighting = warning +resharper_possible_invalid_cast_exception_in_foreach_loop_highlighting = warning +resharper_possible_invalid_operation_exception_highlighting = warning +resharper_possible_loss_of_fraction_highlighting = warning +resharper_possible_mistaken_argument_highlighting = warning +resharper_possible_mistaken_call_to_get_type_1_highlighting = warning +resharper_possible_mistaken_call_to_get_type_2_highlighting = warning +resharper_possible_multiple_enumeration_highlighting = warning +resharper_possible_multiple_write_access_in_double_check_locking_highlighting = warning +resharper_possible_null_reference_exception_highlighting = warning +resharper_possible_struct_member_modification_of_non_variable_struct_highlighting = warning +resharper_possible_unintended_linear_search_in_set_highlighting = warning +resharper_possible_unintended_queryable_as_enumerable_highlighting = warning +resharper_possible_unintended_reference_comparison_highlighting = warning +resharper_possible_write_to_me_highlighting = warning +resharper_possibly_impure_method_call_on_readonly_variable_highlighting = warning +resharper_possibly_missing_indexer_initializer_comma_highlighting = warning +resharper_possibly_mistaken_use_of_interpolated_string_insert_highlighting = warning +resharper_possibly_unintended_usage_parameterless_get_expression_type_highlighting = error +resharper_private_field_can_be_converted_to_local_variable_highlighting = warning +resharper_property_can_be_made_init_only_global_highlighting = warning +resharper_property_can_be_made_init_only_local_highlighting = warning +resharper_property_field_keyword_is_never_assigned_highlighting = warning +resharper_property_field_keyword_is_never_used_highlighting = warning +resharper_property_not_resolved_highlighting = error +resharper_public_constructor_in_abstract_class_highlighting = warning +resharper_pure_attribute_on_void_method_highlighting = warning +resharper_raw_string_can_be_simplified_highlighting = warning +resharper_razor_layout_not_resolved_highlighting = error +resharper_razor_section_not_resolved_highlighting = error +resharper_read_access_in_double_check_locking_highlighting = warning +resharper_redundant_abstract_modifier_highlighting = warning +resharper_redundant_accessor_body_highlighting = warning +resharper_redundant_always_match_subpattern_highlighting = warning +resharper_redundant_anonymous_type_property_name_highlighting = warning +resharper_redundant_argument_default_value_highlighting = warning +resharper_redundant_array_creation_expression_highlighting = warning +resharper_redundant_array_lower_bound_specification_highlighting = warning +resharper_redundant_assignment_highlighting = warning +resharper_redundant_attribute_parentheses_highlighting = warning +resharper_redundant_attribute_suffix_highlighting = warning +resharper_redundant_attribute_usage_property_highlighting = warning +resharper_redundant_base_constructor_call_highlighting = warning +resharper_redundant_blank_lines_highlighting = warning +resharper_redundant_bool_compare_highlighting = warning +resharper_redundant_caller_argument_expression_default_value_highlighting = warning +resharper_redundant_case_label_highlighting = warning +resharper_redundant_cast_highlighting = warning +resharper_redundant_catch_clause_highlighting = warning +resharper_redundant_check_before_assignment_highlighting = warning +resharper_redundant_collection_initializer_element_braces_highlighting = warning +resharper_redundant_configure_await_highlighting = warning +resharper_redundant_declaration_semicolon_highlighting = warning +resharper_redundant_default_member_initializer_highlighting = warning +resharper_redundant_delegate_creation_highlighting = warning +resharper_redundant_dictionary_contains_key_before_adding_highlighting = warning +resharper_redundant_disable_warning_comment_highlighting = warning +resharper_redundant_discard_designation_highlighting = warning +resharper_redundant_empty_case_else_highlighting = warning +resharper_redundant_empty_finally_block_highlighting = warning +resharper_redundant_empty_object_creation_argument_list_highlighting = warning +resharper_redundant_empty_object_or_collection_initializer_highlighting = warning +resharper_redundant_empty_switch_section_highlighting = warning +resharper_redundant_enumerable_cast_call_highlighting = warning +resharper_redundant_enum_case_label_for_default_section_highlighting = warning +resharper_redundant_explicit_array_creation_highlighting = warning +resharper_redundant_explicit_array_size_highlighting = warning +resharper_redundant_explicit_nullable_creation_highlighting = warning +resharper_redundant_explicit_params_array_creation_highlighting = warning +resharper_redundant_explicit_positional_property_declaration_highlighting = warning +resharper_redundant_explicit_tuple_component_name_highlighting = warning +resharper_redundant_extends_list_entry_highlighting = warning +resharper_redundant_fixed_pointer_declaration_highlighting = warning +resharper_redundant_if_else_block_highlighting = warning +resharper_redundant_if_statement_then_keyword_highlighting = warning +resharper_redundant_immediate_delegate_invocation_highlighting = warning +resharper_redundant_is_before_relational_pattern_highlighting = warning +resharper_redundant_iterator_keyword_highlighting = warning +resharper_redundant_jump_statement_highlighting = warning +resharper_redundant_lambda_parameter_type_highlighting = warning +resharper_redundant_lambda_signature_parentheses_highlighting = warning +resharper_redundant_linebreak_highlighting = warning +resharper_redundant_logical_conditional_expression_operand_highlighting = warning +resharper_redundant_me_qualifier_highlighting = warning +resharper_redundant_my_base_qualifier_highlighting = warning +resharper_redundant_my_class_qualifier_highlighting = warning +resharper_redundant_name_qualifier_highlighting = warning +resharper_redundant_not_null_constraint_highlighting = warning +resharper_redundant_nullable_annotation_on_reference_type_constraint_highlighting = warning +resharper_redundant_nullable_annotation_on_type_constraint_has_non_nullable_base_type_highlighting = warning +resharper_redundant_nullable_annotation_on_type_constraint_has_non_nullable_type_kind_highlighting = warning +resharper_redundant_nullable_directive_highlighting = warning +resharper_redundant_nullable_flow_attribute_highlighting = warning +resharper_redundant_nullable_type_mark_highlighting = warning +resharper_redundant_nullness_attribute_with_nullable_reference_types_highlighting = warning +resharper_redundant_overflow_checking_context_highlighting = warning +resharper_redundant_overload_global_highlighting = warning +resharper_redundant_overload_local_highlighting = warning +resharper_redundant_overridden_member_highlighting = warning +resharper_redundant_params_highlighting = warning +resharper_redundant_parentheses_highlighting = warning +resharper_redundant_pattern_parentheses_highlighting = warning +resharper_redundant_property_parentheses_highlighting = warning +resharper_redundant_property_pattern_clause_highlighting = warning +resharper_redundant_qualifier_highlighting = warning +resharper_redundant_query_order_by_ascending_keyword_highlighting = warning +resharper_redundant_range_bound_highlighting = warning +resharper_redundant_readonly_modifier_highlighting = warning +resharper_redundant_record_body_highlighting = warning +resharper_redundant_record_class_keyword_highlighting = warning +resharper_redundant_scoped_parameter_modifier_highlighting = warning +resharper_redundant_setter_value_parameter_declaration_highlighting = warning +resharper_redundant_set_contains_before_adding_highlighting = warning +resharper_redundant_space_highlighting = warning +resharper_redundant_string_format_call_highlighting = warning +resharper_redundant_string_interpolation_highlighting = warning +resharper_redundant_string_to_char_array_call_highlighting = warning +resharper_redundant_string_type_highlighting = warning +resharper_redundant_suppress_nullable_warning_expression_highlighting = warning +resharper_redundant_ternary_expression_highlighting = warning +resharper_redundant_to_string_call_for_value_type_highlighting = warning +resharper_redundant_to_string_call_highlighting = warning +resharper_redundant_type_arguments_of_method_highlighting = warning +resharper_redundant_type_check_in_pattern_highlighting = warning +resharper_redundant_type_declaration_body_highlighting = warning +resharper_redundant_unsafe_context_highlighting = warning +resharper_redundant_using_directive_global_highlighting = warning +resharper_redundant_using_directive_highlighting = warning +resharper_redundant_verbatim_prefix_highlighting = warning +resharper_redundant_verbatim_string_prefix_highlighting = warning +resharper_redundant_virtual_modifier_highlighting = warning +resharper_redundant_with_cancellation_highlighting = warning +resharper_redundant_with_expression_highlighting = warning +resharper_reference_equals_with_value_type_highlighting = warning +resharper_reg_exp_inspections_highlighting = warning +resharper_remove_constructor_invocation_highlighting = warning +resharper_remove_redundant_or_statement_false_highlighting = warning +resharper_remove_redundant_or_statement_true_highlighting = warning +resharper_remove_to_list_1_highlighting = warning +resharper_remove_to_list_2_highlighting = warning +resharper_replace_auto_property_with_computed_property_highlighting = warning +resharper_replace_conditional_expression_with_null_coalescing_highlighting = warning +resharper_replace_object_pattern_with_var_pattern_highlighting = warning +resharper_replace_sequence_equal_with_constant_pattern_highlighting = warning +resharper_replace_slice_with_range_indexer_highlighting = warning +resharper_replace_substring_with_range_indexer_highlighting = warning +resharper_replace_with_field_keyword_highlighting = warning +resharper_replace_with_first_or_default_1_highlighting = warning +resharper_replace_with_first_or_default_2_highlighting = warning +resharper_replace_with_first_or_default_3_highlighting = warning +resharper_replace_with_first_or_default_4_highlighting = warning +resharper_replace_with_last_or_default_1_highlighting = warning +resharper_replace_with_last_or_default_2_highlighting = warning +resharper_replace_with_last_or_default_3_highlighting = warning +resharper_replace_with_last_or_default_4_highlighting = warning +resharper_replace_with_of_type_1_highlighting = warning +resharper_replace_with_of_type_2_highlighting = warning +resharper_replace_with_of_type_3_highlighting = warning +resharper_replace_with_of_type_any_1_highlighting = warning +resharper_replace_with_of_type_any_2_highlighting = warning +resharper_replace_with_of_type_count_1_highlighting = warning +resharper_replace_with_of_type_count_2_highlighting = warning +resharper_replace_with_of_type_first_1_highlighting = warning +resharper_replace_with_of_type_first_2_highlighting = warning +resharper_replace_with_of_type_first_or_default_1_highlighting = warning +resharper_replace_with_of_type_first_or_default_2_highlighting = warning +resharper_replace_with_of_type_last_1_highlighting = warning +resharper_replace_with_of_type_last_2_highlighting = warning +resharper_replace_with_of_type_last_or_default_1_highlighting = warning +resharper_replace_with_of_type_last_or_default_2_highlighting = warning +resharper_replace_with_of_type_long_count_highlighting = warning +resharper_replace_with_of_type_single_1_highlighting = warning +resharper_replace_with_of_type_single_2_highlighting = warning +resharper_replace_with_of_type_single_or_default_1_highlighting = warning +resharper_replace_with_of_type_single_or_default_2_highlighting = warning +resharper_replace_with_of_type_where_highlighting = warning +resharper_replace_with_primary_constructor_parameter_highlighting = warning +resharper_replace_with_simple_assignment_false_highlighting = warning +resharper_replace_with_simple_assignment_true_highlighting = warning +resharper_replace_with_single_assignment_false_highlighting = warning +resharper_replace_with_single_assignment_true_highlighting = warning +resharper_replace_with_single_call_to_any_highlighting = warning +resharper_replace_with_single_call_to_count_highlighting = warning +resharper_replace_with_single_call_to_first_highlighting = warning +resharper_replace_with_single_call_to_first_or_default_highlighting = warning +resharper_replace_with_single_call_to_last_highlighting = warning +resharper_replace_with_single_call_to_last_or_default_highlighting = warning +resharper_replace_with_single_call_to_single_highlighting = warning +resharper_replace_with_single_call_to_single_or_default_highlighting = warning +resharper_replace_with_single_or_default_1_highlighting = warning +resharper_replace_with_single_or_default_2_highlighting = warning +resharper_replace_with_single_or_default_3_highlighting = warning +resharper_replace_with_single_or_default_4_highlighting = warning +resharper_replace_with_string_is_null_or_empty_highlighting = warning +resharper_required_base_types_conflict_highlighting = warning +resharper_required_base_types_direct_conflict_highlighting = warning +resharper_required_base_types_is_not_inherited_highlighting = warning +resharper_resource_item_not_resolved_highlighting = error +resharper_resource_not_resolved_highlighting = error +resharper_resx_not_resolved_highlighting = warning +resharper_return_of_task_produced_by_using_variable_highlighting = warning +resharper_return_of_using_variable_highlighting = warning +resharper_return_type_can_be_enumerable_global_highlighting = warning +resharper_return_type_can_be_enumerable_local_highlighting = warning +resharper_return_type_can_be_not_nullable_highlighting = warning +resharper_return_value_of_pure_method_is_not_used_highlighting = warning +resharper_route_templates_action_route_prefix_can_be_extracted_to_controller_route_highlighting = warning +resharper_route_templates_ambiguous_matching_constraint_constructor_highlighting = warning +resharper_route_templates_constraint_argument_cannot_be_converted_highlighting = warning +resharper_route_templates_controller_route_parameter_is_not_passed_to_methods_highlighting = warning +resharper_route_templates_duplicated_parameter_highlighting = warning +resharper_route_templates_matching_constraint_constructor_not_resolved_highlighting = warning +resharper_route_templates_method_missing_route_parameters_highlighting = warning +resharper_route_templates_optional_parameter_can_be_preceded_only_by_single_period_highlighting = warning +resharper_route_templates_optional_parameter_must_be_at_the_end_of_segment_highlighting = warning +resharper_route_templates_parameter_constraint_can_be_specified_highlighting = warning +resharper_route_templates_parameter_type_and_constraints_mismatch_highlighting = warning +resharper_route_templates_parameter_type_can_be_made_stricter_highlighting = warning +resharper_route_templates_route_parameter_constraint_not_resolved_highlighting = warning +resharper_route_templates_route_parameter_is_not_passed_to_method_highlighting = warning +resharper_route_templates_route_token_not_resolved_highlighting = warning +resharper_route_templates_symbol_not_resolved_highlighting = warning +resharper_route_templates_syntax_error_highlighting = warning +resharper_safe_cast_is_used_as_type_check_highlighting = warning +resharper_script_tag_has_both_src_and_content_attributes_highlighting = error +resharper_sealed_member_in_sealed_class_highlighting = warning +resharper_separate_control_transfer_statement_highlighting = warning +resharper_separate_local_functions_with_jump_statement_highlighting = warning +resharper_service_contract_without_operations_highlighting = warning +resharper_shift_expression_real_shift_count_is_zero_highlighting = warning +resharper_shift_expression_result_equals_zero_highlighting = warning +resharper_shift_expression_right_operand_not_equal_real_count_highlighting = warning +resharper_shift_expression_zero_left_operand_highlighting = warning +resharper_similar_anonymous_type_nearby_highlighting = warning +resharper_simplify_conditional_operator_highlighting = warning +resharper_simplify_conditional_ternary_expression_highlighting = warning +resharper_simplify_i_if_highlighting = warning +resharper_simplify_linq_expression_use_all_highlighting = warning +resharper_simplify_linq_expression_use_any_highlighting = warning +resharper_simplify_linq_expression_use_min_by_and_max_by_highlighting = warning +resharper_simplify_string_interpolation_highlighting = warning +resharper_specify_a_culture_in_string_conversion_explicitly_highlighting = warning +resharper_specify_string_comparison_highlighting = warning +resharper_spin_lock_in_readonly_field_highlighting = warning +resharper_stack_alloc_inside_loop_highlighting = warning +resharper_static_member_initializer_referes_to_member_below_highlighting = warning +resharper_static_member_in_generic_type_highlighting = warning +resharper_static_problem_in_text_highlighting = warning +resharper_std_is_constant_evaluated_will_always_evaluate_to_constant_highlighting = warning +resharper_string_compare_is_culture_specific_1_highlighting = warning +resharper_string_compare_is_culture_specific_2_highlighting = warning +resharper_string_compare_is_culture_specific_3_highlighting = warning +resharper_string_compare_is_culture_specific_4_highlighting = warning +resharper_string_compare_is_culture_specific_5_highlighting = warning +resharper_string_compare_is_culture_specific_6_highlighting = warning +resharper_string_compare_to_is_culture_specific_highlighting = warning +resharper_string_ends_with_is_culture_specific_highlighting = warning +resharper_string_index_of_is_culture_specific_1_highlighting = warning +resharper_string_index_of_is_culture_specific_2_highlighting = warning +resharper_string_index_of_is_culture_specific_3_highlighting = warning +resharper_string_last_index_of_is_culture_specific_1_highlighting = warning +resharper_string_last_index_of_is_culture_specific_2_highlighting = warning +resharper_string_last_index_of_is_culture_specific_3_highlighting = warning +resharper_string_literal_as_interpolation_argument_highlighting = warning +resharper_string_literal_typo_highlighting = none +resharper_string_starts_with_is_culture_specific_highlighting = warning +resharper_structured_message_template_problem_highlighting = warning +resharper_struct_can_be_made_read_only_highlighting = warning +resharper_struct_member_can_be_made_read_only_highlighting = warning +resharper_suggest_base_type_for_parameter_highlighting = warning +resharper_suggest_base_type_for_parameter_in_constructor_highlighting = warning +resharper_suggest_discard_declaration_var_style_highlighting = warning +resharper_suggest_var_or_type_deconstruction_declarations_highlighting = warning +resharper_suppress_nullable_warning_expression_as_inverted_is_expression_highlighting = warning +resharper_suspicious_lock_over_synchronization_primitive_highlighting = warning +resharper_suspicious_math_sign_method_highlighting = warning +resharper_suspicious_parameter_name_in_argument_null_exception_highlighting = warning +resharper_suspicious_type_conversion_global_highlighting = warning +resharper_swap_via_deconstruction_highlighting = warning +resharper_switch_expression_handles_some_known_enum_values_with_exception_in_default_highlighting = warning +resharper_switch_statement_for_enum_misses_default_section_highlighting = warning +resharper_switch_statement_handles_some_known_enum_values_with_default_highlighting = warning +resharper_switch_statement_missing_some_enum_cases_no_default_highlighting = warning +resharper_symbol_from_not_copied_locally_reference_used_warning_highlighting = warning +resharper_tabs_and_spaces_mismatch_highlighting = warning +resharper_tabs_are_disallowed_highlighting = warning +resharper_tabs_outside_indent_highlighting = warning +resharper_tail_recursive_call_highlighting = warning +resharper_template_duplicate_property_problem_highlighting = warning +resharper_template_format_string_problem_highlighting = warning +resharper_template_is_not_compile_time_constant_problem_highlighting = warning +resharper_thread_static_at_instance_field_highlighting = warning +resharper_thread_static_field_has_initializer_highlighting = warning +resharper_too_wide_local_variable_scope_highlighting = warning +resharper_try_cast_always_succeeds_highlighting = warning +resharper_try_statements_can_be_merged_highlighting = warning +resharper_type_parameter_can_be_variant_highlighting = warning +resharper_unassigned_field_global_highlighting = warning +resharper_unassigned_field_local_highlighting = warning +resharper_unassigned_get_only_auto_property_highlighting = warning +resharper_unassigned_readonly_field_highlighting = warning +resharper_unclosed_script_highlighting = error +resharper_unnecessary_whitespace_highlighting = warning +resharper_unreachable_switch_arm_due_to_integer_analysis_highlighting = warning +resharper_unreachable_switch_case_due_to_integer_analysis_highlighting = warning +resharper_unreal_header_tool_error_highlighting = error +resharper_unreal_header_tool_warning_highlighting = warning +resharper_unsupported_required_base_type_highlighting = warning +resharper_unused_anonymous_method_signature_highlighting = warning +resharper_unused_auto_property_accessor_global_highlighting = warning +resharper_unused_auto_property_accessor_local_highlighting = warning +resharper_unused_import_clause_highlighting = warning +resharper_unused_local_function_highlighting = warning +resharper_unused_local_function_parameter_highlighting = warning +resharper_unused_local_function_return_value_highlighting = warning +resharper_unused_member_global_highlighting = warning +resharper_unused_member_hierarchy_global_highlighting = warning +resharper_unused_member_hierarchy_local_highlighting = warning +resharper_unused_member_in_super_global_highlighting = warning +resharper_unused_member_in_super_local_highlighting = warning +resharper_unused_member_local_highlighting = warning +resharper_unused_method_return_value_global_highlighting = warning +resharper_unused_method_return_value_local_highlighting = warning +resharper_unused_nullable_directive_highlighting = warning +resharper_unused_parameter_global_highlighting = warning +resharper_unused_parameter_in_partial_method_highlighting = warning +resharper_unused_parameter_local_highlighting = warning +resharper_unused_tuple_component_in_return_value_highlighting = warning +resharper_unused_type_global_highlighting = warning +resharper_unused_type_local_highlighting = warning +resharper_unused_type_parameter_highlighting = warning +resharper_unused_variable_highlighting = warning +resharper_useless_binary_operation_highlighting = warning +resharper_useless_comparison_to_integral_constant_highlighting = warning +resharper_use_array_creation_expression_1_highlighting = warning +resharper_use_array_creation_expression_2_highlighting = warning +resharper_use_array_empty_method_highlighting = warning +resharper_use_await_using_highlighting = warning +resharper_use_cancellation_token_for_i_async_enumerable_highlighting = warning +resharper_use_collection_count_property_highlighting = warning +resharper_use_configure_await_false_for_async_disposable_highlighting = warning +resharper_use_configure_await_false_highlighting = warning +resharper_use_deconstruction_highlighting = warning +resharper_use_discard_assignment_highlighting = warning +resharper_use_empty_types_field_highlighting = warning +resharper_use_event_args_empty_field_highlighting = warning +resharper_use_format_specifier_in_format_string_highlighting = warning +resharper_use_implicitly_typed_variable_evident_highlighting = warning +resharper_use_implicitly_typed_variable_highlighting = warning +resharper_use_implicit_by_val_modifier_highlighting = warning +resharper_use_indexed_property_highlighting = warning +resharper_use_index_from_end_expression_highlighting = warning +resharper_use_is_operator_1_highlighting = warning +resharper_use_is_operator_2_highlighting = warning +resharper_use_method_any_0_highlighting = warning +resharper_use_method_any_1_highlighting = warning +resharper_use_method_any_2_highlighting = warning +resharper_use_method_any_3_highlighting = warning +resharper_use_method_any_4_highlighting = warning +resharper_use_method_is_instance_of_type_highlighting = warning +resharper_use_nameof_expression_for_part_of_the_string_highlighting = warning +resharper_use_nameof_expression_highlighting = warning +resharper_use_nameof_for_dependency_property_highlighting = warning +resharper_use_name_of_instead_of_type_of_highlighting = warning +resharper_use_negated_pattern_in_is_expression_highlighting = warning +resharper_use_negated_pattern_matching_highlighting = warning +resharper_use_nullable_annotation_instead_of_attribute_highlighting = warning +resharper_use_nullable_attributes_supported_by_compiler_highlighting = warning +resharper_use_nullable_reference_types_annotation_syntax_highlighting = warning +resharper_use_null_propagation_highlighting = warning +resharper_use_object_or_collection_initializer_highlighting = warning +resharper_use_pattern_matching_highlighting = warning +resharper_use_positional_deconstruction_pattern_highlighting = warning +resharper_use_raw_string_highlighting = warning +resharper_use_string_interpolation_highlighting = warning +resharper_use_string_interpolation_when_possible_highlighting = warning +resharper_use_switch_case_pattern_variable_highlighting = warning +resharper_use_throw_if_null_method_highlighting = warning +resharper_use_unsigned_right_shift_operator_highlighting = warning +resharper_use_verbatim_string_highlighting = warning +resharper_use_with_expression_to_copy_anonymous_object_highlighting = warning +resharper_use_with_expression_to_copy_record_highlighting = warning +resharper_use_with_expression_to_copy_struct_highlighting = warning +resharper_use_with_expression_to_copy_tuple_highlighting = warning +resharper_using_statement_resource_initialization_expression_highlighting = warning +resharper_using_statement_resource_initialization_highlighting = warning +resharper_value_parameter_not_used_highlighting = warning +resharper_value_range_attribute_violation_highlighting = warning +resharper_variable_can_be_not_nullable_highlighting = warning +resharper_variable_hides_outer_variable_highlighting = warning +resharper_virtual_member_call_in_constructor_highlighting = warning +resharper_virtual_member_never_overridden_global_highlighting = warning +resharper_virtual_member_never_overridden_local_highlighting = warning +resharper_void_method_with_must_use_return_value_attribute_highlighting = warning +resharper_web_config_module_not_resolved_highlighting = warning +resharper_web_config_module_qualification_resolve_highlighting = warning +resharper_web_config_redundant_add_namespace_tag_highlighting = warning +resharper_web_config_redundant_location_tag_highlighting = warning +resharper_web_config_tag_prefix_redundand_highlighting = warning +resharper_web_config_type_not_resolved_highlighting = warning +resharper_web_config_unused_add_tag_highlighting = warning +resharper_web_config_unused_element_due_to_config_source_attribute_highlighting = warning +resharper_web_config_unused_remove_or_clear_tag_highlighting = warning +resharper_web_config_web_config_path_warning_highlighting = warning +resharper_web_config_wrong_module_highlighting = warning +resharper_web_ignored_path_highlighting = warning +resharper_web_mapped_path_highlighting = warning +resharper_with_expression_instead_of_initializer_highlighting = warning +resharper_with_expression_modifies_all_members_highlighting = warning +resharper_wrong_indent_size_highlighting = warning +resharper_xaml_assign_null_to_not_null_attribute_highlighting = warning +resharper_xaml_avalonia_wrong_binding_mode_for_stream_binding_operator_highlighting = warning +resharper_xaml_binding_without_context_not_resolved_highlighting = warning +resharper_xaml_binding_with_context_not_resolved_highlighting = warning +resharper_xaml_compiled_binding_missing_data_type_error_highlighting_highlighting = error +resharper_xaml_constructor_warning_highlighting = warning +resharper_xaml_decimal_parsing_is_culture_dependent_highlighting = warning +resharper_xaml_dependency_property_resolve_error_highlighting = warning +resharper_xaml_duplicate_style_setter_highlighting = warning +resharper_xaml_dynamic_resource_error_highlighting = error +resharper_xaml_element_name_reference_not_resolved_highlighting = error +resharper_xaml_empty_grid_length_definition_highlighting = error +resharper_xaml_field_modifier_requires_name_attribute_highlighting = warning +resharper_xaml_grid_definitions_can_be_converted_to_attribute_highlighting = warning +resharper_xaml_ignored_path_highlighting_highlighting = warning +resharper_xaml_index_out_of_grid_definition_highlighting = warning +resharper_xaml_invalid_member_type_highlighting = error +resharper_xaml_invalid_resource_target_type_highlighting = error +resharper_xaml_invalid_resource_type_highlighting = error +resharper_xaml_invalid_type_highlighting = error +resharper_xaml_language_level_highlighting = error +resharper_xaml_mapped_path_highlighting_highlighting = warning +resharper_xaml_method_arguments_will_be_ignored_highlighting = warning +resharper_xaml_missing_grid_index_highlighting = warning +resharper_xaml_overloads_collision_highlighting = warning +resharper_xaml_parent_is_out_of_current_component_tree_highlighting = warning +resharper_xaml_path_error_highlighting = warning +resharper_xaml_possible_null_reference_exception_highlighting = warning +resharper_xaml_redundant_attached_property_highlighting = warning +resharper_xaml_redundant_binding_mode_attribute_highlighting = warning +resharper_xaml_redundant_collection_property_highlighting = warning +resharper_xaml_redundant_freeze_attribute_highlighting = warning +resharper_xaml_redundant_grid_definitions_highlighting = warning +resharper_xaml_redundant_grid_span_highlighting = warning +resharper_xaml_redundant_modifiers_attribute_highlighting = warning +resharper_xaml_redundant_namespace_alias_highlighting = warning +resharper_xaml_redundant_name_attribute_highlighting = warning +resharper_xaml_redundant_property_type_qualifier_highlighting = warning +resharper_xaml_redundant_resource_highlighting = warning +resharper_xaml_redundant_styled_value_highlighting = warning +resharper_xaml_redundant_update_source_trigger_attribute_highlighting = warning +resharper_xaml_redundant_xamarin_forms_class_declaration_highlighting = warning +resharper_xaml_resource_file_path_case_mismatch_highlighting = warning +resharper_xaml_routed_event_resolve_error_highlighting = warning +resharper_xaml_static_resource_not_resolved_highlighting = warning +resharper_xaml_style_class_not_found_highlighting = warning +resharper_xaml_style_invalid_target_type_highlighting = error +resharper_xaml_unexpected_element_highlighting = error +resharper_xaml_unexpected_text_token_highlighting = error +resharper_xaml_xaml_duplicate_device_family_type_view_highlighting_highlighting = error +resharper_xaml_xaml_mismatched_device_family_view_clr_name_highlighting_highlighting = warning +resharper_xaml_xaml_relative_source_default_mode_warning_highlighting_highlighting = warning +resharper_xaml_xaml_unknown_device_family_type_highlighting_highlighting = warning +resharper_xaml_xaml_xamarin_forms_data_type_and_binding_context_type_mismatched_highlighting_highlighting = warning +resharper_xaml_x_key_attribute_disallowed_highlighting = error +resharper_xunit_xunit_test_with_console_output_highlighting = warning +resharper_zero_index_from_end_highlighting = warning + +[*.{appxmanifest,asax,ascx,aspx,axaml,build,cg,cginc,compute,cs,cshtml,dtd,fx,fxh,hlsl,hlsli,hlslinc,master,nuspec,paml,razor,resw,resx,skin,usf,ush,vb,xaml,xamlx,xoml,xsd}] +indent_style = space +indent_size = 4 +tab_width = 4 + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..93a190c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @TeamOctolings/octobot +/docs/ @TeamOctolings/octobot-docs diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..9c0524f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,43 @@ +name: Bug Report +description: Create a report to help us improve +labels: [ "type: bug" ] +body: + - type: markdown + attributes: + value: | + We welcome bug reports! Please see our [contribution guidelines](docs/CONTRIBUTING.md#reporting-bugs) for more information on writing a good bug report. This template will help us gather the information we need to start the triage process. + - type: textarea + id: background + attributes: + label: Description + description: Please share a clear and concise description of the problem. + placeholder: Description + validations: + required: true + - type: textarea + id: expected-vs-actual-behavior + attributes: + label: Expected vs. Actual Behavior + description: | + Provide a description of the expected behavior compared to the actual behavior. + placeholder: Expected vs. Actual Behavior + validations: + required: true + - type: textarea + id: repro-steps + attributes: + label: Reproduction Steps + description: | + Please include minimal steps to reproduce the problem if possible. E.g.: the smallest possible command/action sequence. If possible include text as text rather than screenshots (so it shows up in searches). + placeholder: Minimal Reproduction + validations: + required: true + - type: textarea + id: other-info + attributes: + label: Other Information + description: | + If you have an idea where the problem might lie, let us know that here. Please include any pointers to code, relevant changes, or related issues you know of. + placeholder: Other Information + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..2fd43c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,29 @@ +name: Feature Request +description: Create a request for a feature you would like +labels: [ "type: feature" ] +body: + - type: textarea + id: background + attributes: + label: Description + description: Please share a clear and concise description of the feature you want. + placeholder: Description + validations: + required: true + - type: textarea + id: proposed-solution + attributes: + label: Proposed Solution + description: Please describe the solution you would like. + placeholder: Proposed Solution + validations: + required: true + - type: textarea + id: other-info + attributes: + label: Other Information + description: | + Please add any other context or screenshots about the feature request here. + placeholder: Other Information + validations: + required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b961b81 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,41 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + allow: + # Allow both direct and indirect updates for all packages + - dependency-type: "all" + labels: + - "type: change" + - "area: build/ci" + # For all packages, ignore all patch updates + ignore: + - dependency-name: "*" + update-types: [ "version-update:semver-patch" ] + + - package-ecosystem: "nuget" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + allow: + # Allow both direct and indirect updates for all packages + - dependency-type: "all" + labels: + - "type: change" + - "area: build/ci" + groups: + remora: + patterns: + - "Remora.Discord.*" + # For all packages, ignore all patch updates + ignore: + - dependency-name: "GitInfo" + - dependency-name: "*" + update-types: [ "version-update:semver-patch" ] diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..8187db4 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,24 @@ +XS: + name: size/XS + lines: 0 + color: 3CBF00 +S: + name: size/S + lines: 20 + color: 5D9801 +M: + name: size/M + lines: 100 + color: 7F7203 +L: + name: size/L + lines: 200 + color: A14C05 +XL: + name: size/XL + lines: 1000 + color: C32607 +XXL: + name: size/XXL + lines: 2000 + color: E50009 diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml new file mode 100644 index 0000000..07d5b90 --- /dev/null +++ b/.github/workflows/build-pr.yml @@ -0,0 +1,36 @@ +name: "ReSharper" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + branches: [ "master" ] + merge_group: + types: [ checks_requested ] + +jobs: + inspect-code: + name: Inspect code + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: ReSharper CLI InspectCode + uses: muno92/resharper_inspectcode@1.13.0 + with: + solutionPath: ./Octobot.sln + ignoreIssueType: InvertIf, ConvertIfStatementToSwitchStatement, ConvertToPrimaryConstructor + extensions: ReSharperPlugin.CognitiveComplexity + solutionWideAnalysis: true diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml new file mode 100644 index 0000000..e7afe8e --- /dev/null +++ b/.github/workflows/build-push.yml @@ -0,0 +1,87 @@ +name: "Publish and deploy" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [ "master", "deploy-test" ] + +jobs: + upload-image: + name: Upload Octobot Docker image + runs-on: ubuntu-latest + permissions: + packages: write + environment: production + + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + push: true + tags: ghcr.io/${{vars.NAMESPACE}}/${{vars.IMAGE_NAME}}:latest + build-args: | + BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 + PUBLISH_OPTIONS=${{vars.PUBLISH_OPTIONS}} + + update-production: + name: Update Octobot on production + runs-on: ubuntu-latest + environment: production + needs: upload-image + + steps: + - name: Copy SSH key + run: | + install -m 600 -D /dev/null ~/.ssh/id_ed25519 + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + shell: bash + env: + SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}} + + - name: Generate SSH known hosts file + run: | + ssh-keyscan -H -p $SSH_PORT $SSH_HOST > ~/.ssh/known_hosts + shell: bash + env: + SSH_HOST: ${{secrets.SSH_HOST}} + SSH_PORT: ${{secrets.SSH_PORT}} + + - name: Stop currently running instance + run: | + ssh -p $SSH_PORT $SSH_USER@$SSH_HOST $STOP_COMMAND + shell: bash + env: + SSH_PORT: ${{secrets.SSH_PORT}} + SSH_USER: ${{secrets.SSH_USER}} + SSH_HOST: ${{secrets.SSH_HOST}} + STOP_COMMAND: ${{vars.STOP_COMMAND}} + + - name: Update Docker image + run: | + ssh -p $SSH_PORT $SSH_USER@$SSH_HOST docker pull ghcr.io/$NAMESPACE/$IMAGE_NAME:latest + shell: bash + env: + SSH_PORT: ${{secrets.SSH_PORT}} + SSH_USER: ${{secrets.SSH_USER}} + SSH_HOST: ${{secrets.SSH_HOST}} + NAMESPACE: ${{vars.NAMESPACE}} + IMAGE_NAME: ${{vars.IMAGE_NAME}} + + - name: Start new instance + run: | + ssh -p $SSH_PORT $SSH_USER@$SSH_HOST $START_COMMAND + shell: bash + env: + SSH_PORT: ${{secrets.SSH_PORT}} + SSH_USER: ${{secrets.SSH_USER}} + SSH_HOST: ${{secrets.SSH_HOST}} + START_COMMAND: ${{vars.START_COMMAND}} diff --git a/.gitignore b/.gitignore index 9f11b75..fcda727 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,11 @@ .idea/ +*.user +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +/.vs/ +GuildData/ +Logs/ +compose.yaml diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt new file mode 100644 index 0000000..bf444a9 --- /dev/null +++ b/CodeAnalysis/BannedSymbols.txt @@ -0,0 +1,22 @@ +M:System.Object.Equals(System.Object)~System.Boolean;Don't use object.Equals. Use IEquatable or EqualityComparer.Default instead. +M:System.ValueType.Equals(System.Object)~System.Boolean;Don't use object.Equals(Fallbacks to ValueType). Use IEquatable or EqualityComparer.Default instead. +M:System.Nullable`1.Equals(System.Object)~System.Boolean;Use == instead. +T:System.IComparable;Don't use non-generic IComparable. Use generic version instead. +M:System.Guid.#ctor;Probably meaning to use Guid.NewGuid() instead. If actually wanting empty, use Guid.Empty. +M:System.Threading.Tasks.Task.Wait();Don't use Task.Wait. +P:System.Threading.Tasks.Task`1.Result;Don't use Task.Result. +M:System.Threading.ManualResetEventSlim.Wait();Specify a timeout to avoid waiting forever. +M:System.Char.ToLower(System.Char);char.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. +M:System.Char.ToUpper(System.Char);char.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. +M:System.String.ToLower();string.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. +M:System.String.ToUpper();string.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. +M:Humanizer.InflectorExtensions.Pascalize(System.String);Humanizer's .Pascalize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToPascalCase() instead. +M:Humanizer.InflectorExtensions.Camelize(System.String);Humanizer's .Camelize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToCamelCase() instead. +M:Humanizer.InflectorExtensions.Underscore(System.String);Humanizer's .Underscore() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToSnakeCase() instead. +M:Humanizer.InflectorExtensions.Kebaberize(System.String);Humanizer's .Kebaberize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToKebabCase() instead. +P:System.DateTime.Now;Use System.DateTime.UtcNow instead. +P:System.DateTimeOffset.Now;Use System.DateTimeOffset.UtcNow instead. +P:System.DateTimeOffset.DateTime;Use System.DateTimeOffset.UtcDateTime instead. +M:System.IO.File.OpenWrite(System.String);File.OpenWrite(string) does not clear the file before writing to it. Use File.Create(string) instead. +M:System.Threading.Thread.Sleep(System.Int32);Use Task.Delay(int, CancellationToken) instead. +M:System.Threading.Thread.Sleep(System.TimeSpan);Use Task.Delay(TimeSpan, CancellationToken) instead. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6cfeac6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0@sha256:7d24e90a392e88eb56093e4eb325ff883ad609382a55d42f17fd557b997022ca AS build-env +WORKDIR /Octobot + +# Copy everything +COPY . ./ +# Load build argument with publish options +ARG PUBLISH_OPTIONS="-c Release" +# Build and publish a release +RUN dotnet publish ./TeamOctolings.Octobot $PUBLISH_OPTIONS -o out + +# Build runtime image +FROM mcr.microsoft.com/dotnet/runtime:9.0@sha256:1e5eb0ed94ca96a34a914456db80e48bd1bb7bc3e3c8eda5e2c3d89c153c3081 +WORKDIR /Octobot +COPY --from=build-env /Octobot/out . +ENTRYPOINT ["./TeamOctolings.Octobot"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..593265c --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Octobot.sln b/Octobot.sln new file mode 100644 index 0000000..b82f7a9 --- /dev/null +++ b/Octobot.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamOctolings.Octobot", "TeamOctolings.Octobot\TeamOctolings.Octobot.csproj", "{A1679BA2-3A36-4D98-80C0-EEE771398FBD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/TeamOctolings.Octobot/Attributes/StaticCallersOnlyAttribute.cs b/TeamOctolings.Octobot/Attributes/StaticCallersOnlyAttribute.cs new file mode 100644 index 0000000..0256f62 --- /dev/null +++ b/TeamOctolings.Octobot/Attributes/StaticCallersOnlyAttribute.cs @@ -0,0 +1,8 @@ +namespace TeamOctolings.Octobot.Attributes; + +/// +/// Any property marked with should only be accessed by static methods. +/// Such properties may be used to provide dependencies where it is not possible to acquire them through normal means. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class StaticCallersOnlyAttribute : Attribute; diff --git a/TeamOctolings.Octobot/BuildInfo.cs b/TeamOctolings.Octobot/BuildInfo.cs new file mode 100644 index 0000000..a91e7f3 --- /dev/null +++ b/TeamOctolings.Octobot/BuildInfo.cs @@ -0,0 +1,20 @@ +namespace TeamOctolings.Octobot; + +public static class BuildInfo +{ + public const string WebsiteUrl = "https://teamoctolings.github.io/Octobot"; + + private const string RepositoryUrl = "https://github.com/TeamOctolings/Octobot"; + + public const string IssuesUrl = $"{RepositoryUrl}/issues"; + + public const string WikiUrl = $"{RepositoryUrl}/wiki"; + + private const string Commit = ThisAssembly.Git.Commit; + + private const string Branch = ThisAssembly.Git.Branch; + + public static bool IsDirty => ThisAssembly.Git.IsDirty; + + public static string Version => IsDirty ? $"{Branch}-{Commit}-dirty" : $"{Branch}-{Commit}"; +} diff --git a/TeamOctolings.Octobot/ColorsList.cs b/TeamOctolings.Octobot/ColorsList.cs new file mode 100644 index 0000000..3b66c0a --- /dev/null +++ b/TeamOctolings.Octobot/ColorsList.cs @@ -0,0 +1,19 @@ +using System.Drawing; + +namespace TeamOctolings.Octobot; + +/// +/// Contains all colors used in embeds. +/// +public static class ColorsList +{ + public static readonly Color Default = Color.Gray; + public static readonly Color Red = Color.Firebrick; + public static readonly Color Green = Color.PaleGreen; + public static readonly Color Yellow = Color.Gold; + public static readonly Color Blue = Color.RoyalBlue; + public static readonly Color Magenta = Color.Orchid; + public static readonly Color Cyan = Color.LightSkyBlue; + public static readonly Color Black = Color.Black; + public static readonly Color White = Color.WhiteSmoke; +} diff --git a/TeamOctolings.Octobot/Commands/AboutCommandGroup.cs b/TeamOctolings.Octobot/Commands/AboutCommandGroup.cs new file mode 100644 index 0000000..28caccf --- /dev/null +++ b/TeamOctolings.Octobot/Commands/AboutCommandGroup.cs @@ -0,0 +1,137 @@ +using System.ComponentModel; +using System.Text; +using JetBrains.Annotations; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Messages; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Commands; + +/// +/// Handles the command to show information about this bot: /about. +/// +[UsedImplicitly] +public sealed class AboutCommandGroup : CommandGroup +{ + private static readonly (string Username, Snowflake Id)[] Developers = + [ + ("Octol1ttle", new Snowflake(504343489664909322)), + ("mctaylors", new Snowflake(326642240229474304)), + ("neroduckale", new Snowflake(474943797063843851)) + ]; + + private readonly ICommandContext _context; + private readonly IFeedbackService _feedback; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly GuildDataService _guildData; + private readonly IDiscordRestUserAPI _userApi; + + public AboutCommandGroup( + ICommandContext context, GuildDataService guildData, + IFeedbackService feedback, IDiscordRestUserAPI userApi, + IDiscordRestGuildAPI guildApi) + { + _context = context; + _guildData = guildData; + _feedback = feedback; + _userApi = userApi; + _guildApi = guildApi; + } + + /// + /// A slash command that shows information about this bot. + /// + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("about")] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [Description("Shows Octobot's developers")] + [UsedImplicitly] + public async Task ExecuteAboutAsync() + { + if (!_context.TryGetContextIDs(out var guildId, out _, out _)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var cfg = await _guildData.GetSettings(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(cfg); + + return await SendAboutBotAsync(bot, guildId, CancellationToken); + } + + private async Task SendAboutBotAsync(IUser bot, Snowflake guildId, CancellationToken ct = default) + { + var builder = new StringBuilder().Append("### ").AppendLine(Messages.AboutTitleDevelopers); + foreach (var dev in Developers) + { + var guildMemberResult = await _guildApi.GetGuildMemberAsync( + guildId, dev.Id, ct); + var tag = guildMemberResult.IsSuccess + ? $"<@{dev.Id}>" + : Markdown.Hyperlink($"@{dev.Username}", $"https://github.com/{dev.Username}"); + + builder.AppendBulletPointLine($"{tag} — {$"AboutDeveloper@{dev.Username}".Localized()}"); + } + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.AboutBot, bot.Username), bot) + .WithDescription(builder.ToString()) + .WithColour(ColorsList.Cyan) + .WithImageUrl("https://raw.githubusercontent.com/TeamOctolings/Octobot/HEAD/docs/octobot-banner.png") + .WithFooter(string.Format(Messages.Version, BuildInfo.Version)) + .Build(); + + var repositoryButton = new ButtonComponent( + ButtonComponentStyle.Link, + Messages.ButtonOpenWebsite, + new PartialEmoji(Name: "\ud83c\udf10"), // 'GLOBE WITH MERIDIANS' (U+1F310) + URL: BuildInfo.WebsiteUrl + ); + + var wikiButton = new ButtonComponent( + ButtonComponentStyle.Link, + Messages.ButtonOpenWiki, + new PartialEmoji(Name: "\ud83d\udcd6"), // 'OPEN BOOK' (U+1F4D6) + URL: BuildInfo.WikiUrl + ); + + var issuesButton = new ButtonComponent( + ButtonComponentStyle.Link, + BuildInfo.IsDirty + ? Messages.ButtonDirty + : Messages.ButtonReportIssue, + new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0) + URL: BuildInfo.IssuesUrl, + IsDisabled: BuildInfo.IsDirty + ); + + return await _feedback.SendContextualEmbedResultAsync(embed, + new FeedbackMessageOptions(MessageComponents: new[] + { + new ActionRowComponent([repositoryButton, wikiButton, issuesButton]) + }), ct); + } +} diff --git a/TeamOctolings.Octobot/Commands/BanCommandGroup.cs b/TeamOctolings.Octobot/Commands/BanCommandGroup.cs new file mode 100644 index 0000000..1d6b26c --- /dev/null +++ b/TeamOctolings.Octobot/Commands/BanCommandGroup.cs @@ -0,0 +1,300 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text; +using JetBrains.Annotations; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Parsers; +using TeamOctolings.Octobot.Services; +using TeamOctolings.Octobot.Services.Update; + +namespace TeamOctolings.Octobot.Commands; + +/// +/// Handles commands related to ban management: /ban and /unban. +/// +[UsedImplicitly] +public sealed class BanCommandGroup : CommandGroup +{ + private readonly AccessControlService _access; + private readonly IDiscordRestChannelAPI _channelApi; + private readonly ICommandContext _context; + private readonly IFeedbackService _feedback; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly GuildDataService _guildData; + private readonly IDiscordRestUserAPI _userApi; + private readonly Utility _utility; + + public BanCommandGroup(AccessControlService access, IDiscordRestChannelAPI channelApi, ICommandContext context, + IFeedbackService feedback, IDiscordRestGuildAPI guildApi, GuildDataService guildData, + IDiscordRestUserAPI userApi, Utility utility) + { + _access = access; + _channelApi = channelApi; + _context = context; + _feedback = feedback; + _guildApi = guildApi; + _guildData = guildData; + _userApi = userApi; + _utility = utility; + } + + /// + /// A slash command that bans a Discord user with the specified reason. + /// + /// The user to ban. + /// The duration for this ban. The user will be automatically unbanned after this duration. + /// + /// The reason for this ban. Must be encoded with when passed to + /// . + /// + /// + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user + /// was banned and vice versa. + /// + /// + [Command("ban", "бан")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] + [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] + [Description("Ban user")] + [UsedImplicitly] + public async Task ExecuteBanAsync( + [Description("User to ban")] IUser target, + [Description("Ban reason")] [MaxLength(256)] + string reason, + [Description("Ban duration (e.g. 1h30m)")] + string? duration = null) + { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + // The bot's avatar is used when sending error messages + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); + if (!guildResult.IsDefined(out var guild)) + { + return ResultExtensions.FromError(guildResult); + } + + var data = await _guildData.GetData(guild.ID, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + if (duration is null) + { + return await BanUserAsync(executor, target, reason, null, guild, data, channelId, bot, + CancellationToken); + } + + var parseResult = TimeSpanParser.TryParse(duration); + if (!parseResult.IsDefined(out var timeSpan)) + { + var failedEmbed = new EmbedBuilder() + .WithSmallTitle(Messages.InvalidTimeSpan, bot) + .WithDescription(Messages.TimeSpanExample) + .WithColour(ColorsList.Red) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken); + } + + return await BanUserAsync(executor, target, reason, timeSpan, guild, data, channelId, bot, CancellationToken); + } + + private async Task BanUserAsync( + IUser executor, IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data, + Snowflake channelId, + IUser bot, CancellationToken ct = default) + { + var existingBanResult = await _guildApi.GetGuildBanAsync(guild.ID, target.ID, ct); + if (existingBanResult.IsDefined()) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserAlreadyBanned, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var interactionResult + = await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Ban", ct); + if (!interactionResult.IsSuccess) + { + return ResultExtensions.FromError(interactionResult); + } + + if (interactionResult.Entity is not null) + { + var errorEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct); + } + + var builder = + new StringBuilder().AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason)); + if (duration is not null) + { + builder.AppendBulletPoint( + string.Format( + Messages.DescriptionActionExpiresAt, + Markdown.Timestamp(DateTimeOffset.UtcNow.Add(duration.Value)))); + } + + var title = string.Format(Messages.UserBanned, target.GetTag()); + var description = builder.ToString(); + + var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct); + if (dmChannelResult.IsDefined(out var dmChannel)) + { + var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) + .WithTitle(Messages.YouWereBanned) + .WithDescription(description) + .WithActionFooter(executor) + .WithCurrentTimestamp() + .WithColour(ColorsList.Red) + .Build(); + + await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct); + } + + var memberData = data.GetOrCreateMemberData(target.ID); + memberData.BannedUntil + = duration is not null ? DateTimeOffset.UtcNow.Add(duration.Value) : DateTimeOffset.MaxValue; + + var banResult = await _guildApi.CreateGuildBanAsync( + guild.ID, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(), + ct: ct); + if (!banResult.IsSuccess) + { + memberData.BannedUntil = null; + return ResultExtensions.FromError(banResult); + } + + memberData.Roles.Clear(); + + var embed = new EmbedBuilder().WithSmallTitle( + title, target) + .WithColour(ColorsList.Green).Build(); + + _utility.LogAction( + data.Settings, channelId, executor, title, description, target, ColorsList.Red, ct: ct); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + /// + /// A slash command that unbans a Discord user with the specified reason. + /// + /// The user to unban. + /// + /// The reason for this unban. Must be encoded with when passed to + /// . + /// + /// + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user + /// was unbanned and vice versa. + /// + /// + /// + [Command("unban")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] + [RequireBotDiscordPermissions(DiscordPermission.BanMembers)] + [Description("Unban user")] + [UsedImplicitly] + public async Task ExecuteUnban( + [Description("User to unban")] IUser target, + [Description("Unban reason")] [MaxLength(256)] + string reason) + { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + // The bot's avatar is used when sending error messages + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + // Needed to get the tag and avatar + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await UnbanUserAsync(executor, target, reason, guildId, data, channelId, bot, CancellationToken); + } + + private async Task UnbanUserAsync( + IUser executor, IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId, + IUser bot, CancellationToken ct = default) + { + var existingBanResult = await _guildApi.GetGuildBanAsync(guildId, target.ID, ct); + if (!existingBanResult.IsDefined()) + { + var errorEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserNotBanned, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct); + } + + var unbanResult = await _guildApi.RemoveGuildBanAsync( + guildId, target.ID, $"({executor.GetTag()}) {reason}".EncodeHeader(), + ct); + if (!unbanResult.IsSuccess) + { + return ResultExtensions.FromError(unbanResult); + } + + data.GetOrCreateMemberData(target.ID).BannedUntil = null; + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserUnbanned, target.GetTag()), target) + .WithColour(ColorsList.Green).Build(); + + var title = string.Format(Messages.UserUnbanned, target.GetTag()); + var description = + new StringBuilder().AppendBulletPoint(string.Format(Messages.DescriptionActionReason, reason)); + + _utility.LogAction( + data.Settings, channelId, executor, title, description.ToString(), target, ColorsList.Green, ct: ct); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Commands/ClearCommandGroup.cs b/TeamOctolings.Octobot/Commands/ClearCommandGroup.cs new file mode 100644 index 0000000..7f29581 --- /dev/null +++ b/TeamOctolings.Octobot/Commands/ClearCommandGroup.cs @@ -0,0 +1,170 @@ +using System.ComponentModel; +using System.Text; +using JetBrains.Annotations; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Commands; + +/// +/// Handles the command to clear messages in a channel: /clear. +/// +[UsedImplicitly] +public sealed class ClearCommandGroup : CommandGroup +{ + private readonly IDiscordRestChannelAPI _channelApi; + private readonly ICommandContext _context; + private readonly IFeedbackService _feedback; + private readonly GuildDataService _guildData; + private readonly IDiscordRestUserAPI _userApi; + private readonly Utility _utility; + + public ClearCommandGroup( + IDiscordRestChannelAPI channelApi, ICommandContext context, GuildDataService guildData, + IFeedbackService feedback, IDiscordRestUserAPI userApi, Utility utility) + { + _channelApi = channelApi; + _context = context; + _guildData = guildData; + _feedback = feedback; + _userApi = userApi; + _utility = utility; + } + + /// + /// A slash command that clears messages in the channel it was executed, optionally filtering by message author. + /// + /// The amount of messages to clear. + /// The user whose messages will be cleared. + /// + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that any messages + /// were cleared and vice versa. + /// + [Command("clear", "очистить")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] + [RequireBotDiscordPermissions(DiscordPermission.ManageMessages)] + [Description("Remove multiple messages")] + [UsedImplicitly] + public async Task ExecuteClear( + [Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)] + int amount, + [Description("Ignore messages except from the specified author")] + IUser? author = null) + { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + // The bot's avatar is used when sending messages + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var messagesResult = await _channelApi.GetChannelMessagesAsync( + channelId, limit: amount + 1, ct: CancellationToken); + if (!messagesResult.IsDefined(out var messages)) + { + return ResultExtensions.FromError(messagesResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await ClearMessagesAsync(executor, author, data, channelId, messages, bot, CancellationToken); + } + + private async Task ClearMessagesAsync( + IUser executor, IUser? author, GuildData data, Snowflake channelId, IReadOnlyList messages, IUser bot, + CancellationToken ct = default) + { + var idList = new List(messages.Count); + + var logEntries = new List { new() }; + var currentLogEntry = 0; + for (var i = messages.Count - 1; i >= 1; i--) // '>= 1' to skip last message ('Octobot is thinking...') + { + var message = messages[i]; + if (author is not null && message.Author.ID != author.ID) + { + continue; + } + + idList.Add(message.ID); + + var entry = logEntries[currentLogEntry]; + var str = $"{string.Format(Messages.MessageFrom, Mention.User(message.Author))}\n{message.Content.InBlockCode()}"; + if (entry.Builder.Length + str.Length > EmbedConstants.MaxDescriptionLength) + { + logEntries.Add(entry = new ClearedMessageEntry()); + currentLogEntry++; + } + + entry.Builder.Append(str); + entry.DeletedCount++; + } + + if (idList.Count == 0) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.NoMessagesToClear, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var title = author is not null + ? string.Format(Messages.MessagesClearedFiltered, idList.Count.ToString(), author.GetTag()) + : string.Format(Messages.MessagesCleared, idList.Count.ToString()); + + var deleteResult = await _channelApi.BulkDeleteMessagesAsync( + channelId, idList, executor.GetTag().EncodeHeader(), ct); + if (!deleteResult.IsSuccess) + { + return ResultExtensions.FromError(deleteResult); + } + + foreach (var log in logEntries) + { + _utility.LogAction( + data.Settings, channelId, executor, author is not null + ? string.Format(Messages.MessagesClearedFiltered, log.DeletedCount.ToString(), author.GetTag()) + : string.Format(Messages.MessagesCleared, log.DeletedCount.ToString()), + log.Builder.ToString(), bot, ColorsList.Red, false, ct); + } + + var embed = new EmbedBuilder().WithSmallTitle(title, bot) + .WithColour(ColorsList.Green).Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + private sealed class ClearedMessageEntry + { + public StringBuilder Builder { get; } = new(); + public int DeletedCount { get; set; } + } +} diff --git a/TeamOctolings.Octobot/Commands/Events/ErrorLoggingPostExecutionEvent.cs b/TeamOctolings.Octobot/Commands/Events/ErrorLoggingPostExecutionEvent.cs new file mode 100644 index 0000000..ff7339f --- /dev/null +++ b/TeamOctolings.Octobot/Commands/Events/ErrorLoggingPostExecutionEvent.cs @@ -0,0 +1,88 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Messages; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Commands.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Results; +using TeamOctolings.Octobot.Extensions; + +namespace TeamOctolings.Octobot.Commands.Events; + +/// +/// Handles error logging for slash command groups. +/// +[UsedImplicitly] +public sealed class ErrorLoggingPostExecutionEvent : IPostExecutionEvent +{ + private readonly IFeedbackService _feedback; + private readonly ILogger _logger; + private readonly IDiscordRestUserAPI _userApi; + + public ErrorLoggingPostExecutionEvent(ILogger logger, IFeedbackService feedback, + IDiscordRestUserAPI userApi) + { + _logger = logger; + _feedback = feedback; + _userApi = userApi; + } + + /// + /// Logs a warning using the injected if the has not + /// succeeded. + /// + /// The context of the slash command. + /// The result whose success is checked. + /// The cancellation token for this operation. Unused. + /// A result which has succeeded. + public async Task AfterExecutionAsync( + ICommandContext context, IResult commandResult, CancellationToken ct = default) + { + _logger.LogResult(commandResult, $"Error in slash command execution for /{context.Command.Command.Node.Key}."); + + var result = commandResult; + while (result.Inner is not null) + { + result = result.Inner; + } + + if (result.IsSuccess) + { + return Result.Success; + } + + var botResult = await _userApi.GetCurrentUserAsync(ct); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var embed = new EmbedBuilder().WithSmallTitle(Messages.CommandExecutionFailed, bot) + .WithDescription(Markdown.InlineCode(result.Error.Message)) + .WithFooter(Messages.ContactDevelopers) + .WithColour(ColorsList.Red) + .Build(); + + var issuesButton = new ButtonComponent( + ButtonComponentStyle.Link, + BuildInfo.IsDirty + ? Messages.ButtonDirty + : Messages.ButtonReportIssue, + new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0) + URL: BuildInfo.IssuesUrl, + IsDisabled: BuildInfo.IsDirty + ); + + return ResultExtensions.FromError(await _feedback.SendContextualEmbedResultAsync(embed, + new FeedbackMessageOptions(MessageComponents: new[] + { + new ActionRowComponent([issuesButton]) + }), ct) + ); + } +} diff --git a/TeamOctolings.Octobot/Commands/Events/LoggingPreparationErrorEvent.cs b/TeamOctolings.Octobot/Commands/Events/LoggingPreparationErrorEvent.cs new file mode 100644 index 0000000..9e69a7f --- /dev/null +++ b/TeamOctolings.Octobot/Commands/Events/LoggingPreparationErrorEvent.cs @@ -0,0 +1,38 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Services; +using Remora.Results; +using TeamOctolings.Octobot.Extensions; + +namespace TeamOctolings.Octobot.Commands.Events; + +/// +/// Handles error logging for slash commands that couldn't be successfully prepared. +/// +[UsedImplicitly] +public sealed class LoggingPreparationErrorEvent : IPreparationErrorEvent +{ + private readonly ILogger _logger; + + public LoggingPreparationErrorEvent(ILogger logger) + { + _logger = logger; + } + + /// + /// Logs a warning using the injected if the has not + /// succeeded. + /// + /// The context of the slash command. Unused. + /// The result whose success is checked. + /// The cancellation token for this operation. Unused. + /// A result which has succeeded. + public Task PreparationFailed( + IOperationContext context, IResult preparationResult, CancellationToken ct = default) + { + _logger.LogResult(preparationResult, "Error in slash command preparation."); + + return Task.FromResult(Result.Success); + } +} diff --git a/TeamOctolings.Octobot/Commands/InfoCommandGroup.cs b/TeamOctolings.Octobot/Commands/InfoCommandGroup.cs new file mode 100644 index 0000000..f07b210 --- /dev/null +++ b/TeamOctolings.Octobot/Commands/InfoCommandGroup.cs @@ -0,0 +1,329 @@ +using System.ComponentModel; +using System.Drawing; +using System.Text; +using JetBrains.Annotations; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Commands; + +/// +/// Handles info commands: /userinfo, /guildinfo. +/// +[UsedImplicitly] +public sealed class InfoCommandGroup : CommandGroup +{ + private readonly ICommandContext _context; + private readonly IFeedbackService _feedback; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly GuildDataService _guildData; + private readonly IDiscordRestUserAPI _userApi; + + public InfoCommandGroup( + ICommandContext context, IFeedbackService feedback, + GuildDataService guildData, IDiscordRestGuildAPI guildApi, + IDiscordRestUserAPI userApi) + { + _context = context; + _guildData = guildData; + _feedback = feedback; + _guildApi = guildApi; + _userApi = userApi; + } + + /// + /// A slash command that shows information about user. + /// + /// + /// Information in the output: + /// + /// Display name + /// Discord user since + /// Guild nickname + /// Guild member since + /// Nitro booster since + /// Guild roles + /// Active mute information + /// Active ban information + /// Is on guild status + /// + /// + /// The user to show info about. + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("userinfo")] + [DiscordDefaultDMPermission(false)] + [Description("Shows info about user")] + [UsedImplicitly] + public async Task ExecuteUserInfoAsync( + [Description("User to show info about")] + IUser? target = null) + { + if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await ShowUserInfoAsync(target ?? executor, bot, data, guildId, CancellationToken); + } + + private async Task ShowUserInfoAsync( + IUser target, IUser bot, GuildData data, Snowflake guildId, CancellationToken ct = default) + { + var builder = new StringBuilder().AppendLine($"### <@{target.ID}>"); + + if (target.GlobalName.IsDefined(out var globalName)) + { + builder.AppendBulletPointLine(Messages.UserInfoDisplayName) + .AppendLine(Markdown.InlineCode(globalName)); + } + + builder.AppendBulletPointLine(Messages.UserInfoDiscordUserSince) + .AppendLine(Markdown.Timestamp(target.ID.Timestamp)); + + var memberData = data.GetOrCreateMemberData(target.ID); + + var embedColor = ColorsList.Cyan; + + var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, ct); + DateTimeOffset? communicationDisabledUntil = null; + if (guildMemberResult.IsDefined(out var guildMember)) + { + communicationDisabledUntil = guildMember.CommunicationDisabledUntil.OrDefault(null); + + embedColor = AppendGuildInformation(embedColor, guildMember, builder); + } + + var wasMuted = (memberData.MutedUntil is not null && DateTimeOffset.UtcNow <= memberData.MutedUntil) || + communicationDisabledUntil is not null; + var wasBanned = memberData.BannedUntil is not null; + var wasKicked = memberData.Kicked; + + if (wasMuted || wasBanned || wasKicked) + { + builder.Append("### ") + .AppendLine(Markdown.Bold(Messages.UserInfoPunishments)); + + embedColor = AppendPunishmentsInformation(wasMuted, wasKicked, wasBanned, memberData, + builder, embedColor, communicationDisabledUntil); + } + + if (!guildMemberResult.IsSuccess && !wasBanned) + { + builder.Append("### ") + .AppendLine(Markdown.Bold(Messages.UserInfoNotOnGuild)); + + embedColor = ColorsList.Default; + } + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.InformationAbout, target.GetTag()), bot) + .WithDescription(builder.ToString()) + .WithColour(embedColor) + .WithLargeUserAvatar(target) + .WithFooter($"ID: {target.ID.ToString()}") + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + private static Color AppendPunishmentsInformation(bool wasMuted, bool wasKicked, bool wasBanned, + MemberData memberData, StringBuilder builder, Color embedColor, DateTimeOffset? communicationDisabledUntil) + { + if (wasMuted) + { + AppendMuteInformation(memberData, communicationDisabledUntil, builder); + embedColor = ColorsList.Red; + } + + if (wasKicked) + { + builder.AppendBulletPointLine(Messages.UserInfoKicked); + } + + if (wasBanned) + { + AppendBanInformation(memberData, builder); + embedColor = ColorsList.Black; + } + + return embedColor; + } + + private static Color AppendGuildInformation(Color color, IGuildMember guildMember, StringBuilder builder) + { + if (guildMember.Nickname.IsDefined(out var nickname)) + { + builder.AppendBulletPointLine(Messages.UserInfoGuildNickname) + .AppendLine(Markdown.InlineCode(nickname)); + } + + builder.AppendBulletPointLine(Messages.UserInfoGuildMemberSince) + .AppendLine(Markdown.Timestamp(guildMember.JoinedAt)); + + if (guildMember.PremiumSince.IsDefined(out var premiumSince)) + { + builder.AppendBulletPointLine(Messages.UserInfoGuildMemberPremiumSince) + .AppendLine(Markdown.Timestamp(premiumSince.Value)); + color = ColorsList.Magenta; + } + + if (guildMember.Roles.Count > 0) + { + builder.AppendBulletPointLine(Messages.UserInfoGuildRoles); + for (var i = 0; i < guildMember.Roles.Count - 1; i++) + { + builder.Append($"<@&{guildMember.Roles[i]}>, "); + } + + builder.AppendLine($"<@&{guildMember.Roles[^1]}>"); + } + + return color; + } + + private static void AppendBanInformation(MemberData memberData, StringBuilder builder) + { + if (memberData.BannedUntil < DateTimeOffset.MaxValue) + { + builder.AppendBulletPointLine(Messages.UserInfoBanned) + .AppendSubBulletPointLine(string.Format( + Messages.DescriptionActionExpiresAt, Markdown.Timestamp(memberData.BannedUntil.Value))); + return; + } + + builder.AppendBulletPointLine(Messages.UserInfoBannedPermanently); + } + + private static void AppendMuteInformation( + MemberData memberData, DateTimeOffset? communicationDisabledUntil, StringBuilder builder) + { + builder.AppendBulletPointLine(Messages.UserInfoMuted); + if (memberData.MutedUntil is not null && DateTimeOffset.UtcNow <= memberData.MutedUntil) + { + builder.AppendSubBulletPointLine(Messages.UserInfoMutedByMuteRole) + .AppendSubBulletPointLine(string.Format( + Messages.DescriptionActionExpiresAt, Markdown.Timestamp(memberData.MutedUntil.Value))); + } + + if (communicationDisabledUntil is not null) + { + builder.AppendSubBulletPointLine(Messages.UserInfoMutedByTimeout) + .AppendSubBulletPointLine(string.Format( + Messages.DescriptionActionExpiresAt, Markdown.Timestamp(communicationDisabledUntil.Value))); + } + } + + /// + /// A slash command that shows guild information. + /// + /// + /// Information in the output: + /// + /// Guild description + /// Creation date + /// Guild's language + /// Guild's owner + /// Boost level + /// Boost count + /// + /// + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("guildinfo")] + [DiscordDefaultDMPermission(false)] + [Description("Shows info about current guild")] + [UsedImplicitly] + public async Task ExecuteGuildInfoAsync() + { + if (!_context.TryGetContextIDs(out var guildId, out _, out _)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); + if (!guildResult.IsDefined(out var guild)) + { + return ResultExtensions.FromError(guildResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await ShowGuildInfoAsync(bot, guild, CancellationToken); + } + + private Task ShowGuildInfoAsync(IUser bot, IGuild guild, CancellationToken ct = default) + { + var description = new StringBuilder().AppendLine($"## {guild.Name}"); + + if (guild.Description is not null) + { + description.AppendBulletPointLine(Messages.GuildInfoDescription) + .AppendLine(Markdown.InlineCode(guild.Description)); + } + + description.AppendBulletPointLine(Messages.GuildInfoCreatedAt) + .AppendLine(Markdown.Timestamp(guild.ID.Timestamp)) + .AppendBulletPointLine(Messages.GuildInfoOwner) + .AppendLine(Mention.User(guild.OwnerID)); + + var embedColor = ColorsList.Cyan; + + if (guild.PremiumTier > PremiumTier.None) + { + description.Append("### ").AppendLine(Messages.GuildInfoServerBoost) + .AppendBulletPoint(Messages.GuildInfoBoostTier) + .Append(": ").AppendLine(Markdown.InlineCode(guild.PremiumTier.ToString())) + .AppendBulletPoint(Messages.GuildInfoBoostCount) + .Append(": ").AppendLine(Markdown.InlineCode(guild.PremiumSubscriptionCount.ToString())); + embedColor = ColorsList.Magenta; + } + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.InformationAbout, guild.Name), bot) + .WithDescription(description.ToString()) + .WithColour(embedColor) + .WithLargeGuildIcon(guild) + .WithGuildBanner(guild) + .WithFooter($"ID: {guild.ID.ToString()}") + .Build(); + + return _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Commands/KickCommandGroup.cs b/TeamOctolings.Octobot/Commands/KickCommandGroup.cs new file mode 100644 index 0000000..3011375 --- /dev/null +++ b/TeamOctolings.Octobot/Commands/KickCommandGroup.cs @@ -0,0 +1,174 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Commands; + +/// +/// Handles the command to kick members of a guild: /kick. +/// +[UsedImplicitly] +public sealed class KickCommandGroup : CommandGroup +{ + private readonly AccessControlService _access; + private readonly IDiscordRestChannelAPI _channelApi; + private readonly ICommandContext _context; + private readonly IFeedbackService _feedback; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly GuildDataService _guildData; + private readonly IDiscordRestUserAPI _userApi; + private readonly Utility _utility; + + public KickCommandGroup(AccessControlService access, IDiscordRestChannelAPI channelApi, ICommandContext context, + IFeedbackService feedback, IDiscordRestGuildAPI guildApi, GuildDataService guildData, + IDiscordRestUserAPI userApi, Utility utility) + { + _access = access; + _channelApi = channelApi; + _context = context; + _feedback = feedback; + _guildApi = guildApi; + _guildData = guildData; + _userApi = userApi; + _utility = utility; + } + + /// + /// A slash command that kicks a Discord member with the specified reason. + /// + /// The member to kick. + /// + /// The reason for this kick. Must be encoded with when passed to + /// . + /// + /// + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member + /// was kicked and vice versa. + /// + [Command("kick", "кик")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] + [RequireBotDiscordPermissions(DiscordPermission.KickMembers)] + [Description("Kick member")] + [UsedImplicitly] + public async Task ExecuteKick( + [Description("Member to kick")] IUser target, + [Description("Kick reason")] [MaxLength(256)] + string reason) + { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + // The bot's avatar is used when sending error messages + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken); + if (!guildResult.IsDefined(out var guild)) + { + return ResultExtensions.FromError(guildResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken); + if (!memberResult.IsSuccess) + { + var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: CancellationToken); + } + + return await KickUserAsync(executor, target, reason, guild, channelId, data, bot, CancellationToken); + } + + private async Task KickUserAsync( + IUser executor, IUser target, string reason, IGuild guild, Snowflake channelId, GuildData data, IUser bot, + CancellationToken ct = default) + { + var interactionResult + = await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Kick", ct); + if (!interactionResult.IsSuccess) + { + return ResultExtensions.FromError(interactionResult); + } + + if (interactionResult.Entity is not null) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct); + if (dmChannelResult.IsDefined(out var dmChannel)) + { + var dmEmbed = new EmbedBuilder().WithGuildTitle(guild) + .WithTitle(Messages.YouWereKicked) + .WithDescription( + MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason))) + .WithActionFooter(executor) + .WithCurrentTimestamp() + .WithColour(ColorsList.Red) + .Build(); + + await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct); + } + + var memberData = data.GetOrCreateMemberData(target.ID); + memberData.Kicked = true; + + var kickResult = await _guildApi.RemoveGuildMemberAsync( + guild.ID, target.ID, $"({executor.GetTag()}) {reason}".EncodeHeader(), + ct); + if (!kickResult.IsSuccess) + { + memberData.Kicked = false; + return ResultExtensions.FromError(kickResult); + } + + memberData.Roles.Clear(); + + var title = string.Format(Messages.UserKicked, target.GetTag()); + var description = MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason)); + + _utility.LogAction( + data.Settings, channelId, executor, title, description, target, ColorsList.Red, ct: ct); + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserKicked, target.GetTag()), target) + .WithColour(ColorsList.Green).Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Commands/MuteCommandGroup.cs b/TeamOctolings.Octobot/Commands/MuteCommandGroup.cs new file mode 100644 index 0000000..5dce0b6 --- /dev/null +++ b/TeamOctolings.Octobot/Commands/MuteCommandGroup.cs @@ -0,0 +1,388 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text; +using JetBrains.Annotations; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Parsers; +using TeamOctolings.Octobot.Services; +using TeamOctolings.Octobot.Services.Update; + +namespace TeamOctolings.Octobot.Commands; + +/// +/// Handles commands related to mute management: /mute and /unmute. +/// +[UsedImplicitly] +public sealed class MuteCommandGroup : CommandGroup +{ + private readonly AccessControlService _access; + private readonly ICommandContext _context; + private readonly IFeedbackService _feedback; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly GuildDataService _guildData; + private readonly IDiscordRestUserAPI _userApi; + private readonly Utility _utility; + + public MuteCommandGroup(AccessControlService access, ICommandContext context, IFeedbackService feedback, + IDiscordRestGuildAPI guildApi, GuildDataService guildData, IDiscordRestUserAPI userApi, Utility utility) + { + _access = access; + _context = context; + _feedback = feedback; + _guildApi = guildApi; + _guildData = guildData; + _userApi = userApi; + _utility = utility; + } + + /// + /// A slash command that mutes a Discord member with the specified reason. + /// + /// The member to mute. + /// The duration for this mute. The member will be automatically unmuted after this duration. + /// + /// The reason for this mute. Must be encoded with when passed to + /// . + /// + /// + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member + /// was muted and vice versa. + /// + /// + [Command("mute", "мут")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] + [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] + [Description("Mute member")] + [UsedImplicitly] + public async Task ExecuteMute( + [Description("Member to mute")] IUser target, + [Description("Mute reason")] [MaxLength(256)] + string reason, + [Description("Mute duration (e.g. 1h30m)")] [Option("duration")] + string stringDuration) + { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + // The bot's avatar is used when sending error messages + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken); + if (!memberResult.IsSuccess) + { + var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: CancellationToken); + } + + var parseResult = TimeSpanParser.TryParse(stringDuration); + if (!parseResult.IsDefined(out var duration)) + { + var failedEmbed = new EmbedBuilder() + .WithSmallTitle(Messages.InvalidTimeSpan, bot) + .WithDescription(Messages.TimeSpanExample) + .WithColour(ColorsList.Red) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken); + } + + return await MuteUserAsync(executor, target, reason, duration, guildId, data, channelId, bot, + CancellationToken); + } + + private async Task MuteUserAsync( + IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data, + Snowflake channelId, IUser bot, CancellationToken ct = default) + { + var interactionResult + = await _access.CheckInteractionsAsync( + guildId, executor.ID, target.ID, "Mute", ct); + if (!interactionResult.IsSuccess) + { + return ResultExtensions.FromError(interactionResult); + } + + if (interactionResult.Entity is not null) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var until = DateTimeOffset.UtcNow.Add(duration); // >:) + + var muteMethodResult = + await SelectMuteMethodAsync(executor, target, reason, duration, guildId, data, bot, until, ct); + if (!muteMethodResult.IsSuccess) + { + return ResultExtensions.FromError(muteMethodResult); + } + + var title = string.Format(Messages.UserMuted, target.GetTag()); + var description = new StringBuilder() + .AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason)) + .AppendBulletPoint(string.Format( + Messages.DescriptionActionExpiresAt, Markdown.Timestamp(until))).ToString(); + + _utility.LogAction( + data.Settings, channelId, executor, title, description, target, ColorsList.Red, ct: ct); + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserMuted, target.GetTag()), target) + .WithColour(ColorsList.Green).Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + private async Task SelectMuteMethodAsync( + IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data, + IUser bot, DateTimeOffset until, CancellationToken ct = default) + { + var muteRole = GuildSettings.MuteRole.Get(data.Settings); + + if (muteRole.Empty()) + { + var timeoutResult = await TimeoutUserAsync(executor, target, reason, duration, guildId, bot, until, ct); + return timeoutResult; + } + + var muteRoleResult = await RoleMuteUserAsync(executor, target, reason, guildId, data, until, muteRole, ct); + return muteRoleResult; + } + + private async Task RoleMuteUserAsync( + IUser executor, IUser target, string reason, Snowflake guildId, GuildData data, + DateTimeOffset until, Snowflake muteRole, CancellationToken ct = default) + { + var assignRoles = new List { muteRole }; + var memberData = data.GetOrCreateMemberData(target.ID); + if (!GuildSettings.RemoveRolesOnMute.Get(data.Settings)) + { + assignRoles.AddRange(memberData.Roles.ConvertAll(r => r.ToSnowflake())); + } + + var muteResult = await _guildApi.ModifyGuildMemberAsync( + guildId, target.ID, roles: assignRoles, + reason: $"({executor.GetTag()}) {reason}".EncodeHeader(), ct: ct); + if (muteResult.IsSuccess) + { + memberData.MutedUntil = until; + } + + return muteResult; + } + + private async Task TimeoutUserAsync( + IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, + IUser bot, DateTimeOffset until, CancellationToken ct = default) + { + if (duration.TotalDays >= 28) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.BotCannotMuteTarget, bot) + .WithDescription(Messages.DurationRequiredForTimeOuts) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var muteResult = await _guildApi.ModifyGuildMemberAsync( + guildId, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(), + communicationDisabledUntil: until, ct: ct); + return muteResult; + } + + /// + /// A slash command that unmutes a Discord member with the specified reason. + /// + /// The member to unmute. + /// + /// The reason for this unmute. Must be encoded with when passed to + /// . + /// + /// + /// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member + /// was unmuted and vice versa. + /// + /// + /// + [Command("unmute", "размут")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageMessages)] + [RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)] + [Description("Unmute member")] + [UsedImplicitly] + public async Task ExecuteUnmute( + [Description("Member to unmute")] IUser target, + [Description("Unmute reason")] [MaxLength(256)] + string reason) + { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + // The bot's avatar is used when sending error messages + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + // Needed to get the tag and avatar + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken); + if (!memberResult.IsSuccess) + { + var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: CancellationToken); + } + + return await RemoveMuteAsync(executor, target, reason, guildId, data, channelId, bot, CancellationToken); + } + + private async Task RemoveMuteAsync( + IUser executor, IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId, + IUser bot, CancellationToken ct = default) + { + var interactionResult + = await _access.CheckInteractionsAsync( + guildId, executor.ID, target.ID, "Unmute", ct); + if (!interactionResult.IsSuccess) + { + return ResultExtensions.FromError(interactionResult); + } + + if (interactionResult.Entity is not null) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, ct); + DateTimeOffset? communicationDisabledUntil = null; + if (guildMemberResult.IsDefined(out var guildMember)) + { + communicationDisabledUntil = guildMember.CommunicationDisabledUntil.OrDefault(null); + } + + var memberData = data.GetOrCreateMemberData(target.ID); + var wasMuted = memberData.MutedUntil is not null || communicationDisabledUntil is not null; + + if (!wasMuted) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserNotMuted, bot) + .WithColour(ColorsList.Red).Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var removeMuteRoleAsync = + await RemoveMuteRoleAsync(executor, target, reason, guildId, memberData, CancellationToken); + if (!removeMuteRoleAsync.IsSuccess) + { + return ResultExtensions.FromError(removeMuteRoleAsync); + } + + var removeTimeoutResult = + await RemoveTimeoutAsync(executor, target, reason, guildId, communicationDisabledUntil, CancellationToken); + if (!removeTimeoutResult.IsSuccess) + { + return ResultExtensions.FromError(removeTimeoutResult); + } + + var title = string.Format(Messages.UserUnmuted, target.GetTag()); + var description = MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason)); + + _utility.LogAction( + data.Settings, channelId, executor, title, description, target, ColorsList.Green, ct: ct); + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.UserUnmuted, target.GetTag()), target) + .WithColour(ColorsList.Green).Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + private async Task RemoveMuteRoleAsync( + IUser executor, IUser target, string reason, Snowflake guildId, MemberData memberData, + CancellationToken ct = default) + { + if (memberData.MutedUntil is null) + { + return Result.Success; + } + + var unmuteResult = await _guildApi.ModifyGuildMemberAsync( + guildId, target.ID, roles: memberData.Roles.ConvertAll(r => r.ToSnowflake()), + reason: $"({executor.GetTag()}) {reason}".EncodeHeader(), ct: ct); + if (unmuteResult.IsSuccess) + { + memberData.MutedUntil = null; + } + + return unmuteResult; + } + + private async Task RemoveTimeoutAsync( + IUser executor, IUser target, string reason, Snowflake guildId, DateTimeOffset? communicationDisabledUntil, + CancellationToken ct = default) + { + if (communicationDisabledUntil is null) + { + return Result.Success; + } + + var unmuteResult = await _guildApi.ModifyGuildMemberAsync( + guildId, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(), + communicationDisabledUntil: null, ct: ct); + return unmuteResult; + } +} diff --git a/TeamOctolings.Octobot/Commands/PingCommandGroup.cs b/TeamOctolings.Octobot/Commands/PingCommandGroup.cs new file mode 100644 index 0000000..01a1ee2 --- /dev/null +++ b/TeamOctolings.Octobot/Commands/PingCommandGroup.cs @@ -0,0 +1,102 @@ +using System.ComponentModel; +using JetBrains.Annotations; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Gateway; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Commands; + +/// +/// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping +/// +[UsedImplicitly] +public sealed class PingCommandGroup : CommandGroup +{ + private readonly IDiscordRestChannelAPI _channelApi; + private readonly DiscordGatewayClient _client; + private readonly ICommandContext _context; + private readonly IFeedbackService _feedback; + private readonly GuildDataService _guildData; + private readonly IDiscordRestUserAPI _userApi; + + public PingCommandGroup( + IDiscordRestChannelAPI channelApi, ICommandContext context, DiscordGatewayClient client, + GuildDataService guildData, IFeedbackService feedback, IDiscordRestUserAPI userApi) + { + _channelApi = channelApi; + _context = context; + _client = client; + _guildData = guildData; + _feedback = feedback; + _userApi = userApi; + } + + /// + /// A slash command that shows time taken for the gateway to respond to the last heartbeat. + /// + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("ping", "пинг")] + [Description("Get bot latency")] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [UsedImplicitly] + public async Task ExecutePingAsync() + { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var cfg = await _guildData.GetSettings(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(cfg); + + return await SendLatencyAsync(channelId, bot, CancellationToken); + } + + private async Task SendLatencyAsync( + Snowflake channelId, IUser bot, CancellationToken ct = default) + { + var latency = _client.Latency.TotalMilliseconds; + if (latency is 0) + { + // No heartbeat has occurred, estimate latency from local time and "Octobot is thinking..." message + var lastMessageResult = await _channelApi.GetChannelMessagesAsync( + channelId, limit: 1, ct: ct); + if (!lastMessageResult.IsDefined(out var lastMessage)) + { + return ResultExtensions.FromError(lastMessageResult); + } + + latency = DateTimeOffset.UtcNow.Subtract(lastMessage.Single().Timestamp).TotalMilliseconds; + } + + var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot) + .WithTitle($"Generic{Random.Shared.Next(1, 4)}".Localized()) + .WithDescription($"{latency:F0}{Messages.Milliseconds}") + .WithColour(latency < 250 ? ColorsList.Green : latency < 500 ? ColorsList.Yellow : ColorsList.Red) + .WithCurrentTimestamp() + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Commands/RemindCommandGroup.cs b/TeamOctolings.Octobot/Commands/RemindCommandGroup.cs new file mode 100644 index 0000000..3188d27 --- /dev/null +++ b/TeamOctolings.Octobot/Commands/RemindCommandGroup.cs @@ -0,0 +1,382 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text; +using JetBrains.Annotations; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Parsers; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Commands; + +/// +/// Handles commands to manage reminders: /remind, /listremind, /delremind +/// +[UsedImplicitly] +public sealed class RemindCommandGroup : CommandGroup +{ + private readonly IInteractionCommandContext _context; + private readonly IFeedbackService _feedback; + private readonly GuildDataService _guildData; + private readonly IDiscordRestInteractionAPI _interactionApi; + private readonly IDiscordRestUserAPI _userApi; + + public RemindCommandGroup( + IInteractionCommandContext context, GuildDataService guildData, IFeedbackService feedback, + IDiscordRestUserAPI userApi, IDiscordRestInteractionAPI interactionApi) + { + _context = context; + _guildData = guildData; + _feedback = feedback; + _userApi = userApi; + _interactionApi = interactionApi; + } + + /// + /// A slash command that lists reminders of the user that called it. + /// + /// A feedback sending result which may or may not have succeeded. + [Command("listremind")] + [Description("List your reminders")] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [UsedImplicitly] + public async Task ExecuteListReminderAsync() + { + if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await ListRemindersAsync(data.GetOrCreateMemberData(executorId), guildId, executor, bot, CancellationToken); + } + + private Task ListRemindersAsync(MemberData data, Snowflake guildId, IUser executor, IUser bot, CancellationToken ct = default) + { + if (data.Reminders.Count == 0) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.NoRemindersFound, bot) + .WithColour(ColorsList.Red) + .Build(); + + return _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var builder = new StringBuilder(); + for (var i = 0; i < data.Reminders.Count; i++) + { + var reminder = data.Reminders[i]; + builder.AppendBulletPointLine(string.Format(Messages.ReminderPosition, Markdown.InlineCode((i + 1).ToString()))) + .AppendSubBulletPointLine(string.Format(Messages.ReminderText, reminder.Text)) + .AppendSubBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At))) + .AppendSubBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage, $"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}")); + } + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.ReminderList, executor.GetTag()), executor) + .WithDescription(builder.ToString()) + .WithColour(ColorsList.Cyan) + .Build(); + + return _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + /// + /// A slash command that schedules a reminder with the specified text. + /// + /// The period of time which must pass before the reminder will be sent. + /// The text of the reminder. + /// A feedback sending result which may or may not have succeeded. + [Command("remind")] + [Description("Create a reminder")] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [UsedImplicitly] + public async Task ExecuteReminderAsync( + [Description("After what period of time mention the reminder (e.g. 1h30m)")] + [Option("in")] + string timeSpanString, + [Description("Reminder text")] [MaxLength(512)] + string text) + { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + var parseResult = TimeSpanParser.TryParse(timeSpanString); + if (!parseResult.IsDefined(out var timeSpan)) + { + var failedEmbed = new EmbedBuilder() + .WithSmallTitle(Messages.InvalidTimeSpan, bot) + .WithDescription(Messages.TimeSpanExample) + .WithColour(ColorsList.Red) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken); + } + + return await AddReminderAsync(timeSpan, text, data, channelId, executor, CancellationToken); + } + + private async Task AddReminderAsync(TimeSpan timeSpan, string text, GuildData data, + Snowflake channelId, IUser executor, CancellationToken ct = default) + { + var memberData = data.GetOrCreateMemberData(executor.ID); + var remindAt = DateTimeOffset.UtcNow.Add(timeSpan); + var responseResult = await _interactionApi.GetOriginalInteractionResponseAsync(_context.Interaction.ApplicationID, _context.Interaction.Token, ct); + if (!responseResult.IsDefined(out var response)) + { + return (Result)responseResult; + } + + memberData.Reminders.Add( + new Reminder + { + At = remindAt, + ChannelId = channelId.Value, + Text = text, + MessageId = response.ID.Value + }); + + var builder = new StringBuilder() + .AppendLine(MarkdownExtensions.Quote(text)) + .AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt))); + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.ReminderCreated, executor.GetTag()), executor) + .WithDescription(builder.ToString()) + .WithColour(ColorsList.Green) + .WithFooter(string.Format(Messages.ReminderPosition, memberData.Reminders.Count)) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + public enum Parameters + { + [UsedImplicitly] Time, + [UsedImplicitly] Text + } + + /// + /// A slash command that edits a scheduled reminder using the specified text or time. + /// + /// The list position of the reminder to edit. + /// The reminder's parameter to edit. + /// The new value for the reminder as a text or time. + /// A feedback sending result which may or may not have succeeded. + [Command("editremind")] + [Description("Edit a reminder")] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [UsedImplicitly] + public async Task ExecuteEditReminderAsync( + [Description("Position in list")] [MinValue(1)] + int position, + [Description("Parameter to edit")] Parameters parameter, + [Description("Parameter's new value")] string value) + { + if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + var memberData = data.GetOrCreateMemberData(executor.ID); + + if (parameter is Parameters.Time) + { + return await EditReminderTimeAsync(position - 1, value, memberData, bot, executor, CancellationToken); + } + + return await EditReminderTextAsync(position - 1, value, memberData, bot, executor, CancellationToken); + } + + private async Task EditReminderTimeAsync(int index, string value, MemberData data, + IUser bot, IUser executor, CancellationToken ct = default) + { + if (index >= data.Reminders.Count) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.InvalidReminderPosition, bot) + .WithColour(ColorsList.Red) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var parseResult = TimeSpanParser.TryParse(value); + if (!parseResult.IsDefined(out var timeSpan)) + { + var failedEmbed = new EmbedBuilder() + .WithSmallTitle(Messages.InvalidTimeSpan, bot) + .WithDescription(Messages.TimeSpanExample) + .WithColour(ColorsList.Red) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var oldReminder = data.Reminders[index]; + var remindAt = DateTimeOffset.UtcNow.Add(timeSpan); + + data.Reminders.Add(oldReminder with { At = remindAt }); + data.Reminders.RemoveAt(index); + + var builder = new StringBuilder() + .AppendLine(MarkdownExtensions.Quote(oldReminder.Text)) + .AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt))); + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.ReminderEdited, executor.GetTag()), executor) + .WithDescription(builder.ToString()) + .WithColour(ColorsList.Cyan) + .WithFooter(string.Format(Messages.ReminderPosition, data.Reminders.Count)) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + private async Task EditReminderTextAsync(int index, string value, MemberData data, + IUser bot, IUser executor, CancellationToken ct = default) + { + if (index >= data.Reminders.Count) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.InvalidReminderPosition, bot) + .WithColour(ColorsList.Red) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var oldReminder = data.Reminders[index]; + + data.Reminders.Add(oldReminder with { Text = value }); + data.Reminders.RemoveAt(index); + + var builder = new StringBuilder() + .AppendLine(MarkdownExtensions.Quote(value)) + .AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(oldReminder.At))); + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.ReminderEdited, executor.GetTag()), executor) + .WithDescription(builder.ToString()) + .WithColour(ColorsList.Cyan) + .WithFooter(string.Format(Messages.ReminderPosition, data.Reminders.Count)) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + /// + /// A slash command that deletes a reminder using its list position. + /// + /// The list position of the reminder to delete. + /// A feedback sending result which may or may not have succeeded. + [Command("delremind")] + [Description("Delete one of your reminders")] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [UsedImplicitly] + public async Task ExecuteDeleteReminderAsync( + [Description("Position in list")] [MinValue(1)] + int position) + { + if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await DeleteReminderAsync(data.GetOrCreateMemberData(executorId), position - 1, bot, CancellationToken); + } + + private Task DeleteReminderAsync(MemberData data, int index, IUser bot, + CancellationToken ct = default) + { + if (index >= data.Reminders.Count) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.InvalidReminderPosition, bot) + .WithColour(ColorsList.Red) + .Build(); + + return _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var reminder = data.Reminders[index]; + + var description = new StringBuilder() + .AppendLine(MarkdownExtensions.Quote(reminder.Text)) + .AppendBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At))); + + data.Reminders.RemoveAt(index); + + var embed = new EmbedBuilder().WithSmallTitle(Messages.ReminderDeleted, bot) + .WithDescription(description.ToString()) + .WithColour(ColorsList.Green) + .Build(); + + return _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Commands/SettingsCommandGroup.cs b/TeamOctolings.Octobot/Commands/SettingsCommandGroup.cs new file mode 100644 index 0000000..15aa42b --- /dev/null +++ b/TeamOctolings.Octobot/Commands/SettingsCommandGroup.cs @@ -0,0 +1,330 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Json.Nodes; +using JetBrains.Annotations; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Conditions; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Data.Options; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Commands; + +/// +/// Handles the commands to list and modify per-guild settings: /settings and /settings list. +/// +[UsedImplicitly] +public sealed class SettingsCommandGroup : CommandGroup +{ + /// + /// Represents all options as an array of objects implementing . + /// + /// + /// WARNING: If you update this array in any way, you must also update and make sure + /// that the orders match. + /// + private static readonly IGuildOption[] AllOptions = + [ + GuildSettings.Language, + GuildSettings.WelcomeMessage, + GuildSettings.LeaveMessage, + GuildSettings.ReceiveStartupMessages, + GuildSettings.RemoveRolesOnMute, + GuildSettings.ReturnRolesOnRejoin, + GuildSettings.AutoStartEvents, + GuildSettings.RenameHoistedUsers, + GuildSettings.PublicFeedbackChannel, + GuildSettings.PrivateFeedbackChannel, + GuildSettings.WelcomeMessagesChannel, + GuildSettings.EventNotificationChannel, + GuildSettings.DefaultRole, + GuildSettings.MuteRole, + GuildSettings.ModeratorRole, + GuildSettings.EventNotificationRole, + GuildSettings.EventEarlyNotificationOffset + ]; + + private readonly ICommandContext _context; + private readonly IFeedbackService _feedback; + private readonly GuildDataService _guildData; + private readonly IDiscordRestUserAPI _userApi; + private readonly Utility _utility; + + public SettingsCommandGroup( + ICommandContext context, GuildDataService guildData, + IFeedbackService feedback, IDiscordRestUserAPI userApi, Utility utility) + { + _context = context; + _guildData = guildData; + _feedback = feedback; + _userApi = userApi; + _utility = utility; + } + + /// + /// A slash command that sends a page from the list of current GuildSettings. + /// + /// The number of the page to send. + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("listsettings")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageGuild)] + [Description("Shows settings list for this server")] + [UsedImplicitly] + public async Task ExecuteListSettingsAsync( + [Description("Settings list page")] [MinValue(1)] + int page) + { + if (!_context.TryGetContextIDs(out var guildId, out _, out _)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var cfg = await _guildData.GetSettings(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(cfg); + + return await SendSettingsListAsync(cfg, bot, page, CancellationToken); + } + + private Task SendSettingsListAsync(JsonNode cfg, IUser bot, int page, + CancellationToken ct = default) + { + var description = new StringBuilder(); + var footer = new StringBuilder(); + + const int optionsPerPage = 10; + + var totalPages = (AllOptions.Length + optionsPerPage - 1) / optionsPerPage; + var lastOptionOnPage = Math.Min(optionsPerPage * page, AllOptions.Length); + var firstOptionOnPage = optionsPerPage * page - optionsPerPage; + + if (firstOptionOnPage >= AllOptions.Length) + { + var errorEmbed = new EmbedBuilder().WithSmallTitle(Messages.PageNotFound, bot) + .WithDescription(string.Format(Messages.PagesAllowed, Markdown.Bold(totalPages.ToString()))) + .WithColour(ColorsList.Red) + .Build(); + + return _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct); + } + + footer.Append($"{Messages.Page} {page}/{totalPages} "); + for (var i = 0; i < totalPages; i++) + { + footer.Append(i + 1 == page ? "●" : "○"); + } + + for (var i = firstOptionOnPage; i < lastOptionOnPage; i++) + { + var optionName = AllOptions[i].Name; + var optionValue = AllOptions[i].Display(cfg); + + description.AppendBulletPointLine($"Settings{optionName}".Localized()) + .AppendSubBulletPoint(Markdown.InlineCode(optionName)) + .Append(": ").AppendLine(optionValue); + } + + var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingsListTitle, bot) + .WithDescription(description.ToString()) + .WithColour(ColorsList.Default) + .WithFooter(footer.ToString()) + .Build(); + + return _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + /// + /// A slash command that modifies per-guild GuildSettings. + /// + /// The setting to modify. + /// The new value of the setting. + /// A feedback sending result which may or may not have succeeded. + [Command("editsettings")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageGuild)] + [Description("Change settings for this server")] + [UsedImplicitly] + public async Task ExecuteEditSettingsAsync( + [Description("The setting whose value you want to change")] + AllOptionsEnum setting, + [Description("Setting value")] [MaxLength(512)] + string value) + { + if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await EditSettingAsync(AllOptions[(int)setting], value, data, channelId, executor, bot, + CancellationToken); + } + + private async Task EditSettingAsync( + IGuildOption option, string value, GuildData data, Snowflake channelId, IUser executor, IUser bot, + CancellationToken ct = default) + { + var equalsResult = option.ValueEquals(data.Settings, value); + if (!equalsResult.IsSuccess) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, bot) + .WithDescription(equalsResult.Error.Message) + .WithColour(ColorsList.Red) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + if (equalsResult.Entity) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, bot) + .WithDescription(Messages.SettingValueEquals) + .WithColour(ColorsList.Red) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var setResult = option.Set(data.Settings, value); + if (!setResult.IsSuccess) + { + var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, bot) + .WithDescription(setResult.Error.Message) + .WithColour(ColorsList.Red) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct); + } + + var builder = new StringBuilder(); + + builder.Append(Markdown.InlineCode(option.Name)) + .Append($" {Messages.SettingIsNow} ") + .Append(option.Display(data.Settings)); + var title = Messages.SettingSuccessfullyChanged; + var description = builder.ToString(); + + _utility.LogAction( + data.Settings, channelId, executor, title, description, bot, ColorsList.Magenta, false, ct); + + var embed = new EmbedBuilder().WithSmallTitle(title, bot) + .WithDescription(description) + .WithColour(ColorsList.Green) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + /// + /// A slash command that resets per-guild GuildSettings. + /// + /// The setting to reset. + /// A feedback sending result which may have succeeded. + [Command("resetsettings")] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] + [DiscordDefaultDMPermission(false)] + [RequireContext(ChannelContext.Guild)] + [RequireDiscordPermission(DiscordPermission.ManageGuild)] + [Description("Reset settings for this guild")] + [UsedImplicitly] + public async Task ExecuteResetSettingsAsync( + [Description("Setting to reset")] AllOptionsEnum? setting = null) + { + if (!_context.TryGetContextIDs(out var guildId, out _, out _)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var cfg = await _guildData.GetSettings(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(cfg); + + if (setting is not null) + { + return await ResetSingleSettingAsync(cfg, bot, AllOptions[(int)setting], CancellationToken); + } + + return await ResetAllSettingsAsync(cfg, bot, CancellationToken); + } + + private async Task ResetSingleSettingAsync(JsonNode cfg, IUser bot, + IGuildOption option, CancellationToken ct = default) + { + var resetResult = option.Reset(cfg); + if (!resetResult.IsSuccess) + { + return ResultExtensions.FromError(resetResult); + } + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.SingleSettingReset, option.Name), bot) + .WithColour(ColorsList.Green) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + private async Task ResetAllSettingsAsync(JsonNode cfg, IUser bot, + CancellationToken ct = default) + { + var failedResults = new List(); + foreach (var resetResult in AllOptions.Select(option => option.Reset(cfg))) + { + failedResults.AddIfFailed(resetResult); + } + + if (failedResults.Count is not 0) + { + return failedResults.AggregateErrors(); + } + + var embed = new EmbedBuilder().WithSmallTitle(Messages.AllSettingsReset, bot) + .WithColour(ColorsList.Green) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Commands/ToolsCommandGroup.cs b/TeamOctolings.Octobot/Commands/ToolsCommandGroup.cs new file mode 100644 index 0000000..2936392 --- /dev/null +++ b/TeamOctolings.Octobot/Commands/ToolsCommandGroup.cs @@ -0,0 +1,272 @@ +using System.ComponentModel; +using System.Text; +using JetBrains.Annotations; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Parsers; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Commands; + +/// +/// Handles tool commands: /random, /timestamp, /8ball. +/// +[UsedImplicitly] +public sealed class ToolsCommandGroup : CommandGroup +{ + private static readonly TimestampStyle[] AllStyles = + [ + TimestampStyle.ShortDate, + TimestampStyle.LongDate, + TimestampStyle.ShortTime, + TimestampStyle.LongTime, + TimestampStyle.ShortDateTime, + TimestampStyle.LongDateTime, + TimestampStyle.RelativeTime + ]; + + private static readonly string[] AnswerTypes = + [ + "Positive", "Questionable", "Neutral", "Negative" + ]; + + private readonly ICommandContext _context; + private readonly IFeedbackService _feedback; + private readonly GuildDataService _guildData; + private readonly IDiscordRestUserAPI _userApi; + + public ToolsCommandGroup( + ICommandContext context, IFeedbackService feedback, + GuildDataService guildData, IDiscordRestUserAPI userApi) + { + _context = context; + _guildData = guildData; + _feedback = feedback; + _userApi = userApi; + } + + /// + /// A slash command that generates a random number using maximum and minimum numbers. + /// + /// The first number used for randomization. + /// The second number used for randomization. Default value: 0 + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("random")] + [DiscordDefaultDMPermission(false)] + [Description("Generates a random number")] + [UsedImplicitly] + public async Task ExecuteRandomAsync( + [Description("First number")] long first, + [Description("Second number (Default: 0)")] + long? second = null) + { + if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await SendRandomNumberAsync(first, second, executor, CancellationToken); + } + + private Task SendRandomNumberAsync(long first, long? secondNullable, + IUser executor, CancellationToken ct = default) + { + const long secondDefault = 0; + var second = secondNullable ?? secondDefault; + + var min = Math.Min(first, second); + var max = Math.Max(first, second); + + var i = Random.Shared.NextInt64(min, max + 1); + + var description = new StringBuilder().Append("# ").Append(i); + + description.AppendLine().AppendBulletPoint(string.Format( + Messages.RandomMin, Markdown.InlineCode(min.ToString()))); + if (secondNullable is null && first >= secondDefault) + { + description.Append(' ').Append(Messages.Default); + } + + description.AppendLine().AppendBulletPoint(string.Format( + Messages.RandomMax, Markdown.InlineCode(max.ToString()))); + if (secondNullable is null && first < secondDefault) + { + description.Append(' ').Append(Messages.Default); + } + + var embedColor = ColorsList.Blue; + if (secondNullable is not null && min == max) + { + description.AppendLine().Append(Markdown.Italicise(Messages.RandomMinMaxSame)); + embedColor = ColorsList.Red; + } + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.RandomTitle, executor.GetTag()), executor) + .WithDescription(description.ToString()) + .WithColour(embedColor) + .Build(); + + return _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + /// + /// A slash command that shows the current timestamp with an optional offset in all styles supported by Discord. + /// + /// The offset for the current timestamp. + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("timestamp")] + [DiscordDefaultDMPermission(false)] + [Description("Shows a timestamp in all styles")] + [UsedImplicitly] + public async Task ExecuteTimestampAsync( + [Description("Offset from current time")] [Option("offset")] + string? stringOffset = null) + { + if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken); + if (!executorResult.IsDefined(out var executor)) + { + return ResultExtensions.FromError(executorResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + if (stringOffset is null) + { + return await SendTimestampAsync(null, executor, CancellationToken); + } + + var parseResult = TimeSpanParser.TryParse(stringOffset); + if (!parseResult.IsDefined(out var offset)) + { + var failedEmbed = new EmbedBuilder() + .WithSmallTitle(Messages.InvalidTimeSpan, bot) + .WithDescription(Messages.TimeSpanExample) + .WithColour(ColorsList.Red) + .Build(); + + return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken); + } + + return await SendTimestampAsync(offset, executor, CancellationToken); + } + + private Task SendTimestampAsync(TimeSpan? offset, IUser executor, CancellationToken ct = default) + { + var timestamp = DateTimeOffset.UtcNow.Add(offset ?? TimeSpan.Zero).ToUnixTimeSeconds(); + + var description = new StringBuilder().Append("# ").AppendLine(timestamp.ToString()); + + if (offset is not null) + { + description.AppendLine(string.Format( + Messages.TimestampOffset, Markdown.InlineCode(offset.ToString() ?? string.Empty))).AppendLine(); + } + + foreach (var markdownTimestamp in AllStyles.Select(style => Markdown.Timestamp(timestamp, style))) + { + description.AppendBulletPoint(Markdown.InlineCode(markdownTimestamp)) + .Append(" → ").AppendLine(markdownTimestamp); + } + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.TimestampTitle, executor.GetTag()), executor) + .WithDescription(description.ToString()) + .WithColour(ColorsList.Blue) + .Build(); + + return _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } + + /// + /// A slash command that shows a random answer from the Magic 8-Ball. + /// + /// Unused input. + /// + /// The 8-Ball answers were taken from Wikipedia. + /// + /// + /// A feedback sending result which may or may not have succeeded. + /// + [Command("8ball")] + [DiscordDefaultDMPermission(false)] + [Description("Ask the Magic 8-Ball a question")] + [UsedImplicitly] + public async Task ExecuteEightBallAsync( + // let the user think he's actually asking the ball a question + [Description("Question to ask")] string question) + { + if (!_context.TryGetContextIDs(out var guildId, out _, out _)) + { + return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context"); + } + + var botResult = await _userApi.GetCurrentUserAsync(CancellationToken); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + var data = await _guildData.GetData(guildId, CancellationToken); + Messages.Culture = GuildSettings.Language.Get(data.Settings); + + return await AnswerEightBallAsync(bot, CancellationToken); + } + + private Task AnswerEightBallAsync(IUser bot, CancellationToken ct = default) + { + var typeNumber = Random.Shared.Next(0, 4); + var embedColor = typeNumber switch + { + 0 => ColorsList.Blue, + 1 => ColorsList.Green, + 2 => ColorsList.Yellow, + 3 => ColorsList.Red, + _ => throw new ArgumentOutOfRangeException(null, nameof(typeNumber)) + }; + + var answer = $"EightBall{AnswerTypes[typeNumber]}{Random.Shared.Next(1, 6)}".Localized(); + + var embed = new EmbedBuilder().WithSmallTitle(answer, bot) + .WithColour(embedColor) + .Build(); + + return _feedback.SendContextualEmbedResultAsync(embed, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Data/GuildData.cs b/TeamOctolings.Octobot/Data/GuildData.cs new file mode 100644 index 0000000..f393323 --- /dev/null +++ b/TeamOctolings.Octobot/Data/GuildData.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Nodes; +using Remora.Rest.Core; + +namespace TeamOctolings.Octobot.Data; + +/// +/// Stores information about a guild. This information is not accessible via the Discord API. +/// +/// This information is stored on disk as a JSON file. +public sealed class GuildData +{ + public readonly Dictionary MemberData; + public readonly string MemberDataPath; + + public readonly Dictionary ScheduledEvents; + public readonly string ScheduledEventsPath; + public readonly JsonNode Settings; + public readonly string SettingsPath; + + public readonly bool DataLoadFailed; + + public GuildData( + JsonNode settings, string settingsPath, + Dictionary scheduledEvents, string scheduledEventsPath, + Dictionary memberData, string memberDataPath, bool dataLoadFailed) + { + Settings = settings; + SettingsPath = settingsPath; + ScheduledEvents = scheduledEvents; + ScheduledEventsPath = scheduledEventsPath; + MemberData = memberData; + MemberDataPath = memberDataPath; + DataLoadFailed = dataLoadFailed; + } + + public MemberData GetOrCreateMemberData(Snowflake memberId) + { + if (MemberData.TryGetValue(memberId.Value, out var existing)) + { + return existing; + } + + var newData = new MemberData(memberId.Value); + MemberData.Add(memberId.Value, newData); + return newData; + } +} diff --git a/TeamOctolings.Octobot/Data/GuildSettings.cs b/TeamOctolings.Octobot/Data/GuildSettings.cs new file mode 100644 index 0000000..dc59d6f --- /dev/null +++ b/TeamOctolings.Octobot/Data/GuildSettings.cs @@ -0,0 +1,87 @@ +using Remora.Discord.API.Abstractions.Objects; +using TeamOctolings.Octobot.Data.Options; +using TeamOctolings.Octobot.Responders; + +namespace TeamOctolings.Octobot.Data; + +/// +/// Contains all per-guild settings that can be set by a member +/// with using the /settings command +/// +public static class GuildSettings +{ + public static readonly LanguageOption Language = new("Language", "en"); + + /// + /// Controls what message should be sent in when a new member joins the guild. + /// + /// + /// + /// No message will be sent if set to "off", "disable" or "disabled". + /// will be sent if set to "default" or "reset". + /// + /// + /// + public static readonly GuildOption WelcomeMessage = new("WelcomeMessage", "default"); + + /// + /// Controls what message should be sent in when a member leaves the guild. + /// + /// + /// + /// No message will be sent if set to "off", "disable" or "disabled". + /// will be sent if set to "default" or "reset". + /// + /// + /// + public static readonly GuildOption LeaveMessage = new("LeaveMessage", "default"); + + /// + /// Controls whether or not the message should be sent + /// in on startup. + /// + /// + public static readonly BoolOption ReceiveStartupMessages = new("ReceiveStartupMessages", false); + + public static readonly BoolOption RemoveRolesOnMute = new("RemoveRolesOnMute", false); + + /// + /// Controls whether or not a guild member's roles are returned if he/she leaves and then joins back. + /// + /// Roles will not be returned if the member left the guild because of /ban or /kick. + public static readonly BoolOption ReturnRolesOnRejoin = new("ReturnRolesOnRejoin", false); + + public static readonly BoolOption AutoStartEvents = new("AutoStartEvents", false); + + /// + /// Controls whether or not users who try to hoist themselves should be renamed. + /// + public static readonly BoolOption RenameHoistedUsers = new("RenameHoistedUsers", false); + + /// + /// Controls what channel should all public messages be sent to. + /// + public static readonly SnowflakeOption PublicFeedbackChannel = new("PublicFeedbackChannel"); + + /// + /// Controls what channel should all private, moderator-only messages be sent to. + /// + public static readonly SnowflakeOption PrivateFeedbackChannel = new("PrivateFeedbackChannel"); + + /// + /// Controls what channel should welcome messages be sent to. + /// + public static readonly SnowflakeOption WelcomeMessagesChannel = new("WelcomeMessagesChannel"); + + public static readonly SnowflakeOption EventNotificationChannel = new("EventNotificationChannel"); + public static readonly SnowflakeOption DefaultRole = new("DefaultRole"); + public static readonly SnowflakeOption MuteRole = new("MuteRole"); + public static readonly SnowflakeOption ModeratorRole = new("ModeratorRole"); + public static readonly SnowflakeOption EventNotificationRole = new("EventNotificationRole"); + + /// + /// Controls the amount of time before a scheduled event to send a reminder in . + /// + public static readonly TimeSpanOption EventEarlyNotificationOffset = new( + "EventEarlyNotificationOffset", TimeSpan.Zero); +} diff --git a/TeamOctolings.Octobot/Data/MemberData.cs b/TeamOctolings.Octobot/Data/MemberData.cs new file mode 100644 index 0000000..984d4af --- /dev/null +++ b/TeamOctolings.Octobot/Data/MemberData.cs @@ -0,0 +1,23 @@ +namespace TeamOctolings.Octobot.Data; + +/// +/// Stores information about a member +/// +public sealed class MemberData +{ + public MemberData(ulong id, List? reminders = null) + { + Id = id; + if (reminders is not null) + { + Reminders = reminders; + } + } + + public ulong Id { get; } + public DateTimeOffset? BannedUntil { get; set; } + public DateTimeOffset? MutedUntil { get; set; } + public bool Kicked { get; set; } + public List Roles { get; set; } = []; + public List Reminders { get; } = []; +} diff --git a/TeamOctolings.Octobot/Data/Options/AllOptionsEnum.cs b/TeamOctolings.Octobot/Data/Options/AllOptionsEnum.cs new file mode 100644 index 0000000..6a4280e --- /dev/null +++ b/TeamOctolings.Octobot/Data/Options/AllOptionsEnum.cs @@ -0,0 +1,32 @@ +using JetBrains.Annotations; +using TeamOctolings.Octobot.Commands; + +namespace TeamOctolings.Octobot.Data.Options; + +/// +/// Represents all options as enums. +/// +/// +/// WARNING: This enum is order-dependent! It's values are used as indexes for +/// . +/// +public enum AllOptionsEnum +{ + [UsedImplicitly] Language, + [UsedImplicitly] WelcomeMessage, + [UsedImplicitly] LeaveMessage, + [UsedImplicitly] ReceiveStartupMessages, + [UsedImplicitly] RemoveRolesOnMute, + [UsedImplicitly] ReturnRolesOnRejoin, + [UsedImplicitly] AutoStartEvents, + [UsedImplicitly] RenameHoistedUsers, + [UsedImplicitly] PublicFeedbackChannel, + [UsedImplicitly] PrivateFeedbackChannel, + [UsedImplicitly] WelcomeMessagesChannel, + [UsedImplicitly] EventNotificationChannel, + [UsedImplicitly] DefaultRole, + [UsedImplicitly] MuteRole, + [UsedImplicitly] ModeratorRole, + [UsedImplicitly] EventNotificationRole, + [UsedImplicitly] EventEarlyNotificationOffset +} diff --git a/TeamOctolings.Octobot/Data/Options/BoolOption.cs b/TeamOctolings.Octobot/Data/Options/BoolOption.cs new file mode 100644 index 0000000..3b81abb --- /dev/null +++ b/TeamOctolings.Octobot/Data/Options/BoolOption.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Nodes; +using Remora.Results; + +namespace TeamOctolings.Octobot.Data.Options; + +public sealed class BoolOption : GuildOption +{ + public BoolOption(string name, bool defaultValue) : base(name, defaultValue) { } + + public override string Display(JsonNode settings) + { + return Get(settings) ? Messages.Yes : Messages.No; + } + + public override Result ValueEquals(JsonNode settings, string value) + { + if (!TryParseBool(value, out var boolean)) + { + return new ArgumentInvalidError(nameof(value), Messages.InvalidSettingValue); + } + + return Value(settings).Equals(boolean.ToString()); + } + + public override Result Set(JsonNode settings, string from) + { + if (!TryParseBool(from, out var value)) + { + return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue); + } + + settings[Name] = value; + return Result.Success; + } + + private static bool TryParseBool(string from, out bool value) + { + value = false; + switch (from.ToLowerInvariant()) + { + case "true" or "1" or "y" or "yes" or "д" or "да": + value = true; + return true; + case "false" or "0" or "n" or "no" or "н" or "не" or "нет" or "нъет": + value = false; + return true; + default: + return false; + } + } +} diff --git a/TeamOctolings.Octobot/Data/Options/GuildOption.cs b/TeamOctolings.Octobot/Data/Options/GuildOption.cs new file mode 100644 index 0000000..ea9c30e --- /dev/null +++ b/TeamOctolings.Octobot/Data/Options/GuildOption.cs @@ -0,0 +1,67 @@ +using System.Text.Json.Nodes; +using Remora.Discord.Extensions.Formatting; +using Remora.Results; + +namespace TeamOctolings.Octobot.Data.Options; + +/// +/// Represents a per-guild option. +/// +/// The type of the option. +public class GuildOption : IGuildOption + where T : notnull +{ + protected readonly T DefaultValue; + + public GuildOption(string name, T defaultValue) + { + Name = name; + DefaultValue = defaultValue; + } + + public string Name { get; } + + protected virtual string Value(JsonNode settings) + { + return Get(settings).ToString() ?? throw new InvalidOperationException(); + } + + public virtual string Display(JsonNode settings) + { + return Markdown.InlineCode(Value(settings)); + } + + public virtual Result ValueEquals(JsonNode settings, string value) + { + return Value(settings).Equals(value); + } + + /// + /// Sets the value of the option from a to the provided JsonNode. + /// + /// The to set the value to. + /// The string from which the new value of the option will be parsed. + /// A value setting result which may or may not have succeeded. + public virtual Result Set(JsonNode settings, string from) + { + settings[Name] = from; + return Result.Success; + } + + public Result Reset(JsonNode settings) + { + settings[Name] = null; + return Result.Success; + } + + /// + /// Gets the value of the option from the provided . + /// + /// The to get the value from. + /// The value of the option. + public virtual T Get(JsonNode settings) + { + var property = settings[Name]; + return property != null ? property.GetValue() : DefaultValue; + } +} diff --git a/TeamOctolings.Octobot/Data/Options/IGuildOption.cs b/TeamOctolings.Octobot/Data/Options/IGuildOption.cs new file mode 100644 index 0000000..9920281 --- /dev/null +++ b/TeamOctolings.Octobot/Data/Options/IGuildOption.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Nodes; +using Remora.Results; + +namespace TeamOctolings.Octobot.Data.Options; + +public interface IGuildOption +{ + string Name { get; } + string Display(JsonNode settings); + Result ValueEquals(JsonNode settings, string value); + Result Set(JsonNode settings, string from); + Result Reset(JsonNode settings); +} diff --git a/TeamOctolings.Octobot/Data/Options/LanguageOption.cs b/TeamOctolings.Octobot/Data/Options/LanguageOption.cs new file mode 100644 index 0000000..f58e011 --- /dev/null +++ b/TeamOctolings.Octobot/Data/Options/LanguageOption.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Remora.Results; + +namespace TeamOctolings.Octobot.Data.Options; + +/// +public sealed class LanguageOption : GuildOption +{ + private static readonly Dictionary CultureInfoCache = new() + { + { "en", new CultureInfo("en-US") }, + { "ru", new CultureInfo("ru-RU") } + }; + + public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { } + + protected override string Value(JsonNode settings) + { + return settings[Name]?.GetValue() ?? "en"; + } + + /// + public override CultureInfo Get(JsonNode settings) + { + var property = settings[Name]; + return property != null ? CultureInfoCache[property.GetValue()] : DefaultValue; + } + + /// + public override Result Set(JsonNode settings, string from) + { + return CultureInfoCache.ContainsKey(from.ToLowerInvariant()) + ? base.Set(settings, from.ToLowerInvariant()) + : new ArgumentInvalidError(nameof(from), Messages.LanguageNotSupported); + } +} diff --git a/TeamOctolings.Octobot/Data/Options/SnowflakeOption.cs b/TeamOctolings.Octobot/Data/Options/SnowflakeOption.cs new file mode 100644 index 0000000..b7405f2 --- /dev/null +++ b/TeamOctolings.Octobot/Data/Options/SnowflakeOption.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Extensions; + +namespace TeamOctolings.Octobot.Data.Options; + +public sealed partial class SnowflakeOption : GuildOption +{ + public SnowflakeOption(string name) : base(name, 0UL.ToSnowflake()) { } + + public override string Display(JsonNode settings) + { + return Name.EndsWith("Channel", StringComparison.Ordinal) + ? Mention.Channel(Get(settings)) + : Mention.Role(Get(settings)); + } + + public override Snowflake Get(JsonNode settings) + { + var property = settings[Name]; + return property != null ? property.GetValue().ToSnowflake() : DefaultValue; + } + + public override Result Set(JsonNode settings, string from) + { + if (!ulong.TryParse(NonNumbers().Replace(from, ""), out var parsed)) + { + return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue); + } + + settings[Name] = parsed; + return Result.Success; + } + + [GeneratedRegex("[^0-9]")] + private static partial Regex NonNumbers(); +} diff --git a/TeamOctolings.Octobot/Data/Options/TimeSpanOption.cs b/TeamOctolings.Octobot/Data/Options/TimeSpanOption.cs new file mode 100644 index 0000000..7e21343 --- /dev/null +++ b/TeamOctolings.Octobot/Data/Options/TimeSpanOption.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Nodes; +using Remora.Results; +using TeamOctolings.Octobot.Parsers; + +namespace TeamOctolings.Octobot.Data.Options; + +public sealed class TimeSpanOption : GuildOption +{ + public TimeSpanOption(string name, TimeSpan defaultValue) : base(name, defaultValue) { } + + public override Result ValueEquals(JsonNode settings, string value) + { + if (!TimeSpanParser.TryParse(value).IsDefined(out var span)) + { + return new ArgumentInvalidError(nameof(value), Messages.InvalidSettingValue); + } + + return Value(settings).Equals(span.ToString()); + } + + public override TimeSpan Get(JsonNode settings) + { + var property = settings[Name]; + return property != null ? TimeSpanParser.TryParse(property.GetValue()).Entity : DefaultValue; + } + + public override Result Set(JsonNode settings, string from) + { + if (!TimeSpanParser.TryParse(from).IsDefined(out var span)) + { + return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue); + } + + settings[Name] = span.ToString(); + return Result.Success; + } +} diff --git a/TeamOctolings.Octobot/Data/Reminder.cs b/TeamOctolings.Octobot/Data/Reminder.cs new file mode 100644 index 0000000..40f29e1 --- /dev/null +++ b/TeamOctolings.Octobot/Data/Reminder.cs @@ -0,0 +1,9 @@ +namespace TeamOctolings.Octobot.Data; + +public sealed record Reminder +{ + public required DateTimeOffset At { get; init; } + public required string Text { get; init; } + public required ulong ChannelId { get; init; } + public required ulong MessageId { get; init; } +} diff --git a/TeamOctolings.Octobot/Data/ScheduledEventData.cs b/TeamOctolings.Octobot/Data/ScheduledEventData.cs new file mode 100644 index 0000000..7ba6e92 --- /dev/null +++ b/TeamOctolings.Octobot/Data/ScheduledEventData.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; +using Remora.Discord.API.Abstractions.Objects; + +namespace TeamOctolings.Octobot.Data; + +/// +/// Stores information about scheduled events. This information is not provided by the Discord API. +/// +/// This information is stored on disk as a JSON file. +public sealed class ScheduledEventData +{ + public ScheduledEventData(ulong id, string name, DateTimeOffset scheduledStartTime, + GuildScheduledEventStatus status) + { + Id = id; + Name = name; + ScheduledStartTime = scheduledStartTime; + Status = status; + } + + [JsonConstructor] + public ScheduledEventData(ulong id, string name, bool earlyNotificationSent, DateTimeOffset scheduledStartTime, + DateTimeOffset? actualStartTime, GuildScheduledEventStatus? status, bool scheduleOnStatusUpdated) + { + Id = id; + Name = name; + EarlyNotificationSent = earlyNotificationSent; + ScheduledStartTime = scheduledStartTime; + ActualStartTime = actualStartTime; + Status = status; + ScheduleOnStatusUpdated = scheduleOnStatusUpdated; + } + + public ulong Id { get; } + public string Name { get; set; } + public bool EarlyNotificationSent { get; set; } + public DateTimeOffset ScheduledStartTime { get; set; } + public DateTimeOffset? ActualStartTime { get; set; } + public GuildScheduledEventStatus? Status { get; set; } + public bool ScheduleOnStatusUpdated { get; set; } = true; +} diff --git a/TeamOctolings.Octobot/Extensions/ChannelApiExtensions.cs b/TeamOctolings.Octobot/Extensions/ChannelApiExtensions.cs new file mode 100644 index 0000000..82f8889 --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/ChannelApiExtensions.cs @@ -0,0 +1,30 @@ +using OneOf; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Rest.Core; +using Remora.Results; + +namespace TeamOctolings.Octobot.Extensions; + +public static class ChannelApiExtensions +{ + public static async Task CreateMessageWithEmbedResultAsync(this IDiscordRestChannelAPI channelApi, + Snowflake channelId, Optional message = default, Optional nonce = default, + Optional isTextToSpeech = default, Optional> embedResult = default, + Optional allowedMentions = default, Optional messageReference = default, + Optional> components = default, + Optional> stickerIds = default, + Optional>> attachments = default, + Optional flags = default, Optional enforceNonce = default, + Optional poll = default, CancellationToken ct = default) + { + if (!embedResult.IsDefined() || !embedResult.Value.IsDefined(out var embed)) + { + return ResultExtensions.FromError(embedResult.Value); + } + + return (Result)await channelApi.CreateMessageAsync(channelId, message, nonce, isTextToSpeech, new[] { embed }, + allowedMentions, messageReference, components, stickerIds, attachments, flags, enforceNonce, poll, ct); + } +} diff --git a/TeamOctolings.Octobot/Extensions/CollectionExtensions.cs b/TeamOctolings.Octobot/Extensions/CollectionExtensions.cs new file mode 100644 index 0000000..3ea13a8 --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/CollectionExtensions.cs @@ -0,0 +1,40 @@ +using Remora.Results; + +namespace TeamOctolings.Octobot.Extensions; + +public static class CollectionExtensions +{ + public static TResult? MaxOrDefault( + this IEnumerable source, Func selector) + { + var list = source.ToList(); + return list.Count > 0 ? list.Max(selector) : default; + } + + public static void AddIfFailed(this List list, Result result) + { + if (!result.IsSuccess) + { + list.Add(result); + } + } + + /// + /// Return an appropriate result for a list of failed results. The list must only contain failed results. + /// + /// The list of failed results. + /// + /// A successful result if the list is empty, the only Result in the list, or + /// containing all results from the list. + /// + /// + public static Result AggregateErrors(this List list) + { + return list.Count switch + { + 0 => Result.Success, + 1 => list[0], + _ => new AggregateError(list.Cast().ToArray()) + }; + } +} diff --git a/TeamOctolings.Octobot/Extensions/CommandContextExtensions.cs b/TeamOctolings.Octobot/Extensions/CommandContextExtensions.cs new file mode 100644 index 0000000..16b8b56 --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/CommandContextExtensions.cs @@ -0,0 +1,19 @@ +using Remora.Discord.Commands.Contexts; +using Remora.Discord.Commands.Extensions; +using Remora.Rest.Core; + +namespace TeamOctolings.Octobot.Extensions; + +public static class CommandContextExtensions +{ + public static bool TryGetContextIDs( + this ICommandContext context, out Snowflake guildId, + out Snowflake channelId, out Snowflake executorId) + { + channelId = default; + executorId = default; + return context.TryGetGuildID(out guildId) + && context.TryGetChannelID(out channelId) + && context.TryGetUserID(out executorId); + } +} diff --git a/TeamOctolings.Octobot/Extensions/DiffPaneModelExtensions.cs b/TeamOctolings.Octobot/Extensions/DiffPaneModelExtensions.cs new file mode 100644 index 0000000..3bb707b --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/DiffPaneModelExtensions.cs @@ -0,0 +1,31 @@ +using System.Text; +using DiffPlex.DiffBuilder.Model; + +namespace TeamOctolings.Octobot.Extensions; + +public static class DiffPaneModelExtensions +{ + public static string AsMarkdown(this DiffPaneModel model) + { + var builder = new StringBuilder(); + foreach (var line in model.Lines) + { + if (line.Type is ChangeType.Deleted) + { + builder.Append("-- "); + } + + if (line.Type is ChangeType.Inserted) + { + builder.Append("++ "); + } + + if (line.Type is not ChangeType.Imaginary) + { + builder.AppendLine(line.Text.SanitizeForDiffBlock()); + } + } + + return builder.ToString().InBlockCode("diff"); + } +} diff --git a/TeamOctolings.Octobot/Extensions/EmbedBuilderExtensions.cs b/TeamOctolings.Octobot/Extensions/EmbedBuilderExtensions.cs new file mode 100644 index 0000000..dab0265 --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/EmbedBuilderExtensions.cs @@ -0,0 +1,149 @@ +using Remora.Discord.API; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Discord.Extensions.Embeds; +using Remora.Rest.Core; + +namespace TeamOctolings.Octobot.Extensions; + +public static class EmbedBuilderExtensions +{ + /// + /// Adds a footer representing that an action was performed by a . + /// + /// The builder to add the footer to. + /// The user that performed the action whose tag and avatar to use. + /// The builder with the added footer. + public static EmbedBuilder WithActionFooter(this EmbedBuilder builder, IUser user) + { + var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256); + var avatarUrl = avatarUrlResult.IsSuccess + ? avatarUrlResult.Entity.AbsoluteUri + : CDN.GetDefaultUserAvatarUrl(user, imageSize: 256).Entity.AbsoluteUri; + + return builder.WithFooter( + new EmbedFooter($"{Messages.IssuedBy}:\n{user.GetTag()}", avatarUrl)); + } + + /// + /// Adds a title using the author field, making it smaller than using the title field. + /// + /// The builder to add the small title to. + /// The text of the small title. + /// The user whose avatar to use in the small title. + /// The builder with the added small title in the author field. + public static EmbedBuilder WithSmallTitle( + this EmbedBuilder builder, string text, IUser? avatarSource = null) + { + Uri? avatarUrl = null; + if (avatarSource is not null) + { + var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256); + + avatarUrl = avatarUrlResult.IsSuccess + ? avatarUrlResult.Entity + : CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity; + } + + builder.Author = new EmbedAuthorBuilder(text, iconUrl: avatarUrl?.AbsoluteUri); + return builder; + } + + /// + /// Adds a user avatar in the thumbnail field. + /// + /// The builder to add the thumbnail to. + /// The user whose avatar to use in the thumbnail field. + /// The builder with the added avatar in the thumbnail field. + public static EmbedBuilder WithLargeUserAvatar( + this EmbedBuilder builder, IUser avatarSource) + { + var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256); + var avatarUrl = avatarUrlResult.IsSuccess + ? avatarUrlResult.Entity + : CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity; + + return builder.WithThumbnailUrl(avatarUrl.AbsoluteUri); + } + + /// + /// Adds a guild icon in the thumbnail field. + /// + /// The builder to add the thumbnail to. + /// The guild whose icon to use in the thumbnail field. + /// The builder with the added icon in the thumbnail field. + public static EmbedBuilder WithLargeGuildIcon( + this EmbedBuilder builder, IGuild iconSource) + { + var iconUrlResult = CDN.GetGuildIconUrl(iconSource, imageSize: 256); + return iconUrlResult.IsSuccess + ? builder.WithThumbnailUrl(iconUrlResult.Entity.AbsoluteUri) + : builder; + } + + /// + /// Adds a guild banner in the image field. + /// + /// The builder to add the image to. + /// The guild whose banner to use in the image field. + /// The builder with the added banner in the image field. + public static EmbedBuilder WithGuildBanner( + this EmbedBuilder builder, IGuild bannerSource) + { + return bannerSource.Banner is not null + ? builder.WithImageUrl(CDN.GetGuildBannerUrl(bannerSource).Entity.AbsoluteUri) + : builder; + } + + /// + /// Adds a footer representing that the action was performed in the . + /// + /// The builder to add the footer to. + /// The guild whose name and icon to use. + /// The builder with the added footer. + public static EmbedBuilder WithGuildFooter(this EmbedBuilder builder, IGuild guild) + { + var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); + var iconUrl = iconUrlResult.IsSuccess + ? iconUrlResult.Entity.AbsoluteUri + : default(Optional); + + return builder.WithFooter(new EmbedFooter(guild.Name, iconUrl)); + } + + /// + /// Adds a title representing that the action happened in the . + /// + /// The builder to add the title to. + /// The guild whose name and icon to use. + /// The builder with the added title. + public static EmbedBuilder WithGuildTitle(this EmbedBuilder builder, IGuild guild) + { + var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256); + var iconUrl = iconUrlResult.IsSuccess + ? iconUrlResult.Entity.AbsoluteUri + : null; + + builder.Author = new EmbedAuthorBuilder(guild.Name, iconUrl: iconUrl); + return builder; + } + + /// + /// Adds a scheduled event's cover image. + /// + /// The builder to add the image to. + /// The ID of the scheduled event whose image to use. + /// The Optional containing the image hash. + /// The builder with the added cover image. + public static EmbedBuilder WithEventCover( + this EmbedBuilder builder, Snowflake eventId, Optional imageHashOptional) + { + if (!imageHashOptional.IsDefined(out var imageHash)) + { + return builder; + } + + var iconUrlResult = CDN.GetGuildScheduledEventCoverUrl(eventId, imageHash, imageSize: 1024); + return iconUrlResult.IsDefined(out var iconUrl) ? builder.WithImageUrl(iconUrl.AbsoluteUri) : builder; + } +} diff --git a/TeamOctolings.Octobot/Extensions/FeedbackServiceExtensions.cs b/TeamOctolings.Octobot/Extensions/FeedbackServiceExtensions.cs new file mode 100644 index 0000000..c66c946 --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/FeedbackServiceExtensions.cs @@ -0,0 +1,21 @@ +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Feedback.Messages; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Results; + +namespace TeamOctolings.Octobot.Extensions; + +public static class FeedbackServiceExtensions +{ + public static async Task SendContextualEmbedResultAsync( + this IFeedbackService feedback, Result embedResult, + FeedbackMessageOptions? options = null, CancellationToken ct = default) + { + if (!embedResult.IsDefined(out var embed)) + { + return ResultExtensions.FromError(embedResult); + } + + return (Result)await feedback.SendContextualEmbedAsync(embed, options, ct); + } +} diff --git a/TeamOctolings.Octobot/Extensions/GuildScheduledEventExtensions.cs b/TeamOctolings.Octobot/Extensions/GuildScheduledEventExtensions.cs new file mode 100644 index 0000000..7822d9b --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/GuildScheduledEventExtensions.cs @@ -0,0 +1,28 @@ +using Remora.Discord.API.Abstractions.Objects; +using Remora.Rest.Core; +using Remora.Results; + +namespace TeamOctolings.Octobot.Extensions; + +public static class GuildScheduledEventExtensions +{ + public static Result TryGetExternalEventData(this IGuildScheduledEvent scheduledEvent, out DateTimeOffset endTime, + out string? location) + { + endTime = default; + location = null; + if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata)) + { + return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)); + } + + if (!metadata.Location.IsDefined(out location)) + { + return new ArgumentNullError(nameof(metadata.Location)); + } + + return scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out endTime) + ? Result.Success + : new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)); + } +} diff --git a/TeamOctolings.Octobot/Extensions/LoggerExtensions.cs b/TeamOctolings.Octobot/Extensions/LoggerExtensions.cs new file mode 100644 index 0000000..fac4dda --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/LoggerExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Logging; +using Remora.Results; + +namespace TeamOctolings.Octobot.Extensions; + +public static class LoggerExtensions +{ + /// + /// Checks if the has failed due to an error that has resulted from neither invalid user + /// input nor the execution environment and logs the error using the provided . + /// + /// + /// This has special behavior for - its exception will be passed to the + /// + /// + /// The logger to use. + /// The Result whose error check. + /// The message to use if this result has failed. + public static void LogResult(this ILogger logger, IResult result, string? message = "") + { + if (result.IsSuccess) + { + return; + } + + if (result.Error is ExceptionError exe) + { + if (exe.Exception is OperationCanceledException) + { + return; + } + + logger.LogError(exe.Exception, "{ErrorMessage}", message); + return; + } + + logger.LogWarning("{UserMessage}{NewLine}{ResultErrorMessage}", message, Environment.NewLine, + result.Error.Message); + } +} diff --git a/TeamOctolings.Octobot/Extensions/MarkdownExtensions.cs b/TeamOctolings.Octobot/Extensions/MarkdownExtensions.cs new file mode 100644 index 0000000..30ddff5 --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/MarkdownExtensions.cs @@ -0,0 +1,28 @@ +namespace TeamOctolings.Octobot.Extensions; + +public static class MarkdownExtensions +{ + /// + /// Formats a string to use Markdown Bullet formatting. + /// + /// The input text to format. + /// + /// A markdown-formatted bullet string. + /// + public static string BulletPoint(string text) + { + return $"- {text}"; + } + + /// + /// Formats a string to use Markdown Quote formatting. + /// + /// The input text to format. + /// + /// A markdown-formatted quote string. + /// + public static string Quote(string text) + { + return $"> {text}"; + } +} diff --git a/TeamOctolings.Octobot/Extensions/ResultExtensions.cs b/TeamOctolings.Octobot/Extensions/ResultExtensions.cs new file mode 100644 index 0000000..6872d34 --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/ResultExtensions.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Remora.Results; + +namespace TeamOctolings.Octobot.Extensions; + +public static class ResultExtensions +{ + public static Result FromError(Result result) + { + LogResultStackTrace(result); + + return result; + } + + public static Result FromError(Result result) + { + var casted = (Result)result; + LogResultStackTrace(casted); + + return casted; + } + + private static void LogResultStackTrace(Result result) + { + if (result.IsSuccess || result.Error is ExceptionError { Exception: OperationCanceledException }) + { + return; + } + + if (Utility.StaticLogger is null) + { + throw new InvalidOperationException(); + } + + Utility.StaticLogger.LogError("{ErrorType}: {ErrorMessage}{NewLine}{StackTrace}", + result.Error.GetType().FullName, result.Error.Message, Environment.NewLine, ConstructStackTrace()); + + var inner = result.Inner; + while (inner is { IsSuccess: false }) + { + Utility.StaticLogger.LogError("Caused by: {ResultType}: {ResultMessage}", + inner.Error.GetType().FullName, inner.Error.Message); + + inner = inner.Inner; + } + } + + private static string ConstructStackTrace() + { + var stackArray = new StackTrace(3, true).ToString().Split(Environment.NewLine).ToList(); + for (var i = stackArray.Count - 1; i >= 0; i--) + { + var frame = stackArray[i]; + var trimmed = frame.TrimStart(); + if (trimmed.StartsWith("at System.Threading", StringComparison.Ordinal) + || trimmed.StartsWith("at System.Runtime.CompilerServices", StringComparison.Ordinal)) + { + stackArray.RemoveAt(i); + } + } + + return string.Join(Environment.NewLine, stackArray); + } +} diff --git a/TeamOctolings.Octobot/Extensions/SnowflakeExtensions.cs b/TeamOctolings.Octobot/Extensions/SnowflakeExtensions.cs new file mode 100644 index 0000000..70810ef --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/SnowflakeExtensions.cs @@ -0,0 +1,32 @@ +using Remora.Rest.Core; + +namespace TeamOctolings.Octobot.Extensions; + +public static class SnowflakeExtensions +{ + /// + /// Checks whether this Snowflake has any value set. + /// + /// The Snowflake to check. + /// true if the Snowflake has no value set or it's set to 0, false otherwise. + public static bool Empty(this Snowflake snowflake) + { + return snowflake.Value is 0; + } + + /// + /// Checks whether this snowflake is empty (see ) or it's equal to + /// + /// + /// The Snowflake to check for emptiness + /// The Snowflake to check for equality with . + /// + /// true if is empty or is equal to , false + /// otherwise. + /// + /// + public static bool EmptyOrEqualTo(this Snowflake snowflake, Snowflake anotherSnowflake) + { + return snowflake.Empty() || snowflake == anotherSnowflake; + } +} diff --git a/TeamOctolings.Octobot/Extensions/StringBuilderExtensions.cs b/TeamOctolings.Octobot/Extensions/StringBuilderExtensions.cs new file mode 100644 index 0000000..25b7b5b --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/StringBuilderExtensions.cs @@ -0,0 +1,62 @@ +using System.Text; + +namespace TeamOctolings.Octobot.Extensions; + +public static class StringBuilderExtensions +{ + /// + /// Appends the input string with Markdown Bullet formatting to the specified object. + /// + /// The object. + /// The string to append with bullet point. + /// + /// The builder with the appended string with Markdown Bullet formatting. + /// + public static StringBuilder AppendBulletPoint(this StringBuilder builder, string? value) + { + return builder.Append("- ").Append(value); + } + + /// + /// Appends the input string with Markdown Sub-Bullet formatting to the specified object. + /// + /// The object. + /// The string to append with sub-bullet point. + /// + /// The builder with the appended string with Markdown Sub-Bullet formatting. + /// + public static StringBuilder AppendSubBulletPoint(this StringBuilder builder, string? value) + { + return builder.Append(" - ").Append(value); + } + + /// + /// Appends the input string with Markdown Bullet formatting followed by + /// the default line terminator to the end of specified object. + /// + /// The object. + /// The string to append with bullet point. + /// + /// The builder with the appended string with Markdown Bullet formatting + /// and default line terminator at the end. + /// + public static StringBuilder AppendBulletPointLine(this StringBuilder builder, string? value) + { + return builder.Append("- ").AppendLine(value); + } + + /// + /// Appends the input string with Markdown Sub-Bullet formatting followed by + /// the default line terminator to the end of specified object. + /// + /// The object. + /// The string to append with sub-bullet point. + /// + /// The builder with the appended string with Markdown Sub-Bullet formatting + /// and default line terminator at the end. + /// + public static StringBuilder AppendSubBulletPointLine(this StringBuilder builder, string? value) + { + return builder.Append(" - ").AppendLine(value); + } +} diff --git a/TeamOctolings.Octobot/Extensions/StringExtensions.cs b/TeamOctolings.Octobot/Extensions/StringExtensions.cs new file mode 100644 index 0000000..bf7f6c8 --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/StringExtensions.cs @@ -0,0 +1,66 @@ +using System.Net; +using Remora.Discord.Extensions.Formatting; + +namespace TeamOctolings.Octobot.Extensions; + +public static class StringExtensions +{ + private const string ZeroWidthSpace = "​"; + + /// + /// Sanitizes a string for use in by inserting zero-width spaces in between + /// symbols used to format the string with block code. + /// + /// The string to sanitize. + /// The sanitized string that can be safely used in . + private static string SanitizeForBlockCode(this string s) + { + return s.Replace("```", $"{ZeroWidthSpace}`{ZeroWidthSpace}`{ZeroWidthSpace}`{ZeroWidthSpace}"); + } + + /// + /// Sanitizes a string for use in when "language" is "diff" by + /// prepending a zero-width space before the input string to prevent Discord from applying syntax highlighting. + /// + /// This does not call , you have to do so yourself if needed. + /// The string to sanitize. + /// The sanitized string that can be safely used in with "diff" as the language. + public static string SanitizeForDiffBlock(this string s) + { + return $"{ZeroWidthSpace}{s}"; + } + + /// + /// Sanitizes a string (see ) and formats the string to use Markdown Block Code + /// formatting with a specified + /// language for syntax highlighting. + /// + /// The string to sanitize and format. + /// + /// + /// The sanitized string formatted to use Markdown Block Code with a specified + /// language for syntax highlighting. + /// + public static string InBlockCode(this string s, string language = "") + { + s = s.SanitizeForBlockCode(); + return + $"```{language}\n{s.SanitizeForBlockCode()}{(s.EndsWith('`') || string.IsNullOrWhiteSpace(s) ? " " : "")}```"; + } + + public static string Localized(this string key) + { + return Messages.ResourceManager.GetString(key, Messages.Culture) ?? key; + } + + /// + /// Encodes a string to allow its transmission in request headers. + /// + /// Used when encountering "Request headers must contain only ASCII characters". + /// The string to encode. + /// An encoded string with spaces kept intact. + public static string EncodeHeader(this string s) + { + return WebUtility.UrlEncode(s).Replace('+', ' '); + } +} diff --git a/TeamOctolings.Octobot/Extensions/UInt64Extensions.cs b/TeamOctolings.Octobot/Extensions/UInt64Extensions.cs new file mode 100644 index 0000000..2b9c0a2 --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/UInt64Extensions.cs @@ -0,0 +1,12 @@ +using Remora.Discord.API; +using Remora.Rest.Core; + +namespace TeamOctolings.Octobot.Extensions; + +public static class UInt64Extensions +{ + public static Snowflake ToSnowflake(this ulong id) + { + return DiscordSnowflake.New(id); + } +} diff --git a/TeamOctolings.Octobot/Extensions/UserExtensions.cs b/TeamOctolings.Octobot/Extensions/UserExtensions.cs new file mode 100644 index 0000000..d9eff33 --- /dev/null +++ b/TeamOctolings.Octobot/Extensions/UserExtensions.cs @@ -0,0 +1,11 @@ +using Remora.Discord.API.Abstractions.Objects; + +namespace TeamOctolings.Octobot.Extensions; + +public static class UserExtensions +{ + public static string GetTag(this IUser user) + { + return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}"; + } +} diff --git a/TeamOctolings.Octobot/Messages.Designer.cs b/TeamOctolings.Octobot/Messages.Designer.cs new file mode 100644 index 0000000..1a81e02 --- /dev/null +++ b/TeamOctolings.Octobot/Messages.Designer.cs @@ -0,0 +1,1206 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace TeamOctolings.Octobot { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Messages { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Messages() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("TeamOctolings.Octobot.Messages", typeof(Messages).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string Ready { + get { + return ResourceManager.GetString("Ready", resourceCulture); + } + } + + internal static string CachedMessageDeleted { + get { + return ResourceManager.GetString("CachedMessageDeleted", resourceCulture); + } + } + + internal static string CachedMessageEdited { + get { + return ResourceManager.GetString("CachedMessageEdited", resourceCulture); + } + } + + internal static string DefaultWelcomeMessage { + get { + return ResourceManager.GetString("DefaultWelcomeMessage", resourceCulture); + } + } + + internal static string Generic1 { + get { + return ResourceManager.GetString("Generic1", resourceCulture); + } + } + + internal static string Generic2 { + get { + return ResourceManager.GetString("Generic2", resourceCulture); + } + } + + internal static string Generic3 { + get { + return ResourceManager.GetString("Generic3", resourceCulture); + } + } + + internal static string YouWereBanned { + get { + return ResourceManager.GetString("YouWereBanned", resourceCulture); + } + } + + internal static string PunishmentExpired { + get { + return ResourceManager.GetString("PunishmentExpired", resourceCulture); + } + } + + internal static string YouWereKicked { + get { + return ResourceManager.GetString("YouWereKicked", resourceCulture); + } + } + + internal static string Milliseconds { + get { + return ResourceManager.GetString("Milliseconds", resourceCulture); + } + } + + internal static string ChannelNotSpecified { + get { + return ResourceManager.GetString("ChannelNotSpecified", resourceCulture); + } + } + + internal static string RoleNotSpecified { + get { + return ResourceManager.GetString("RoleNotSpecified", resourceCulture); + } + } + + internal static string SettingsLanguage { + get { + return ResourceManager.GetString("SettingsLanguage", resourceCulture); + } + } + + internal static string SettingsPrefix { + get { + return ResourceManager.GetString("SettingsPrefix", resourceCulture); + } + } + + internal static string SettingsRemoveRolesOnMute { + get { + return ResourceManager.GetString("SettingsRemoveRolesOnMute", resourceCulture); + } + } + + internal static string SettingsSendWelcomeMessages { + get { + return ResourceManager.GetString("SettingsSendWelcomeMessages", resourceCulture); + } + } + + internal static string SettingsMuteRole { + get { + return ResourceManager.GetString("SettingsMuteRole", resourceCulture); + } + } + + internal static string LanguageNotSupported { + get { + return ResourceManager.GetString("LanguageNotSupported", resourceCulture); + } + } + + internal static string Yes { + get { + return ResourceManager.GetString("Yes", resourceCulture); + } + } + + internal static string No { + get { + return ResourceManager.GetString("No", resourceCulture); + } + } + + internal static string UserNotBanned { + get { + return ResourceManager.GetString("UserNotBanned", resourceCulture); + } + } + + internal static string MemberNotMuted { + get { + return ResourceManager.GetString("MemberNotMuted", resourceCulture); + } + } + + internal static string SettingsWelcomeMessage { + get { + return ResourceManager.GetString("SettingsWelcomeMessage", resourceCulture); + } + } + + internal static string UserBanned { + get { + return ResourceManager.GetString("UserBanned", resourceCulture); + } + } + + internal static string SettingsReceiveStartupMessages { + get { + return ResourceManager.GetString("SettingsReceiveStartupMessages", resourceCulture); + } + } + + internal static string InvalidSettingValue { + get { + return ResourceManager.GetString("InvalidSettingValue", resourceCulture); + } + } + + internal static string DurationRequiredForTimeOuts { + get { + return ResourceManager.GetString("DurationRequiredForTimeOuts", resourceCulture); + } + } + + internal static string CannotTimeOutBot { + get { + return ResourceManager.GetString("CannotTimeOutBot", resourceCulture); + } + } + + internal static string SettingsEventNotificationRole { + get { + return ResourceManager.GetString("SettingsEventNotificationRole", resourceCulture); + } + } + + internal static string SettingsEventNotificationChannel { + get { + return ResourceManager.GetString("SettingsEventNotificationChannel", resourceCulture); + } + } + + internal static string SettingsEventStartedReceivers { + get { + return ResourceManager.GetString("SettingsEventStartedReceivers", resourceCulture); + } + } + + internal static string EventStarted { + get { + return ResourceManager.GetString("EventStarted", resourceCulture); + } + } + + internal static string EventCancelled { + get { + return ResourceManager.GetString("EventCancelled", resourceCulture); + } + } + + internal static string EventCompleted { + get { + return ResourceManager.GetString("EventCompleted", resourceCulture); + } + } + + internal static string MessagesCleared { + get { + return ResourceManager.GetString("MessagesCleared", resourceCulture); + } + } + + internal static string SettingsNothingChanged { + get { + return ResourceManager.GetString("SettingsNothingChanged", resourceCulture); + } + } + + internal static string SettingNotDefined { + get { + return ResourceManager.GetString("SettingNotDefined", resourceCulture); + } + } + + internal static string MissingUser { + get { + return ResourceManager.GetString("MissingUser", resourceCulture); + } + } + + internal static string UserCannotBanMembers { + get { + return ResourceManager.GetString("UserCannotBanMembers", resourceCulture); + } + } + + internal static string UserCannotManageMessages { + get { + return ResourceManager.GetString("UserCannotManageMessages", resourceCulture); + } + } + + internal static string UserCannotKickMembers { + get { + return ResourceManager.GetString("UserCannotKickMembers", resourceCulture); + } + } + + internal static string UserCannotMuteMembers { + get { + return ResourceManager.GetString("UserCannotMuteMembers", resourceCulture); + } + } + + internal static string UserCannotUnmuteMembers { + get { + return ResourceManager.GetString("UserCannotUnmuteMembers", resourceCulture); + } + } + + internal static string UserCannotManageGuild { + get { + return ResourceManager.GetString("UserCannotManageGuild", resourceCulture); + } + } + + internal static string BotCannotBanMembers { + get { + return ResourceManager.GetString("BotCannotBanMembers", resourceCulture); + } + } + + internal static string BotCannotManageMessages { + get { + return ResourceManager.GetString("BotCannotManageMessages", resourceCulture); + } + } + + internal static string BotCannotKickMembers { + get { + return ResourceManager.GetString("BotCannotKickMembers", resourceCulture); + } + } + + internal static string BotCannotModerateMembers { + get { + return ResourceManager.GetString("BotCannotModerateMembers", resourceCulture); + } + } + + internal static string BotCannotManageGuild { + get { + return ResourceManager.GetString("BotCannotManageGuild", resourceCulture); + } + } + + internal static string UserCannotBanOwner { + get { + return ResourceManager.GetString("UserCannotBanOwner", resourceCulture); + } + } + + internal static string UserCannotBanThemselves { + get { + return ResourceManager.GetString("UserCannotBanThemselves", resourceCulture); + } + } + + internal static string UserCannotBanBot { + get { + return ResourceManager.GetString("UserCannotBanBot", resourceCulture); + } + } + + internal static string BotCannotBanTarget { + get { + return ResourceManager.GetString("BotCannotBanTarget", resourceCulture); + } + } + + internal static string UserCannotBanTarget { + get { + return ResourceManager.GetString("UserCannotBanTarget", resourceCulture); + } + } + + internal static string UserCannotKickOwner { + get { + return ResourceManager.GetString("UserCannotKickOwner", resourceCulture); + } + } + + internal static string UserCannotKickThemselves { + get { + return ResourceManager.GetString("UserCannotKickThemselves", resourceCulture); + } + } + + internal static string UserCannotKickBot { + get { + return ResourceManager.GetString("UserCannotKickBot", resourceCulture); + } + } + + internal static string BotCannotKickTarget { + get { + return ResourceManager.GetString("BotCannotKickTarget", resourceCulture); + } + } + + internal static string UserCannotKickTarget { + get { + return ResourceManager.GetString("UserCannotKickTarget", resourceCulture); + } + } + + internal static string UserCannotMuteOwner { + get { + return ResourceManager.GetString("UserCannotMuteOwner", resourceCulture); + } + } + + internal static string UserCannotMuteThemselves { + get { + return ResourceManager.GetString("UserCannotMuteThemselves", resourceCulture); + } + } + + internal static string UserCannotMuteBot { + get { + return ResourceManager.GetString("UserCannotMuteBot", resourceCulture); + } + } + + internal static string BotCannotMuteTarget { + get { + return ResourceManager.GetString("BotCannotMuteTarget", resourceCulture); + } + } + + internal static string UserCannotMuteTarget { + get { + return ResourceManager.GetString("UserCannotMuteTarget", resourceCulture); + } + } + + internal static string UserCannotUnmuteOwner { + get { + return ResourceManager.GetString("UserCannotUnmuteOwner", resourceCulture); + } + } + + internal static string UserCannotUnmuteThemselves { + get { + return ResourceManager.GetString("UserCannotUnmuteThemselves", resourceCulture); + } + } + + internal static string UserCannotUnmuteBot { + get { + return ResourceManager.GetString("UserCannotUnmuteBot", resourceCulture); + } + } + + internal static string BotCannotUnmuteTarget { + get { + return ResourceManager.GetString("BotCannotUnmuteTarget", resourceCulture); + } + } + + internal static string UserCannotUnmuteTarget { + get { + return ResourceManager.GetString("UserCannotUnmuteTarget", resourceCulture); + } + } + + internal static string EventEarlyNotification { + get { + return ResourceManager.GetString("EventEarlyNotification", resourceCulture); + } + } + + internal static string SettingsEventEarlyNotificationOffset { + get { + return ResourceManager.GetString("SettingsEventEarlyNotificationOffset", resourceCulture); + } + } + + internal static string UserNotFound { + get { + return ResourceManager.GetString("UserNotFound", resourceCulture); + } + } + + internal static string SettingsDefaultRole { + get { + return ResourceManager.GetString("SettingsDefaultRole", resourceCulture); + } + } + + internal static string SettingsPublicFeedbackChannel { + get { + return ResourceManager.GetString("SettingsPublicFeedbackChannel", resourceCulture); + } + } + + internal static string SettingsPrivateFeedbackChannel { + get { + return ResourceManager.GetString("SettingsPrivateFeedbackChannel", resourceCulture); + } + } + + internal static string SettingsReturnRolesOnRejoin { + get { + return ResourceManager.GetString("SettingsReturnRolesOnRejoin", resourceCulture); + } + } + + internal static string SettingsAutoStartEvents { + get { + return ResourceManager.GetString("SettingsAutoStartEvents", resourceCulture); + } + } + + internal static string IssuedBy { + get { + return ResourceManager.GetString("IssuedBy", resourceCulture); + } + } + + internal static string EventCreatedTitle { + get { + return ResourceManager.GetString("EventCreatedTitle", resourceCulture); + } + } + + internal static string DescriptionLocalEventCreated { + get { + return ResourceManager.GetString("DescriptionLocalEventCreated", resourceCulture); + } + } + + internal static string DescriptionExternalEventCreated { + get { + return ResourceManager.GetString("DescriptionExternalEventCreated", resourceCulture); + } + } + + internal static string ButtonOpenEventInfo { + get { + return ResourceManager.GetString("ButtonOpenEventInfo", resourceCulture); + } + } + + internal static string EventDuration { + get { + return ResourceManager.GetString("EventDuration", resourceCulture); + } + } + + internal static string DescriptionLocalEventStarted { + get { + return ResourceManager.GetString("DescriptionLocalEventStarted", resourceCulture); + } + } + + internal static string DescriptionExternalEventStarted { + get { + return ResourceManager.GetString("DescriptionExternalEventStarted", resourceCulture); + } + } + + internal static string UserAlreadyBanned { + get { + return ResourceManager.GetString("UserAlreadyBanned", resourceCulture); + } + } + + internal static string UserUnbanned { + get { + return ResourceManager.GetString("UserUnbanned", resourceCulture); + } + } + + internal static string UserMuted { + get { + return ResourceManager.GetString("UserMuted", resourceCulture); + } + } + + internal static string UserUnmuted { + get { + return ResourceManager.GetString("UserUnmuted", resourceCulture); + } + } + + internal static string UserNotMuted { + get { + return ResourceManager.GetString("UserNotMuted", resourceCulture); + } + } + + internal static string UserNotFoundShort { + get { + return ResourceManager.GetString("UserNotFoundShort", resourceCulture); + } + } + + internal static string UserKicked { + get { + return ResourceManager.GetString("UserKicked", resourceCulture); + } + } + + internal static string DescriptionActionReason { + get { + return ResourceManager.GetString("DescriptionActionReason", resourceCulture); + } + } + + internal static string DescriptionActionExpiresAt { + get { + return ResourceManager.GetString("DescriptionActionExpiresAt", resourceCulture); + } + } + + internal static string UserAlreadyMuted { + get { + return ResourceManager.GetString("UserAlreadyMuted", resourceCulture); + } + } + + internal static string MessageFrom { + get { + return ResourceManager.GetString("MessageFrom", resourceCulture); + } + } + + internal static string AboutTitleDevelopers { + get { + return ResourceManager.GetString("AboutTitleDevelopers", resourceCulture); + } + } + + internal static string ButtonOpenWebsite { + get { + return ResourceManager.GetString("ButtonOpenWebsite", resourceCulture); + } + } + + internal static string AboutBot { + get { + return ResourceManager.GetString("AboutBot", resourceCulture); + } + } + + internal static string AboutDeveloper_mctaylors { + get { + return ResourceManager.GetString("AboutDeveloper@mctaylors", resourceCulture); + } + } + + internal static string AboutDeveloper_Octol1ttle { + get { + return ResourceManager.GetString("AboutDeveloper@Octol1ttle", resourceCulture); + } + } + + internal static string AboutDeveloper_neroduckale { + get { + return ResourceManager.GetString("AboutDeveloper@neroduckale", resourceCulture); + } + } + + internal static string ReminderCreated { + get { + return ResourceManager.GetString("ReminderCreated", resourceCulture); + } + } + + internal static string Reminder { + get { + return ResourceManager.GetString("Reminder", resourceCulture); + } + } + + internal static string DescriptionReminder { + get { + return ResourceManager.GetString("DescriptionReminder", resourceCulture); + } + } + + internal static string SettingsListTitle { + get { + return ResourceManager.GetString("SettingsListTitle", resourceCulture); + } + } + + internal static string SettingSuccessfullyChanged { + get { + return ResourceManager.GetString("SettingSuccessfullyChanged", resourceCulture); + } + } + + internal static string SettingNotChanged { + get { + return ResourceManager.GetString("SettingNotChanged", resourceCulture); + } + } + + internal static string SettingIsNow { + get { + return ResourceManager.GetString("SettingIsNow", resourceCulture); + } + } + + internal static string SettingsRenameHoistedUsers { + get { + return ResourceManager.GetString("SettingsRenameHoistedUsers", resourceCulture); + } + } + + internal static string Page { + get { + return ResourceManager.GetString("Page", resourceCulture); + } + } + + internal static string PageNotFound { + get { + return ResourceManager.GetString("PageNotFound", resourceCulture); + } + } + + internal static string PagesAllowed { + get { + return ResourceManager.GetString("PagesAllowed", resourceCulture); + } + } + + internal static string Next { + get { + return ResourceManager.GetString("Next", resourceCulture); + } + } + + internal static string Previous { + get { + return ResourceManager.GetString("Previous", resourceCulture); + } + } + + internal static string ReminderList { + get { + return ResourceManager.GetString("ReminderList", resourceCulture); + } + } + + internal static string InvalidReminderPosition { + get { + return ResourceManager.GetString("InvalidReminderPosition", resourceCulture); + } + } + + internal static string ReminderDeleted { + get { + return ResourceManager.GetString("ReminderDeleted", resourceCulture); + } + } + + internal static string NoRemindersFound { + get { + return ResourceManager.GetString("NoRemindersFound", resourceCulture); + } + } + + internal static string SingleSettingReset { + get { + return ResourceManager.GetString("SingleSettingReset", resourceCulture); + } + } + + internal static string AllSettingsReset { + get { + return ResourceManager.GetString("AllSettingsReset", resourceCulture); + } + } + + internal static string DescriptionActionJumpToMessage { + get { + return ResourceManager.GetString("DescriptionActionJumpToMessage", resourceCulture); + } + } + + internal static string DescriptionActionJumpToChannel { + get { + return ResourceManager.GetString("DescriptionActionJumpToChannel", resourceCulture); + } + } + + internal static string ReminderPosition { + get { + return ResourceManager.GetString("ReminderPosition", resourceCulture); + } + } + + internal static string ReminderTime { + get { + return ResourceManager.GetString("ReminderTime", resourceCulture); + } + } + + internal static string ReminderText { + get { + return ResourceManager.GetString("ReminderText", resourceCulture); + } + } + + internal static string UserInfoDisplayName { + get { + return ResourceManager.GetString("UserInfoDisplayName", resourceCulture); + } + } + + internal static string InformationAbout { + get { + return ResourceManager.GetString("InformationAbout", resourceCulture); + } + } + + internal static string UserInfoMuted { + get { + return ResourceManager.GetString("UserInfoMuted", resourceCulture); + } + } + + internal static string UserInfoDiscordUserSince { + get { + return ResourceManager.GetString("UserInfoDiscordUserSince", resourceCulture); + } + } + + internal static string UserInfoBanned { + get { + return ResourceManager.GetString("UserInfoBanned", resourceCulture); + } + } + + internal static string UserInfoPunishments { + get { + return ResourceManager.GetString("UserInfoPunishments", resourceCulture); + } + } + + internal static string UserInfoBannedPermanently { + get { + return ResourceManager.GetString("UserInfoBannedPermanently", resourceCulture); + } + } + + internal static string UserInfoNotOnGuild { + get { + return ResourceManager.GetString("UserInfoNotOnGuild", resourceCulture); + } + } + + internal static string UserInfoMutedByTimeout { + get { + return ResourceManager.GetString("UserInfoMutedByTimeout", resourceCulture); + } + } + + internal static string UserInfoMutedByMuteRole { + get { + return ResourceManager.GetString("UserInfoMutedByMuteRole", resourceCulture); + } + } + + internal static string UserInfoGuildMemberSince { + get { + return ResourceManager.GetString("UserInfoGuildMemberSince", resourceCulture); + } + } + + internal static string UserInfoGuildNickname { + get { + return ResourceManager.GetString("UserInfoGuildNickname", resourceCulture); + } + } + + internal static string UserInfoGuildRoles { + get { + return ResourceManager.GetString("UserInfoGuildRoles", resourceCulture); + } + } + + internal static string UserInfoGuildMemberPremiumSince { + get { + return ResourceManager.GetString("UserInfoGuildMemberPremiumSince", resourceCulture); + } + } + + internal static string RandomTitle { + get { + return ResourceManager.GetString("RandomTitle", resourceCulture); + } + } + + internal static string RandomMinMaxSame { + get { + return ResourceManager.GetString("RandomMinMaxSame", resourceCulture); + } + } + + internal static string RandomMin { + get { + return ResourceManager.GetString("RandomMin", resourceCulture); + } + } + + internal static string RandomMax { + get { + return ResourceManager.GetString("RandomMax", resourceCulture); + } + } + + internal static string Default { + get { + return ResourceManager.GetString("Default", resourceCulture); + } + } + + internal static string TimestampTitle { + get { + return ResourceManager.GetString("TimestampTitle", resourceCulture); + } + } + + internal static string TimestampOffset { + get { + return ResourceManager.GetString("TimestampOffset", resourceCulture); + } + } + + internal static string GuildInfoDescription { + get { + return ResourceManager.GetString("GuildInfoDescription", resourceCulture); + } + } + + internal static string GuildInfoCreatedAt { + get { + return ResourceManager.GetString("GuildInfoCreatedAt", resourceCulture); + } + } + + internal static string GuildInfoOwner { + get { + return ResourceManager.GetString("GuildInfoOwner", resourceCulture); + } + } + + internal static string GuildInfoServerBoost { + get { + return ResourceManager.GetString("GuildInfoServerBoost", resourceCulture); + } + } + + internal static string GuildInfoBoostTier { + get { + return ResourceManager.GetString("GuildInfoBoostTier", resourceCulture); + } + } + + internal static string GuildInfoBoostCount { + get { + return ResourceManager.GetString("GuildInfoBoostCount", resourceCulture); + } + } + + internal static string NoMessagesToClear { + get { + return ResourceManager.GetString("NoMessagesToClear", resourceCulture); + } + } + + internal static string MessagesClearedFiltered { + get { + return ResourceManager.GetString("MessagesClearedFiltered", resourceCulture); + } + } + + internal static string DataLoadFailedTitle { + get { + return ResourceManager.GetString("DataLoadFailedTitle", resourceCulture); + } + } + + internal static string DataLoadFailedDescription { + get { + return ResourceManager.GetString("DataLoadFailedDescription", resourceCulture); + } + } + + internal static string CommandExecutionFailed { + get { + return ResourceManager.GetString("CommandExecutionFailed", resourceCulture); + } + } + + internal static string ContactDevelopers { + get { + return ResourceManager.GetString("ContactDevelopers", resourceCulture); + } + } + + internal static string ButtonReportIssue { + get { + return ResourceManager.GetString("ButtonReportIssue", resourceCulture); + } + } + + internal static string DefaultLeaveMessage { + get { + return ResourceManager.GetString("DefaultLeaveMessage", resourceCulture); + } + } + + internal static string SettingsLeaveMessage { + get { + return ResourceManager.GetString("SettingsLeaveMessage", resourceCulture); + } + } + + internal static string InvalidTimeSpan { + get { + return ResourceManager.GetString("InvalidTimeSpan", resourceCulture); + } + } + + internal static string UserInfoKicked { + get { + return ResourceManager.GetString("UserInfoKicked", resourceCulture); + } + } + + internal static string ReminderEdited { + get { + return ResourceManager.GetString("ReminderEdited", resourceCulture); + } + } + + internal static string EightBallPositive1 { + get { + return ResourceManager.GetString("EightBallPositive1", resourceCulture); + } + } + + internal static string EightBallPositive2 { + get { + return ResourceManager.GetString("EightBallPositive2", resourceCulture); + } + } + + internal static string EightBallPositive3 { + get { + return ResourceManager.GetString("EightBallPositive3", resourceCulture); + } + } + + internal static string EightBallPositive4 { + get { + return ResourceManager.GetString("EightBallPositive4", resourceCulture); + } + } + + internal static string EightBallPositive5 { + get { + return ResourceManager.GetString("EightBallPositive5", resourceCulture); + } + } + + internal static string EightBallQuestionable1 { + get { + return ResourceManager.GetString("EightBallQuestionable1", resourceCulture); + } + } + + internal static string EightBallQuestionable2 { + get { + return ResourceManager.GetString("EightBallQuestionable2", resourceCulture); + } + } + + internal static string EightBallQuestionable3 { + get { + return ResourceManager.GetString("EightBallQuestionable3", resourceCulture); + } + } + + internal static string EightBallQuestionable4 { + get { + return ResourceManager.GetString("EightBallQuestionable4", resourceCulture); + } + } + + internal static string EightBallQuestionable5 { + get { + return ResourceManager.GetString("EightBallQuestionable5", resourceCulture); + } + } + + internal static string EightBallNeutral1 { + get { + return ResourceManager.GetString("EightBallNeutral1", resourceCulture); + } + } + + internal static string EightBallNeutral2 { + get { + return ResourceManager.GetString("EightBallNeutral2", resourceCulture); + } + } + + internal static string EightBallNeutral3 { + get { + return ResourceManager.GetString("EightBallNeutral3", resourceCulture); + } + } + + internal static string EightBallNeutral4 { + get { + return ResourceManager.GetString("EightBallNeutral4", resourceCulture); + } + } + + internal static string EightBallNeutral5 { + get { + return ResourceManager.GetString("EightBallNeutral5", resourceCulture); + } + } + + internal static string EightBallNegative1 { + get { + return ResourceManager.GetString("EightBallNegative1", resourceCulture); + } + } + + internal static string EightBallNegative2 { + get { + return ResourceManager.GetString("EightBallNegative2", resourceCulture); + } + } + + internal static string EightBallNegative3 { + get { + return ResourceManager.GetString("EightBallNegative3", resourceCulture); + } + } + + internal static string EightBallNegative4 { + get { + return ResourceManager.GetString("EightBallNegative4", resourceCulture); + } + } + + internal static string EightBallNegative5 { + get { + return ResourceManager.GetString("EightBallNegative5", resourceCulture); + } + } + + internal static string TimeSpanExample { + get { + return ResourceManager.GetString("TimeSpanExample", resourceCulture); + } + } + + internal static string Version { + get { + return ResourceManager.GetString("Version", resourceCulture); + } + } + + internal static string SettingsWelcomeMessagesChannel { + get { + return ResourceManager.GetString("SettingsWelcomeMessagesChannel", resourceCulture); + } + } + + internal static string ButtonDirty { + get { + return ResourceManager.GetString("ButtonDirty", resourceCulture); + } + } + + internal static string ButtonOpenWiki { + get { + return ResourceManager.GetString("ButtonOpenWiki", resourceCulture); + } + } + + internal static string SettingsModeratorRole { + get { + return ResourceManager.GetString("SettingsModeratorRole", resourceCulture); + } + } + + internal static string SettingValueEquals { + get { + return ResourceManager.GetString("SettingValueEquals", resourceCulture); + } + } + } +} diff --git a/TeamOctolings.Octobot/Messages.resx b/TeamOctolings.Octobot/Messages.resx new file mode 100644 index 0000000..e4107fb --- /dev/null +++ b/TeamOctolings.Octobot/Messages.resx @@ -0,0 +1,687 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + I'm ready! + + + Deleted message by {0}: + + + Edited message by {0}: + + + {0}, welcome to {1} + + + Veemo! + + + Woomy! + + + Ngyes! + + + You were banned + + + Punishment expired + + + You were kicked + + + ms + + + Not specified + + + Not specified + + + Language + + + Prefix + + + Remove roles on mute + + + Send welcome messages + + + Mute role + + + Language not supported! + + + Yes + + + No + + + This user is not banned! + + + Member not muted! + + + Welcome message + + + {0} was banned + + + Receive startup messages + + + Invalid setting value specified! + + + I cannot mute someone for more than 28 days using timeouts! Either specify a duration shorter than 28 days, or set a mute role in settings + + + I cannot use time-outs on other bots! Try to set a mute role in settings + + + Role for event creation notifications + + + Channel for event notifications + + + Event start notifications receivers + + + Event "{0}" started + + + Event "{0}" is cancelled! + + + Event "{0}" has completed! + + + Cleared {0} messages + + + Nothing changed! `{0}` is already set to {1} + + + Not specified + + + You need to specify a user! + + + You cannot ban users from this guild! + + + You cannot manage messages in this guild! + + + You cannot kick members from this guild! + + + You cannot mute members in this guild! + + + You cannot unmute members in this guild! + + + You cannot manage this guild! + + + I cannot ban users from this guild! + + + I cannot manage messages in this guild! + + + I cannot kick members from this guild! + + + I cannot moderate members in this guild! + + + I cannot manage this guild! + + + You cannot ban the owner of this guild! + + + You cannot ban yourself! + + + You cannot ban me! + + + I cannot ban this user! + + + You cannot ban this user! + + + You cannot kick the owner of this guild! + + + You cannot kick yourself! + + + You cannot kick me! + + + I cannot kick this member! + + + You cannot kick this member! + + + You cannot mute the owner of this guild! + + + You cannot mute yourself! + + + You cannot mute me! + + + I cannot mute this member! + + + You cannot mute this member! + + + You don't need to unmute the owner of this guild! + + + You are muted! + + + ... + + + I cannot unmute this member! + + + You cannot unmute this user! + + + Event "{0}" will start {1}! + + + Early event start notification offset + + + I could not find this user in any guild I'm a member of! Check if the ID is correct and that the user was on this server no longer than 30 days ago + + + Default role + + + Channel for public notifications + + + Channel for private notifications + + + Return roles on rejoin + + + Automatically start scheduled events + + + Issued by + + + {0} has created a new event: + + + The event will start at {0} in {1} + + + The event will start at {0} until {1} in {2} + + + Open Event Info + + + The event has lasted for `{0}` + + + The event is happening at {0} + + + The event is happening at {0} until {1} + + + This user is already banned! + + + {0} was unbanned + + + {0} was muted + + + {0} was unmuted + + + This member is not muted! + + + I could not find this user! + + + {0} was kicked + + + Reason: {0} + + + Expires at: {0} + + + This user is already muted! + + + From {0}: + + + Developers: + + + Open Website + + + About {0} + + + developer & designer, Octobot's Wiki creator + + + main developer + + + developer + + + Reminder for {0} created + + + Reminder for {0} + + + You asked me to remind you {0} + + + Octobot's Settings + + + Setting successfully changed + + + Setting not changed + + + is now + + + Rename members who attempt to hoist themselves + + + Page + + + Page not found! + + + There are {0} total pages + + + Next + + + Previous + + + {0}'s reminders + + + There's no reminder in this position! + + + Reminder deleted + + + You don't have any reminders created! + + + Setting {0} reset + + + All settings have been reset + + + Jump to message: {0} + + + Jump to channel: {0} + + + Position in list: {0} + + + Reminder send time: {0} + + + Reminder text: {0} + + + Display name + + + Information about {0} + + + Muted + + + Discord user since + + + Banned + + + Punishments + + + Banned permanently + + + Not in the guild + + + Muted by timeout + + + Muted by mute role + + + Guild member since + + + Nickname + + + Roles + + + Nitro booster since + + + Random number for {0} is: + + + Isn't it obvious? + + + Minimum number: {0} + + + Maximum number: {0} + + + (default) + + + Timestamp for {0}: + + + Offset: {0} + + + Guild description + + + Creation date + + + Guild owner + + + Server Boost + + + Boost level + + + Boost count + + + There are no messages matching your filter! + + + Cleared {0} messages from {1} + + + An error occurred during guild data load. + + + This will lead to unexpected behavior. Data will no longer be saved + + + An error occurred during command execution, try again later. + + + Contact the developers if the problem occurs again. + + + Report an issue + + + See you soon, {0}! + + + Leave message + + + Time specified incorrectly! + + + Kicked + + + Reminder edited + + + It is certain + + + It is decidedly so + + + Without a doubt + + + Yes — definitely + + + You may rely on it + + + As I see it, yes + + + Most likely + + + Outlook good + + + Signs point to yes + + + Yes + + + Reply hazy, try again + + + Ask again later + + + Better not tell you now + + + Cannot predict now + + + Concentrate and ask again + + + Don’t count on it + + + My reply is no + + + My sources say no + + + Outlook not so good + + + Very doubtful + + + Example of a valid input: `1h30m` + + + Version: {0} + + + Welcome messages channel + + + Can't report an issue in the development version + + + Open Octobot's Wiki + + + Moderator role + + + The setting value is the same as the input value. + + diff --git a/TeamOctolings.Octobot/Messages.ru.resx b/TeamOctolings.Octobot/Messages.ru.resx new file mode 100644 index 0000000..d942cec --- /dev/null +++ b/TeamOctolings.Octobot/Messages.ru.resx @@ -0,0 +1,687 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Я запустился! + + + Сообщение {0} удалено: + + + Сообщение {0} отредактировано: + + + {0}, добро пожаловать на сервер {1} + + + Виимо! + + + Вууми! + + + Нгьес! + + + Время наказания истекло + + + Вы были выгнаны + + + мс + + + Не указан + + + Не указана + + + Язык + + + Префикс + + + Удалять роли при муте + + + Отправлять приветствия + + + Роль мута + + + Язык не поддерживается! + + + Да + + + Нет + + + Этот пользователь не забанен! + + + Участник не заглушен! + + + Приветствие + + + {0} был(-а) забанен(-а) + + + Получать сообщения о запуске + + + Указано недействительное значение для настройки! + + + Я не могу заглушить кого-то на более чем 28 дней, используя тайм-ауты! Или укажи продолжительность менее 28 дней, или установи роль мута в настройках + + + Я не могу использовать тайм-ауты на других ботах! Попробуй указать роль мута в настройках + + + Роль для уведомлений о создании событий + + + Канал для уведомлений о событиях + + + Получатели уведомлений о начале событий + + + Событие "{0}" началось + + + Событие "{0}" отменено! + + + Событие "{0}" завершено! + + + Очищено {0} сообщений + + + Ничего не изменилось! Значение настройки `{0}` уже {1} + + + Не указано + + + Надо указать пользователя! + + + Ты не можешь банить пользователей на этом сервере! + + + Ты не можешь управлять сообщениями этого сервера! + + + Ты не можешь выгонять участников с этого сервера! + + + Ты не можешь глушить участников этого сервера! + + + Ты не можешь разглушать участников этого сервера! + + + Ты не можешь настраивать этот сервер! + + + Я не могу банить пользователей на этом сервере! + + + Я не могу управлять сообщениями этого сервера! + + + Я не могу выгонять участников с этого сервера! + + + Я не могу модерировать участников этого сервера! + + + Я не могу настраивать этот сервер! + + + Ты не можешь меня забанить! + + + Ты не можешь забанить владельца этого сервера! + + + Ты не можешь забанить этого участника! + + + Ты не можешь себя забанить! + + + Я не могу забанить этого пользователя! + + + Ты не можешь выгнать владельца этого сервера! + + + Ты не можешь себя выгнать! + + + Ты не можешь меня выгнать! + + + Я не могу выгнать этого участника + + + Ты не можешь выгнать этого участника! + + + Ты не можешь заглушить владельца этого сервера! + + + Ты не можешь себя заглушить! + + + Ты не можешь заглушить меня! + + + Я не могу заглушить этого пользователя! + + + Ты не можешь заглушить этого участника! + + + Тебе не надо возвращать из мута владельца этого сервера! + + + Ты заглушен! + + + ... + + + Ты не можешь вернуть из мута этого пользователя! + + + Я не могу вернуть из мута этого пользователя! + + + Событие "{0}" начнется {1}! + + + Офсет отправки преждевременного уведомления о начале события + + + Я не смог найти этого пользователя ни в одном из серверов, в которых я есть. Проверь правильность ID и нахождение пользователя на этом сервере максимум 30 дней назад + + + Роль по умолчанию + + + Канал для публичных уведомлений + + + Канал для приватных уведомлений + + + Возвращать роли при перезаходе + + + Автоматически начинать события + + + Ответственный + + + {0} создаёт новое событие: + + + Событие пройдёт {0} в канале {1} + + + Событие пройдёт с {0} до {1} в {2} + + + Открыть сведения о событии + + + Событие длилось `{0}` + + + Событие происходит в {0} + + + Событие происходит в {0} до {1} + + + Этот пользователь уже забанен! + + + {0} был(-а) разбанен(-а) + + + {0} был(-а) заглушен(-а) + + + Этот участник не заглушен! + + + {0} был(-а) разглушен(-а) + + + Я не смог найти этого пользователя! + + + {0} был(-а) выгнан(-а) + + + Причина: {0} + + + Закончится: {0} + + + Этот пользователь уже в муте! + + + Вы были забанены + + + От {0}: + + + Разработчики: + + + Открыть веб-сайт + + + О боте {0} + + + разработчик + + + основной разработчик + + + разработчик и дизайнер, создатель Octobot's Wiki + + + Напоминание для {0} создано + + + Напоминание для {0} + + + Вы просили напомнить вам {0} + + + Настройки Octobot + + + Настройка успешно изменена + + + Настройка не редактирована + + + теперь имеет значение + + + Переименовывать участников, которые пытаются поднять себя + + + Страница + + + Страница не найдена! + + + Всего есть {0} страниц(-ы) + + + Далее + + + Назад + + + Напоминания {0} + + + У тебя нет напоминания на этой позиции! + + + Напоминание удалено + + + У вас нет созданных напоминаний! + + + Настройка {0} сброшена + + + Все настройки были сброшены + + + Перейти к сообщению: {0} + + + Перейти к каналу: {0} + + + Позиция в списке: {0} + + + Время отправки напоминания: {0} + + + Текст напоминания: {0} + + + Отображаемое имя + + + Информация о {0} + + + Заглушен + + + Вступил в Discord + + + Забанен + + + Наказания + + + Забанен навсегда + + + Не на сервере + + + Заглушен с помощью тайм-аута + + + Заглушен с помощью роли мута + + + Вступил на сервер + + + Никнейм + + + Роли + + + Начал бустить сервер + + + Случайное число для {0}: + + + Разве это не очевидно? + + + Максимальное число: {0} + + + Минимальное число: {0} + + + (по умолчанию) + + + Временная метка для {0}: + + + Офсет: {0} + + + Описание сервера + + + Дата создания + + + Владелец сервера + + + Буст сервера + + + Уровень буста + + + Количество бустов + + + Нет сообщений, которые подходят под твой фильтр! + + + Очищено {0} сообщений от {1} + + + Произошла ошибка при загрузке данных сервера. + + + Это может привести к неожиданному поведению. Данные больше не будут сохраняться. + + + Произошла ошибка при выполнении команды, повтори попытку позже. + + + Обратись к разработчикам, если проблема возникнет снова. + + + Сообщить о проблеме + + + До скорой встречи, {0}! + + + Сообщение о выходе + + + Неправильно указано время! + + + Выгнан + + + Напоминание отредактировано + + + Бесспорно + + + Предрешено + + + Никаких сомнений + + + Определённо да + + + Можешь быть уверен в этом + + + Мне кажется — «да» + + + Вероятнее всего + + + Хорошие перспективы + + + Знаки говорят — «да» + + + Да + + + Пока не ясно, попробуй снова + + + Спроси позже + + + Лучше не рассказывать + + + Сейчас нельзя предсказать + + + Сконцентрируйся и спроси снова + + + Даже не думай + + + Мой ответ — «нет» + + + По моим данным — «нет» + + + Перспективы не очень хорошие + + + Весьма сомнительно + + + Пример правильного ввода: `1ч30м` + + + Версия: {0} + + + Канал для приветствий + + + Нельзя сообщить о проблеме в версии под разработкой + + + Открыть Octobot's Wiki + + + Роль модератора + + + Значение настройки такое же, как и вводное значение. + + diff --git a/TeamOctolings.Octobot/Parsers/TimeSpanParser.cs b/TeamOctolings.Octobot/Parsers/TimeSpanParser.cs new file mode 100644 index 0000000..99a8b90 --- /dev/null +++ b/TeamOctolings.Octobot/Parsers/TimeSpanParser.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using JetBrains.Annotations; +using Remora.Commands.Parsers; +using Remora.Results; + +namespace TeamOctolings.Octobot.Parsers; + +/// +/// Parses s. +/// +[PublicAPI] +public partial class TimeSpanParser : AbstractTypeParser +{ + private static readonly Regex Pattern = ParseRegex(); + + /// + /// Parses a from the . + /// + /// + /// The parsed , or if parsing failed. + /// + public static Result TryParse(string timeSpanString) + { + if (timeSpanString.StartsWith('-')) + { + return new ArgumentInvalidError(nameof(timeSpanString), "TimeSpans cannot be negative."); + } + + if (TimeSpan.TryParse(timeSpanString, DateTimeFormatInfo.InvariantInfo, out var parsedTimeSpan)) + { + return parsedTimeSpan; + } + + var matches = ParseRegex().Matches(timeSpanString); + return matches.Count > 0 + ? ParseFromRegex(matches) + : new ArgumentInvalidError(nameof(timeSpanString), "The regex did not produce any matches."); + } + + private static Result ParseFromRegex(MatchCollection matches) + { + var timeSpan = TimeSpan.Zero; + + foreach (var groups in matches.Select(match => match.Groups + .Cast() + .Where(g => g.Success) + .Skip(1) + .Select(g => (g.Name, g.Value)))) + { + foreach ((var key, var groupValue) in groups) + { + if (!int.TryParse(groupValue, out var parsedIntegerValue)) + { + return new ArgumentInvalidError(nameof(groupValue), "The input value was not an integer."); + } + + var now = DateTimeOffset.UtcNow; + timeSpan += key switch + { + "Years" => now.AddYears(parsedIntegerValue) - now, + "Months" => now.AddMonths(parsedIntegerValue) - now, + "Weeks" => TimeSpan.FromDays(parsedIntegerValue * 7), + "Days" => TimeSpan.FromDays(parsedIntegerValue), + "Hours" => TimeSpan.FromHours(parsedIntegerValue), + "Minutes" => TimeSpan.FromMinutes(parsedIntegerValue), + "Seconds" => TimeSpan.FromSeconds(parsedIntegerValue), + _ => throw new ArgumentOutOfRangeException(key) + }; + } + } + + return timeSpan; + } + + [GeneratedRegex("(?\\d+(?=y|л|г))|(?\\d+(?=mo|мес))|(?\\d+(?=w|н|нед))|(?\\d+(?=d|д|дн))|(?\\d+(?=h|ч))|(?\\d+(?=m|min|мин|м))|(?\\d+(?=s|sec|с|сек))")] + private static partial Regex ParseRegex(); +} diff --git a/TeamOctolings.Octobot/Program.cs b/TeamOctolings.Octobot/Program.cs new file mode 100644 index 0000000..8cdbdcf --- /dev/null +++ b/TeamOctolings.Octobot/Program.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Gateway.Commands; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.Caching.Extensions; +using Remora.Discord.Caching.Services; +using Remora.Discord.Commands.Extensions; +using Remora.Discord.Commands.Services; +using Remora.Discord.Extensions.Extensions; +using Remora.Discord.Gateway; +using Remora.Discord.Hosting.Extensions; +using Serilog.Extensions.Logging; +using TeamOctolings.Octobot.Commands.Events; +using TeamOctolings.Octobot.Services; +using TeamOctolings.Octobot.Services.Update; + +namespace TeamOctolings.Octobot; + +public sealed class Program +{ + public static async Task Main(string[] args) + { + var host = CreateHostBuilder(args).UseConsoleLifetime().Build(); + var services = host.Services; + Utility.StaticLogger = services.GetRequiredService>(); + + var slashService = services.GetRequiredService(); + // Providing a guild ID to this call will result in command duplicates! + // To get rid of them, provide the ID of the guild containing duplicates, + // comment out calls to WithCommandGroup in CreateHostBuilder + // then launch the bot again and remove the guild ID + await slashService.UpdateSlashCommandsAsync(); + + await host.RunAsync(); + } + + private static IHostBuilder CreateHostBuilder(string[] args) + { + return Host.CreateDefaultBuilder(args) + .AddDiscordService(services => + { + var configuration = services.GetRequiredService(); + + return configuration.GetValue("BOT_TOKEN") + ?? throw new InvalidOperationException( + "No bot token has been provided. Set the " + + "BOT_TOKEN environment variable to a valid token."); + } + ).ConfigureServices((_, services) => + { + services.Configure(options => + { + options.Intents |= GatewayIntents.MessageContents + | GatewayIntents.GuildMembers + | GatewayIntents.GuildPresences + | GatewayIntents.GuildScheduledEvents; + }); + services.Configure(cSettings => + { + cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1)); + cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30)); + cSettings.SetAbsoluteExpiration(TimeSpan.FromDays(7)); + cSettings.SetSlidingExpiration(TimeSpan.FromDays(7)); + }); + + services.AddTransient() + // Init + .AddDiscordCaching() + .AddDiscordCommands(true, false) + .AddRespondersFromAssembly(typeof(Program).Assembly) + .AddCommandGroupsFromAssembly(typeof(Program).Assembly) + // Slash command event handlers + .AddPreparationErrorEvent() + .AddPostExecutionEvent() + // Services + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddHostedService(provider => provider.GetRequiredService()) + .AddHostedService() + .AddHostedService() + .AddHostedService(); + } + ).ConfigureLogging(c => c.AddConsole() + .AddFile("Logs/Octobot-{Date}.log", + outputTemplate: "{Timestamp:o} [{Level:u4}] {Message} {NewLine}{Exception}") + .AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning) + .AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning) + .AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning) + .AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning) + ); + } +} diff --git a/TeamOctolings.Octobot/Responders/GuildLoadedResponder.cs b/TeamOctolings.Octobot/Responders/GuildLoadedResponder.cs new file mode 100644 index 0000000..b24ef0b --- /dev/null +++ b/TeamOctolings.Octobot/Responders/GuildLoadedResponder.cs @@ -0,0 +1,125 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Gateway.Events; +using Remora.Discord.API.Objects; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Gateway.Responders; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Responders; + +/// +/// Handles sending a message to a guild that has just initialized if that guild +/// has enabled +/// +[UsedImplicitly] +public sealed class GuildLoadedResponder : IResponder +{ + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _guildData; + private readonly ILogger _logger; + private readonly IDiscordRestUserAPI _userApi; + private readonly Utility _utility; + + public GuildLoadedResponder( + IDiscordRestChannelAPI channelApi, GuildDataService guildData, ILogger logger, + IDiscordRestUserAPI userApi, Utility utility) + { + _channelApi = channelApi; + _guildData = guildData; + _logger = logger; + _userApi = userApi; + _utility = utility; + } + + public async Task RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default) + { + if (!gatewayEvent.Guild.IsT0) // Guild is not IAvailableGuild + { + return Result.Success; + } + + var guild = gatewayEvent.Guild.AsT0; + + var data = await _guildData.GetData(guild.ID, ct); + var cfg = data.Settings; + foreach (var member in guild.Members.Where(m => m.User.HasValue)) + { + data.GetOrCreateMemberData(member.User.Value.ID); + } + + var botResult = await _userApi.GetCurrentUserAsync(ct); + if (!botResult.IsDefined(out var bot)) + { + return ResultExtensions.FromError(botResult); + } + + if (data.DataLoadFailed) + { + return await SendDataLoadFailed(guild, data, bot, ct); + } + + var ownerResult = await _userApi.GetUserAsync(guild.OwnerID, ct); + if (!ownerResult.IsDefined(out var owner)) + { + return ResultExtensions.FromError(ownerResult); + } + + _logger.LogInformation("Loaded guild \"{Name}\" ({ID}) owned by {Owner} ({OwnerID}) with {MemberCount} members", + guild.Name, guild.ID, owner.GetTag(), owner.ID, guild.MemberCount); + + if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty() + || !GuildSettings.ReceiveStartupMessages.Get(cfg)) + { + return Result.Success; + } + + Messages.Culture = GuildSettings.Language.Get(cfg); + var i = Random.Shared.Next(1, 4); + + var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot) + .WithTitle($"Generic{i}".Localized()) + .WithDescription(Messages.Ready) + .WithCurrentTimestamp() + .WithColour(ColorsList.Blue) + .Build(); + + return await _channelApi.CreateMessageWithEmbedResultAsync( + GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, ct: ct); + } + + private async Task SendDataLoadFailed(IGuild guild, GuildData data, IUser bot, CancellationToken ct = default) + { + var channelResult = await _utility.GetEmergencyFeedbackChannel(guild, data, ct); + if (!channelResult.IsDefined(out var channel)) + { + return ResultExtensions.FromError(channelResult); + } + + var errorEmbed = new EmbedBuilder() + .WithSmallTitle(Messages.DataLoadFailedTitle, bot) + .WithDescription(Messages.DataLoadFailedDescription) + .WithFooter(Messages.ContactDevelopers) + .WithColour(ColorsList.Red) + .Build(); + + var issuesButton = new ButtonComponent( + ButtonComponentStyle.Link, + BuildInfo.IsDirty + ? Messages.ButtonDirty + : Messages.ButtonReportIssue, + new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0) + URL: BuildInfo.IssuesUrl, + IsDisabled: BuildInfo.IsDirty + ); + + return await _channelApi.CreateMessageWithEmbedResultAsync(channel, embedResult: errorEmbed, + components: new[] { new ActionRowComponent([issuesButton]) }, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Responders/GuildMemberJoinedResponder.cs b/TeamOctolings.Octobot/Responders/GuildMemberJoinedResponder.cs new file mode 100644 index 0000000..ae9f174 --- /dev/null +++ b/TeamOctolings.Octobot/Responders/GuildMemberJoinedResponder.cs @@ -0,0 +1,106 @@ +using System.Text.Json.Nodes; +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Gateway.Responders; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Responders; + +/// +/// Handles sending a guild's if one is set. +/// If is enabled, roles will be returned. +/// +/// +[UsedImplicitly] +public sealed class GuildMemberJoinedResponder : IResponder +{ + private readonly IDiscordRestChannelAPI _channelApi; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly GuildDataService _guildData; + + public GuildMemberJoinedResponder( + IDiscordRestChannelAPI channelApi, GuildDataService guildData, IDiscordRestGuildAPI guildApi) + { + _channelApi = channelApi; + _guildData = guildData; + _guildApi = guildApi; + } + + public async Task RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default) + { + if (!gatewayEvent.User.IsDefined(out var user)) + { + return new ArgumentNullError(nameof(gatewayEvent.User)); + } + + var data = await _guildData.GetData(gatewayEvent.GuildID, ct); + var cfg = data.Settings; + var memberData = data.GetOrCreateMemberData(user.ID); + + memberData.Kicked = false; + + var returnRolesResult = await TryReturnRolesAsync(cfg, memberData, gatewayEvent.GuildID, user.ID, ct); + if (!returnRolesResult.IsSuccess) + { + return ResultExtensions.FromError(returnRolesResult); + } + + if (GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty() + || GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled") + { + return Result.Success; + } + + Messages.Culture = GuildSettings.Language.Get(cfg); + var welcomeMessage = GuildSettings.WelcomeMessage.Get(cfg) is "default" or "reset" + ? Messages.DefaultWelcomeMessage + : GuildSettings.WelcomeMessage.Get(cfg); + + var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct); + if (!guildResult.IsDefined(out var guild)) + { + return ResultExtensions.FromError(guildResult); + } + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user) + .WithGuildFooter(guild) + .WithTimestamp(gatewayEvent.JoinedAt) + .WithColour(ColorsList.Green) + .Build(); + + return await _channelApi.CreateMessageWithEmbedResultAsync( + GuildSettings.WelcomeMessagesChannel.Get(cfg), embedResult: embed, + allowedMentions: Utility.NoMentions, ct: ct); + } + + private async Task TryReturnRolesAsync( + JsonNode cfg, MemberData memberData, Snowflake guildId, Snowflake userId, CancellationToken ct = default) + { + if (!GuildSettings.ReturnRolesOnRejoin.Get(cfg)) + { + return Result.Success; + } + + var assignRoles = new List(); + + if (memberData.MutedUntil is null || !GuildSettings.RemoveRolesOnMute.Get(cfg)) + { + assignRoles.AddRange(memberData.Roles.ConvertAll(r => r.ToSnowflake())); + } + + if (memberData.MutedUntil is not null) + { + assignRoles.Add(GuildSettings.MuteRole.Get(cfg)); + } + + return await _guildApi.ModifyGuildMemberAsync( + guildId, userId, roles: assignRoles, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Responders/GuildMemberLeftResponder.cs b/TeamOctolings.Octobot/Responders/GuildMemberLeftResponder.cs new file mode 100644 index 0000000..957a107 --- /dev/null +++ b/TeamOctolings.Octobot/Responders/GuildMemberLeftResponder.cs @@ -0,0 +1,68 @@ +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Gateway.Responders; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Responders; + +/// +/// Handles sending a guild's if one is set. +/// +/// +[UsedImplicitly] +public sealed class GuildMemberLeftResponder : IResponder +{ + private readonly IDiscordRestChannelAPI _channelApi; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly GuildDataService _guildData; + + public GuildMemberLeftResponder( + IDiscordRestChannelAPI channelApi, GuildDataService guildData, IDiscordRestGuildAPI guildApi) + { + _channelApi = channelApi; + _guildData = guildData; + _guildApi = guildApi; + } + + public async Task RespondAsync(IGuildMemberRemove gatewayEvent, CancellationToken ct = default) + { + var user = gatewayEvent.User; + var data = await _guildData.GetData(gatewayEvent.GuildID, ct); + var cfg = data.Settings; + + var memberData = data.GetOrCreateMemberData(user.ID); + if (memberData.BannedUntil is not null || memberData.Kicked + || GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty() + || GuildSettings.LeaveMessage.Get(cfg) is "off" or "disable" or "disabled") + { + return Result.Success; + } + + Messages.Culture = GuildSettings.Language.Get(cfg); + var leaveMessage = GuildSettings.LeaveMessage.Get(cfg) is "default" or "reset" + ? Messages.DefaultLeaveMessage + : GuildSettings.LeaveMessage.Get(cfg); + + var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct); + if (!guildResult.IsDefined(out var guild)) + { + return ResultExtensions.FromError(guildResult); + } + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(leaveMessage, user.GetTag(), guild.Name), user) + .WithGuildFooter(guild) + .WithTimestamp(DateTimeOffset.UtcNow) + .WithColour(ColorsList.Black) + .Build(); + + return await _channelApi.CreateMessageWithEmbedResultAsync( + GuildSettings.WelcomeMessagesChannel.Get(cfg), embedResult: embed, + allowedMentions: Utility.NoMentions, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Responders/GuildUnloadedResponder.cs b/TeamOctolings.Octobot/Responders/GuildUnloadedResponder.cs new file mode 100644 index 0000000..c73c134 --- /dev/null +++ b/TeamOctolings.Octobot/Responders/GuildUnloadedResponder.cs @@ -0,0 +1,38 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.Gateway.Responders; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Responders; + +/// +/// Handles removing guild ID from if the guild becomes unavailable. +/// +[UsedImplicitly] +public sealed class GuildUnloadedResponder : IResponder +{ + private readonly GuildDataService _guildData; + private readonly ILogger _logger; + + public GuildUnloadedResponder( + GuildDataService guildData, ILogger logger) + { + _guildData = guildData; + _logger = logger; + } + + public Task RespondAsync(IGuildDelete gatewayEvent, CancellationToken ct = default) + { + var guildId = gatewayEvent.ID; + var isDataRemoved = _guildData.UnloadGuildData(guildId); + if (isDataRemoved) + { + _logger.LogInformation("Unloaded guild {GuildId}", guildId); + } + + return Task.FromResult(Result.Success); + } +} diff --git a/TeamOctolings.Octobot/Responders/MessageDeletedResponder.cs b/TeamOctolings.Octobot/Responders/MessageDeletedResponder.cs new file mode 100644 index 0000000..f0e3d22 --- /dev/null +++ b/TeamOctolings.Octobot/Responders/MessageDeletedResponder.cs @@ -0,0 +1,107 @@ +using System.Text; +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Discord.Gateway.Responders; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Responders; + +/// +/// Handles logging the contents of a deleted message and the user who deleted the message +/// to a guild's if one is set. +/// +[UsedImplicitly] +public sealed class MessageDeletedResponder : IResponder +{ + private readonly IDiscordRestAuditLogAPI _auditLogApi; + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _guildData; + private readonly IDiscordRestUserAPI _userApi; + + public MessageDeletedResponder( + IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi, + GuildDataService guildData, IDiscordRestUserAPI userApi) + { + _auditLogApi = auditLogApi; + _channelApi = channelApi; + _guildData = guildData; + _userApi = userApi; + } + + public async Task RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default) + { + if (!gatewayEvent.GuildID.IsDefined(out var guildId)) + { + return Result.Success; + } + + var cfg = await _guildData.GetSettings(guildId, ct); + if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) + { + return Result.Success; + } + + var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct); + if (!messageResult.IsDefined(out var message)) + { + return ResultExtensions.FromError(messageResult); + } + + if (string.IsNullOrWhiteSpace(message.Content)) + { + return Result.Success; + } + + var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync( + guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct); + if (!auditLogResult.IsDefined(out var auditLogPage)) + { + return ResultExtensions.FromError(auditLogResult); + } + + var deleterResult = Result.FromSuccess(message.Author); + + var auditLog = auditLogPage.AuditLogEntries.SingleOrDefault(); + if (auditLog is { UserID: not null } + && auditLog.Options.Value.ChannelID == gatewayEvent.ChannelID + && DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2) + { + deleterResult = await _userApi.GetUserAsync(auditLog.UserID.Value, ct); + } + + if (!deleterResult.IsDefined(out var deleter)) + { + return ResultExtensions.FromError(deleterResult); + } + + Messages.Culture = GuildSettings.Language.Get(cfg); + + var builder = new StringBuilder() + .AppendLine(message.Content.InBlockCode()) + .AppendLine( + string.Format(Messages.DescriptionActionJumpToChannel, Mention.Channel(gatewayEvent.ChannelID)) + ); + + var embed = new EmbedBuilder() + .WithSmallTitle( + string.Format( + Messages.CachedMessageDeleted, + message.Author.GetTag()), message.Author) + .WithDescription(builder.ToString()) + .WithActionFooter(deleter) + .WithTimestamp(message.Timestamp) + .WithColour(ColorsList.Red) + .Build(); + + return await _channelApi.CreateMessageWithEmbedResultAsync( + GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, + allowedMentions: Utility.NoMentions, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Responders/MessageEditedResponder.cs b/TeamOctolings.Octobot/Responders/MessageEditedResponder.cs new file mode 100644 index 0000000..e3d1c58 --- /dev/null +++ b/TeamOctolings.Octobot/Responders/MessageEditedResponder.cs @@ -0,0 +1,98 @@ +using System.Text; +using DiffPlex.DiffBuilder; +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Caching; +using Remora.Discord.Caching.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Gateway.Responders; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; +using TeamOctolings.Octobot.Services; + +namespace TeamOctolings.Octobot.Responders; + +/// +/// Handles logging the difference between an edited message's old and new content +/// to a guild's if one is set. +/// +[UsedImplicitly] +public sealed class MessageEditedResponder : IResponder +{ + private readonly CacheService _cacheService; + private readonly IDiscordRestChannelAPI _channelApi; + private readonly GuildDataService _guildData; + + public MessageEditedResponder( + CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService guildData) + { + _cacheService = cacheService; + _channelApi = channelApi; + _guildData = guildData; + } + + public async Task RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default) + { + if (!gatewayEvent.GuildID.IsDefined(out var guildId) + || !gatewayEvent.EditedTimestamp.HasValue + || gatewayEvent.Author.IsBot.OrDefault(false)) + { + return Result.Success; + } + + var cfg = await _guildData.GetSettings(guildId, ct); + if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()) + { + return Result.Success; + } + + var cacheKey = new KeyHelpers.MessageCacheKey(gatewayEvent.ChannelID, gatewayEvent.ID); + var messageResult = await _cacheService.TryGetValueAsync( + cacheKey, ct); + if (!messageResult.IsDefined(out var message)) + { + _ = _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct); + return Result.Success; + } + + if (message.Content == gatewayEvent.Content) + { + return Result.Success; + } + + // Custom event responders are called earlier than responders responsible for message caching + // This means that subsequent edit logs may contain the wrong content + // We can work around this by evicting the message from the cache + await _cacheService.EvictAsync(cacheKey, ct); + // However, since we evicted the message, subsequent edits won't have a cached instance to work with + // Getting the message will put it back in the cache, resolving all issues + // We don't need to await this since the result is not needed + // NOTE: Because this is not awaited, there may be a race condition depending on how fast clients are able to edit their messages + // NOTE: Awaiting this might not even solve this if the same responder is called asynchronously + _ = _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct); + + var diff = InlineDiffBuilder.Diff(message.Content, gatewayEvent.Content); + + Messages.Culture = GuildSettings.Language.Get(cfg); + + var builder = new StringBuilder() + .AppendLine(diff.AsMarkdown()) + .AppendLine(string.Format(Messages.DescriptionActionJumpToMessage, + $"https://discord.com/channels/{guildId}/{gatewayEvent.ChannelID}/{gatewayEvent.ID}") + ); + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author) + .WithDescription(builder.ToString()) + .WithTimestamp(gatewayEvent.EditedTimestamp.Value) + .WithColour(ColorsList.Yellow) + .Build(); + + return await _channelApi.CreateMessageWithEmbedResultAsync( + GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, + allowedMentions: Utility.NoMentions, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Responders/MessageReceivedResponder.cs b/TeamOctolings.Octobot/Responders/MessageReceivedResponder.cs new file mode 100644 index 0000000..24d53a5 --- /dev/null +++ b/TeamOctolings.Octobot/Responders/MessageReceivedResponder.cs @@ -0,0 +1,39 @@ +using JetBrains.Annotations; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Gateway.Responders; +using Remora.Rest.Core; +using Remora.Results; + +namespace TeamOctolings.Octobot.Responders; + +/// +/// Handles sending replies to easter egg messages. +/// +[UsedImplicitly] +public sealed class MessageCreateResponder : IResponder +{ + private readonly IDiscordRestChannelAPI _channelApi; + + public MessageCreateResponder(IDiscordRestChannelAPI channelApi) + { + _channelApi = channelApi; + } + + public Task RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default) + { + _ = _channelApi.CreateMessageAsync( + gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content.ToLowerInvariant() switch + { + "whoami" => "`nobody`", + "сука !!" => "`root`", + "воооо" => "`removing /...`", + "пон" => "https://i.ibb.co/Kw6QVcw/parry.jpg", + "++++" => "#", + "осу" => "https://github.com/ppy/osu", + "лан" => "https://i.ibb.co/VYH2QLc/lan.jpg", + _ => default(Optional) + }); + return Task.FromResult(Result.Success); + } +} diff --git a/TeamOctolings.Octobot/Services/AccessControlService.cs b/TeamOctolings.Octobot/Services/AccessControlService.cs new file mode 100644 index 0000000..d39c9e5 --- /dev/null +++ b/TeamOctolings.Octobot/Services/AccessControlService.cs @@ -0,0 +1,142 @@ +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; + +namespace TeamOctolings.Octobot.Services; + +public sealed class AccessControlService +{ + private readonly GuildDataService _data; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly IDiscordRestUserAPI _userApi; + + public AccessControlService(GuildDataService data, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi) + { + _data = data; + _guildApi = guildApi; + _userApi = userApi; + } + + private static bool CheckPermission(IEnumerable roles, GuildData data, MemberData memberData, + DiscordPermission permission) + { + var moderatorRole = GuildSettings.ModeratorRole.Get(data.Settings); + if (!moderatorRole.Empty() && memberData.Roles.Contains(moderatorRole.Value)) + { + return true; + } + + return roles + .Where(r => memberData.Roles.Contains(r.ID.Value)) + .Any(r => + r.Permissions.HasPermission(permission) + ); + } + + /// + /// Checks whether or not a member can interact with another member + /// + /// The ID of the guild in which an operation is being performed. + /// The executor of the operation. + /// The target of the operation. + /// The operation. + /// The cancellation token for this operation. + /// + /// + /// A result which has succeeded with a null string if the member can interact with the target. + /// + /// A result which has succeeded with a non-null string containing the error message if the member cannot + /// interact with the target. + /// + /// A result which has failed if an error occurred during the execution of this method. + /// + /// + public async Task> CheckInteractionsAsync( + Snowflake guildId, Snowflake? interacterId, Snowflake targetId, string action, CancellationToken ct = default) + { + if (interacterId == targetId) + { + return Result.FromSuccess($"UserCannot{action}Themselves".Localized()); + } + + var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct); + if (!guildResult.IsDefined(out var guild)) + { + return Result.FromError(guildResult); + } + + if (interacterId == guild.OwnerID) + { + return Result.FromSuccess(null); + } + + var botResult = await _userApi.GetCurrentUserAsync(ct); + if (!botResult.IsDefined(out var bot)) + { + return Result.FromError(botResult); + } + + var rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct); + if (!rolesResult.IsDefined(out var roles)) + { + return Result.FromError(rolesResult); + } + + var data = await _data.GetData(guildId, ct); + var targetData = data.GetOrCreateMemberData(targetId); + var botData = data.GetOrCreateMemberData(bot.ID); + + if (interacterId is null) + { + return CheckInteractions(action, guild, roles, targetData, botData, botData); + } + + var interacterData = data.GetOrCreateMemberData(interacterId.Value); + var hasPermission = CheckPermission(roles, data, interacterData, + action switch + { + "Ban" => DiscordPermission.BanMembers, + "Kick" => DiscordPermission.KickMembers, + "Mute" or "Unmute" => DiscordPermission.ModerateMembers, + _ => throw new Exception() + }); + + return hasPermission + ? CheckInteractions(action, guild, roles, targetData, botData, interacterData) + : Result.FromSuccess($"UserCannot{action}Members".Localized()); + } + + private static Result CheckInteractions( + string action, IGuild guild, IReadOnlyList roles, MemberData targetData, MemberData botData, + MemberData interacterData) + { + if (botData.Id == targetData.Id) + { + return Result.FromSuccess($"UserCannot{action}Bot".Localized()); + } + + if (targetData.Id == guild.OwnerID) + { + return Result.FromSuccess($"UserCannot{action}Owner".Localized()); + } + + var targetRoles = roles.Where(r => targetData.Roles.Contains(r.ID.Value)).ToList(); + var botRoles = roles.Where(r => botData.Roles.Contains(r.ID.Value)); + + var targetBotRoleDiff = targetRoles.MaxOrDefault(r => r.Position) - botRoles.MaxOrDefault(r => r.Position); + if (targetBotRoleDiff >= 0) + { + return Result.FromSuccess($"BotCannot{action}Target".Localized()); + } + + var interacterRoles = roles.Where(r => interacterData.Roles.Contains(r.ID.Value)); + var targetInteracterRoleDiff + = targetRoles.MaxOrDefault(r => r.Position) - interacterRoles.MaxOrDefault(r => r.Position); + return targetInteracterRoleDiff < 0 + ? Result.FromSuccess(null) + : Result.FromSuccess($"UserCannot{action}Target".Localized()); + } +} diff --git a/TeamOctolings.Octobot/Services/GuildDataService.cs b/TeamOctolings.Octobot/Services/GuildDataService.cs new file mode 100644 index 0000000..88edb5f --- /dev/null +++ b/TeamOctolings.Octobot/Services/GuildDataService.cs @@ -0,0 +1,297 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Remora.Rest.Core; +using TeamOctolings.Octobot.Data; + +namespace TeamOctolings.Octobot.Services; + +/// +/// Handles saving, loading, initializing and providing . +/// +public sealed class GuildDataService : BackgroundService +{ + private readonly ConcurrentDictionary _datas = new(); + private readonly ILogger _logger; + + public GuildDataService(ILogger logger) + { + _logger = logger; + } + + public override Task StopAsync(CancellationToken ct) + { + base.StopAsync(ct); + return SaveAsync(ct); + } + + private Task SaveAsync(CancellationToken ct = default) + { + var tasks = new List(); + var datas = _datas.Values.ToArray(); + foreach (var data in datas.Where(data => !data.DataLoadFailed)) + { + tasks.Add(SerializeObjectSafelyAsync(data.Settings, data.SettingsPath, ct)); + tasks.Add(SerializeObjectSafelyAsync(data.ScheduledEvents, data.ScheduledEventsPath, ct)); + + var memberDatas = data.MemberData.Values.ToArray(); + tasks.AddRange(memberDatas.Select(memberData => + SerializeObjectSafelyAsync(memberData, $"{data.MemberDataPath}/{memberData.Id}.json", ct))); + } + + return Task.WhenAll(tasks); + } + + private static async Task SerializeObjectSafelyAsync(T obj, string path, CancellationToken ct = default) + { + var tempFilePath = path + ".tmp"; + await using (var tempFileStream = File.Create(tempFilePath)) + { + await JsonSerializer.SerializeAsync(tempFileStream, obj, cancellationToken: ct); + } + + File.Copy(tempFilePath, path, true); + File.Delete(tempFilePath); + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5)); + + while (await timer.WaitForNextTickAsync(ct)) + { + await SaveAsync(ct); + } + } + + public async Task GetData(Snowflake guildId, CancellationToken ct = default) + { + return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct); + } + + private async Task InitializeData(Snowflake guildId, CancellationToken ct = default) + { + var path = $"GuildData/{guildId}"; + var memberDataPath = $"{path}/MemberData"; + + var settingsPath = $"{path}/Settings.json"; + + var scheduledEventsPath = $"{path}/ScheduledEvents.json"; + + MigrateDataDirectory(guildId, path); + + Directory.CreateDirectory(path); + + var dataLoadFailed = false; + + var jsonSettings = await LoadGuildSettings(settingsPath, ct); + if (jsonSettings is not null) + { + FixJsonSettings(jsonSettings); + } + else + { + dataLoadFailed = true; + } + + var events = await LoadScheduledEvents(scheduledEventsPath, ct); + if (events is null) + { + dataLoadFailed = true; + } + + var memberData = new Dictionary(); + foreach (var dataFileInfo in Directory.CreateDirectory(memberDataPath).GetFiles() + .Where(dataFileInfo => + !memberData.ContainsKey( + ulong.Parse(dataFileInfo.Name.Replace(".json", "").Replace(".tmp", ""))))) + { + var data = await LoadMemberData(dataFileInfo, memberDataPath, true, ct); + + if (data == null) + { + dataLoadFailed = true; + continue; + } + + memberData.TryAdd(data.Id, data); + } + + var finalData = new GuildData( + jsonSettings ?? new JsonObject(), settingsPath, + events ?? new Dictionary(), scheduledEventsPath, + memberData, memberDataPath, + dataLoadFailed); + + _datas.TryAdd(guildId, finalData); + + return finalData; + } + + private async Task LoadMemberData(FileInfo dataFileInfo, string memberDataPath, bool loadTmp, + CancellationToken ct = default) + { + MemberData? data; + var temporaryPath = $"{dataFileInfo.FullName}.tmp"; + var usedInfo = loadTmp && File.Exists(temporaryPath) ? new FileInfo(temporaryPath) : dataFileInfo; + + var isTmp = usedInfo.Extension is ".tmp"; + try + { + await using var dataStream = usedInfo.OpenRead(); + data = await JsonSerializer.DeserializeAsync(dataStream, cancellationToken: ct); + if (isTmp) + { + usedInfo.CopyTo(usedInfo.FullName.Replace(".tmp", ""), true); + usedInfo.Delete(); + } + } + catch (Exception e) + { + if (isTmp) + { + _logger.LogWarning(e, + "Unable to load temporary member data file, deleting: {MemberDataPath}/{FileName}", memberDataPath, + usedInfo.Name); + usedInfo.Delete(); + return await LoadMemberData(dataFileInfo, memberDataPath, false, ct); + } + + _logger.LogError(e, "Member data load failed: {MemberDataPath}/{FileName}", memberDataPath, + usedInfo.Name); + return null; + } + + return data; + } + + private async Task?> LoadScheduledEvents(string scheduledEventsPath, + CancellationToken ct = default) + { + var tempScheduledEventsPath = $"{scheduledEventsPath}.tmp"; + + if (!File.Exists(scheduledEventsPath) && !File.Exists(tempScheduledEventsPath)) + { + return new Dictionary(); + } + + if (File.Exists(tempScheduledEventsPath)) + { + _logger.LogWarning("Found temporary scheduled events file, will try to parse and copy to main: ${Path}", + tempScheduledEventsPath); + try + { + await using var tempEventsStream = File.OpenRead(tempScheduledEventsPath); + var events = await JsonSerializer.DeserializeAsync>( + tempEventsStream, cancellationToken: ct); + File.Copy(tempScheduledEventsPath, scheduledEventsPath, true); + File.Delete(tempScheduledEventsPath); + + _logger.LogInformation("Successfully loaded temporary scheduled events file: ${Path}", + tempScheduledEventsPath); + return events; + } + catch (Exception e) + { + _logger.LogError(e, "Unable to load temporary scheduled events file: {Path}, deleting", + tempScheduledEventsPath); + File.Delete(tempScheduledEventsPath); + } + } + + try + { + await using var eventsStream = File.OpenRead(scheduledEventsPath); + return await JsonSerializer.DeserializeAsync>( + eventsStream, cancellationToken: ct); + } + catch (Exception e) + { + _logger.LogError(e, "Guild scheduled events load failed: {Path}", scheduledEventsPath); + return null; + } + } + + private async Task LoadGuildSettings(string settingsPath, CancellationToken ct = default) + { + var tempSettingsPath = $"{settingsPath}.tmp"; + + if (!File.Exists(settingsPath) && !File.Exists(tempSettingsPath)) + { + return new JsonObject(); + } + + if (File.Exists(tempSettingsPath)) + { + _logger.LogWarning("Found temporary settings file, will try to parse and copy to main: ${Path}", + tempSettingsPath); + try + { + await using var tempSettingsStream = File.OpenRead(tempSettingsPath); + var jsonSettings = await JsonNode.ParseAsync(tempSettingsStream, cancellationToken: ct); + + File.Copy(tempSettingsPath, settingsPath, true); + File.Delete(tempSettingsPath); + + _logger.LogInformation("Successfully loaded temporary settings file: ${Path}", tempSettingsPath); + return jsonSettings; + } + catch (Exception e) + { + _logger.LogError(e, "Unable to load temporary settings file: {Path}, deleting", tempSettingsPath); + File.Delete(tempSettingsPath); + } + } + + try + { + await using var settingsStream = File.OpenRead(settingsPath); + return await JsonNode.ParseAsync(settingsStream, cancellationToken: ct); + } + catch (Exception e) + { + _logger.LogError(e, "Guild settings load failed: {Path}", settingsPath); + return null; + } + } + + private void MigrateDataDirectory(Snowflake guildId, string newPath) + { + var oldPath = $"{guildId}"; + + if (Directory.Exists(oldPath)) + { + Directory.CreateDirectory($"{newPath}/.."); + Directory.Move(oldPath, newPath); + + _logger.LogInformation("Moved guild data to separate folder: \"{OldPath}\" -> \"{NewPath}\"", oldPath, + newPath); + } + } + + private static void FixJsonSettings(JsonNode settings) + { + var language = settings[GuildSettings.Language.Name]?.GetValue(); + if (language is "mctaylors-ru") + { + settings[GuildSettings.Language.Name] = "ru"; + } + } + + public async Task GetSettings(Snowflake guildId, CancellationToken ct = default) + { + return (await GetData(guildId, ct)).Settings; + } + + public ICollection GetGuildIds() + { + return _datas.Keys; + } + + public bool UnloadGuildData(Snowflake id) + { + return _datas.TryRemove(id, out _); + } +} diff --git a/TeamOctolings.Octobot/Services/Update/MemberUpdateService.cs b/TeamOctolings.Octobot/Services/Update/MemberUpdateService.cs new file mode 100644 index 0000000..3170060 --- /dev/null +++ b/TeamOctolings.Octobot/Services/Update/MemberUpdateService.cs @@ -0,0 +1,257 @@ +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; + +namespace TeamOctolings.Octobot.Services.Update; + +public sealed partial class MemberUpdateService : BackgroundService +{ + private static readonly string[] GenericNicknames = + [ + "Albatross", "Alpha", "Anchor", "Banjo", "Bell", "Beta", "Blackbird", "Bulldog", "Canary", + "Cat", "Calf", "Cyclone", "Daisy", "Dalmatian", "Dart", "Delta", "Diamond", "Donkey", "Duck", + "Emu", "Eclipse", "Flamingo", "Flute", "Frog", "Goose", "Hatchet", "Heron", "Husky", "Hurricane", + "Iceberg", "Iguana", "Kiwi", "Kite", "Lamb", "Lily", "Macaw", "Manatee", "Maple", "Mask", + "Nautilus", "Ostrich", "Octopus", "Pelican", "Puffin", "Pyramid", "Rattle", "Robin", "Rose", + "Salmon", "Seal", "Shark", "Sheep", "Snake", "Sonar", "Stump", "Sparrow", "Toaster", "Toucan", + "Torus", "Violet", "Vortex", "Vulture", "Wagon", "Whale", "Woodpecker", "Zebra", "Zigzag" + ]; + + private readonly AccessControlService _access; + private readonly IDiscordRestChannelAPI _channelApi; + private readonly IDiscordRestGuildAPI _guildApi; + private readonly GuildDataService _guildData; + private readonly ILogger _logger; + + public MemberUpdateService(AccessControlService access, IDiscordRestChannelAPI channelApi, + IDiscordRestGuildAPI guildApi, GuildDataService guildData, ILogger logger) + { + _access = access; + _channelApi = channelApi; + _guildApi = guildApi; + _guildData = guildData; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + var tasks = new List(); + + while (await timer.WaitForNextTickAsync(ct)) + { + var guildIds = _guildData.GetGuildIds(); + + tasks.AddRange(guildIds.Select(async id => + { + var tickResult = await TickMemberDatasAsync(id, ct); + _logger.LogResult(tickResult, $"Error in member data update for guild {id}."); + })); + + await Task.WhenAll(tasks); + tasks.Clear(); + } + } + + private async Task TickMemberDatasAsync(Snowflake guildId, CancellationToken ct = default) + { + var guildData = await _guildData.GetData(guildId, ct); + var defaultRole = GuildSettings.DefaultRole.Get(guildData.Settings); + var failedResults = new List(); + var memberDatas = guildData.MemberData.Values.ToArray(); + foreach (var data in memberDatas) + { + var tickResult = await TickMemberDataAsync(guildId, guildData, defaultRole, data, ct); + failedResults.AddIfFailed(tickResult); + } + + return failedResults.AggregateErrors(); + } + + private async Task TickMemberDataAsync(Snowflake guildId, GuildData guildData, Snowflake defaultRole, + MemberData data, + CancellationToken ct = default) + { + var failedResults = new List(); + var id = data.Id.ToSnowflake(); + + var autoUnbanResult = await TryAutoUnbanAsync(guildId, id, data, ct); + failedResults.AddIfFailed(autoUnbanResult); + + var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, id, ct); + if (!guildMemberResult.IsDefined(out var guildMember)) + { + return failedResults.AggregateErrors(); + } + + var interactionResult + = await _access.CheckInteractionsAsync(guildId, null, id, "Update", ct); + if (!interactionResult.IsSuccess) + { + return ResultExtensions.FromError(interactionResult); + } + + var canInteract = interactionResult.Entity is null; + + if (data.MutedUntil is null) + { + data.Roles = guildMember.Roles.ToList().ConvertAll(r => r.Value); + } + + if (!guildMember.User.IsDefined(out var user)) + { + failedResults.AddIfFailed(new ArgumentNullError(nameof(guildMember.User))); + return failedResults.AggregateErrors(); + } + + for (var i = data.Reminders.Count - 1; i >= 0; i--) + { + var reminderTickResult = await TickReminderAsync(data.Reminders[i], user, data, guildId, ct); + failedResults.AddIfFailed(reminderTickResult); + } + + if (!canInteract) + { + return Result.Success; + } + + var autoUnmuteResult = await TryAutoUnmuteAsync(guildId, id, data, ct); + failedResults.AddIfFailed(autoUnmuteResult); + + if (!defaultRole.Empty() && !data.Roles.Contains(defaultRole.Value)) + { + var addResult = await _guildApi.AddGuildMemberRoleAsync( + guildId, id, defaultRole, ct: ct); + failedResults.AddIfFailed(addResult); + } + + if (GuildSettings.RenameHoistedUsers.Get(guildData.Settings)) + { + var filterResult = await FilterNicknameAsync(guildId, user, guildMember, ct); + failedResults.AddIfFailed(filterResult); + } + + return failedResults.AggregateErrors(); + } + + private async Task TryAutoUnbanAsync( + Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct = default) + { + if (data.BannedUntil is null || DateTimeOffset.UtcNow <= data.BannedUntil) + { + return Result.Success; + } + + var existingBanResult = await _guildApi.GetGuildBanAsync(guildId, id, ct); + if (!existingBanResult.IsDefined()) + { + data.BannedUntil = null; + return Result.Success; + } + + var unbanResult = await _guildApi.RemoveGuildBanAsync( + guildId, id, Messages.PunishmentExpired.EncodeHeader(), ct); + if (unbanResult.IsSuccess) + { + data.BannedUntil = null; + } + + return unbanResult; + } + + private async Task TryAutoUnmuteAsync( + Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct = default) + { + if (data.MutedUntil is null || DateTimeOffset.UtcNow <= data.MutedUntil) + { + return Result.Success; + } + + var unmuteResult = await _guildApi.ModifyGuildMemberAsync( + guildId, id, roles: data.Roles.ConvertAll(r => r.ToSnowflake()), + reason: Messages.PunishmentExpired.EncodeHeader(), ct: ct); + if (unmuteResult.IsSuccess) + { + data.MutedUntil = null; + } + + return unmuteResult; + } + + private async Task FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member, + CancellationToken ct = default) + { + var currentNickname = member.Nickname.IsDefined(out var nickname) + ? nickname + : user.GlobalName.OrDefault(user.Username); + var characterList = currentNickname.ToList(); + var usernameChanged = false; + foreach (var character in currentNickname) + { + if (IllegalChars().IsMatch(character.ToString())) + { + characterList.Remove(character); + usernameChanged = true; + continue; + } + + break; + } + + if (!usernameChanged) + { + return Result.Success; + } + + var newNickname = string.Concat(characterList.ToArray()); + + return await _guildApi.ModifyGuildMemberAsync( + guildId, user.ID, + !string.IsNullOrWhiteSpace(newNickname) + ? newNickname + : GenericNicknames[Random.Shared.Next(GenericNicknames.Length)], + ct: ct); + } + + [GeneratedRegex("[^0-9A-Za-zА-Яа-яЁё]")] + private static partial Regex IllegalChars(); + + private async Task TickReminderAsync(Reminder reminder, IUser user, MemberData data, Snowflake guildId, + CancellationToken ct = default) + { + if (DateTimeOffset.UtcNow < reminder.At) + { + return Result.Success; + } + + var builder = new StringBuilder() + .AppendLine(MarkdownExtensions.Quote(reminder.Text)) + .AppendBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage, + $"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}")); + + var embed = new EmbedBuilder().WithSmallTitle( + string.Format(Messages.Reminder, user.GetTag()), user) + .WithDescription(builder.ToString()) + .WithColour(ColorsList.Magenta) + .Build(); + + var messageResult = await _channelApi.CreateMessageWithEmbedResultAsync( + reminder.ChannelId.ToSnowflake(), Mention.User(user), embedResult: embed, ct: ct); + if (!messageResult.IsSuccess) + { + return ResultExtensions.FromError(messageResult); + } + + data.Reminders.Remove(reminder); + return Result.Success; + } +} diff --git a/TeamOctolings.Octobot/Services/Update/ScheduledEventUpdateService.cs b/TeamOctolings.Octobot/Services/Update/ScheduledEventUpdateService.cs new file mode 100644 index 0000000..389a6a8 --- /dev/null +++ b/TeamOctolings.Octobot/Services/Update/ScheduledEventUpdateService.cs @@ -0,0 +1,434 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; + +namespace TeamOctolings.Octobot.Services.Update; + +public sealed class ScheduledEventUpdateService : BackgroundService +{ + private readonly IDiscordRestChannelAPI _channelApi; + private readonly IDiscordRestGuildScheduledEventAPI _eventApi; + private readonly GuildDataService _guildData; + private readonly ILogger _logger; + private readonly Utility _utility; + + public ScheduledEventUpdateService(IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, + GuildDataService guildData, ILogger logger, Utility utility) + { + _channelApi = channelApi; + _eventApi = eventApi; + _guildData = guildData; + _logger = logger; + _utility = utility; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + while (await timer.WaitForNextTickAsync(ct)) + { + var guildIds = _guildData.GetGuildIds(); + foreach (var id in guildIds) + { + var tickResult = await TickScheduledEventsAsync(id, ct); + _logger.LogResult(tickResult, $"Error in scheduled events update for guild {id}."); + } + } + } + + private async Task TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct = default) + { + var failedResults = new List(); + var data = await _guildData.GetData(guildId, ct); + var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct); + if (!eventsResult.IsDefined(out var events)) + { + return ResultExtensions.FromError(eventsResult); + } + + SyncScheduledEvents(data, events); + + foreach (var storedEvent in data.ScheduledEvents.Values) + { + var scheduledEvent = TryGetScheduledEvent(events, storedEvent.Id); + if (!scheduledEvent.IsSuccess) + { + storedEvent.ScheduleOnStatusUpdated = true; + storedEvent.Status = storedEvent.ActualStartTime != null + ? GuildScheduledEventStatus.Completed + : GuildScheduledEventStatus.Canceled; + } + + if (!storedEvent.ScheduleOnStatusUpdated) + { + var tickResult = + await TickScheduledEventAsync(guildId, data, scheduledEvent.Entity, storedEvent, ct); + failedResults.AddIfFailed(tickResult); + continue; + } + + var statusUpdatedResponseResult = storedEvent.Status switch + { + GuildScheduledEventStatus.Scheduled => + await SendScheduledEventCreatedMessage(scheduledEvent.Entity, data.Settings, ct), + GuildScheduledEventStatus.Canceled => + await SendScheduledEventCancelledMessage(storedEvent, data, ct), + GuildScheduledEventStatus.Active => + await SendScheduledEventStartedMessage(scheduledEvent.Entity, data, ct), + GuildScheduledEventStatus.Completed => + await SendScheduledEventCompletedMessage(storedEvent, data, ct), + _ => new ArgumentOutOfRangeError(nameof(storedEvent.Status)) + }; + if (statusUpdatedResponseResult.IsSuccess) + { + storedEvent.ScheduleOnStatusUpdated = false; + } + + failedResults.AddIfFailed(statusUpdatedResponseResult); + } + + return failedResults.AggregateErrors(); + } + + private static void SyncScheduledEvents(GuildData data, IEnumerable events) + { + foreach (var @event in events) + { + if (!data.ScheduledEvents.TryGetValue(@event.ID.Value, out var eventData)) + { + data.ScheduledEvents.Add(@event.ID.Value, + new ScheduledEventData(@event.ID.Value, @event.Name, @event.ScheduledStartTime, @event.Status)); + continue; + } + + eventData.Name = @event.Name; + eventData.ScheduledStartTime = @event.ScheduledStartTime; + if (!eventData.ScheduleOnStatusUpdated) + { + eventData.ScheduleOnStatusUpdated = eventData.Status != @event.Status; + } + + eventData.Status = @event.Status; + } + } + + private static Result TryGetScheduledEvent(IEnumerable from, ulong id) + { + var filtered = from.Where(schEvent => schEvent.ID == id); + var filteredArray = filtered.ToArray(); + return filteredArray.Length > 0 + ? Result.FromSuccess(filteredArray.Single()) + : new NotFoundError(); + } + + private async Task TickScheduledEventAsync( + Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData, + CancellationToken ct = default) + { + if (GuildSettings.AutoStartEvents.Get(data.Settings) + && DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime + && scheduledEvent.Status is not GuildScheduledEventStatus.Active) + { + return await AutoStartEventAsync(guildId, scheduledEvent, ct); + } + + var offset = GuildSettings.EventEarlyNotificationOffset.Get(data.Settings); + if (offset == TimeSpan.Zero + || eventData.EarlyNotificationSent + || DateTimeOffset.UtcNow < scheduledEvent.ScheduledStartTime - offset) + { + return Result.Success; + } + + var sendResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct); + if (sendResult.IsSuccess) + { + eventData.EarlyNotificationSent = true; + } + + return sendResult; + } + + private async Task AutoStartEventAsync( + Snowflake guildId, IGuildScheduledEvent scheduledEvent, CancellationToken ct = default) + { + return (Result)await _eventApi.ModifyGuildScheduledEventAsync( + guildId, scheduledEvent.ID, + status: GuildScheduledEventStatus.Active, ct: ct); + } + + /// + /// Handles sending a notification, mentioning the if one is + /// set, + /// when a scheduled event is created + /// in a guild's if one is set. + /// + /// The scheduled event that has just been created. + /// The settings of the guild containing the scheduled event. + /// The cancellation token for this operation. + /// A notification sending result which may or may not have succeeded. + private async Task SendScheduledEventCreatedMessage( + IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default) + { + if (GuildSettings.EventNotificationChannel.Get(settings).Empty()) + { + return Result.Success; + } + + if (!scheduledEvent.Creator.IsDefined(out var creator)) + { + return new ArgumentNullError(nameof(scheduledEvent.Creator)); + } + + var eventDescription = scheduledEvent.Description.IsDefined(out var description) + ? description + : string.Empty; + var embedDescriptionResult = scheduledEvent.EntityType switch + { + GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice => + GetLocalEventCreatedEmbedDescription(scheduledEvent, eventDescription), + GuildScheduledEventEntityType.External => GetExternalScheduledEventCreatedEmbedDescription( + scheduledEvent, eventDescription), + _ => new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType)) + }; + + if (!embedDescriptionResult.IsDefined(out var embedDescription)) + { + return ResultExtensions.FromError(embedDescriptionResult); + } + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator) + .WithTitle(Markdown.Sanitize(scheduledEvent.Name)) + .WithDescription(embedDescription) + .WithEventCover(scheduledEvent.ID, scheduledEvent.Image) + .WithCurrentTimestamp() + .WithColour(ColorsList.White) + .Build(); + + var roleMention = !GuildSettings.EventNotificationRole.Get(settings).Empty() + ? Mention.Role(GuildSettings.EventNotificationRole.Get(settings)) + : string.Empty; + + var button = new ButtonComponent( + ButtonComponentStyle.Link, + Messages.ButtonOpenEventInfo, + new PartialEmoji(Name: "\ud83d\udccb"), // 'CLIPBOARD' (U+1F4CB) + URL: $"https://discord.com/events/{scheduledEvent.GuildID}/{scheduledEvent.ID}" + ); + + return await _channelApi.CreateMessageWithEmbedResultAsync( + GuildSettings.EventNotificationChannel.Get(settings), roleMention, embedResult: embed, + components: new[] { new ActionRowComponent([button]) }, ct: ct); + } + + private static Result GetExternalScheduledEventCreatedEmbedDescription( + IGuildScheduledEvent scheduledEvent, string eventDescription) + { + var dataResult = scheduledEvent.TryGetExternalEventData(out var endTime, out var location); + if (!dataResult.IsSuccess) + { + return Result.FromError(dataResult); + } + + return $"{eventDescription}\n\n{Markdown.BlockQuote( + string.Format( + Messages.DescriptionExternalEventCreated, + Markdown.Timestamp(scheduledEvent.ScheduledStartTime), + Markdown.Timestamp(endTime), + Markdown.InlineCode(location ?? string.Empty) + ))}"; + } + + private static Result GetLocalEventCreatedEmbedDescription( + IGuildScheduledEvent scheduledEvent, string eventDescription) + { + if (scheduledEvent.ChannelID is null) + { + return new ArgumentNullError(nameof(scheduledEvent.ChannelID)); + } + + return $"{eventDescription}\n\n{Markdown.BlockQuote( + string.Format( + Messages.DescriptionLocalEventCreated, + Markdown.Timestamp(scheduledEvent.ScheduledStartTime), + Mention.Channel(scheduledEvent.ChannelID.Value) + ))}"; + } + + /// + /// Handles sending a notification, mentioning the and event + /// subscribers, + /// when a scheduled event has started or completed + /// in a guild's if one is set. + /// + /// The scheduled event that is about to start, has started or completed. + /// The data for the guild containing the scheduled event. + /// The cancellation token for this operation + /// A reminder/notification sending result which may or may not have succeeded. + private async Task SendScheduledEventStartedMessage( + IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default) + { + data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow; + + if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) + { + return Result.Success; + } + + var embedDescriptionResult = scheduledEvent.EntityType switch + { + GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice => + GetLocalEventStartedEmbedDescription(scheduledEvent), + GuildScheduledEventEntityType.External => GetExternalEventStartedEmbedDescription(scheduledEvent), + _ => new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType)) + }; + + var contentResult = await _utility.GetEventNotificationMentions( + scheduledEvent, data, ct); + if (!contentResult.IsDefined(out var content)) + { + return ResultExtensions.FromError(contentResult); + } + + if (!embedDescriptionResult.IsDefined(out var embedDescription)) + { + return ResultExtensions.FromError(embedDescriptionResult); + } + + var startedEmbed = new EmbedBuilder() + .WithTitle(string.Format(Messages.EventStarted, Markdown.Sanitize(scheduledEvent.Name))) + .WithDescription(embedDescription) + .WithColour(ColorsList.Green) + .WithCurrentTimestamp() + .Build(); + + return await _channelApi.CreateMessageWithEmbedResultAsync( + GuildSettings.EventNotificationChannel.Get(data.Settings), + content, embedResult: startedEmbed, ct: ct); + } + + private async Task SendScheduledEventCompletedMessage(ScheduledEventData eventData, GuildData data, + CancellationToken ct = default) + { + if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) + { + data.ScheduledEvents.Remove(eventData.Id); + return Result.Success; + } + + var completedEmbed = new EmbedBuilder() + .WithTitle(string.Format(Messages.EventCompleted, Markdown.Sanitize(eventData.Name))) + .WithDescription( + string.Format( + Messages.EventDuration, + DateTimeOffset.UtcNow.Subtract( + eventData.ActualStartTime + ?? eventData.ScheduledStartTime).ToString())) + .WithColour(ColorsList.Black) + .WithCurrentTimestamp() + .Build(); + + var createResult = await _channelApi.CreateMessageWithEmbedResultAsync( + GuildSettings.EventNotificationChannel.Get(data.Settings), + embedResult: completedEmbed, ct: ct); + if (createResult.IsSuccess) + { + data.ScheduledEvents.Remove(eventData.Id); + } + + return createResult; + } + + private async Task SendScheduledEventCancelledMessage(ScheduledEventData eventData, GuildData data, + CancellationToken ct = default) + { + if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) + { + data.ScheduledEvents.Remove(eventData.Id); + return Result.Success; + } + + var embed = new EmbedBuilder() + .WithSmallTitle(string.Format(Messages.EventCancelled, Markdown.Sanitize(eventData.Name))) + .WithDescription(":(") + .WithColour(ColorsList.Red) + .WithCurrentTimestamp() + .Build(); + + var createResult = await _channelApi.CreateMessageWithEmbedResultAsync( + GuildSettings.EventNotificationChannel.Get(data.Settings), embedResult: embed, ct: ct); + if (createResult.IsSuccess) + { + data.ScheduledEvents.Remove(eventData.Id); + } + + return createResult; + } + + private static Result GetLocalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent) + { + if (scheduledEvent.ChannelID is null) + { + return new ArgumentNullError(nameof(scheduledEvent.ChannelID)); + } + + return string.Format( + Messages.DescriptionLocalEventStarted, + Mention.Channel(scheduledEvent.ChannelID.Value) + ); + } + + private static Result GetExternalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent) + { + var dataResult = scheduledEvent.TryGetExternalEventData(out var endTime, out var location); + if (!dataResult.IsSuccess) + { + return Result.FromError(dataResult); + } + + return string.Format( + Messages.DescriptionExternalEventStarted, + Markdown.InlineCode(location ?? string.Empty), + Markdown.Timestamp(endTime) + ); + } + + private async Task SendEarlyEventNotificationAsync( + IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default) + { + if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty()) + { + return Result.Success; + } + + var contentResult = await _utility.GetEventNotificationMentions( + scheduledEvent, data, ct); + if (!contentResult.IsDefined(out var content)) + { + return ResultExtensions.FromError(contentResult); + } + + var earlyResult = new EmbedBuilder() + .WithDescription( + string.Format(Messages.EventEarlyNotification, Markdown.Sanitize(scheduledEvent.Name), + Markdown.Timestamp(scheduledEvent.ScheduledStartTime, TimestampStyle.RelativeTime))) + .WithColour(ColorsList.Default) + .Build(); + + return await _channelApi.CreateMessageWithEmbedResultAsync( + GuildSettings.EventNotificationChannel.Get(data.Settings), + content, + embedResult: earlyResult, ct: ct); + } +} diff --git a/TeamOctolings.Octobot/Services/Update/SongUpdateService.cs b/TeamOctolings.Octobot/Services/Update/SongUpdateService.cs new file mode 100644 index 0000000..8eaa4c2 --- /dev/null +++ b/TeamOctolings.Octobot/Services/Update/SongUpdateService.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Hosting; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Gateway.Commands; +using Remora.Discord.API.Objects; +using Remora.Discord.Gateway; + +namespace TeamOctolings.Octobot.Services.Update; + +public sealed class SongUpdateService : BackgroundService +{ + private static readonly (string Author, string Name, TimeSpan Duration)[] SongList = + [ + ("Yoko & the Gold Bazookas", "Rockagilly Blues", new TimeSpan(0, 2, 52)), + ("Deep Cut", "Big Betrayal", new TimeSpan(0, 5, 55)), + ("Squid Sisters", "Tomorrow's Nostalgia Today", new TimeSpan(0, 3, 7)), + ("Deep Cut", "Anarchy Rainbow", new TimeSpan(0, 3, 20)), + ("Squid Sisters feat. Ian BGM", "Liquid Sunshine", new TimeSpan(0, 2, 37)), + ("Damp Socks feat. Off the Hook", "Candy-Coated Rocks", new TimeSpan(0, 2, 58)), + ("H2Whoa", "Aquasonic", new TimeSpan(0, 2, 51)), + ("Yoko & the Gold Bazookas", "Ska-BLAM", new TimeSpan(0, 2, 57)), + ("Off the Hook", "Muck Warfare", new TimeSpan(0, 3, 20)), + ("Off the Hook", "Acid Hues", new TimeSpan(0, 3, 15)), + ("Off the Hook", "Shark Bytes", new TimeSpan(0, 3, 34)), + ("Squid Sisters", "Calamari Inkantation", new TimeSpan(0, 2, 14)), + ("Squid Sisters", "Ink Me Up", new TimeSpan(0, 2, 13)), + ("Chirpy Chips", "No Quarters", new TimeSpan(0, 2, 36)), + ("Chirpy Chips", "Shellfie", new TimeSpan(0, 2, 1)), + ("Dedf1sh", "#11 above", new TimeSpan(0, 2, 10)), + ("Callie", "Bomb Rush Blush", new TimeSpan(0, 2, 18)), + ("Turquoise October", "Octoling Rendezvous", new TimeSpan(0, 1, 57)), + ("Damp Socks feat. Off the Hook", "Tentacle to the Metal", new TimeSpan(0, 2, 51)), + ("Off the Hook feat. Dedf1sh", "Spectrum Obligato ~ Ebb & Flow (Out of Order)", new TimeSpan(0, 4, 30)), + ("Dedf1sh feat. Off the Hook", "#47 onward", new TimeSpan(0, 4, 40)), + ("Free Association", "EchΘ Θnslaught", new TimeSpan(0, 2, 52)), + ("Off the Hook", "Short Order", new TimeSpan(0, 3, 36)), + ("Deep Cut", "Fins in the Air", new TimeSpan(0, 3, 1)) + ]; + + private static readonly (string Author, string Name, TimeSpan Duration)[] SpecialSongList = + [ + ("Squid Sisters", "Maritime Memory", new TimeSpan(0, 2, 47)) + ]; + + private readonly List _activityList = [new("with Remora.Discord", ActivityType.Game)]; + + private readonly DiscordGatewayClient _client; + private readonly GuildDataService _guildData; + + private uint _nextSongIndex; + + public SongUpdateService(DiscordGatewayClient client, GuildDataService guildData) + { + _client = client; + _guildData = guildData; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + while (_guildData.GetGuildIds().Count is 0) + { + await Task.Delay(TimeSpan.FromSeconds(5), ct); + } + + while (!ct.IsCancellationRequested) + { + var nextSong = NextSong(); + _activityList[0] = new Activity($"{nextSong.Name} / {nextSong.Author}", + ActivityType.Listening); + _client.SubmitCommand( + new UpdatePresence( + UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList)); + + await Task.Delay(nextSong.Duration, ct); + } + } + + private (string Author, string Name, TimeSpan Duration) NextSong() + { + var today = DateTime.Today; + // Discontinuation of Online Services for Nintendo Wii U + if (today.Day is 8 or 9 && today.Month is 4) + { + return SpecialSongList[0]; // Maritime Memory / Squid Sisters + } + + var nextSong = SongList[_nextSongIndex]; + _nextSongIndex++; + if (_nextSongIndex >= SongList.Length) + { + _nextSongIndex = 0; + } + + return nextSong; + } +} diff --git a/TeamOctolings.Octobot/TeamOctolings.Octobot.csproj b/TeamOctolings.Octobot/TeamOctolings.Octobot.csproj new file mode 100644 index 0000000..b67eaf8 --- /dev/null +++ b/TeamOctolings.Octobot/TeamOctolings.Octobot.csproj @@ -0,0 +1,46 @@ + + + + Exe + net9.0 + enable + enable + 2.0.0 + Octobot + Octol1ttle, mctaylors, neroduckale + AGPLv3 + https://github.com/TeamOctolings/Octobot + https://github.com/TeamOctolings/Octobot/blob/master/LICENSE + https://github.com/TeamOctolings/Octobot + github + TeamOctolings + en + A general-purpose Discord bot for moderation written in C# + ../docs/octobot.ico + false + + + + + + + + + + + + + + + + + + + ResXFileCodeGenerator + Messages.Designer.cs + + + + + + diff --git a/TeamOctolings.Octobot/Utility.cs b/TeamOctolings.Octobot/Utility.cs new file mode 100644 index 0000000..a2f7aca --- /dev/null +++ b/TeamOctolings.Octobot/Utility.cs @@ -0,0 +1,153 @@ +using System.Drawing; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Extensions.Embeds; +using Remora.Discord.Extensions.Formatting; +using Remora.Rest.Core; +using Remora.Results; +using TeamOctolings.Octobot.Attributes; +using TeamOctolings.Octobot.Data; +using TeamOctolings.Octobot.Extensions; + +namespace TeamOctolings.Octobot; + +/// +/// Provides utility methods that cannot be transformed to extension methods because they require usage +/// of some Discord APIs. +/// +public sealed class Utility +{ + public static readonly AllowedMentions NoMentions = new( + Array.Empty(), Array.Empty(), Array.Empty()); + + private readonly IDiscordRestChannelAPI _channelApi; + private readonly IDiscordRestGuildScheduledEventAPI _eventApi; + private readonly IDiscordRestGuildAPI _guildApi; + + public Utility( + IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi) + { + _channelApi = channelApi; + _eventApi = eventApi; + _guildApi = guildApi; + } + + [StaticCallersOnly] + public static ILogger? StaticLogger { get; set; } + + /// + /// Gets the string mentioning the and event subscribers related to + /// a scheduled + /// event. + /// + /// + /// The scheduled event whose subscribers will be mentioned. + /// + /// The data of the guild containing the scheduled event. + /// The cancellation token for this operation. + /// A result containing the string which may or may not have succeeded. + public async Task> GetEventNotificationMentions( + IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default) + { + var builder = new StringBuilder(); + var role = GuildSettings.EventNotificationRole.Get(data.Settings); + var subscribersResult = await _eventApi.GetGuildScheduledEventUsersAsync( + scheduledEvent.GuildID, scheduledEvent.ID, ct: ct); + if (!subscribersResult.IsDefined(out var subscribers)) + { + return Result.FromError(subscribersResult); + } + + if (!role.Empty()) + { + builder.Append($"{Mention.Role(role)} "); + } + + builder = subscribers.Where(subscriber => + !data.GetOrCreateMemberData(subscriber.User.ID).Roles.Contains(role.Value)) + .Aggregate(builder, (current, subscriber) => current.Append($"{Mention.User(subscriber.User)} ")); + return builder.ToString(); + } + + /// + /// Logs an action in the and + /// . + /// + /// The guild configuration. + /// The ID of the channel where the action was executed. + /// The user who performed the action. + /// The title for the embed. + /// The description of the embed. + /// The user whose avatar will be displayed next to the of the embed. + /// The color of the embed. + /// + /// Whether or not the embed should be sent in + /// + /// The cancellation token for this operation. + /// A result which has succeeded. + public void LogAction( + JsonNode cfg, Snowflake channelId, IUser user, string title, string description, IUser avatar, + Color color, bool isPublic = true, CancellationToken ct = default) + { + var publicChannel = GuildSettings.PublicFeedbackChannel.Get(cfg); + var privateChannel = GuildSettings.PrivateFeedbackChannel.Get(cfg); + if (GuildSettings.PublicFeedbackChannel.Get(cfg).EmptyOrEqualTo(channelId) + && GuildSettings.PrivateFeedbackChannel.Get(cfg).EmptyOrEqualTo(channelId)) + { + return; + } + + var logEmbed = new EmbedBuilder().WithSmallTitle(title, avatar) + .WithDescription(description) + .WithActionFooter(user) + .WithCurrentTimestamp() + .WithColour(color) + .Build(); + + // Not awaiting to reduce response time + if (isPublic && publicChannel != channelId) + { + _ = _channelApi.CreateMessageWithEmbedResultAsync( + publicChannel, embedResult: logEmbed, + ct: ct); + } + + if (privateChannel != publicChannel + && privateChannel != channelId) + { + _ = _channelApi.CreateMessageWithEmbedResultAsync( + privateChannel, embedResult: logEmbed, + ct: ct); + } + } + + public async Task> GetEmergencyFeedbackChannel(IGuild guild, GuildData data, CancellationToken ct = default) + { + var privateFeedback = GuildSettings.PrivateFeedbackChannel.Get(data.Settings); + if (!privateFeedback.Empty()) + { + return privateFeedback; + } + + var publicFeedback = GuildSettings.PublicFeedbackChannel.Get(data.Settings); + if (!publicFeedback.Empty()) + { + return publicFeedback; + } + + if (guild.SystemChannelID.AsOptional().IsDefined(out var systemChannel)) + { + return systemChannel; + } + + var channelsResult = await _guildApi.GetGuildChannelsAsync(guild.ID, ct); + + return channelsResult.IsDefined(out var channels) + ? channels[0].ID + : Result.FromError(channelsResult); + } +} diff --git a/assets/css/fonts.css b/assets/css/fonts.css deleted file mode 100644 index 01a61b0..0000000 --- a/assets/css/fonts.css +++ /dev/null @@ -1,11 +0,0 @@ -@font-face { - font-family: 'BlitzBold'; - font-weight: normal; - src: url(../woff2/BlitzBold.woff2); -} - -@font-face { - font-family: 'BlitzMain'; - font-weight: normal; - src: url(../woff2/BlitzMain.woff2); -} \ No newline at end of file diff --git a/assets/css/styles.css b/assets/css/styles.css deleted file mode 100644 index 42412b1..0000000 --- a/assets/css/styles.css +++ /dev/null @@ -1,196 +0,0 @@ -/* - Octobot for Discord. Made by mctaylors. - Inspired by splatoon3.ink. -*/ - -@import url(fonts.css); - -:root { - color: #eee; - background-color: #000; - background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAOIAAADiBAMAAAChPgbkAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAqUExURRcXF0dwTBUVFRYWFgAAABoaGhkZGRUVFRgYGAAAAAAAAB4eHiQkJBISEkRDNy8AAAAOdFJOUxYAGBcDEwoMFQUBEQcOBwHpdgAAAjdJREFUeNrt271NBDEUReGnjUlGIoGICCKkbYIKqIASqIEm6IGMDiiBjtj5XXvGz9k9Cde5dfTFvo6Xc6TnacjP/Xd+7/TbuRi3r52b752bXw/5xedecaCRMdDIGGjkpQgjL0UYORZZ5FhkkVMRRU5FFDkXSeRcJJFLEUQuRRC5FjnkWuSQWxFDbkUMeS1SyGuRQhZFCFkUIWRZZJBlkUFWRQRZFRFkXSSQdZFA7ooAclcEkPuiHrkv6pGHohx5KMqRx6IaeSyqkY2iGNkoipGtohbZKmqRzaIU2SxKke2iEtkuKpFJUYhMikJkVtQhs6IOmRZlyLQoQ+ZFFTIvqpCdogjZKYqQvaIGGW80Mh5pZJxoZBhppJFGGmmkkUYaaaSRRhpppJFGGmmkkUYaaeR/Rp5pZHzQyPikkXFHI2OgkTHQyBho5KUII8f3RxY5Flnk9MaKIqciipzfkUnkXCSRy1s5iFyKIHLdA3DItcght80DhtyKGPK666CQ1yKFLLYrELIoQshyn8MgyyKDrDZICLIqIsh6Z0Ug6yKB3G3JAOSuCCD3ezk9cl/UIw+bQDnyUJQjj7tHNfJYVCMb204xslEUI1v7VS2yVdQimxtdKbJZlCLbO2Qlsl1UIpOttRCZFIXIbE+uQ2ZFHTLdzMuQaVGGzP8FqJB5UYXs/H0QITtFEbL3v0OD7BU1yPihkXFDIwNHhpFGGmmkkUYaaaSRRhpppJFGGmmkkUYaaaSRRhoJIgNH/gHUGcb6oO4YfwAAAABJRU5ErkJggg=="); - font-family: BlitzMain, sans-serif; -} - -a, a:visited { - color: chartreuse; -} - -a:hover { - color: aquamarine; -} - -a:active { - color: darkcyan; -} - -a.alternative { - text-decoration: none; -} - -.highlight { - font-family: BlitzBold, sans-serif; - font-size: 32px; -} - -.header { - font-size: 24px; - padding: 16px; - position: fixed; - width: calc(100% - 48px); - z-index: 10; -} - -.header > .left { - float: left; -} - -.header > .right { - float: right; -} - -.header > .left img { - margin: 0 8px; - height: 64px; -} - -.header > .right .social img { - height: 32px; - width: 32px; - border: #999 1px solid; - border-radius: 16px; - background-color: #0009; - padding: 8px; - transition: 200ms; -} - -.header > .right .social img:hover { - border-color: #eee; -} - -.content { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 32px 64px; - padding: 100px 16px 80px; -} - -.content > .card { - mask-image: url("../svg/card-header.svg"); - mask-size: 2000px auto; - mask-position: top; - background-image: url("../png/tapes-transparent.png"); - background-size: contain; - width: 480px; - min-height: 520px; - padding: 48px 16px 8px; - border-radius: 16px; -} - -.content > .card.first { - background-color: #1bbeab; /* Splatoon 3 TurquoisePink Alpha */ - rotate: -2deg; -} - -.content > .card.second { - background-color: #c43a6e; /* Splatoon 3 TurquoisePink Bravo */ - rotate: 2deg; -} - -.content > .card * { - text-align: left; -} - -.content > .card span { - line-height: 1em; - filter: drop-shadow(1px 1px #000); -} - -.content > .card > .title { - margin: 8px 0; -} - -.content > .card > .title > * { - vertical-align: middle; -} - -.content > .card > .title > span { - font-size: 24px; -} - -.content > .card > .title > img { - height: 32px; - width: 32px; -} - -.content > .card > .frame { - padding: 4px 8px 8px; - border-radius: 8px; - background-color: #0009; - backdrop-filter: blur(4px); -} - -.content > .card > .frame > ul { - padding: 0 0 0 24px; -} - -.invite { - margin-top: 8px; - padding: 12px 0; - width: 100%; - font-family: BlitzBold, sans-serif; - font-size: 20px; - color: white; - background-color: #4d5058; - border-radius: 4px; - border: 0; - display: flex; - justify-content: center; - gap: 8px; - transition: 200ms; -} - -.invite:hover { - background-color: #6d6f78; - cursor: pointer; -} - -.invite:active { - background-color: #80848e; -} - -.invite > img { - height: 24px; - width: 24px; -} - -.invite > span { - filter: none !important; -} - -.invite > * { - vertical-align: middle; -} - -.footer { - position: fixed; - left: 0; - bottom: 0; - width: 100%; - padding: 8px; - color: #999; - font-size: 14px; - text-align: center; - background-color: #0009; - backdrop-filter: blur(4px); -} - -.footer img { - vertical-align: sub; -} - -.splatoon { - height: 24px; - filter: brightness(75%); -} \ No newline at end of file diff --git a/assets/png/mem-cake-mole.png b/assets/png/mem-cake-mole.png deleted file mode 100644 index 6c7c244..0000000 Binary files a/assets/png/mem-cake-mole.png and /dev/null differ diff --git a/assets/png/mem-cake-octoling.png b/assets/png/mem-cake-octoling.png deleted file mode 100644 index ca882d6..0000000 Binary files a/assets/png/mem-cake-octoling.png and /dev/null differ diff --git a/assets/png/mem-cake-sardinium.png b/assets/png/mem-cake-sardinium.png deleted file mode 100644 index ecf358b..0000000 Binary files a/assets/png/mem-cake-sardinium.png and /dev/null differ diff --git a/assets/png/octo.png b/assets/png/octo.png deleted file mode 100644 index a5a7b5e..0000000 Binary files a/assets/png/octo.png and /dev/null differ diff --git a/assets/png/octobot-web-logo.png b/assets/png/octobot-web-logo.png deleted file mode 100644 index 4f8aa19..0000000 Binary files a/assets/png/octobot-web-logo.png and /dev/null differ diff --git a/assets/png/tapes-transparent.png b/assets/png/tapes-transparent.png deleted file mode 100644 index 6e10758..0000000 Binary files a/assets/png/tapes-transparent.png and /dev/null differ diff --git a/assets/svg/add-circle-white.svg b/assets/svg/add-circle-white.svg deleted file mode 100644 index 0ee4d6e..0000000 --- a/assets/svg/add-circle-white.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/svg/card-header.svg b/assets/svg/card-header.svg deleted file mode 100644 index 5dad4bf..0000000 --- a/assets/svg/card-header.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/assets/svg/github-mark-white.svg b/assets/svg/github-mark-white.svg deleted file mode 100644 index d5e6491..0000000 --- a/assets/svg/github-mark-white.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/svg/splatoon.svg b/assets/svg/splatoon.svg deleted file mode 100644 index 75094c7..0000000 --- a/assets/svg/splatoon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/woff2/BlitzBold.woff2 b/assets/woff2/BlitzBold.woff2 deleted file mode 100644 index 9756e0c..0000000 Binary files a/assets/woff2/BlitzBold.woff2 and /dev/null differ diff --git a/assets/woff2/BlitzMain.woff2 b/assets/woff2/BlitzMain.woff2 deleted file mode 100644 index 59feb45..0000000 Binary files a/assets/woff2/BlitzMain.woff2 and /dev/null differ diff --git a/compose.example.yaml b/compose.example.yaml new file mode 100644 index 0000000..522281f --- /dev/null +++ b/compose.example.yaml @@ -0,0 +1,17 @@ +services: + octobot: + container_name: octobot + build: + context: . + args: + - PUBLISH_OPTIONS + environment: + - BOT_TOKEN + volumes: + - guild-data:/Octobot/GuildData + - logs:/Octobot/Logs + restart: unless-stopped + +volumes: + guild-data: + logs: diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0b5e0cd --- /dev/null +++ b/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via the "Report Content" feature or via email at +l1ttleofficial@outlook.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..dc5a793 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing Guidelines + +Thank you for showing interest in the development of Octobot. We aim to provide a good collaborating environment for +everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. +Before starting, please read our [Code of Conduct](CODE_OF_CONDUCT.md) + +## Reporting bugs + +A **bug** is a situation in which there is something clearly wrong with the bot. Examples of applicable bug reports are: + +- The bot doesn't reply to a command +- The bot sends the same message twice +- The bot takes a long time to a respond if I use this specific command +- An embed the bot sent has incorrect information in it + +To track bug reports, we primarily use GitHub **issues**. When opening an issue, please keep in mind the following: + +- Before opening the issue, please search for any similar existing issues using the text search bar and the issue + labels. This includes both open and closed issues (we may have already fixed something, but the fix hasn't yet been + released). +- When opening the issue, please fill out as much of the issue template as you can. In particular, please make sure to + include console output and screenshots as much as possible. +- We may ask you for follow-up information to reproduce or debug the problem. Please look out for this and provide + follow-up info if we request it. + +## Submitting pull requests + +While pull requests from unaffiliated contributors are welcome, please note that the core team *may* be focused on +internal issues that haven't been published to the issue tracker yet. Reviewing PRs is done on a best-effort basis, so +please be aware that it may take a while before a core maintainer gets around to review your change. + +The [issue tracker](https://github.com/TeamOctolings/Octobot/issues) should provide plenty of issues to start with. +Make sure to check that an issue you're planning to resolve does not already have people working on it and that there +are no PRs associated with it + +In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't +seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the +correct direction on how to address it. + +If you'd like to propose a subjective change to one of the UI/UX aspects of the bot, or there is a bigger task you'd +like to work on, but there is no corresponding issue yet for it, **please open an issue first** to avoid wasted effort. + +Aside from the above, below is a brief checklist of things to watch out when you're preparing your code changes: + +- Make sure you're comfortable with the principles of object-oriented programming, the syntax of C\# and your + development environment. +- Make sure you are familiar with [git](https://git-scm.com/) + and [the pull request workflow](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests). +- Please do not make code changes via the GitHub web interface. +- Please make sure your development environment respects the .editorconfig file present in the repository. Our code + style differs from most C\# projects and is closer to something you see in Java projects. +- Please test your changes. We expect most new features and bugfixes to be tested in an environment similar to + production. + +After you're done with your changes and you wish to open the PR, please observe the following recommendations: + +- Please submit the pull request from + a [topic branch](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows#_topic_branch) (not `master`), and + keep the *Allow edits from maintainers* check box selected, so that we can push fixes to your PR if necessary. +- Please avoid pushing untested or incomplete code. +- Please do not force-push or rebase unless we ask you to. +- Please do not merge `master` continually if there are no conflicts to resolve. We will do this for you when the change + is ready for merge. + +We are highly committed to quality when it comes to Octobot. This means that contributions from less experienced +community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never +conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please +consider our comments and requests a learning experience. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ccc3b83 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,47 @@ +

+ Octobot banner +

+ + + + + +Veemo! I'm a general-purpose bot for moderation (formerly known as Boyfriend) written by [Team Octolings](https://github.com/TeamOctolings) in C# and Remora.Discord + +## Features + +* Banning, muting, kicking, etc. +* Reminding you about something if you wish +* Reminding everyone about that new event you made +* Renaming those annoying self-hoisting members +* Log everything from joining the server to deleting messages +* Listen to Inkantation! + +*...a-a-and more!* + +## Building Octobot + +Check out the Octobot's Wiki for details. + +| [Windows](https://github.com/TeamOctolings/Octobot/wiki/Installing-Windows) | [Linux/macOS](https://github.com/TeamOctolings/Octobot/wiki/Installing-Unix) | +| --- | --- | + +## Contributing + +When it comes to contributing to the project, the two main things you can do to help out are reporting issues and +submitting pull requests. Please refer to the [contributing guidelines](CONTRIBUTING.md) to understand how to help in +the most effective way possible. + +## Special Thanks + +![JetBrains Logo (Main) logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg) + +[JetBrains](https://www.jetbrains.com/), creators of [ReSharper](https://www.jetbrains.com/resharper) +and [Rider](https://www.jetbrains.com/rider), supports Octobot with one of +their [Open Source Licenses](https://jb.gg/OpenSourceSupport). +Rider is the recommended IDE when working with Octobot, and everyone on the Octobot team uses it. +Additionally, ReSharper command-line tools made by JetBrains are used for status checks on pull requests to ensure code +quality even when not using ReSharper or Rider. + +# +Not an official Splatoon™ product. We are in no way affiliated with or endorsed by Nintendo Company, or other rightsholders. diff --git a/docs/octobot-banner.png b/docs/octobot-banner.png new file mode 100644 index 0000000..2ab5f5b Binary files /dev/null and b/docs/octobot-banner.png differ diff --git a/assets/ico/octobot.ico b/docs/octobot.ico similarity index 100% rename from assets/ico/octobot.ico rename to docs/octobot.ico diff --git a/index.html b/index.html deleted file mode 100644 index acd199b..0000000 --- a/index.html +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - Octobot for Discord - - -
-
- Octobot Web logo -
-
- -
-
-
-
-
- Octobot icon - Veemo! -
-
- I'm a general-purpose Discord bot for moderation written by Team Octolings in C# and Remora.Discord! -
-
- Mem Cake (Sardinium) - Features -
-
-
    -
  • Banning, muting, kicking, etc.
  • -
  • Reminding you about something if you wish
  • -
  • Reminding everyone about that new event you made
  • -
  • Renaming those annoying self-hoisting members
  • -
  • Log everything from joining the server to deleting messages
  • -
  • Listen to Inkantation!
  • - ...a-a-and more! -
-
- - - -
-
-
- Mem Cake (Rival Octoling) - Bug Report / Feature Request -
-
- If you find some bug or want some new feature in Octobot, you can always use the Issues menu in our GitHub repository. - -
-
- Mem Cake (Mole) - Building Octobot -
-
- Want to make your own Octobot with, for example, even more features? Then, Octobot's Wiki is at your service! - -
-
-
- - -