Compare commits

...

No commits in common. "legacy" and "main" have entirely different histories.
legacy ... main

156 changed files with 6842 additions and 6086 deletions

12
.config/dotnet-tools.json Normal file
View file

@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.6",
"commands": [
"dotnet-ef"
]
}
}
}

223
.editorconfig Normal file
View file

@ -0,0 +1,223 @@
# Remove the line below if you want to inherit .editorconfig settings from higher directories
root = true
# C# files
[*.cs]
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
indent_style = space
tab_width = 4
# New line preferences
end_of_line = crlf
insert_final_newline = true
#### .NET Coding Conventions ####
# Organize usings
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = true
file_header_template = unset
# this. and Me. preferences
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Expression-level preferences
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_namespace_match_folder = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_compound_assignment = true
dotnet_style_prefer_conditional_expression_over_assignment = true
dotnet_style_prefer_conditional_expression_over_return = true
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
# Field preferences
dotnet_style_readonly_field = true
# Parameter preferences
dotnet_code_quality_unused_parameters = true
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
# New line preferences
dotnet_style_allow_multiple_blank_lines_experimental = false
dotnet_style_allow_statement_immediately_after_block_experimental = true
#### C# Coding Conventions ####
# var preferences
csharp_style_var_elsewhere = false:silent
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = true:silent
csharp_style_expression_bodied_methods = true:silent
csharp_style_expression_bodied_operators = true:silent
csharp_style_expression_bodied_properties = true:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_prefer_pattern_matching = true:suggestion
csharp_style_prefer_switch_expression = true:suggestion
# Null-checking preferences
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences
csharp_prefer_static_local_function = true:suggestion
# Code-block preferences
csharp_prefer_braces = when_multiline:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = file_scoped:suggestion
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_pattern_local_over_anonymous_function = true
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:silent
# New line preferences
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false:suggestion
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:suggestion
csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
#### C# Formatting Rules ####
# New line preferences
csharp_new_line_before_catch = false
csharp_new_line_before_else = false
csharp_new_line_before_finally = false
csharp_new_line_before_members_in_anonymous_types = false
csharp_new_line_before_members_in_object_initializers = false
csharp_new_line_before_open_brace = none
csharp_new_line_between_query_expression_clauses = false
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = flush_left
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_extended_property_pattern = true:suggestion
csharp_style_prefer_primary_constructors = true:suggestion

2
.gitignore vendored
View file

@ -2,4 +2,4 @@
[Oo]bj/
.vs/
*.user
.vscode
output/

26
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,26 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/bin/Debug/net8.0/RegexBot.dll",
"args": [ "-c", "${workspaceFolder}/bin/Debug/config.json" ],
"cwd": "${workspaceFolder}",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "internalConsole",
"stopAtEntry": false
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}

41
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/RegexBot.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/RegexBot.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/RegexBot.csproj"
],
"problemMatcher": "$msCompile"
}
]
}

674
COPYING Normal file
View file

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is 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. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
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.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
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 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. Use with the GNU Affero General Public License.
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 Affero 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 special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU 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 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 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 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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
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 GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

120
Common/EntityList.cs Normal file
View file

@ -0,0 +1,120 @@
using System.Collections;
using System.Collections.ObjectModel;
namespace RegexBot.Common;
/// <summary>
/// Represents a commonly-used configuration structure: an array of strings consisting of <see cref="EntityName"/> values.
/// </summary>
public class EntityList : IEnumerable<EntityName> {
private readonly ReadOnlyCollection<EntityName> _innerList;
/// <summary>Gets an enumerable collection of all role names defined in this list.</summary>
public IEnumerable<EntityName> Roles
=> _innerList.Where(n => n.Type == EntityType.Role);
/// <summary>Gets an enumerable collection of all channel names defined in this list.</summary>
public IEnumerable<EntityName> Channels
=> _innerList.Where(n => n.Type == EntityType.Channel);
/// <summary>Gets an enumerable collection of all user names defined in this list.</summary>
public IEnumerable<EntityName> Users
=> _innerList.Where(n => n.Type == EntityType.User);
/// <summary>
/// Creates a new EntityList instance with no data.
/// </summary>
public EntityList() : this(null) { }
/// <summary>
/// Creates a new EntityList instance using the given JSON token as input.
/// </summary>
/// <param name="input">JSON array to be used for input. For ease of use, null values are also accepted.</param>
/// <exception cref="ArgumentException">The input is not a JSON array.</exception>
/// <exception cref="ArgumentNullException">
/// Unintiutively, this exception is thrown if a user-provided configuration value is blank.
/// </exception>
/// <exception cref="FormatException">
/// When enforceTypes is set, this is thrown if an EntityName results in having its Type be Unspecified.
/// </exception>
public EntityList(JToken? input) {
if (input == null) {
_innerList = new List<EntityName>().AsReadOnly();
return;
}
if (input.Type != JTokenType.Array)
throw new ArgumentException("JToken input must be a JSON array.");
var inputArray = (JArray)input;
var list = new List<EntityName>();
foreach (var item in inputArray.Values<string>()) {
if (string.IsNullOrWhiteSpace(item)) continue;
var itemName = new EntityName(item);
list.Add(itemName);
}
_innerList = list.AsReadOnly();
}
/// <summary>
/// Checks if the parameters of the given <see cref="SocketMessage"/> matches with
/// any entity specified in this list.
/// </summary>
/// <param name="msg">The incoming message object with which to scan for a match.</param>
/// <param name="keepId">
/// Specifies if EntityName instances within this list should have their internal ID value
/// updated if found during the matching process.
/// </param>
/// <returns>
/// True if the message author exists in this list, or if the message's channel exists in this list,
/// or if the message author contains a role that exists in this list.
/// </returns>
public bool IsListMatch(SocketMessage msg, bool keepId) {
var author = (SocketGuildUser)msg.Author;
var authorRoles = author.Roles;
var channel = msg.Channel;
foreach (var entry in this) {
if (entry.Type == EntityType.Role) {
if (entry.Id.HasValue) {
if (authorRoles.Any(r => r.Id == entry.Id.Value)) return true;
} else {
foreach (var r in authorRoles) {
if (!string.Equals(r.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
if (keepId) entry.Id = r.Id;
return true;
}
}
} else if (entry.Type == EntityType.Channel) {
if (entry.Id.HasValue) {
if (entry.Id.Value == channel.Id) return true;
} else {
if (!string.Equals(channel.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
if (keepId) entry.Id = channel.Id;
return true;
}
} else { // User
if (entry.Id.HasValue) {
if (entry.Id.Value == author.Id) return true;
} else {
if (!string.Equals(author.Username, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
if (keepId) entry.Id = author.Id;
return true;
}
}
}
return false;
}
/// <summary>
/// Determines if this list contains no entries.
/// </summary>
public bool IsEmpty() => _innerList.Count == 0;
/// <inheritdoc/>
public override string ToString() => $"Entity list contains {_innerList.Count} item(s).";
/// <inheritdoc/>
public IEnumerator<EntityName> GetEnumerator() => _innerList.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

190
Common/EntityName.cs Normal file
View file

@ -0,0 +1,190 @@
namespace RegexBot.Common;
/// <summary>
/// Helper class that holds an entity's name, ID, or both.
/// Meant to be used during configuration processing in cases where the configuration expects
/// an entity name to be defined in a certain way which may or may not include its snowflake ID.
/// </summary>
public class EntityName {
/// <summary>
/// The entity's type, as specified in configuration.
/// </summary>
public EntityType Type { get; private set; }
private ulong? _id;
/// <summary>
/// Entity's unique ID value (snowflake). May be null if the value is not known.
/// </summary>
/// <remarks>
/// This property may be updated during runtime if instructed to update the ID for persistence.
/// </remarks>
public ulong? Id {
get => _id;
internal set => _id ??= value;
}
/// <summary>
/// Entity's name as specified in configuration. May be null if it was not specified.
/// </summary>
/// <remarks>This value is not updated during runtime.</remarks>
public string? Name { get; private set; }
/// <summary>
/// Creates a new object instance from the given input string.
/// Documentation for the EntityName format can be found elsewhere in this project's documentation.
/// </summary>
/// <param name="input">Input string in EntityName format.</param>
/// <exception cref="ArgumentNullException">Input string is null or blank.</exception>
/// <exception cref="ArgumentException">Input string cannot be resolved to an entity type.</exception>
public EntityName(string input) {
if (string.IsNullOrWhiteSpace(input))
throw new ArgumentNullException(nameof(input), "Specified name is blank.");
if (input.Length < 2) throw new ArgumentException("Input is not in a valid entity name format.");
if (input[0] == '&') Type = EntityType.Role;
else if (input[0] == '#') Type = EntityType.Channel;
else if (input[0] == '@') Type = EntityType.User;
else throw new ArgumentException("Entity type unable to be inferred by given input.");
input = input[1..]; // Remove prefix
// Input contains ID/Label separator?
var separator = input.IndexOf("::");
if (separator != -1) {
Name = input[(separator + 2)..];
if (ulong.TryParse(input.AsSpan(0, separator), out var parseOut)) {
// Got an ID.
Id = parseOut;
} else {
// It's not actually an ID. Assuming the entire string is a name.
Name = input;
Id = null;
}
} else {
// No separator. Input is either entirely an ID or entirely a Name.
if (ulong.TryParse(input, out var parseOut)) {
// ID without name.
Id = parseOut;
Name = null;
} else {
// Name without ID.
Name = input;
Id = null;
}
}
}
/// <summary>
/// Creates a new object instance from the given input string.
/// Documentation for the EntityName format can be found elsewhere in this project's documentation.
/// </summary>
/// <param name="input">Input string in EntityName format.</param>
/// <param name="expectedType">The <see cref="EntityType"/> expected for this instance.</param>
/// <exception cref="ArgumentNullException">Input string is null or blank.</exception>
/// <exception cref="ArgumentException">Input string cannot be resolved to an entity type.</exception>
/// <exception cref="FormatException">Input string was resolved to a type other than specified.</exception>
public EntityName(string input, EntityType expectedType) : this(input) {
if (Type != expectedType) throw new FormatException("Resolved EntityType does not match expected type.");
}
/// <summary>
/// Returns the appropriate prefix corresponding to an EntityType.
/// </summary>
public static char GetPrefix(EntityType t) => t switch {
EntityType.Role => '&',
EntityType.Channel => '#',
EntityType.User => '@',
_ => '\0',
};
/// <summary>
/// Returns a string representation of this item in proper EntityName format.
/// </summary>
public override string ToString() {
var pf = GetPrefix(Type);
if (Id.HasValue && Name != null)
return $"{pf}{Id.Value}::{Name}";
else if (Id.HasValue)
return $"{pf}{Id}";
else
return $"{pf}{Name}";
}
#region Helper methods
// TODO convert all to extension methods
/// <summary>
/// Attempts to find the corresponding role within the given guild.
/// </summary>
/// <param name="guild">The guild in which to search for the role.</param>
/// <param name="updateMissingID">
/// Specifies if this EntityName instance should cache the snowflake ID of the
/// corresponding role found in this guild if it is not already known by this instance.
/// </param>
public SocketRole? FindRoleIn(SocketGuild guild, bool updateMissingID = false) {
if (Type != EntityType.Role)
throw new ArgumentException("This EntityName instance must correspond to a Role.");
var dirty = false; // flag for updating ID if possible regardless of updateMissingId setting
if (Id.HasValue) {
var role = guild.GetRole(Id.Value);
if (role != null) return role;
else dirty = true; // only set if ID already existed but is now invalid
}
var r = guild.Roles.FirstOrDefault(rq => string.Equals(rq.Name, Name, StringComparison.OrdinalIgnoreCase));
if (r != null && (updateMissingID || dirty)) Id = r.Id;
return r;
}
/// <summary>
/// Attempts to find the corresponding user within the given guild.
/// </summary>
/// <param name="guild">The guild in which to search for the user.</param>
/// <param name="updateMissingID">
/// Specifies if this EntityName instance should cache the snowflake ID of the
/// corresponding user found in this guild if it is not already known by this instance.
/// </param>
public SocketGuildUser? FindUserIn(SocketGuild guild, bool updateMissingID = false) {
if (Type != EntityType.User)
throw new ArgumentException("This EntityName instance must correspond to a User.");
var dirty = false; // flag for updating ID if possible regardless of updateMissingId setting
if (Id.HasValue) {
var user = guild.GetUser(Id.Value);
if (user != null) return user;
else dirty = true; // only set if ID already existed but is now invalid
}
var u = guild.Users.FirstOrDefault(rq => string.Equals(rq.Username, Name, StringComparison.OrdinalIgnoreCase));
if (u != null && (updateMissingID || dirty)) Id = u.Id;
return u;
}
/// <summary>
/// Attempts to find the corresponding channel within the given guild.
/// </summary>
/// <param name="guild">The guild in which to search for the channel.</param>
/// <param name="updateMissingID">
/// Specifies if this EntityName instance should cache the snowflake ID of the
/// corresponding channel found in this guild if it is not already known by this instance.
/// </param>
public SocketTextChannel? FindChannelIn(SocketGuild guild, bool updateMissingID = false) {
if (Type != EntityType.Channel)
throw new ArgumentException("This EntityName instance must correspond to a Channel.");
var dirty = false; // flag for updating ID if possible regardless of updateMissingId setting
if (Id.HasValue) {
var channel = guild.GetTextChannel(Id.Value);
if (channel != null) return channel;
else dirty = true; // only set if ID already existed but is now invalid
}
var c = guild.TextChannels.FirstOrDefault(rq => string.Equals(rq.Name, Name, StringComparison.OrdinalIgnoreCase));
if (c != null && (updateMissingID || dirty)) Id = c.Id;
return c;
}
#endregion
}

18
Common/EntityType.cs Normal file
View file

@ -0,0 +1,18 @@
namespace RegexBot.Common;
/// <summary>
/// The type of entity specified in an <see cref="EntityName"/>.
/// </summary>
public enum EntityType {
/// <summary>
/// Userd when the <see cref="EntityName"/> represents a role.
/// </summary>
Role,
/// <summary>
/// Used when the <see cref="EntityName"/> represents a channel.
/// </summary>
Channel,
/// <summary>
/// Used when the <see cref="EntityName"/> represents a user.
/// </summary>
User
}

122
Common/FilterList.cs Normal file
View file

@ -0,0 +1,122 @@
namespace RegexBot.Common;
/// <summary>
/// Represents commonly-used configuration regarding whitelist/blacklist filtering, including exemptions.
/// </summary>
public class FilterList {
/// <summary>
/// The mode at which the <see cref="FilterList"/>'s filter criteria is operating.
/// </summary>
public enum FilterMode {
/// <summary>
/// A <see cref="FilterList"/> setting which does no filtering on the list.
/// </summary>
None,
/// <summary>
/// A <see cref="FilterList"/> setting which excludes only entites not in the list, excluding those exempted.
/// </summary>
Whitelist,
/// <summary>
/// A <see cref="FilterList"/> setting which allows all entities except those in the list, but allowing those exempted.
/// </summary>
Blacklist
}
/// <summary>
/// Gets the mode at which the <see cref="FilterList"/>'s filter criteria is operating.
/// </summary>
public FilterMode Mode { get; }
/// <summary>
/// Gets the inner list that this instance is using for its filtering criteria.
/// </summary>
public EntityList FilteredList { get; }
/// <summary>
/// Gets the list of entities that may override filtering rules for this instance.
/// </summary>
public EntityList FilterExemptions { get; }
/// <summary>
/// Sets up a FilterList instance with the given JSON configuration section.
/// </summary>
/// <param name="config">
/// JSON object in which to attempt to find the given whitelist, blacklist, and/or excemption keys.
/// </param>
/// <param name="whitelistKey">The key in which to search for the whitelist. Set as null to disable.</param>
/// <param name="blacklistKey">The key in which to search for the blacklist. Set as null to disable.</param>
/// <param name="exemptKey">The key in which to search for filter exemptions. Set as null to disable.</param>
/// <exception cref="FormatException">
/// Thrown if there is a problem with input. The exception message specifies the reason.
/// </exception>
public FilterList(JObject config, string whitelistKey = "Whitelist", string blacklistKey = "Blacklist", string exemptKey = "Exempt") {
if (whitelistKey != null && config[whitelistKey] != null &&
blacklistKey != null && config[blacklistKey] != null) {
// User has defined both keys at once, which is not allowed
throw new FormatException($"Cannot have both '{whitelistKey}' and '{blacklistKey}' defined at once.");
}
JToken? incoming = null;
if (whitelistKey != null) {
// Try getting a whitelist
incoming = config[whitelistKey];
Mode = FilterMode.Whitelist;
}
if (incoming == null && blacklistKey != null) {
// Try getting a blacklist
incoming = config[blacklistKey];
Mode = FilterMode.Blacklist;
}
if (incoming == null) {
// Got neither. Have an empty list.
Mode = FilterMode.None;
FilteredList = new EntityList();
FilterExemptions = new EntityList();
return;
}
if (incoming.Type != JTokenType.Array)
throw new FormatException("Filtering list must be a JSON array.");
FilteredList = new EntityList((JArray)incoming);
// Verify the same for the exemption list.
if (exemptKey != null) {
var incomingEx = config[exemptKey];
if (incomingEx == null) {
FilterExemptions = new EntityList();
} else if (incomingEx.Type != JTokenType.Array) {
throw new FormatException("Filtering exemption list must be a JSON array.");
} else {
FilterExemptions = new EntityList(incomingEx);
}
} else {
FilterExemptions = new EntityList();
}
}
/// <summary>
/// Determines if the parameters of the given message match up against the filtering
/// rules described within this instance.
/// </summary>
/// <param name="msg">
/// The incoming message to be checked.
/// </param>
/// <param name="keepId">
/// See equivalent documentation for <see cref="EntityList.IsListMatch(SocketMessage, bool)"/>.
/// </param>
/// <returns>
/// True if the author or associated channel exists in and is not exempted by this instance.
/// </returns>
public bool IsFiltered(SocketMessage msg, bool keepId) {
if (Mode == FilterMode.None) return false;
var isInFilter = FilteredList.IsListMatch(msg, keepId);
if (Mode == FilterMode.Whitelist) {
if (!isInFilter) return true;
return FilterExemptions.IsListMatch(msg, keepId);
} else if (Mode == FilterMode.Blacklist) {
if (!isInFilter) return false;
return !FilterExemptions.IsListMatch(msg, keepId);
}
throw new Exception("it is not possible for this to happen");
}
}

10
Common/Messages.cs Normal file
View file

@ -0,0 +1,10 @@
namespace RegexBot.Common;
/// <summary>
/// Commonly used strings used throughout the bot and modules.
/// </summary>
public static class Messages {
/// <summary>
/// Gets a string generally appropriate to display in the event of a 403 error.
/// </summary>
public const string ForbiddenGenericError = "Failed to perform the action due to a permissions issue.";
}

48
Common/RateLimit.cs Normal file
View file

@ -0,0 +1,48 @@
namespace RegexBot.Common;
/// <summary>
/// Helper class for managing rate limit data.
/// Specifically, this class holds entries and does not allow the same entry to be held more than once until a specified
/// amount of time has passed since the entry was originally tracked; useful for a rate limit system.
/// </summary>
public class RateLimit<T> where T : notnull {
private const int DefaultTimeout = 20; // Skeeter's a cool guy and you can't convince me otherwise.
/// <summary>
/// Time until an entry within this instance expires, in seconds.
/// </summary>
public int Timeout { get; }
private Dictionary<T, DateTime> Entries { get; } = [];
/// <summary>
/// Creates a new <see cref="RateLimit&lt;T&gt;"/> instance with the default timeout value.
/// </summary>
public RateLimit() : this(DefaultTimeout) { }
/// <summary>
/// Creates a new <see cref="RateLimit&lt;T&gt;"/> instance with the given timeout value.
/// </summary>
/// <param name="timeout">Time until an entry within this instance will expire, in seconds.</param>
public RateLimit(int timeout) {
if (timeout < 0) throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout valie cannot be negative.");
Timeout = timeout;
}
/// <summary>
/// Checks if the given value is permitted through the rate limit.
/// Executing this method may create a rate limit entry for the given value.
/// </summary>
/// <returns>True if the given value is permitted by the rate limiter.</returns>
public bool IsPermitted(T value) {
if (Timeout == 0) return true;
// Take a moment to clean out expired entries
var now = DateTime.Now;
var expired = Entries.Where(x => x.Value.AddSeconds(Timeout) <= now).Select(x => x.Key).ToList();
foreach (var item in expired) Entries.Remove(item);
if (Entries.ContainsKey(value)) return false;
else {
Entries.Add(value, DateTime.Now);
return true;
}
}
}

138
Common/Utilities.cs Normal file
View file

@ -0,0 +1,138 @@
using Discord;
using RegexBot.Data;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.RegularExpressions;
namespace RegexBot.Common;
/// <summary>
/// Miscellaneous utility methods useful for the bot and modules.
/// </summary>
public static partial class Utilities {
/// <summary>
/// Gets a precompiled regex that matches a channel tag and pulls its snowflake value.
/// </summary>
[GeneratedRegex(@"<#(?<snowflake>\d+)>")]
public static partial Regex ChannelMentionRegex();
/// <summary>
/// Gets a precompiled regex that matches a custom emoji and pulls its name and ID.
/// </summary>
[GeneratedRegex(@"<:(?<name>[A-Za-z0-9_]{2,}):(?<ID>\d+)>")]
public static partial Regex CustomEmojiRegex();
/// <summary>
/// Gets a precompiled regex that matches a fully formed Discord handle, extracting the name and discriminator.
/// </summary>
[GeneratedRegex(@"(.+)#(\d{4}(?!\d))")]
public static partial Regex DiscriminatorSearchRegex();
/// <summary>
/// Gets a precompiled regex that matches a user tag and pulls its snowflake value.
/// </summary>
[GeneratedRegex(@"<@!?(?<snowflake>\d+)>")]
public static partial Regex UserMentionRegex();
/// <summary>
/// Performs common checks on the specified message to see if it fits all the criteria of a
/// typical, ordinary message sent by an ordinary guild user.
/// </summary>
public static bool IsValidUserMessage(SocketMessage msg, [NotNullWhen(true)] out SocketTextChannel? channel) {
channel = default;
if (msg.Channel is not SocketTextChannel ch) return false;
if (msg.Author.IsBot || msg.Author.IsWebhook) return false;
if (((IMessage)msg).Type != MessageType.Default) return false;
if (msg is SocketSystemMessage) return false;
channel = ch;
return true;
}
/// <summary>
/// Given a JToken, gets all string-based values out of it if the token may be a string
/// or an array of strings.
/// </summary>
/// <param name="token">The JSON token to analyze and retrieve strings from.</param>
/// <exception cref="ArgumentException">Thrown if the given token is not a string or array containing all strings.</exception>
/// <exception cref="ArgumentNullException">Thrown if the given token is null.</exception>
public static List<string> LoadStringOrStringArray(JToken? token) {
const string ExNotString = "This token contains a non-string element.";
if (token == null) throw new ArgumentNullException(nameof(token), "The provided token is null.");
var results = new List<string>();
if (token.Type == JTokenType.String) {
results.Add(token.Value<string>()!);
} else if (token.Type == JTokenType.Array) {
foreach (var entry in token.Values()) {
if (entry.Type != JTokenType.String) throw new ArgumentException(ExNotString, nameof(token));
results.Add(entry.Value<string>()!);
}
} else {
throw new ArgumentException(ExNotString, nameof(token));
}
return results;
}
/// <summary>
/// Returns a representation of this entity that can be parsed by the <seealso cref="EntityName"/> constructor.
/// </summary>
public static string AsEntityNameString(this IUser entity) => $"@{entity.Id}::{entity.Username}";
/// <summary>
/// If given string is in an EntityName format, returns a displayable representation of it based on
/// a cache query. Otherwise, returns the input string as-is.
/// </summary>
[return: NotNullIfNotNull(nameof(input))]
public static string? TryFromEntityNameString(string? input, RegexbotClient bot) {
string? result = null;
try {
var entityTry = new EntityName(input!, EntityType.User);
var issueq = bot.EcQueryUser(entityTry.Id!.Value.ToString());
if (issueq != null) result = $"<@{issueq.UserId}> - {issueq.GetDisplayableUsername()} `{issueq.UserId}`";
else result = $"Unknown user with ID `{entityTry.Id!.Value}`";
} catch (Exception) { }
return result ?? input;
}
/// <summary>
/// Given an input string, replaces certain special character combinations with information
/// from the specified message.
/// </summary>
public static string ProcessTextTokens(string input, SocketMessage m) {
// TODO elaborate on this
// For now, replaces all instances of @_ with the message sender.
return input
.Replace("@_", m.Author.Mention)
.Replace("@\\_", "@_");
}
/// <inheritdoc cref="GetDisplayableUsernameCommon"/>
public static string GetDisplayableUsername(this SocketUser user)
=> GetDisplayableUsernameCommon(user.Username, user.Discriminator, user.GlobalName);
/// <inheritdoc cref="GetDisplayableUsernameCommon"/>
public static string GetDisplayableUsername(this CachedUser user)
=> GetDisplayableUsernameCommon(user.Username, user.Discriminator, user.GlobalName);
/// <summary>
/// Returns a string representation of the user's username suitable for display purposes.
/// For the sake of consistency, it is preferable using this instead of any other means, including Discord.Net's ToString.
/// </summary>
private static string GetDisplayableUsernameCommon(string username, string discriminator, string? global) {
static string escapeFormattingCharacters(string input) {
var result = new StringBuilder();
foreach (var c in input) {
if (c is '\\' or '_' or '~' or '*' or '@' or '`') {
result.Append('\\');
}
result.Append(c);
}
return result.ToString();
}
if (discriminator == "0000") {
if (global != null) return $"{escapeFormattingCharacters(global)} ({username})";
return username;
} else {
return $"{escapeFormattingCharacters(username)}#{discriminator}";
}
}
}

88
Configuration.cs Normal file
View file

@ -0,0 +1,88 @@
using CommandLine;
using Newtonsoft.Json;
using System.Diagnostics.CodeAnalysis;
namespace RegexBot;
class Configuration {
/// <summary>
/// Token used for Discord authentication.
/// </summary>
internal string BotToken { get; }
/// <summary>
/// List of assemblies to load, by file. Paths are always relative to the bot directory.
/// </summary>
internal IReadOnlyList<string> Assemblies { get; }
public JObject ServerConfigs { get; }
// SQL properties:
public string? Host { get; }
public string? Database { get; }
public string Username { get; }
public string Password { get; }
/// <summary>
/// Sets up instance configuration object from file and command line parameters.
/// </summary>
internal Configuration() {
var args = CommandLineParameters.Parse(Environment.GetCommandLineArgs());
var path = args?.ConfigFile!;
JObject conf;
try {
var conftxt = File.ReadAllText(path);
conf = JObject.Parse(conftxt);
} catch (Exception ex) {
string pfx;
if (ex is JsonException) pfx = "Unable to parse configuration: ";
else pfx = "Unable to access configuration: ";
throw new Exception(pfx + ex.Message, ex);
}
BotToken = ReadConfKey<string>(conf, nameof(BotToken), true);
try {
Assemblies = Common.Utilities.LoadStringOrStringArray(conf[nameof(Assemblies)]).AsReadOnly();
} catch (ArgumentNullException) {
Assemblies = Array.Empty<string>();
} catch (ArgumentException) {
throw new Exception($"'{nameof(Assemblies)}' is not properly specified in configuration.");
}
var dbconf = (conf["DatabaseOptions"]?.Value<JObject>())
?? throw new Exception("Database settings were not specified in configuration.");
Host = ReadConfKey<string>(dbconf, nameof(Host), false);
Database = ReadConfKey<string?>(dbconf, nameof(Database), false);
Username = ReadConfKey<string>(dbconf, nameof(Username), true);
Password = ReadConfKey<string>(dbconf, nameof(Password), true);
ServerConfigs = conf["Servers"]?.Value<JObject>();
if (ServerConfigs == null) throw new Exception("No server configurations were specified.");
}
private static T? ReadConfKey<T>(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) {
if (jc.ContainsKey(key)) return jc[key]!.Value<T>();
if (failOnEmpty) throw new Exception($"'{key}' must be specified in the instance configuration.");
return default;
}
class CommandLineParameters {
[Option('c', "config", Default = "config.json")]
public string? ConfigFile { get; set; } = null;
public static CommandLineParameters? Parse(string[] args) {
CommandLineParameters? result = null;
new Parser(settings => {
settings.IgnoreUnknownArguments = true;
settings.AutoHelp = false;
settings.AutoVersion = false;
}).ParseArguments<CommandLineParameters>(args)
.WithParsed(p => result = p)
.WithNotParsed(e => { /* ignore */ });
return result;
}
}
}

44
ConfigurationSchema.json Normal file
View file

@ -0,0 +1,44 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"BotToken": {
"type": "string",
"description": "The token used by the bot to connect to Discord."
},
"Assemblies": {
"type": "array",
"description": "A list of additional files to be loaded to extend the bot's functionality.",
"default": [ "RegexBot.dll" ]
},
"DatabaseOptions": {
"type": "object",
"description": "A set of options for the SQL database connection.",
"properties": {
"Host": {
"type": "string",
"description": "The SQL host, whether a hostname, IP address, or path to a socket."
},
"Database": {
"type": "string",
"description": "The target SQL database name to connect to, if different from the default."
},
"Username": {
"type": "string",
"description": "The username used for SQL server authentication."
},
"Password": {
"type": "string",
"description": "The password used for SQL server authentication."
}
},
"required": [ "Username", "Password" ]
},
"Servers": {
"type": "object",
"description": "A collection of server configurations with keys representing server IDs and values containing the respective server's configuration."
/* TODO unfinished */
}
},
"required": [ "BotToken", "DatabaseOptions", "Servers" ]
}

View file

@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace RegexBot.Data;
/// <summary>
/// Represents a database connection using the settings defined in the bot's global configuration.
/// </summary>
public class BotDatabaseContext : DbContext {
private static readonly string _connectionString;
static BotDatabaseContext() {
// Get our own config loaded just for the SQL stuff
// TODO this should probably be cached, or otherwise loaded in a better way
var conf = new Configuration();
_connectionString = new NpgsqlConnectionStringBuilder() {
#if DEBUG
IncludeErrorDetail = true,
#endif
Host = conf.Host ?? "localhost", // default to localhost
Database = conf.Database,
Username = conf.Username,
Password = conf.Password
}.ToString();
}
/// <summary>
/// Retrieves the <seealso cref="CachedUser">user cache</seealso>.
/// </summary>
public DbSet<CachedUser> UserCache { get; set; } = null!;
/// <summary>
/// Retrieves the <seealso cref="CachedGuildUser">guild user cache</seealso>.
/// </summary>
public DbSet<CachedGuildUser> GuildUserCache { get; set; } = null!;
/// <summary>
/// Retrieves the <seealso cref="CachedGuildMessage">guild message cache</seealso>.
/// </summary>
public DbSet<CachedGuildMessage> GuildMessageCache { get; set; } = null!;
/// <summary>
/// Retrieves the <seealso cref="ModLogEntry">moderator logs</seealso>.
/// </summary>
public DbSet<ModLogEntry> ModLogs { get; set; } = null!;
/// <inheritdoc />
protected sealed override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseNpgsql(_connectionString)
.UseSnakeCaseNamingConvention();
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.Entity<CachedUser>(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength());
modelBuilder.Entity<CachedGuildUser>(e => {
e.HasKey(p => new { p.GuildId, p.UserId });
e.Property(p => p.FirstSeenTime).HasDefaultValueSql("now()");
});
modelBuilder.Entity<CachedGuildMessage>(e => e.Property(p => p.CreatedAt).HasDefaultValueSql("now()"));
modelBuilder.HasPostgresEnum<ModLogType>();
modelBuilder.Entity<ModLogEntry>(e => {
e.Property(p => p.Timestamp).HasDefaultValueSql("now()");
e.HasOne(entry => entry.User)
.WithMany(gu => gu.Logs)
.HasForeignKey(entry => new { entry.GuildId, entry.UserId });
});
}
}

View file

@ -0,0 +1,76 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RegexBot.Data;
/// <summary>
/// Represents an item in the guild message cache.
/// </summary>
[Table("cache_guildmessages")]
public class CachedGuildMessage {
/// <summary>
/// Gets the message's snowflake ID.
/// </summary>
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public long MessageId { get; set; }
/// <summary>
/// Gets the message author's snowflake ID.
/// </summary>
public long AuthorId { get; set; }
/// <summary>
/// Gets the associated guild's snowflake ID.
/// </summary>
public long GuildId { get; set; }
/// <summary>
/// Gets the corresponding channel's snowflake ID.
/// </summary>
public long ChannelId { get; set; }
/// <summary>
/// Gets the timestamp showing when this message was originally created.
/// </summary>
/// <remarks>
/// Though it's possible to draw this from <see cref="MessageId"/>, it is stored in the database
/// as a separate field for any possible necessary use via database queries.
/// </remarks>
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Gets the timestamp, if any, showing when this message was last edited.
/// </summary>
public DateTimeOffset? EditedAt { get; set; }
/// <summary>
/// Gets a list of file names that were attached to this message.
/// </summary>
public List<string> AttachmentNames { get; set; } = null!;
/// <summary>
/// Gets this message's content.
/// </summary>
public string? Content { get; set; } = null!;
/// <inheritdoc cref="CachedGuildUser.User" />
[ForeignKey(nameof(AuthorId))]
[InverseProperty(nameof(CachedUser.GuildMessages))]
public CachedUser Author { get; set; } = null!;
// TODO set up composite foreign key. will require rewriting some parts in modules...
// Used by MessageCachingSubservice
internal static CachedGuildMessage? Clone(CachedGuildMessage? original) {
if (original == null) return null;
return new() {
MessageId = original.MessageId,
AuthorId = original.AuthorId,
GuildId = original.GuildId,
ChannelId = original.ChannelId,
CreatedAt = original.CreatedAt,
EditedAt = original.EditedAt,
AttachmentNames = new(original.AttachmentNames),
Content = original.Content
};
}
}

41
Data/CachedGuildUser.cs Normal file
View file

@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace RegexBot.Data;
/// <summary>
/// Represents an item in the guild user cache.
/// </summary>
[Table("cache_usersinguild")]
public class CachedGuildUser {
/// <summary>
/// Gets the associated guild's snowflake ID.
/// </summary>
public long GuildId { get; set; }
/// <inheritdoc cref="CachedUser.UserId"/>
public long UserId { get; set; }
/// <inheritdoc cref="CachedUser.ULastUpdateTime"/>
public DateTimeOffset GULastUpdateTime { get; set; }
/// <summary>
/// Gets the timestamp showing when this cache entry was first added into the database.
/// </summary>
public DateTimeOffset FirstSeenTime { get; set; }
/// <summary>
/// Gets the user's cached nickname in the guild.
/// </summary>
public string? Nickname { get; set; }
/// <summary>
/// If included in the query, references the associated <seealso cref="CachedUser"/> for this entry.
/// </summary>
[ForeignKey(nameof(UserId))]
[InverseProperty(nameof(CachedUser.Guilds))]
public CachedUser User { get; set; } = null!;
/// <summary>
/// If included in the query, references all <seealso cref="ModLogEntry"/> items associated with this entry.
/// </summary>
public ICollection<ModLogEntry> Logs { get; set; } = null!;
}

53
Data/CachedUser.cs Normal file
View file

@ -0,0 +1,53 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RegexBot.Data;
/// <summary>
/// Represents an item in the user cache.
/// </summary>
[Table("cache_users")]
public class CachedUser {
/// <summary>
/// Gets the associated user's snowflake ID.
/// </summary>
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public long UserId { get; set; }
/// <summary>
/// Gets the timestamp showing when this cache entry was last updated.
/// </summary>
public DateTimeOffset ULastUpdateTime { get; set; }
/// <summary>
/// Gets the user's username value, without the discriminator.
/// </summary>
public string Username { get; set; } = null!;
/// <summary>
/// Gets the user's discriminator value. A value of "0000" means the user is on the new username system.
/// </summary>
public string Discriminator { get; set; } = null!;
/// <summary>
/// Gets the user's display name. A user <em>may</em> have a global name if they are on the new username system.
/// </summary>
public string? GlobalName { get; set; } = null!;
/// <summary>
/// Gets the avatar URL, if any, for the associated user.
/// </summary>
public string? AvatarUrl { get; set; }
/// <summary>
/// If included in the query, gets the list of associated <seealso cref="CachedGuildUser"/> entries for this entry.
/// </summary>
[InverseProperty(nameof(CachedGuildUser.User))]
public ICollection<CachedGuildUser> Guilds { get; set; } = null!;
/// <summary>
/// If included in the query, gets the list of associated <seealso cref="CachedGuildMessage"/> entries for this entry.
/// </summary>
[InverseProperty(nameof(CachedGuildMessage.Author))]
public ICollection<CachedGuildMessage> GuildMessages { get; set; } = null!;
}

View file

@ -0,0 +1,4 @@
[*.cs]
generated_code = true
dotnet_analyzer_diagnostic.category-CodeQuality.severity = none
dotnet_diagnostic.CS1591.severity = none

View file

@ -0,0 +1,169 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RegexBot.Data;
#nullable disable
namespace RegexBot.Data.Migrations
{
[DbContext(typeof(BotDatabaseContext))]
[Migration("20220723220624_InitialMigration")]
partial class InitialMigration
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
{
b.Property<long>("MessageId")
.HasColumnType("bigint")
.HasColumnName("message_id");
b.Property<List<string>>("AttachmentNames")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("attachment_names");
b.Property<long>("AuthorId")
.HasColumnType("bigint")
.HasColumnName("author_id");
b.Property<long>("ChannelId")
.HasColumnType("bigint")
.HasColumnName("channel_id");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset?>("EditedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("edited_at");
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.HasKey("MessageId")
.HasName("pk_cache_guildmessages");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_cache_guildmessages_author_id");
b.ToTable("cache_guildmessages", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
{
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<DateTimeOffset>("FirstSeenTime")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_time")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("GULastUpdateTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("gu_last_update_time");
b.Property<string>("Nickname")
.HasColumnType("text")
.HasColumnName("nickname");
b.HasKey("UserId", "GuildId")
.HasName("pk_cache_usersinguild");
b.ToTable("cache_usersinguild", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
{
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.Property<string>("AvatarUrl")
.HasColumnType("text")
.HasColumnName("avatar_url");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("character(4)")
.HasColumnName("discriminator")
.IsFixedLength();
b.Property<DateTimeOffset>("ULastUpdateTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("u_last_update_time");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
.HasColumnName("username");
b.HasKey("UserId")
.HasName("pk_cache_users");
b.ToTable("cache_users", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
{
b.HasOne("RegexBot.Data.CachedUser", "Author")
.WithMany("GuildMessages")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cache_guildmessages_cache_users_author_id");
b.Navigation("Author");
});
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
{
b.HasOne("RegexBot.Data.CachedUser", "User")
.WithMany("Guilds")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cache_usersinguild_cache_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
{
b.Navigation("GuildMessages");
b.Navigation("Guilds");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RegexBot.Data.Migrations
{
public partial class InitialMigration : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "cache_users",
columns: table => new
{
user_id = table.Column<long>(type: "bigint", nullable: false),
u_last_update_time = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
username = table.Column<string>(type: "text", nullable: false),
discriminator = table.Column<string>(type: "character(4)", fixedLength: true, maxLength: 4, nullable: false),
avatar_url = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_cache_users", x => x.user_id);
});
migrationBuilder.CreateTable(
name: "cache_guildmessages",
columns: table => new
{
message_id = table.Column<long>(type: "bigint", nullable: false),
author_id = table.Column<long>(type: "bigint", nullable: false),
guild_id = table.Column<long>(type: "bigint", nullable: false),
channel_id = table.Column<long>(type: "bigint", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
edited_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
attachment_names = table.Column<List<string>>(type: "text[]", nullable: false),
content = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_cache_guildmessages", x => x.message_id);
table.ForeignKey(
name: "fk_cache_guildmessages_cache_users_author_id",
column: x => x.author_id,
principalTable: "cache_users",
principalColumn: "user_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "cache_usersinguild",
columns: table => new
{
user_id = table.Column<long>(type: "bigint", nullable: false),
guild_id = table.Column<long>(type: "bigint", nullable: false),
gu_last_update_time = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
first_seen_time = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
nickname = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_cache_usersinguild", x => new { x.user_id, x.guild_id });
table.ForeignKey(
name: "fk_cache_usersinguild_cache_users_user_id",
column: x => x.user_id,
principalTable: "cache_users",
principalColumn: "user_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_cache_guildmessages_author_id",
table: "cache_guildmessages",
column: "author_id");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "cache_guildmessages");
migrationBuilder.DropTable(
name: "cache_usersinguild");
migrationBuilder.DropTable(
name: "cache_users");
}
}
}

View file

@ -0,0 +1,235 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RegexBot.Data;
#nullable disable
namespace RegexBot.Data.Migrations
{
[DbContext(typeof(BotDatabaseContext))]
[Migration("20220827041853_AddModLogs")]
partial class AddModLogs
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "mod_log_type", new[] { "other", "note", "warn", "timeout", "kick", "ban" });
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
{
b.Property<long>("MessageId")
.HasColumnType("bigint")
.HasColumnName("message_id");
b.Property<List<string>>("AttachmentNames")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("attachment_names");
b.Property<long>("AuthorId")
.HasColumnType("bigint")
.HasColumnName("author_id");
b.Property<long>("ChannelId")
.HasColumnType("bigint")
.HasColumnName("channel_id");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset?>("EditedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("edited_at");
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.HasKey("MessageId")
.HasName("pk_cache_guildmessages");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_cache_guildmessages_author_id");
b.ToTable("cache_guildmessages", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
{
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.Property<DateTimeOffset>("FirstSeenTime")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_time")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("GULastUpdateTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("gu_last_update_time");
b.Property<string>("Nickname")
.HasColumnType("text")
.HasColumnName("nickname");
b.HasKey("GuildId", "UserId")
.HasName("pk_cache_usersinguild");
b.HasIndex("UserId")
.HasDatabaseName("ix_cache_usersinguild_user_id");
b.ToTable("cache_usersinguild", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
{
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.Property<string>("AvatarUrl")
.HasColumnType("text")
.HasColumnName("avatar_url");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("character(4)")
.HasColumnName("discriminator")
.IsFixedLength();
b.Property<DateTimeOffset>("ULastUpdateTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("u_last_update_time");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
.HasColumnName("username");
b.HasKey("UserId")
.HasName("pk_cache_users");
b.ToTable("cache_users", (string)null);
});
modelBuilder.Entity("RegexBot.Data.ModLogEntry", b =>
{
b.Property<int>("LogId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("log_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("LogId"));
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<string>("IssuedBy")
.IsRequired()
.HasColumnType("text")
.HasColumnName("issued_by");
b.Property<int>("LogType")
.HasColumnType("integer")
.HasColumnName("log_type");
b.Property<string>("Message")
.HasColumnType("text")
.HasColumnName("message");
b.Property<DateTimeOffset>("Timestamp")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("timestamp")
.HasDefaultValueSql("now()");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("LogId")
.HasName("pk_modlogs");
b.HasIndex("GuildId", "UserId")
.HasDatabaseName("ix_modlogs_guild_id_user_id");
b.ToTable("modlogs", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
{
b.HasOne("RegexBot.Data.CachedUser", "Author")
.WithMany("GuildMessages")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cache_guildmessages_cache_users_author_id");
b.Navigation("Author");
});
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
{
b.HasOne("RegexBot.Data.CachedUser", "User")
.WithMany("Guilds")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cache_usersinguild_cache_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("RegexBot.Data.ModLogEntry", b =>
{
b.HasOne("RegexBot.Data.CachedGuildUser", "User")
.WithMany("Logs")
.HasForeignKey("GuildId", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_modlogs_cache_usersinguild_user_temp_id");
b.Navigation("User");
});
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
{
b.Navigation("Logs");
});
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
{
b.Navigation("GuildMessages");
b.Navigation("Guilds");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace RegexBot.Data.Migrations
{
public partial class AddModLogs : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "pk_cache_usersinguild",
table: "cache_usersinguild");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:mod_log_type", "other,note,warn,timeout,kick,ban");
migrationBuilder.AddPrimaryKey(
name: "pk_cache_usersinguild",
table: "cache_usersinguild",
columns: new[] { "guild_id", "user_id" });
migrationBuilder.CreateTable(
name: "modlogs",
columns: table => new
{
log_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
timestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
guild_id = table.Column<long>(type: "bigint", nullable: false),
user_id = table.Column<long>(type: "bigint", nullable: false),
log_type = table.Column<int>(type: "integer", nullable: false),
issued_by = table.Column<string>(type: "text", nullable: false),
message = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_modlogs", x => x.log_id);
table.ForeignKey(
name: "fk_modlogs_cache_usersinguild_user_temp_id",
columns: x => new { x.guild_id, x.user_id },
principalTable: "cache_usersinguild",
principalColumns: new[] { "guild_id", "user_id" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_cache_usersinguild_user_id",
table: "cache_usersinguild",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_modlogs_guild_id_user_id",
table: "modlogs",
columns: new[] { "guild_id", "user_id" });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "modlogs");
migrationBuilder.DropPrimaryKey(
name: "pk_cache_usersinguild",
table: "cache_usersinguild");
migrationBuilder.DropIndex(
name: "ix_cache_usersinguild_user_id",
table: "cache_usersinguild");
migrationBuilder.AlterDatabase()
.OldAnnotation("Npgsql:Enum:mod_log_type", "other,note,warn,timeout,kick,ban");
migrationBuilder.AddPrimaryKey(
name: "pk_cache_usersinguild",
table: "cache_usersinguild",
columns: new[] { "user_id", "guild_id" });
}
}
}

View file

@ -0,0 +1,239 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RegexBot.Data;
#nullable disable
namespace RegexBot.Data.Migrations
{
[DbContext(typeof(BotDatabaseContext))]
[Migration("20231115032040_NewUsernames")]
partial class NewUsernames
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "mod_log_type", new[] { "other", "note", "warn", "timeout", "kick", "ban" });
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
{
b.Property<long>("MessageId")
.HasColumnType("bigint")
.HasColumnName("message_id");
b.Property<List<string>>("AttachmentNames")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("attachment_names");
b.Property<long>("AuthorId")
.HasColumnType("bigint")
.HasColumnName("author_id");
b.Property<long>("ChannelId")
.HasColumnType("bigint")
.HasColumnName("channel_id");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset?>("EditedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("edited_at");
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.HasKey("MessageId")
.HasName("pk_cache_guildmessages");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_cache_guildmessages_author_id");
b.ToTable("cache_guildmessages", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
{
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.Property<DateTimeOffset>("FirstSeenTime")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_time")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("GULastUpdateTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("gu_last_update_time");
b.Property<string>("Nickname")
.HasColumnType("text")
.HasColumnName("nickname");
b.HasKey("GuildId", "UserId")
.HasName("pk_cache_usersinguild");
b.HasIndex("UserId")
.HasDatabaseName("ix_cache_usersinguild_user_id");
b.ToTable("cache_usersinguild", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
{
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.Property<string>("AvatarUrl")
.HasColumnType("text")
.HasColumnName("avatar_url");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("character(4)")
.HasColumnName("discriminator")
.IsFixedLength();
b.Property<string>("GlobalName")
.HasColumnType("text")
.HasColumnName("global_name");
b.Property<DateTimeOffset>("ULastUpdateTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("u_last_update_time");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
.HasColumnName("username");
b.HasKey("UserId")
.HasName("pk_cache_users");
b.ToTable("cache_users", (string)null);
});
modelBuilder.Entity("RegexBot.Data.ModLogEntry", b =>
{
b.Property<int>("LogId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("log_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("LogId"));
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<string>("IssuedBy")
.IsRequired()
.HasColumnType("text")
.HasColumnName("issued_by");
b.Property<int>("LogType")
.HasColumnType("integer")
.HasColumnName("log_type");
b.Property<string>("Message")
.HasColumnType("text")
.HasColumnName("message");
b.Property<DateTimeOffset>("Timestamp")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("timestamp")
.HasDefaultValueSql("now()");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("LogId")
.HasName("pk_modlogs");
b.HasIndex("GuildId", "UserId")
.HasDatabaseName("ix_modlogs_guild_id_user_id");
b.ToTable("modlogs", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
{
b.HasOne("RegexBot.Data.CachedUser", "Author")
.WithMany("GuildMessages")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cache_guildmessages_cache_users_author_id");
b.Navigation("Author");
});
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
{
b.HasOne("RegexBot.Data.CachedUser", "User")
.WithMany("Guilds")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cache_usersinguild_cache_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("RegexBot.Data.ModLogEntry", b =>
{
b.HasOne("RegexBot.Data.CachedGuildUser", "User")
.WithMany("Logs")
.HasForeignKey("GuildId", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_modlogs_cache_usersinguild_user_temp_id");
b.Navigation("User");
});
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
{
b.Navigation("Logs");
});
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
{
b.Navigation("GuildMessages");
b.Navigation("Guilds");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RegexBot.Data.Migrations
{
public partial class NewUsernames : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "global_name",
table: "cache_users",
type: "text",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "global_name",
table: "cache_users");
}
}
}

View file

@ -0,0 +1,237 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RegexBot.Data;
#nullable disable
namespace RegexBot.Data.Migrations
{
[DbContext(typeof(BotDatabaseContext))]
partial class BotDatabaseContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "mod_log_type", new[] { "other", "note", "warn", "timeout", "kick", "ban" });
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
{
b.Property<long>("MessageId")
.HasColumnType("bigint")
.HasColumnName("message_id");
b.Property<List<string>>("AttachmentNames")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("attachment_names");
b.Property<long>("AuthorId")
.HasColumnType("bigint")
.HasColumnName("author_id");
b.Property<long>("ChannelId")
.HasColumnType("bigint")
.HasColumnName("channel_id");
b.Property<string>("Content")
.HasColumnType("text")
.HasColumnName("content");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset?>("EditedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("edited_at");
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.HasKey("MessageId")
.HasName("pk_cache_guildmessages");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_cache_guildmessages_author_id");
b.ToTable("cache_guildmessages", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
{
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.Property<DateTimeOffset>("FirstSeenTime")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_time")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("GULastUpdateTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("gu_last_update_time");
b.Property<string>("Nickname")
.HasColumnType("text")
.HasColumnName("nickname");
b.HasKey("GuildId", "UserId")
.HasName("pk_cache_usersinguild");
b.HasIndex("UserId")
.HasDatabaseName("ix_cache_usersinguild_user_id");
b.ToTable("cache_usersinguild", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
{
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.Property<string>("AvatarUrl")
.HasColumnType("text")
.HasColumnName("avatar_url");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("character(4)")
.HasColumnName("discriminator")
.IsFixedLength();
b.Property<string>("GlobalName")
.HasColumnType("text")
.HasColumnName("global_name");
b.Property<DateTimeOffset>("ULastUpdateTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("u_last_update_time");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
.HasColumnName("username");
b.HasKey("UserId")
.HasName("pk_cache_users");
b.ToTable("cache_users", (string)null);
});
modelBuilder.Entity("RegexBot.Data.ModLogEntry", b =>
{
b.Property<int>("LogId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("log_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("LogId"));
b.Property<long>("GuildId")
.HasColumnType("bigint")
.HasColumnName("guild_id");
b.Property<string>("IssuedBy")
.IsRequired()
.HasColumnType("text")
.HasColumnName("issued_by");
b.Property<int>("LogType")
.HasColumnType("integer")
.HasColumnName("log_type");
b.Property<string>("Message")
.HasColumnType("text")
.HasColumnName("message");
b.Property<DateTimeOffset>("Timestamp")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("timestamp")
.HasDefaultValueSql("now()");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("LogId")
.HasName("pk_modlogs");
b.HasIndex("GuildId", "UserId")
.HasDatabaseName("ix_modlogs_guild_id_user_id");
b.ToTable("modlogs", (string)null);
});
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
{
b.HasOne("RegexBot.Data.CachedUser", "Author")
.WithMany("GuildMessages")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cache_guildmessages_cache_users_author_id");
b.Navigation("Author");
});
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
{
b.HasOne("RegexBot.Data.CachedUser", "User")
.WithMany("Guilds")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cache_usersinguild_cache_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("RegexBot.Data.ModLogEntry", b =>
{
b.HasOne("RegexBot.Data.CachedGuildUser", "User")
.WithMany("Logs")
.HasForeignKey("GuildId", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_modlogs_cache_usersinguild_user_temp_id");
b.Navigation("User");
});
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
{
b.Navigation("Logs");
});
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
{
b.Navigation("GuildMessages");
b.Navigation("Guilds");
});
#pragma warning restore 612, 618
}
}
}

50
Data/ModLogEntry.cs Normal file
View file

@ -0,0 +1,50 @@
using RegexBot.Common;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RegexBot.Data;
/// <summary>
/// Represents a moderation log entry.
/// </summary>
[Table("modlogs")]
public class ModLogEntry : ISharedEvent {
/// <summary>
/// Gets the ID number for this entry.
/// </summary>
[Key]
public int LogId { get; set; }
/// <summary>
/// Gets the date and time when this entry was logged.
/// </summary>
public DateTimeOffset Timestamp { get; set; }
/// <inheritdoc cref="CachedGuildUser.GuildId"/>
public long GuildId { get; set; }
/// <summary>
/// Gets the ID of the users for which this log entry pertains.
/// </summary>
public long UserId { get; set; }
/// <summary>
/// Gets the type of log message this represents.
/// </summary>
public ModLogType LogType { get; set; }
/// <summary>
/// Gets the the entity which issued this log item.
/// If it was a user, this value preferably is in the <seealso cref="EntityName"/> format.
/// </summary>
public string IssuedBy { get; set; } = null!;
/// <summary>
/// Gets any additional message associated with this log entry.
/// </summary>
public string? Message { get; set; }
/// <summary>
/// If included in the query, gets the associated <seealso cref="CachedGuildUser"/> for this entry.
/// </summary>
public CachedGuildUser User { get; set; } = null!;
}

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2017 Noikoio <noikoio1 AT gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

7
ModuleLoadException.cs Normal file
View file

@ -0,0 +1,7 @@
namespace RegexBot;
/// <summary>
/// Represents an error occurring when a module attempts to create a new guild state object
/// (that is, read or refresh its configuration).
/// </summary>
public class ModuleLoadException(string message) : Exception(message) { }

57
ModuleLoader.cs Normal file
View file

@ -0,0 +1,57 @@
using System.Collections.ObjectModel;
using System.Reflection;
using System.Text;
namespace RegexBot;
static class ModuleLoader {
/// <summary>
/// Given the instance configuration, loads all appropriate types from file specified in it.
/// </summary>
internal static ReadOnlyCollection<RegexbotModule> Load(Configuration conf, RegexbotClient rb) {
var path = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location) + Path.DirectorySeparatorChar;
var modules = new List<RegexbotModule>();
// Load self, then others if defined
modules.AddRange(LoadModulesFromAssembly(Assembly.GetExecutingAssembly(), rb));
foreach (var file in conf.Assemblies) {
Assembly? a = null;
try {
a = Assembly.LoadFrom(path + file);
} catch (Exception ex) {
Console.WriteLine("An error occurred when attempting to load a module assembly.");
Console.WriteLine($"File: {file}");
Console.WriteLine(ex.ToString());
Environment.Exit(2);
}
IEnumerable<RegexbotModule>? amods = null;
try {
amods = LoadModulesFromAssembly(a, rb);
} catch (Exception ex) {
Console.WriteLine("An error occurred when attempting to create a module instance.");
Console.WriteLine(ex.ToString());
Environment.Exit(2);
}
modules.AddRange(amods);
}
return modules.AsReadOnly();
}
static List<RegexbotModule> LoadModulesFromAssembly(Assembly asm, RegexbotClient rb) {
var eligibleTypes = from type in asm.GetTypes()
where !type.IsAssignableFrom(typeof(RegexbotModule))
where type.GetCustomAttribute<RegexbotModuleAttribute>() != null
select type;
var newreport = new StringBuilder($"---> Modules in {asm.GetName().Name}:");
var newmods = new List<RegexbotModule>();
foreach (var t in eligibleTypes) {
var mod = Activator.CreateInstance(t, rb)!;
newreport.Append($" {t.Name}");
newmods.Add((RegexbotModule)mod);
}
rb._svcLogging.DoLog(nameof(ModuleLoader), newreport.ToString());
return newmods;
}
}

View file

@ -0,0 +1,73 @@
using System.Diagnostics;
namespace RegexBot.Modules.AutoResponder;
/// <summary>
/// Provides the capability to define text responses to pattern-based triggers for fun or informational
/// purposes. Although in essence similar to <see cref="RegexModerator.RegexModerator"/>, it is a better
/// fit for non-moderation use cases and has specific features suitable to that end.
/// </summary>
[RegexbotModule]
internal class AutoResponder : RegexbotModule {
public AutoResponder(RegexbotClient bot) : base(bot) {
DiscordClient.MessageReceived += DiscordClient_MessageReceived;
}
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken? config) {
if (config == null) return Task.FromResult<object?>(null);
var defs = new List<Definition>();
if (config.Type != JTokenType.Array)
throw new ModuleLoadException(Name + " configuration must be a JSON array.");
foreach (var def in config.Children<JObject>())
defs.Add(new Definition(def));
if (defs.Count == 0) return Task.FromResult<object?>(null);
Log(DiscordClient.GetGuild(guildID), $"Loaded {defs.Count} definition(s).");
return Task.FromResult<object?>(defs.AsReadOnly());
}
private async Task DiscordClient_MessageReceived(SocketMessage arg) {
if (!Common.Utilities.IsValidUserMessage(arg, out var ch)) return;
var definitions = GetGuildState<IEnumerable<Definition>>(ch.Guild.Id);
if (definitions == null) return; // No configuration in this guild; do no further processing
foreach (var def in definitions) {
await ProcessMessageAsync(arg, def, ch);
}
}
private async Task ProcessMessageAsync(SocketMessage msg, Definition def, SocketTextChannel ch) {
if (!def.Match(msg)) return;
Log(ch.Guild, $"Definition '{def.Label}' triggered by {msg.Author}.");
if (def.Command == null) {
await msg.Channel.SendMessageAsync(def.GetResponse());
} else {
var cmdline = def.Command.Split([' '], 2);
var ps = new ProcessStartInfo() {
FileName = cmdline[0],
Arguments = cmdline.Length == 2 ? cmdline[1] : "",
UseShellExecute = false, // ???
CreateNoWindow = true,
RedirectStandardOutput = true
};
using var p = Process.Start(ps)!;
p.WaitForExit(5000); // waiting 5 seconds at most
if (p.HasExited) {
if (p.ExitCode != 0) {
Log(ch.Guild, $"Command execution: Process exited abnormally (with code {p.ExitCode}).");
}
using var stdout = p.StandardOutput;
var result = await stdout.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(result)) await msg.Channel.SendMessageAsync(result);
} else {
Log(ch.Guild, $"Command execution: Process has not exited in 5 seconds. Killing process.");
p.Kill();
}
}
}
}

View file

@ -0,0 +1,149 @@
using RegexBot.Common;
using System.Text.RegularExpressions;
namespace RegexBot.Modules.AutoResponder;
/// <summary>
/// Representation of a single <see cref="AutoResponder"/> configuration definition.
/// </summary>
class Definition {
private static readonly Random Chance = new();
public string Label { get; }
public IEnumerable<Regex> Regex { get; }
public IReadOnlyList<string> Reply { get; }
public string? Command { get; }
public FilterList Filter { get; }
public RateLimit<ulong> RateLimit { get; }
public double RandomChance { get; }
/// <summary>
/// Creates an instance based on JSON configuration.
/// </summary>
public Definition(JObject def) {
Label = def[nameof(Label)]?.Value<string>()
?? throw new ModuleLoadException($"Encountered a rule without a defined {nameof(Label)}.");
var errpostfx = $" in the rule definition for '{Label}'.";
// Regex
var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
// Reminder: in Singleline mode, all contents are subject to the same regex (useful if e.g. spammer separates words line by line)
opts |= RegexOptions.Singleline;
// IgnoreCase is enabled by default; must be explicitly set to false
if (def["IgnoreCase"]?.Value<bool>() ?? true) opts |= RegexOptions.IgnoreCase;
const string ErrNoRegex = $"No patterns were defined under {nameof(Regex)}";
var regexRules = new List<Regex>();
List<string> inputs;
try {
inputs = Utilities.LoadStringOrStringArray(def[nameof(Regex)]);
} catch (ArgumentNullException) {
throw new ModuleLoadException(ErrNoRegex + errpostfx);
}
foreach (var inputRule in inputs) {
try {
regexRules.Add(new Regex(inputRule, opts));
} catch (Exception ex) when (ex is ArgumentException or NullReferenceException) {
throw new ModuleLoadException("Unable to parse regular expression pattern" + errpostfx);
}
}
if (regexRules.Count == 0) throw new ModuleLoadException(ErrNoRegex + errpostfx);
Regex = regexRules.AsReadOnly();
// Filtering
Filter = new FilterList(def);
bool haveResponse;
// Reply options
var replyConf = def[nameof(Reply)];
try {
Reply = Utilities.LoadStringOrStringArray(replyConf);
haveResponse = Reply.Count > 0;
} catch (ArgumentNullException) {
Reply = Array.Empty<string>();
haveResponse = false;
} catch (ArgumentException) {
throw new ModuleLoadException($"Encountered a problem within 'Reply'{errpostfx}");
}
// Command options
Command = def[nameof(Command)]?.Value<string>()!;
if (Command != null && haveResponse)
throw new ModuleLoadException($"Only one of either '{nameof(Reply)}' or '{nameof(Command)}' may be defined{errpostfx}");
if (Command != null) {
if (string.IsNullOrWhiteSpace(Command))
throw new ModuleLoadException($"'{nameof(Command)}' must have a non-blank value{errpostfx}");
haveResponse = true;
}
if (!haveResponse) throw new ModuleLoadException($"Neither '{nameof(Reply)}' nor '{nameof(Command)}' were defined{errpostfx}");
// Rate limiting
var rlconf = def[nameof(RateLimit)];
if (rlconf?.Type == JTokenType.Integer) {
var rlval = rlconf.Value<int>();
RateLimit = new RateLimit<ulong>(rlval);
} else if (rlconf != null) {
throw new ModuleLoadException($"'{nameof(RateLimit)}' must be a non-negative integer{errpostfx}");
} else {
RateLimit = new(0);
}
// Random chance parameter
var randconf = def[nameof(RandomChance)];
if (randconf?.Type == JTokenType.Float) {
RandomChance = randconf.Value<float>();
if (RandomChance is > 1 or < 0) {
throw new ModuleLoadException($"Random value is invalid (not between 0 and 1){errpostfx}");
}
} else if (randconf != null) {
throw new ModuleLoadException($"{nameof(RandomChance)} is not correctly defined{errpostfx}");
} else {
// Default to none if undefined
RandomChance = double.NaN;
}
}
/// <summary>
/// Checks the given message to determine if it matches this rule's constraints.
/// This method also maintains rate limiting and performs random number generation.
/// </summary>
/// <returns>True if the rule's response(s) should be executed.</returns>
public bool Match(SocketMessage m) {
// Filter check
if (Filter.IsFiltered(m, true)) return false;
// Match check
var matchFound = false;
foreach (var item in Regex) {
if (item.IsMatch(m.Content)) {
matchFound = true;
break;
}
}
if (!matchFound) return false;
// Rate limit check - currently per channel
if (!RateLimit.IsPermitted(m.Channel.Id)) return false;
// Random chance check
if (!double.IsNaN(RandomChance)) {
// Fail if randomly generated value is higher than the parameter
// Example: To fail a 75% chance, the check value must be between 0.75000...001 and 1.0.
var chk = Chance.NextDouble();
if (chk > RandomChance) return false;
}
return true;
}
/// <summary>
/// Gets a response string to display in the channel.
/// </summary>
public string GetResponse() {
// TODO feature request: option to show responses in order instead of random
if (Reply.Count == 1) return Reply[0];
return Reply[Chance.Next(0, Reply.Count - 1)];
}
}

View file

@ -0,0 +1,151 @@
using RegexBot.Common;
using System.Text;
namespace RegexBot.Modules.EntryRole;
/// <summary>
/// Automatically sets a role onto users entering the guild after a predefined amount of time.
/// </summary>
[RegexbotModule]
internal sealed class EntryRole : RegexbotModule, IDisposable {
readonly Task _workerTask;
readonly CancellationTokenSource _workerTaskToken;
public EntryRole(RegexbotClient bot) : base(bot) {
DiscordClient.GuildMembersDownloaded += DiscordClient_GuildMembersDownloaded;
DiscordClient.UserJoined += DiscordClient_UserJoined;
DiscordClient.UserLeft += DiscordClient_UserLeft;
_workerTaskToken = new CancellationTokenSource();
_workerTask = Task.Factory.StartNew(RoleApplyWorker, _workerTaskToken.Token,
TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
void IDisposable.Dispose() {
_workerTaskToken.Cancel();
_workerTask.Wait(2000);
_workerTask.Dispose();
}
private Task DiscordClient_GuildMembersDownloaded(SocketGuild arg) {
var data = GetGuildState<GuildData>(arg.Id);
if (data == null) return Task.CompletedTask;
var rolecheck = data.TargetRole.FindRoleIn(arg);
if (rolecheck == null) {
Log(arg, "Unable to find target role to be applied. Initial check has been skipped.");
return Task.CompletedTask;
}
foreach (var user in arg.Users.Where(u => !u.Roles.Contains(rolecheck))) {
data.WaitlistAdd(user.Id);
}
return Task.CompletedTask;
}
private Task DiscordClient_UserJoined(SocketGuildUser arg) {
GetGuildState<GuildData>(arg.Guild.Id)?.WaitlistAdd(arg.Id);
return Task.CompletedTask;
}
private Task DiscordClient_UserLeft(SocketGuild guild, SocketUser user) {
GetGuildState<GuildData>(guild.Id)?.WaitlistRemove(user.Id);
return Task.CompletedTask;
}
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken? config) {
if (config == null) return Task.FromResult<object?>(null);
if (config.Type != JTokenType.Object)
throw new ModuleLoadException("Configuration is not properly defined.");
var g = DiscordClient.GetGuild(guildID);
// Attempt to preserve existing timer list on reload
var oldconf = GetGuildState<GuildData>(guildID);
if (oldconf == null) {
var newconf = new GuildData((JObject)config);
Log(g, $"Configured for {newconf.WaitTime} seconds.");
return Task.FromResult<object?>(newconf);
} else {
var newconf = new GuildData((JObject)config, oldconf.WaitingList);
Log(g, $"Reconfigured for {newconf.WaitTime} seconds; keeping {newconf.WaitingList.Count} existing timers.");
return Task.FromResult<object?>(newconf);
}
}
/// <summary>
/// Main worker task.
/// </summary>
private async Task RoleApplyWorker() {
while (!_workerTaskToken.IsCancellationRequested) {
await Task.Delay(5000);
var subworkers = new List<Task>();
foreach (var g in DiscordClient.Guilds) {
subworkers.Add(RoleApplyGuildSubWorker(g));
}
Task.WaitAll([.. subworkers]);
}
}
/// <summary>
/// Guild-specific processing by worker task.
/// </summary>
internal async Task RoleApplyGuildSubWorker(SocketGuild g) {
var gconf = GetGuildState<GuildData>(g.Id);
if (gconf == null) return;
// Get list of users to be affected
ulong[] userIds;
lock (gconf.WaitingList) {
if (gconf.WaitingList.Count == 0) return;
var now = DateTimeOffset.UtcNow;
var queryIds = from item in gconf.WaitingList
where item.Value > now
select item.Key;
userIds = queryIds.ToArray();
foreach (var item in userIds) gconf.WaitingList.Remove(item);
}
var gusers = new List<SocketGuildUser>();
foreach (var item in userIds) {
var gu = g.GetUser(item);
if (gu == null) continue; // silently drop unknown users (is this fine?)
gusers.Add(gu);
}
if (gusers.Count == 0) return;
// Attempt to get role.
var targetRole = gconf.TargetRole.FindRoleIn(g, true);
if (targetRole == null) {
ReportFailure(g, "Unable to determine role to be applied. Does it still exist?", gusers);
return;
}
// Apply roles
try {
foreach (var item in gusers) {
if (item.Roles.Contains(targetRole)) continue;
await item.AddRoleAsync(targetRole);
}
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
ReportFailure(g, "Unable to set role due to a permissions issue.", gusers);
}
}
private void ReportFailure(SocketGuild g, string message, IEnumerable<SocketGuildUser> failedUserList) {
var failList = new StringBuilder();
var count = 0;
foreach (var item in failedUserList) {
failList.Append($", {item.GetDisplayableUsername()}");
count++;
if (count > 5) {
failList.Append($"and {count} other(s).");
break;
}
}
failList.Remove(0, 2);
Log(g, message + " Failed while attempting to set role on the following users: " + failList.ToString());
}
}

View file

@ -0,0 +1,61 @@
using RegexBot.Common;
namespace RegexBot.Modules.EntryRole;
/// <summary>
/// Contains configuration data as well as per-guild timers for those awaiting role assignment.
/// </summary>
class GuildData {
/// <summary>
/// Lock on self.
/// </summary>
public Dictionary<ulong, DateTimeOffset> WaitingList { get; }
/// <summary>
/// Role to apply.
/// </summary>
public EntityName TargetRole { get; }
/// <summary>
/// Time to wait until applying the role, in seconds.
/// </summary>
public int WaitTime { get; }
const int WaitTimeMax = 600; // 10 minutes
public GuildData(JObject conf) : this(conf, []) { }
public GuildData(JObject conf, Dictionary<ulong, DateTimeOffset> _waitingList) {
WaitingList = _waitingList;
try {
TargetRole = new EntityName(conf["Role"]?.Value<string>()!, EntityType.Role);
} catch (Exception) {
throw new ModuleLoadException("'Role' was not properly specified.");
}
try {
WaitTime = conf[nameof(WaitTime)]!.Value<int>();
} catch (NullReferenceException) {
throw new ModuleLoadException("WaitTime value not specified.");
} catch (InvalidCastException) {
throw new ModuleLoadException("WaitTime value must be a number.");
}
if (WaitTime > WaitTimeMax) {
// don't silently correct it
throw new ModuleLoadException($"WaitTime value may not exceed {WaitTimeMax} seconds.");
}
if (WaitTime < 0) {
throw new ModuleLoadException("WaitTime value may not be negative.");
}
}
public void WaitlistAdd(ulong userId) {
lock (WaitingList) {
if (!WaitingList.ContainsKey(userId)) WaitingList.Add(userId, DateTimeOffset.UtcNow.AddSeconds(WaitTime));
}
}
public void WaitlistRemove(ulong userId) {
lock (WaitingList) WaitingList.Remove(userId);
}
}

View file

@ -0,0 +1,137 @@
using RegexBot.Common;
using RegexBot.Data;
namespace RegexBot.Modules.ModCommands.Commands;
// Ban and kick commands are highly similar in implementation, and thus are handled in a single class.
class Ban : BanKick {
public Ban(ModCommands module, JObject config) : base(module, config, true) {
if (PurgeDays is > 7 or < 0)
throw new ModuleLoadException($"The value of '{nameof(PurgeDays)}' must be between 0 and 7.");
}
protected override async Task ContinueInvoke(SocketGuild g, SocketMessage msg, string? reason,
ulong targetId, CachedGuildUser? targetQuery, SocketUser? targetUser) {
// Ban: Unlike kick, the minimum required is just the target ID
var result = await Module.Bot.BanAsync(g, msg.Author.ToString(), targetId, PurgeDays, reason, SendNotify);
if (result.OperationSuccess && SuccessMessage != null) {
var success = Utilities.ProcessTextTokens(SuccessMessage, msg);
await msg.Channel.SendMessageAsync($"{success}\n{result.GetResultString(Module.Bot)}");
} else {
await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot));
}
}
}
class Kick(ModCommands module, JObject config) : BanKick(module, config, false) {
protected override async Task ContinueInvoke(SocketGuild g, SocketMessage msg, string? reason,
ulong targetId, CachedGuildUser? targetQuery, SocketUser? targetUser) {
// Kick: Unlike ban, must find the guild user in order to proceed
if (targetUser == null) {
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
var result = await Module.Bot.KickAsync(g, msg.Author.ToString(), targetId, reason, SendNotify);
if (result.OperationSuccess && SuccessMessage != null) {
var success = Utilities.ProcessTextTokens(SuccessMessage, msg);
await msg.Channel.SendMessageAsync($"{success}\n{result.GetResultString(Module.Bot)}");
} else {
await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot));
}
}
}
abstract class BanKick : CommandConfig {
protected bool ForceReason { get; }
protected int PurgeDays { get; }
protected bool SendNotify { get; }
protected string? SuccessMessage { get; }
// Configuration:
// "ForceReason" - boolean; Force a reason to be given. Defaults to false.
// "PurgeDays" - integer; Number of days of target's post history to delete, if banning.
// Must be between 0-7 inclusive. Defaults to 0.
// "SendNotify" - boolean; Whether to send a notification DM explaining the action. Defaults to true.
// "SuccessMessage" - string; Additional message to display on command success.
protected BanKick(ModCommands module, JObject config, bool ban) : base(module, config) {
ForceReason = config[nameof(ForceReason)]?.Value<bool>() ?? false;
PurgeDays = config[nameof(PurgeDays)]?.Value<int>() ?? 0;
SendNotify = config[nameof(SendNotify)]?.Value<bool>() ?? true;
SuccessMessage = config[nameof(SuccessMessage)]?.Value<string>();
_usage = $"{Command} `user ID or tag` `" + (ForceReason ? "reason" : "[reason]") + "`\n"
+ "Removes the given user from this server"
+ (ban ? " and prevents the user from rejoining" : "") + ". "
+ (ForceReason ? "L" : "Optionally l") + "ogs the reason for the "
+ (ban ? "ban" : "kick") + " to the Audit Log.";
if (PurgeDays > 0)
_usage += $"\nAdditionally removes the user's post history for the last {PurgeDays} day(s).";
}
private readonly string _usage;
protected override string DefaultUsageMsg => _usage;
// Usage: (command) (user) (reason)
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
var line = SplitToParams(msg, 3);
string targetstr;
string? reason;
if (line.Length < 2) {
await SendUsageMessageAsync(msg.Channel, null);
return;
}
targetstr = line[1];
if (line.Length == 3) reason = line[2]; // Reason given - keep it
else {
// No reason given
if (ForceReason) {
await SendUsageMessageAsync(msg.Channel, ":x: **You must specify a reason.**");
return;
}
reason = null;
}
// Gather info to send to specific handlers
var targetQuery = Module.Bot.EcQueryGuildUser(g.Id, targetstr);
ulong targetId;
if (targetQuery != null) targetId = (ulong)targetQuery.UserId;
else if (ulong.TryParse(targetstr, out var parsed)) targetId = parsed;
else targetId = default;
var targetUser = targetId != default ? g.GetUser(targetId) : null;
if (targetId == default) {
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
if (targetUser != null) {
// Bot check
if (targetUser.IsBot) {
await SendUsageMessageAsync(msg.Channel, ":x: I will not do that. Please remove bots manually.");
return;
}
// Hierarchy check
if (((SocketGuildUser)msg.Author).Hierarchy <= targetUser.Hierarchy) {
// Block kick attempts if the invoking user is at or above the target in role hierarchy
await SendUsageMessageAsync(msg.Channel, ":x: You do not have sufficient permissions to do that.");
return;
}
}
// Preparation complete, go to specific actions
try {
await ContinueInvoke(g, msg, reason, targetId, targetQuery, targetUser);
} catch (Discord.Net.HttpException ex) {
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
await msg.Channel.SendMessageAsync(":x: " + Messages.ForbiddenGenericError);
} else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound) {
await msg.Channel.SendMessageAsync(":x: Encountered a 404 error when processing the request.");
}
}
}
protected abstract Task ContinueInvoke(SocketGuild g, SocketMessage msg, string? reason,
ulong targetId, CachedGuildUser? targetQuery, SocketUser? targetUser);
}

View file

@ -0,0 +1,45 @@
using Discord;
using System.Diagnostics;
namespace RegexBot.Modules.ModCommands.Commands;
[DebuggerDisplay("Command definition '{Label}'")]
abstract class CommandConfig(ModCommands module, JObject config) {
public string Label { get; } = config[nameof(Label)]!.Value<string>()!;
public string Command { get; } = config[nameof(Command)]!.Value<string>()!;
protected ModCommands Module { get; } = module;
public abstract Task Invoke(SocketGuild g, SocketMessage msg);
protected const string FailDefault = "An unknown error occurred. Notify the bot operator.";
protected const string TargetNotFound = ":x: **Unable to find the given user.**";
protected abstract string DefaultUsageMsg { get; }
/// <summary>
/// Sends out the default usage message (<see cref="DefaultUsageMsg"/>) within an embed.
/// An optional message can be included, for uses such as notifying users of incorrect usage.
/// </summary>
/// <param name="target">Target channel for sending the message.</param>
/// <param name="message">The message to send alongside the default usage message.</param>
protected async Task SendUsageMessageAsync(ISocketMessageChannel target, string? message = null) {
if (DefaultUsageMsg == null)
throw new InvalidOperationException("DefaultUsage was not defined.");
var usageEmbed = new EmbedBuilder() {
Title = "Usage",
Description = DefaultUsageMsg
};
await target.SendMessageAsync(message ?? "", embed: usageEmbed.Build());
}
internal static readonly char[] separator = [' '];
/// <summary>
/// For the given message's content, assumes its message is a command and returns its parameters
/// as an array of substrings.
/// </summary>
/// <param name="msg">The incoming message to process.</param>
/// <param name="maxParams">The number of parameters to expect.</param>
/// <returns>A string array with 0 to maxParams - 1 elements.</returns>
protected static string[] SplitToParams(SocketMessage msg, int maxParams)
=> msg.Content.Split(separator, maxParams, StringSplitOptions.RemoveEmptyEntries);
}

View file

@ -0,0 +1,14 @@
namespace RegexBot.Modules.ModCommands.Commands;
class ConfReload(ModCommands module, JObject config) : CommandConfig(module, config) {
protected override string DefaultUsageMsg => null!;
// Usage: (command)
public override Task Invoke(SocketGuild g, SocketMessage msg) {
throw new NotImplementedException();
// bool status = await RegexBot.Config.ReloadServerConfig();
// string res;
// if (status) res = ":white_check_mark: Configuration reloaded with no issues. Check the console to verify.";
// else res = ":x: Reload failed. Check the console.";
// await msg.Channel.SendMessageAsync(res);
}
}

View file

@ -0,0 +1,72 @@
using RegexBot.Common;
namespace RegexBot.Modules.ModCommands.Commands;
// Note and Warn commands are highly similar in implementnation, and thus are handled in a single class.
class Note(ModCommands module, JObject config) : NoteWarn(module, config) {
protected override string DefaultUsageMsg => string.Format(_usageHeader, Command)
+ "Appends a note to the moderation log for the given user.";
protected override async Task ContinueInvoke(SocketGuild g, SocketMessage msg, string logMessage, SocketUser targetUser) {
var result = await Module.Bot.AddUserNoteAsync(g, targetUser.Id, msg.Author.AsEntityNameString(), logMessage);
await msg.Channel.SendMessageAsync($":white_check_mark: Note \\#{result.LogId} logged for {targetUser}.");
}
}
class Warn(ModCommands module, JObject config) : NoteWarn(module, config) {
protected override string DefaultUsageMsg => string.Format(_usageHeader, Command)
+ "Issues a warning to the given user, logging the instance to this bot's moderation log "
+ "and notifying the offending user over DM of the issued warning.";
protected override async Task ContinueInvoke(SocketGuild g, SocketMessage msg, string logMessage, SocketUser targetUser) {
// Won't warn a bot
if (targetUser.IsBot) {
await SendUsageMessageAsync(msg.Channel, ":x: I don't want to do that. If you must, please warn bots manually.");
return;
}
var (_, result) = await Module.Bot.AddUserWarnAsync(g, targetUser.Id, msg.Author.AsEntityNameString(), logMessage);
await msg.Channel.SendMessageAsync(result.GetResultString());
}
}
abstract class NoteWarn : CommandConfig {
protected string? SuccessMessage { get; }
protected const string _usageHeader = "{0} `user ID or tag` `message`\n";
// Configuration:
// "SuccessMessage" - string; Additional message to display on command success.
protected NoteWarn(ModCommands module, JObject config) : base(module, config) {
SuccessMessage = config[nameof(SuccessMessage)]?.Value<string>();
}
// Usage: (command) (user) (message)
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
var line = SplitToParams(msg, 3);
if (line.Length != 3) {
await SendUsageMessageAsync(msg.Channel, ":x: Not all required parameters were specified.");
return;
}
var targetstr = line[1];
var logMessage = line[2];
// Get target user. Required to find for our purposes.
var targetQuery = Module.Bot.EcQueryGuildUser(g.Id, targetstr);
ulong targetId;
if (targetQuery != null) targetId = (ulong)targetQuery.UserId;
else if (ulong.TryParse(targetstr, out var parsed)) targetId = parsed;
else {
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
var targetUser = g.GetUser(targetId);
// Go to specific action
if (targetUser == null) {
await msg.Channel.SendMessageAsync(":x: Unable to find the specified user.");
} else {
await ContinueInvoke(g, msg, logMessage, targetUser);
}
}
protected abstract Task ContinueInvoke(SocketGuild g, SocketMessage msg, string logMessage, SocketUser targetUser);
}

View file

@ -0,0 +1,77 @@
using RegexBot.Common;
namespace RegexBot.Modules.ModCommands.Commands;
class RoleAdd(ModCommands module, JObject config) : RoleManipulation(module, config) {
protected override (string, string) String1 => ("Adds", "to");
protected override string String2 => "set";
protected override async Task ContinueInvoke(SocketGuildUser target, SocketRole role) => await target.AddRoleAsync(role);
}
class RoleDel(ModCommands module, JObject config) : RoleManipulation(module, config) {
protected override (string, string) String1 => ("Removes", "from");
protected override string String2 => "unset";
protected override async Task ContinueInvoke(SocketGuildUser target, SocketRole role) => await target.RemoveRoleAsync(role);
}
// Role adding and removing is largely the same, and thus are handled in a single class.
abstract class RoleManipulation : CommandConfig {
private readonly string _usage;
protected EntityName Role { get; }
protected string? SuccessMessage { get; }
protected override string DefaultUsageMsg => _usage;
protected abstract (string, string) String1 { get; }
protected abstract string String2 { get; }
// Configuration:
// "role" - string; The given role that applies to this command.
// "successmsg" - string; Messages to display on command success. Overrides default.
protected RoleManipulation(ModCommands module, JObject config) : base(module, config) {
try {
Role = new EntityName(config[nameof(Role)]?.Value<string>()!, EntityType.Role);
} catch (ArgumentNullException) {
throw new ModuleLoadException($"'{nameof(Role)}' must be provided.");
} catch (FormatException) {
throw new ModuleLoadException($"The value in '{nameof(Role)}' is not a role.");
}
SuccessMessage = config[nameof(SuccessMessage)]?.Value<string>();
_usage = $"{Command} `user or user ID`\n" +
string.Format("{0} the '{1}' role {2} the specified user.",
String1.Item1, Role.Name ?? Role.Id.ToString(), String1.Item2);
}
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
// TODO reason in further parameters?
var line = SplitToParams(msg, 3);
string targetstr;
if (line.Length < 2) {
await SendUsageMessageAsync(msg.Channel, null);
return;
}
targetstr = line[1];
// Retrieve targets
var targetQuery = Module.Bot.EcQueryGuildUser(g.Id, targetstr);
var targetUser = targetQuery != null ? g.GetUser((ulong)targetQuery.UserId) : null;
if (targetUser == null) {
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
var targetRole = Role.FindRoleIn(g, true);
if (targetRole == null) {
await SendUsageMessageAsync(msg.Channel, ":x: **Failed to determine the specified role for this command.**");
return;
}
// Do the specific thing and report back
await ContinueInvoke(targetUser, targetRole);
const string defaultmsg = ":white_check_mark: Successfully {0} role for **$target**.";
var success = SuccessMessage ?? string.Format(defaultmsg, String2);
success = success.Replace("$target", targetUser.Nickname ?? targetUser.Username);
await msg.Channel.SendMessageAsync(success);
}
protected abstract Task ContinueInvoke(SocketGuildUser target, SocketRole role);
}

View file

@ -0,0 +1,34 @@
using RegexBot.Common;
namespace RegexBot.Modules.ModCommands.Commands;
class Say : CommandConfig {
private readonly string _usage;
protected override string DefaultUsageMsg => _usage;
// No configuration at the moment.
// TODO: Whitelist/blacklist - to limit which channels it can "say" into
public Say(ModCommands module, JObject config) : base(module, config) {
_usage = $"{Command} `channel` `message`\n"
+ "Displays the given message exactly as specified to the given channel.";
}
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
var line = SplitToParams(msg, 3);
if (line.Length <= 1) {
await SendUsageMessageAsync(msg.Channel, ":x: You must specify a channel.");
return;
}
if (line.Length <= 2 || string.IsNullOrWhiteSpace(line[2])) {
await SendUsageMessageAsync(msg.Channel, ":x: You must specify a message.");
return;
}
var getCh = Utilities.ChannelMentionRegex().Match(line[1]);
if (!getCh.Success) {
await SendUsageMessageAsync(msg.Channel, ":x: Unable to find given channel.");
return;
}
var ch = g.GetTextChannel(ulong.Parse(getCh.Groups["snowflake"].Value));
await ch.SendMessageAsync(line[2]);
}
}

View file

@ -0,0 +1,81 @@
using Discord;
using Microsoft.EntityFrameworkCore;
using RegexBot.Common;
using RegexBot.Data;
namespace RegexBot.Modules.ModCommands.Commands;
class ShowModLogs : CommandConfig {
const int LogEntriesPerMessage = 10;
private readonly string _usage;
protected override string DefaultUsageMsg => _usage;
// No configuration.
// TODO bring in some options from BanKick. Particularly custom success msg.
// TODO when ModLogs fully implemented, add a reason?
public ShowModLogs(ModCommands module, JObject config) : base(module, config) {
_usage = $"{Command} `user or user ID` [page]\n"
+ "Retrieves moderation log entries regarding the specified user.";
}
// Usage: (command) (query) [page]
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
var line = SplitToParams(msg, 3);
if (line.Length < 2) {
await SendUsageMessageAsync(msg.Channel, null);
return;
}
int pagenum;
if (line.Length == 3) {
const string PageNumError = ":x: Requested page must be a non-negative number.";
if (!int.TryParse(line[2], out pagenum)) {
await SendUsageMessageAsync(msg.Channel, PageNumError);
}
if (pagenum <= 0) await SendUsageMessageAsync(msg.Channel, PageNumError);
} else pagenum = 1;
var query = Module.Bot.EcQueryGuildUser(g.Id, line[1]);
if (query == null) {
await msg.Channel.SendMessageAsync(":x: Unable to find the given user.");
return;
}
int totalPages;
List<ModLogEntry> results;
using (var db = new BotDatabaseContext()) {
var totalEntries = db.ModLogs
.Where(l => l.GuildId == query.GuildId && l.UserId == query.UserId)
.Count();
totalPages = (int)Math.Ceiling((double)totalEntries / LogEntriesPerMessage);
results = [.. db.ModLogs
.Where(l => l.GuildId == query.GuildId && l.UserId == query.UserId)
.OrderByDescending(l => l.LogId)
.Skip((pagenum - 1) * LogEntriesPerMessage)
.Take(LogEntriesPerMessage)
.AsNoTracking()];
}
var resultList = new EmbedBuilder() {
Author = new EmbedAuthorBuilder() {
Name = $"{query.User.GetDisplayableUsername()}",
IconUrl = query.User.AvatarUrl
},
Footer = new EmbedFooterBuilder() {
Text = $"Page {pagenum} of {totalPages}",
IconUrl = Module.Bot.DiscordClient.CurrentUser.GetAvatarUrl()
},
Title = "Moderation logs"
};
foreach (var item in results) {
var f = new EmbedFieldBuilder() {
Name = $"{Enum.GetName(item.LogType)} \\#{item.LogId}",
Value = $"**Timestamp**: <t:{item.Timestamp.ToUnixTimeSeconds()}:f>\n"
+ $"**Issued by**: {Utilities.TryFromEntityNameString(item.IssuedBy, Module.Bot)}\n"
+ $"**Message**: {item.Message ?? "*none specified*"}"
};
resultList.AddField(f);
}
await msg.Channel.SendMessageAsync(embed: resultList.Build());
}
}

View file

@ -0,0 +1,73 @@
using RegexBot.Common;
namespace RegexBot.Modules.ModCommands.Commands;
class Timeout : CommandConfig {
protected bool ForceReason { get; }
protected bool SendNotify { get; }
protected string? SuccessMessage { get; }
// Configuration:
// "ForceReason" - boolean; Force a reason to be given. Defaults to false.
// "SendNotify" - boolean; Whether to send a notification DM explaining the action. Defaults to true.
// "SuccessMessage" - string; Additional message to display on command success.
// TODO future configuration ideas: max timeout, min timeout, default timeout span...
public Timeout(ModCommands module, JObject config) : base(module, config) {
ForceReason = config[nameof(ForceReason)]?.Value<bool>() ?? false;
SendNotify = config[nameof(SendNotify)]?.Value<bool>() ?? true;
SuccessMessage = config[nameof(SuccessMessage)]?.Value<string>();
_usage = $"{Command} `user ID or tag` `time in minutes` `" + (ForceReason ? "reason" : "[reason]") + "`\n"
+ "Issues a timeout to the given user, preventing them from participating in the server for a set amount of time. "
+ (ForceReason ? "L" : "Optionally l") + "ogs the reason for the timeout to the Audit Log.";
}
private readonly string _usage;
protected override string DefaultUsageMsg => _usage;
// Usage: (command) (user) (duration) (reason)
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
var line = SplitToParams(msg, 4);
string targetstr;
string? reason;
if (line.Length < 3) {
await SendUsageMessageAsync(msg.Channel, null);
return;
}
targetstr = line[1];
if (line.Length == 4) reason = line[3]; // Reason given - keep it
else {
// No reason given
if (ForceReason) {
await SendUsageMessageAsync(msg.Channel, ":x: **You must specify a reason.**");
return;
}
reason = null;
}
if (!int.TryParse(line[2], out var timeParam)) {
await SendUsageMessageAsync(msg.Channel, ":x: You must specify a duration for the timeout (in minutes).");
return;
}
// Get target user. Required to find for our purposes.
var targetQuery = Module.Bot.EcQueryGuildUser(g.Id, targetstr);
ulong targetId;
if (targetQuery != null) targetId = (ulong)targetQuery.UserId;
else if (ulong.TryParse(targetstr, out var parsed)) targetId = parsed;
else {
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
var targetUser = g.GetUser(targetId);
var result = await Module.Bot.SetTimeoutAsync(g, msg.Author.AsEntityNameString(), targetUser,
TimeSpan.FromMinutes(timeParam), reason, SendNotify);
if (result.Success && SuccessMessage != null) {
var success = Utilities.ProcessTextTokens(SuccessMessage, msg);
await msg.Channel.SendMessageAsync($"{success}\n{result.ToResultString()}");
} else {
await msg.Channel.SendMessageAsync(result.ToResultString());
}
}
}

View file

@ -0,0 +1,54 @@
using RegexBot.Common;
namespace RegexBot.Modules.ModCommands.Commands;
class Unban : CommandConfig {
private readonly string _usage;
protected override string DefaultUsageMsg => _usage;
// No configuration.
// TODO bring in some options from BanKick. Particularly custom success msg.
// TODO when ModLogs fully implemented, add a reason?
public Unban(ModCommands module, JObject config) : base(module, config) {
_usage = $"{Command} `user or user ID`\n"
+ "Unbans the given user, allowing them to rejoin the server.";
}
// Usage: (command) (user query)
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
var line = SplitToParams(msg, 3);
string targetstr;
if (line.Length < 2) {
await SendUsageMessageAsync(msg.Channel, null);
return;
}
targetstr = line[1];
ulong targetId;
string targetDisplay;
var query = Module.Bot.EcQueryUser(targetstr);
if (query != null) {
targetId = (ulong)query.UserId;
targetDisplay = $"**{query.GetDisplayableUsername()}**";
} else {
if (!ulong.TryParse(targetstr, out targetId)) {
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
targetDisplay = $"with ID **{targetId}**";
}
// Do the action
try {
await g.RemoveBanAsync(targetId);
await msg.Channel.SendMessageAsync($":white_check_mark: Unbanned user **{targetDisplay}**.");
} catch (Discord.Net.HttpException ex) {
const string FailPrefix = ":x: **Could not unban:** ";
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
await msg.Channel.SendMessageAsync(FailPrefix + Messages.ForbiddenGenericError);
else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound)
await msg.Channel.SendMessageAsync(FailPrefix + "The specified user does not exist or is not in the ban list.");
else throw;
}
}
}

View file

@ -0,0 +1,51 @@
using RegexBot.Common;
namespace RegexBot.Modules.ModCommands.Commands;
class Untimeout : CommandConfig {
private readonly string _usage;
protected override string DefaultUsageMsg => _usage;
// No configuration.
// TODO bring in some options from BanKick. Particularly custom success msg.
// TODO when ModLogs fully implemented, add a reason?
public Untimeout(ModCommands module, JObject config) : base(module, config) {
_usage = $"{Command} `user or user ID`\n"
+ "Unsets a timeout from a given user.";
}
// Usage: (command) (user query)
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
var line = SplitToParams(msg, 3);
string targetstr;
if (line.Length < 2) {
await SendUsageMessageAsync(msg.Channel, null);
return;
}
targetstr = line[1];
SocketGuildUser? target = null;
var query = Module.Bot.EcQueryUser(targetstr);
if (query != null) {
target = g.GetUser((ulong)query.UserId);
}
if (target == null) {
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
// Check if timed out, respond accordingly
if (target.TimedOutUntil.HasValue && target.TimedOutUntil.Value <= DateTimeOffset.UtcNow) {
await msg.Channel.SendMessageAsync($":x: **{target}** is not timed out.");
return;
}
// Do the action
try {
await target.RemoveTimeOutAsync();
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
const string FailPrefix = ":x: **Could not remove timeout:** ";
await msg.Channel.SendMessageAsync(FailPrefix + Messages.ForbiddenGenericError);
}
}
}

View file

@ -0,0 +1,52 @@
namespace RegexBot.Modules.ModCommands;
/// <summary>
/// Provides a way to define highly configurable text-based commands for use by moderators within a guild.
/// </summary>
[RegexbotModule]
internal class ModCommands : RegexbotModule {
public ModCommands(RegexbotClient bot) : base(bot) {
DiscordClient.MessageReceived += Client_MessageReceived;
}
private async Task Client_MessageReceived(SocketMessage arg) {
if (Common.Utilities.IsValidUserMessage(arg, out var channel)) {
var cfg = GetGuildState<ModuleConfig>(channel.Guild.Id);
if (cfg != null) await CommandCheckInvoke(cfg, arg);
}
}
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken? config) {
if (config == null) return Task.FromResult<object?>(null);
var conf = new ModuleConfig(this, config);
if (conf.Commands.Count > 0) {
Log(DiscordClient.GetGuild(guildID), $"{conf.Commands.Count} commands loaded.");
return Task.FromResult<object?>(conf);
}
return Task.FromResult<object?>(null);
}
private async Task CommandCheckInvoke(ModuleConfig cfg, SocketMessage arg) {
SocketGuild g = ((SocketGuildUser)arg.Author).Guild;
if (!GetModerators(g.Id).IsListMatch(arg, true)) return; // Mods only
// Disregard if the message contains a newline character
if (arg.Content.Contains('\n')) return; // TODO remove?
// Check for and invoke command
string cmdchk;
var space = arg.Content.IndexOf(' ');
if (space != -1) cmdchk = arg.Content[..space];
else cmdchk = arg.Content;
if (cfg.Commands.TryGetValue(cmdchk, out var c)) {
try {
await c.Invoke(g, arg);
Log(g, $"{c.Command} invoked by {arg.Author} in #{arg.Channel.Name}.");
} catch (Exception ex) {
Log(g, $"Unhandled exception while processing '{c.Label}':\n" + ex.ToString());
await arg.Channel.SendMessageAsync($":x: An error occurred during processing ({ex.GetType().FullName}). " +
"Check the console for details.");
}
}
}
}

View file

@ -0,0 +1,73 @@
using RegexBot.Modules.ModCommands.Commands;
using System.Collections.ObjectModel;
using System.Reflection;
namespace RegexBot.Modules.ModCommands;
class ModuleConfig {
public ReadOnlyDictionary<string, CommandConfig> Commands { get; }
public ModuleConfig(ModCommands instance, JToken conf) {
if (conf.Type != JTokenType.Array)
throw new ModuleLoadException("Command definitions must be defined as objects in a JSON array.");
// Command instance creation
var commands = new Dictionary<string, CommandConfig>(StringComparer.OrdinalIgnoreCase);
foreach (var def in conf.Children<JObject>()) {
string Label;
Label = def[nameof(Label)]?.Value<string>()
?? throw new ModuleLoadException($"'{nameof(Label)}' was not defined in a command definition.");
var cmd = CreateCommandInstance(instance, def);
if (commands.TryGetValue(cmd.Command, out CommandConfig? existing)) {
throw new ModuleLoadException(
$"{Label}: The command name '{cmd.Command}' is already in use by '{existing.Label}'.");
}
commands.Add(cmd.Command, cmd);
}
Commands = new ReadOnlyDictionary<string, CommandConfig>(commands);
}
private static readonly ReadOnlyDictionary<string, Type> _commandTypes = new(
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase) {
{ "ban", typeof(Ban) },
{ "confreload", typeof(ConfReload) },
{ "kick", typeof(Kick) },
{ "say", typeof(Say) },
{ "unban", typeof(Unban) },
{ "note", typeof(Note) },
{ "addnote", typeof(Note) },
{ "warn", typeof(Warn) },
{ "timeout", typeof(Commands.Timeout) },
{ "untimeout", typeof(Untimeout)},
{ "addrole", typeof(RoleAdd) },
{ "roleadd", typeof(RoleAdd) },
{ "delrole", typeof(RoleDel) },
{ "roledel", typeof(RoleDel) },
{ "modlogs", typeof(ShowModLogs) },
{ "showmodlogs", typeof(ShowModLogs) }
}
);
private static CommandConfig CreateCommandInstance(ModCommands instance, JObject def) {
var label = def[nameof(CommandConfig.Label)]?.Value<string>()!;
var command = def[nameof(CommandConfig.Command)]?.Value<string>();
if (string.IsNullOrWhiteSpace(command))
throw new ModuleLoadException($"{label}: '{nameof(CommandConfig.Command)}' was not specified.");
if (command.Contains(' '))
throw new ModuleLoadException($"{label}: '{nameof(CommandConfig.Command)}' must not contain spaces.");
string? Type;
Type = def[nameof(Type)]?.Value<string>();
if (string.IsNullOrWhiteSpace(Type))
throw new ModuleLoadException($"'{nameof(Type)}' must be specified within definition for '{label}'.");
if (!_commandTypes.TryGetValue(Type, out Type? cmdType)) {
throw new ModuleLoadException($"{label}: '{nameof(Type)}' does not have a valid value.");
} else {
try {
return (CommandConfig)Activator.CreateInstance(cmdType, instance, def)!;
} catch (TargetInvocationException ex) when (ex.InnerException is ModuleLoadException) {
throw new ModuleLoadException($"{label}: {ex.InnerException.Message}");
}
}
}
}

View file

@ -0,0 +1,68 @@
using System.Text;
namespace RegexBot.Modules.ModLogs;
/// <summary>
/// Logs certain events of note to a database for moderators to keep track of user behavior.
/// Makes use of a helper class, <see cref="MessageCache"/>.
/// </summary>
[RegexbotModule]
internal partial class ModLogs : RegexbotModule {
// TODO consider resurrecting 2.x idea of logging actions to db, making it searchable?
// TODO more robust channel filtering. define channels in config array, add check to it out here.
public ModLogs(RegexbotClient bot) : base(bot) {
// TODO missing logging features: joins, leaves, user edits (nick/username/discr)
DiscordClient.MessageDeleted += HandleDelete;
bot.SharedEventReceived += HandleReceivedSharedEvent;
}
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken? config) {
if (config == null) return Task.FromResult<object?>(null);
if (config.Type != JTokenType.Object)
throw new ModuleLoadException("Configuration for this section is invalid.");
var newconf = new ModuleConfig((JObject)config);
Log(DiscordClient.GetGuild(guildID), $"Writing logs to {newconf.ReportingChannel}.");
return Task.FromResult<object?>(new ModuleConfig((JObject)config));
}
private async Task HandleReceivedSharedEvent(ISharedEvent ev) {
if (ev is MessageCacheUpdateEvent upd) await HandleUpdate(upd.OldMessage, upd.NewMessage);
else if (ev is Data.ModLogEntry log) await HandleLog(log);
}
private static string MakeTimestamp(DateTimeOffset time) {
var result = new StringBuilder();
//result.Append(time.ToString("yyyy-MM-dd hh:mm:ss"));
result.Append($"<t:{time.ToUnixTimeSeconds()}:f>");
var now = DateTimeOffset.UtcNow;
var diff = now - time;
if (diff < new TimeSpan(3, 0, 0, 0)) {
// Difference less than 3 days. Generate relative time format.
result.Append(" - ");
if (diff.TotalSeconds < 60) {
// Under a minute ago. Show only seconds.
result.Append((int)Math.Ceiling(diff.TotalSeconds) + "s ago");
} else {
// over a minute. Show days, hours, minutes, seconds.
var ts = (int)Math.Ceiling(diff.TotalSeconds);
var m = ts % 3600 / 60;
var h = ts % 86400 / 3600;
var d = ts / 86400;
if (d > 0) result.AppendFormat("{0}d{1}h{2}m", d, h, m);
else if (h > 0) result.AppendFormat("{0}h{1}m", h, m);
else result.AppendFormat("{0}m", m);
result.Append(" ago");
}
}
return result.ToString();
}
private static string GetDefaultAvatarUrl(string discriminator) {
var discVal = int.Parse(discriminator);
return $"https://cdn.discordapp.com/embed/avatars/{discVal % 5}.png";
}
}

View file

@ -0,0 +1,50 @@
using Discord;
using RegexBot.Common;
using RegexBot.Data;
using System.Text;
namespace RegexBot.Modules.ModLogs;
// Contains all logic relating to reporting new database mod log entries
internal partial class ModLogs {
public async Task HandleLog(ModLogEntry entry) {
var guild = Bot.DiscordClient.GetGuild((ulong)entry.GuildId);
if (guild == null) return;
var conf = GetGuildState<ModuleConfig>(guild.Id);
if ((conf?.LogModLogs ?? false) == false) return;
var reportChannel = conf?.ReportingChannel?.FindChannelIn(guild, true);
if (reportChannel == null) return;
await reportChannel.SendMessageAsync(embed: BuildLogEmbed(entry));
}
/// <summary>
/// Builds and returns an embed which displays this log entry.
/// </summary>
private Embed BuildLogEmbed(ModLogEntry entry) {
var issuedDisplay = Utilities.TryFromEntityNameString(entry.IssuedBy, Bot);
string targetDisplay;
var targetq = Bot.EcQueryUser(entry.UserId.ToString());
if (targetq != null) targetDisplay = $"<@{targetq.UserId}> - {targetq.GetDisplayableUsername()} `{targetq.UserId}`";
else targetDisplay = $"User with ID `{entry.UserId}`";
var logEmbed = new EmbedBuilder()
.WithColor(Color.DarkGrey)
.WithTitle(Enum.GetName(typeof(ModLogType), entry.LogType) + " logged:")
.WithTimestamp(entry.Timestamp)
.WithFooter($"Log #{entry.LogId}", Bot.DiscordClient.CurrentUser.GetAvatarUrl()); // Escaping '#' not necessary here
if (entry.Message != null) {
logEmbed.Description = entry.Message;
}
var contextStr = new StringBuilder();
contextStr.AppendLine($"User: {targetDisplay}");
contextStr.AppendLine($"Logged by: {issuedDisplay}");
logEmbed.AddField(new EmbedFieldBuilder() {
Name = "Context",
Value = contextStr.ToString()
});
return logEmbed.Build();
}
}

View file

@ -0,0 +1,164 @@
using Discord;
using Microsoft.EntityFrameworkCore;
using RegexBot.Common;
using RegexBot.Data;
using System.Text;
namespace RegexBot.Modules.ModLogs;
// Contains handlers and all logic relating to logging message edits and deletions
internal partial class ModLogs {
const string PreviewCutoffNotify = "**Message too long to preview; showing first {0} characters.**\n\n";
const string NotCached = "Message not cached.";
const string MessageContentNull = "(blank)";
private async Task HandleDelete(Cacheable<IMessage, ulong> argMsg, Cacheable<IMessageChannel, ulong> argChannel) {
const int MaxPreviewLength = 750;
if (argChannel.Value is not SocketTextChannel channel) return;
var conf = GetGuildState<ModuleConfig>(channel.Guild.Id);
if ((conf?.LogMessageDeletions ?? false) == false) return;
var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true);
if (reportChannel == null) return;
if (reportChannel.Id == channel.Id) {
Log(channel.Guild, "Message deleted in the reporting channel. Suppressing report.");
return;
}
using var db = new BotDatabaseContext();
var cachedMsg = db.GuildMessageCache
.Include(gm => gm.Author)
.Where(gm => gm.MessageId == (long)argMsg.Id)
.SingleOrDefault();
var reportEmbed = new EmbedBuilder()
.WithColor(Color.Red)
.WithTitle("Message deleted")
.WithCurrentTimestamp()
.WithFooter($"Message ID: {argMsg.Id}");
if (cachedMsg != null) {
if (cachedMsg.Content == null) {
reportEmbed.Description = MessageContentNull;
} else if (cachedMsg.Content.Length > MaxPreviewLength) {
reportEmbed.Description = string.Format(PreviewCutoffNotify, MaxPreviewLength) +
cachedMsg.Content[MaxPreviewLength..];
} else {
reportEmbed.Description = cachedMsg.Content;
}
if (cachedMsg.Author == null) {
reportEmbed.Author = new EmbedAuthorBuilder() {
Name = $"User ID {cachedMsg.AuthorId}",
IconUrl = GetDefaultAvatarUrl("0")
};
} else {
reportEmbed.Author = new EmbedAuthorBuilder() {
Name = $"{cachedMsg.Author.GetDisplayableUsername()}",
IconUrl = cachedMsg.Author.AvatarUrl ?? GetDefaultAvatarUrl(cachedMsg.Author.Discriminator)
};
}
SetAttachmentsField(reportEmbed, cachedMsg.AttachmentNames);
} else {
reportEmbed.Description = NotCached;
}
var editLine = $"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(argMsg.Id))}";
if (cachedMsg?.EditedAt != null) editLine += $"\nLast edit: {MakeTimestamp(cachedMsg.EditedAt.Value)}";
SetContextField(reportEmbed, (ulong?)cachedMsg?.AuthorId, channel, editLine);
await reportChannel.SendMessageAsync(embed: reportEmbed.Build());
}
private async Task HandleUpdate(CachedGuildMessage? oldMsg, SocketMessage newMsg) {
const int MaxPreviewLength = 500;
var channel = (SocketTextChannel)newMsg.Channel;
var conf = GetGuildState<ModuleConfig>(channel.Guild.Id);
if (newMsg.Author.IsBot || newMsg.Author.IsWebhook) return;
var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true);
if (reportChannel == null) return;
if (reportChannel.Id == channel.Id) {
Log(channel.Guild, "Message edited in the reporting channel. Suppressing report.");
return;
}
var reportEmbed = new EmbedBuilder()
.WithColor(new Color(0xffff00)) // yellow
.WithTitle("Message edited")
.WithCurrentTimestamp()
.WithFooter($"Message ID: {newMsg.Id}");
reportEmbed.Author = new EmbedAuthorBuilder() {
Name = $"{newMsg.Author.GetDisplayableUsername()}",
IconUrl = newMsg.Author.GetAvatarUrl() ?? newMsg.Author.GetDefaultAvatarUrl()
};
var oldField = new EmbedFieldBuilder() { Name = "Old" };
if (oldMsg != null) {
if (oldMsg.Content == null) {
oldField.Value = MessageContentNull;
} else if (oldMsg.Content.Length > MaxPreviewLength) {
oldField.Value = string.Format(PreviewCutoffNotify, MaxPreviewLength) +
oldMsg.Content[MaxPreviewLength..];
} else {
oldField.Value = oldMsg.Content;
}
} else {
oldField.Value = NotCached;
}
reportEmbed.AddField(oldField);
// TODO shorten 'new' preview, add clickable? check if this would be good usability-wise
var newField = new EmbedFieldBuilder() { Name = "New" };
if (newMsg.Content == null) {
newField.Value = MessageContentNull;
} else if (newMsg.Content.Length > MaxPreviewLength) {
newField.Value = string.Format(PreviewCutoffNotify, MaxPreviewLength) +
newMsg.Content[MaxPreviewLength..];
} else {
newField.Value = newMsg.Content;
}
reportEmbed.AddField(newField);
SetAttachmentsField(reportEmbed, newMsg.Attachments.Select(a => a.Filename));
string editLine;
if ((oldMsg?.EditedAt) == null) editLine = $"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(newMsg.Id))}";
else editLine = $"Previous edit: {MakeTimestamp(oldMsg.EditedAt.Value)}";
SetContextField(reportEmbed, newMsg.Author.Id, channel, editLine);
await reportChannel.SendMessageAsync(embed: reportEmbed.Build());
}
private void SetContextField(EmbedBuilder e, ulong? userId, SocketTextChannel channel, string editLine) {
string userDisplay;
if (userId.HasValue) {
var q = Bot.EcQueryUser(userId.Value.ToString());
if (q != null) userDisplay = $"<@{q.UserId}> - {q.GetDisplayableUsername()} `{q.UserId}`";
else userDisplay = $"Unknown user with ID `{userId}`";
} else {
userDisplay = "Unknown";
}
var contextStr = new StringBuilder();
contextStr.AppendLine($"User: {userDisplay}");
contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})");
contextStr.AppendLine(editLine);
e.AddField(new EmbedFieldBuilder() {
Name = "Context",
Value = contextStr.ToString()
});
}
private static void SetAttachmentsField(EmbedBuilder e, IEnumerable<string> attachments) {
if (attachments.Any()) {
var field = new EmbedFieldBuilder { Name = "Attachments" };
var attachNames = new StringBuilder();
foreach (var name in attachments) {
attachNames.AppendLine($"`{name}`");
}
field.Value = attachNames.ToString().TrimEnd();
e.AddField(field);
}
}
}

View file

@ -0,0 +1,24 @@
using RegexBot.Common;
namespace RegexBot.Modules.ModLogs;
class ModuleConfig {
public EntityName ReportingChannel { get; }
public bool LogMessageDeletions { get; }
public bool LogMessageEdits { get; }
public bool LogModLogs { get; }
public ModuleConfig(JObject config) {
const string RptChError = $"'{nameof(ReportingChannel)}' must be set to a valid channel name.";
try {
ReportingChannel = new EntityName(config[nameof(ReportingChannel)]?.Value<string>()!, EntityType.Channel);
} catch (Exception) {
throw new ModuleLoadException(RptChError);
}
// Individual logging settings - all default to false
LogMessageDeletions = config[nameof(LogMessageDeletions)]?.Value<bool>() ?? false;
LogMessageEdits = config[nameof(LogMessageEdits)]?.Value<bool>() ?? false;
LogModLogs = config[nameof(LogModLogs)]?.Value<bool>() ?? false;
}
}

View file

@ -0,0 +1,16 @@
using RegexBot.Common;
namespace RegexBot.Modules.PendingOutRole;
class ModuleConfig {
public EntityName Role { get; }
public ModuleConfig(JObject conf) {
try {
Role = new EntityName(conf[nameof(Role)]?.Value<string>()!, EntityType.Role);
} catch (ArgumentException) {
throw new ModuleLoadException("Role was not properly specified.");
} catch (FormatException) {
throw new ModuleLoadException("Name specified in configuration is not a role.");
}
}
}

View file

@ -0,0 +1,51 @@
namespace RegexBot.Modules.PendingOutRole;
/// <summary>
/// Automatically sets a specified role when a user is no longer in (gets out of) pending status -
/// that is, the user has passed the requirements needed to fully access the guild such as welcome messages, etc.
/// </summary>
[RegexbotModule]
internal class PendingOutRole : RegexbotModule {
public PendingOutRole(RegexbotClient bot) : base(bot) {
DiscordClient.GuildMembersDownloaded += DiscordClient_GuildMembersDownloaded;
DiscordClient.GuildMemberUpdated += DiscordClient_GuildMemberUpdated;
}
private async Task DiscordClient_GuildMembersDownloaded(SocketGuild arg) {
var conf = GetGuildState<ModuleConfig>(arg.Id);
if (conf == null) return;
var targetRole = conf.Role.FindRoleIn(arg, true);
if (targetRole == null) {
Log(arg, "Unable to find role to be applied. Initial check has been skipped.");
return;
}
foreach (var user in arg.Users.Where(u => u.IsPending.HasValue && u.IsPending.Value == false)) {
if (user.Roles.Contains(targetRole)) continue;
await user.AddRoleAsync(targetRole);
}
}
private async Task DiscordClient_GuildMemberUpdated(Discord.Cacheable<SocketGuildUser, ulong> previous, SocketGuildUser current) {
var conf = GetGuildState<ModuleConfig>(current.Guild.Id);
if (conf == null) return;
if (!(previous.Value.IsPending.HasValue && current.IsPending.HasValue)) return;
if (previous.Value.IsPending == true && current.IsPending == false) {
var r = conf.Role.FindRoleIn(current.Guild, true);
if (r == null) {
Log(current.Guild, $"Failed to update role for {current} - was the role renamed or deleted?");
return;
}
await current.AddRoleAsync(r);
}
}
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken? config) {
if (config == null) return Task.FromResult<object?>(null);
if (config.Type != JTokenType.Object)
throw new ModuleLoadException("Configuration for this section is invalid.");
Log(DiscordClient.GetGuild(guildID), "Active.");
return Task.FromResult<object?>(new ModuleConfig((JObject)config));
}
}

View file

@ -0,0 +1,127 @@
using Discord;
using RegexBot.Common;
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
namespace RegexBot.Modules.RegexModerator;
/// <summary>
/// Representation of a single RegexModerator rule for a guild.
/// Data in this class is immutable. Contains various helper methods.
/// </summary>
[DebuggerDisplay("RM rule '{Label}'")]
class ConfDefinition {
public string Label { get; }
// Matching settings
private IEnumerable<Regex> Regex { get; }
private FilterList Filter { get; }
private bool IgnoreMods { get; }
private bool ScanEmbeds { get; }
// Response settings
public EntityName? ReportingChannel { get; }
public IReadOnlyList<string> Response { get; }
public int BanPurgeDays { get; }
public bool NotifyChannel { get; }
public bool NotifyUser { get; }
public ConfDefinition(JObject def) {
Label = def[nameof(Label)]?.Value<string>()
?? throw new ModuleLoadException($"Encountered a rule without a defined {nameof(Label)}.");
var errpostfx = $" in the rule definition for '{Label}'.";
var rptch = def[nameof(ReportingChannel)]?.Value<string>();
if (rptch != null) {
try {
ReportingChannel = new EntityName(rptch, EntityType.Channel);
} catch (FormatException) {
throw new ModuleLoadException($"'{nameof(ReportingChannel)}' is not defined as a channel{errpostfx}");
}
}
// Regex loading
var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
// Reminder: in Singleline mode, all contents are subject to the same regex (useful if e.g. spammer separates words line by line)
opts |= RegexOptions.Singleline;
// IgnoreCase is enabled by default; must be explicitly set to false
if (def["IgnoreCase"]?.Value<bool>() ?? true) opts |= RegexOptions.IgnoreCase;
const string ErrBadRegex = "Unable to parse regular expression pattern";
var regexRules = new List<Regex>();
List<string> regexStrings;
try {
regexStrings = Utilities.LoadStringOrStringArray(def[nameof(Regex)]);
} catch (ArgumentNullException) {
throw new ModuleLoadException($"No patterns were defined under '{nameof(Regex)}'{errpostfx}");
} catch (ArgumentException) {
throw new ModuleLoadException($"'{nameof(Regex)}' is not properly defined{errpostfx}");
}
foreach (var input in regexStrings) {
try {
regexRules.Add(new Regex(input, opts));
} catch (ArgumentException) {
throw new ModuleLoadException($"{ErrBadRegex}{errpostfx}");
}
}
Regex = regexRules.AsReadOnly();
// Filtering
Filter = new FilterList(def);
// Misc options
// IgnoreMods is enabled by default; must be explicitly set to false
IgnoreMods = def[nameof(IgnoreMods)]?.Value<bool>() ?? true;
ScanEmbeds = def[nameof(ScanEmbeds)]?.Value<bool>() ?? false; // false by default
// Load response(s) and response settings
try {
Response = Utilities.LoadStringOrStringArray(def[nameof(Response)]).AsReadOnly();
} catch (ArgumentNullException) {
throw new ModuleLoadException($"No responses were defined under '{nameof(Response)}'{errpostfx}");
} catch (ArgumentException) {
throw new ModuleLoadException($"'{nameof(Response)}' is not properly defined{errpostfx}");
}
BanPurgeDays = def[nameof(BanPurgeDays)]?.Value<int>() ?? 0;
NotifyChannel = def[nameof(NotifyChannel)]?.Value<bool>() ?? true;
NotifyUser = def[nameof(NotifyUser)]?.Value<bool>() ?? true;
}
/// <summary>
/// Checks the given message to determine if it matches this definition's constraints.
/// </summary>
/// <returns>True if match.</returns>
public bool IsMatch(SocketMessage m, bool senderIsModerator) {
if (Filter.IsFiltered(m, false)) return false;
if (senderIsModerator && IgnoreMods) return false;
foreach (var regex in Regex) {
if (ScanEmbeds && regex.IsMatch(SerializeEmbed(m.Embeds))) return true;
if (regex.IsMatch(m.Content)) return true;
}
return false;
}
private static string SerializeEmbed(IReadOnlyCollection<Embed> e) {
static string serialize(Embed e) {
var result = new StringBuilder();
if (e.Author.HasValue) result.AppendLine($"{e.Author.Value.Name} {e.Author.Value.Url}");
if (!string.IsNullOrWhiteSpace(e.Title)) result.AppendLine(e.Title);
if (!string.IsNullOrWhiteSpace(e.Description)) result.AppendLine(e.Description);
foreach (var f in e.Fields) {
if (!string.IsNullOrWhiteSpace(f.Name)) result.AppendLine(f.Name);
if (!string.IsNullOrWhiteSpace(f.Value)) result.AppendLine(f.Value);
}
if (e.Footer.HasValue) {
result.AppendLine(e.Footer.Value.Text ?? "");
}
return result.ToString();
}
var text = new StringBuilder();
foreach (var item in e) text.AppendLine(serialize(item));
return text.ToString();
}
}

View file

@ -0,0 +1,60 @@
using Discord;
namespace RegexBot.Modules.RegexModerator;
/// <summary>
/// The namesake of RegexBot. This module allows users to define pattern-based rules with other constraints.
/// When triggered, one or more actions are executed as defined in its configuration.
/// </summary>
[RegexbotModule]
internal class RegexModerator : RegexbotModule {
public RegexModerator(RegexbotClient bot) : base(bot) {
DiscordClient.MessageReceived += DiscordClient_MessageReceived;
DiscordClient.MessageUpdated += DiscordClient_MessageUpdated;
}
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken? config) {
if (config == null) return Task.FromResult<object?>(null);
var defs = new List<ConfDefinition>();
if (config.Type != JTokenType.Array)
throw new ModuleLoadException(Name + " configuration must be a JSON array.");
// TODO better error reporting during this process
foreach (var def in config.Children<JObject>())
defs.Add(new ConfDefinition(def));
if (defs.Count == 0) return Task.FromResult<object?>(null);
Log(DiscordClient.GetGuild(guildID), $"Loaded {defs.Count} definition(s).");
return Task.FromResult<object?>(defs.AsReadOnly());
}
private Task DiscordClient_MessageReceived(SocketMessage arg) => ReceiveIncomingMessage(arg);
private Task DiscordClient_MessageUpdated(Cacheable<Discord.IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3) {
// Ignore embed edits (see comment in MessageCachingSubservice)
if (!arg2.EditedTimestamp.HasValue) return Task.CompletedTask;
return ReceiveIncomingMessage(arg2);
}
/// <summary>
/// Does initial message checking before further processing.
/// </summary>
private async Task ReceiveIncomingMessage(SocketMessage msg) {
if (!Common.Utilities.IsValidUserMessage(msg, out var ch)) return;
// Get config?
var defs = GetGuildState<IEnumerable<ConfDefinition>>(ch.Guild.Id);
if (defs == null) return;
// Matching and response processing
foreach (var item in defs) {
// Need to check sender's moderator status here. Definition can't access mod list.
var isMod = GetModerators(ch.Guild.Id).IsListMatch(msg, true);
if (!item.IsMatch(msg, isMod)) continue;
Log(ch.Guild, $"Rule '{item.Label}' triggered by {msg.Author}.");
var exec = new ResponseExecutor(item, Bot, msg, (string logLine) => Log(ch.Guild, logLine));
await exec.Execute();
}
}
}

View file

@ -0,0 +1,281 @@
using Discord;
using RegexBot.Common;
using System.Text;
namespace RegexBot.Modules.RegexModerator;
/// <summary>
/// Transient helper class which handles response interpreting and execution.
/// </summary>
class ResponseExecutor {
private const string ErrParamNeedNone = "This response type does not accept parameters.";
private const string ErrParamWrongAmount = "Incorrect number of parameters defined in the response.";
private const string ErrMissingUser = "The target user is no longer in the server.";
delegate Task<ResponseResult> ResponseHandler(string? parameter);
private readonly ConfDefinition _rule;
private readonly RegexbotClient _bot;
private readonly SocketGuild _guild;
private readonly SocketGuildUser _user;
private readonly SocketMessage _msg;
private readonly List<(string, ResponseResult)> _reports;
private Action<string> Log { get; }
private string LogSource => $"{_rule.Label} ({nameof(RegexModerator)})";
public ResponseExecutor(ConfDefinition rule, RegexbotClient bot, SocketMessage msg, Action<string> logger) {
_rule = rule;
_bot = bot;
_msg = msg;
_user = (SocketGuildUser)msg.Author;
_guild = _user.Guild;
_reports = [];
Log = logger;
}
public async Task Execute() {
var reportTarget = _rule.ReportingChannel?.FindChannelIn(_guild, true);
if (_rule.ReportingChannel != null && reportTarget == null)
Log("Could not find target reporting channel.");
foreach (var line in _rule.Response) {
var item = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries & StringSplitOptions.TrimEntries);
var cmd = item[0];
var param = item.Length >= 2 ? item[1] : null;
ResponseHandler runLine = cmd.ToLowerInvariant() switch {
"comment" => CmdComment,
"rem" => CmdComment,
"#" => CmdComment,
"ban" => CmdBan,
"delete" => CmdDelete,
"remove" => CmdDelete,
"kick" => CmdKick,
"note" => CmdNote,
"roleadd" => CmdRoleAdd,
"addrole" => CmdRoleAdd,
"roledel" => CmdRoleDel,
"delrole" => CmdRoleDel,
"say" => CmdSay,
"send" => CmdSay,
"reply" => CmdSay,
"timeout" => CmdTimeout,
"mute" => CmdTimeout,
"warn" => CmdWarn,
_ => delegate (string? p) { return Task.FromResult(FromError($"Unknown command '{cmd}'.")); }
};
try {
var result = await runLine(param);
_reports.Add((cmd, result));
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
_reports.Add((cmd, FromError(Messages.ForbiddenGenericError)));
}
}
// Handle reporting
if (reportTarget != null) {
// Set up report
var rptOutput = new StringBuilder();
foreach (var (action, result) in _reports) {
rptOutput.Append(result.Success ? ":white_check_mark:" : ":x:");
rptOutput.Append($" `{action}`");
if (result.LogLine != null) {
rptOutput.Append(": ");
rptOutput.Append(result.LogLine);
}
rptOutput.AppendLine();
}
// We can only afford to show a preview of the message being reported, due to embeds
// being constrained to the same 2000 character limit as normal messages.
const string TruncateWarning = "**Notice: Full message has been truncated.**\n";
const int TruncateMaxLength = 990;
var invokingLine = _msg.Content;
if (invokingLine.Length > TruncateMaxLength) {
invokingLine = string.Concat(TruncateWarning, invokingLine.AsSpan(0, TruncateMaxLength - TruncateWarning.Length));
}
var resultEmbed = new EmbedBuilder()
.WithFields(
new EmbedFieldBuilder() {
Name = "Context",
Value =
$"User: {_user.Mention} `{_user.Id}`\n" +
$"Channel: <#{_msg.Channel.Id}> `#{_msg.Channel.Name}`"
},
new EmbedFieldBuilder() {
Name = "Response status",
Value = rptOutput.ToString()
}
)
.WithAuthor(
name: $"{_user.GetDisplayableUsername()} said:",
iconUrl: _user.GetAvatarUrl(),
url: _msg.GetJumpUrl()
)
.WithDescription(invokingLine)
.WithFooter(
text: LogSource,
iconUrl: _bot.DiscordClient.CurrentUser.GetAvatarUrl()
)
.WithCurrentTimestamp()
.Build();
try {
await reportTarget.SendMessageAsync(embed: resultEmbed);
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
Log("Encountered error 403 when attempting to send report.");
}
}
}
#region Response delegates
private static Task<ResponseResult> CmdComment(string? parameter) => Task.FromResult(FromSuccess(parameter));
private Task<ResponseResult> CmdBan(string? parameter) => CmdBanKick(true, parameter);
private Task<ResponseResult> CmdKick(string? parameter) => CmdBanKick(false, parameter);
private async Task<ResponseResult> CmdBanKick(bool isBan, string? parameter) {
BanKickResult result;
if (isBan) {
result = await _bot.BanAsync(_guild, LogSource, _user.Id,
_rule.BanPurgeDays, parameter, _rule.NotifyUser);
} else {
result = await _bot.KickAsync(_guild, LogSource, _user.Id,
parameter, _rule.NotifyUser);
}
if (result.ErrorForbidden) return FromError(Messages.ForbiddenGenericError);
if (result.ErrorNotFound) return FromError(ErrMissingUser);
if (_rule.NotifyChannel) await _msg.Channel.SendMessageAsync(result.GetResultString(_bot));
return FromSuccess(result.MessageSendSuccess ? null : "Unable to send notification DM.");
}
private Task<ResponseResult> CmdRoleAdd(string? parameter) => CmdRoleManipulation(parameter, true);
private Task<ResponseResult> CmdRoleDel(string? parameter) => CmdRoleManipulation(parameter, false);
private async Task<ResponseResult> CmdRoleManipulation(string? parameter, bool add) {
// parameters: @_, &, reason?
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
var param = parameter.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (param.Length != 2) return FromError(ErrParamWrongAmount);
// Find targets
SocketGuildUser? tuser;
SocketRole? trole;
try {
var userName = new EntityName(param[0], EntityType.User);
if (userName.Id.HasValue) tuser = _guild.GetUser(userName.Id.Value);
else {
if (userName.Name == "_") tuser = _user;
else tuser = userName.FindUserIn(_guild);
}
if (tuser == null) return FromError($"Unable to find user '{userName.Name}'.");
var roleName = new EntityName(param[1], EntityType.Role);
if (roleName.Id.HasValue) trole = _guild.GetRole(roleName.Id.Value);
else trole = roleName.FindRoleIn(_guild);
if (trole == null) return FromError($"Unable to find role '{roleName.Name}'.");
} catch (ArgumentException) {
return FromError("User or role were not correctly set in configuration.");
}
// Do action
var rq = new RequestOptions() { AuditLogReason = LogSource };
if (add) await tuser.AddRoleAsync(trole, rq);
else await tuser.RemoveRoleAsync(trole, rq);
return FromSuccess($"{(add ? "Set" : "Unset")} {trole.Mention}.");
}
private async Task<ResponseResult> CmdDelete(string? parameter) {
if (!string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamNeedNone);
try {
await _msg.DeleteAsync(new RequestOptions { AuditLogReason = LogSource });
return FromSuccess();
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound) {
return FromError("The message had already been deleted.");
}
}
private async Task<ResponseResult> CmdSay(string? parameter) {
// parameters: [#_/@_] message
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
var param = parameter.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (param.Length != 2) return FromError(ErrParamWrongAmount);
// Get target
IMessageChannel? targetCh;
EntityName name;
try {
name = new EntityName(param[0]);
} catch (ArgumentException) {
return FromError("Reply target was not correctly set in configuration.");
}
bool isUser;
if (name.Type == EntityType.Channel) {
if (name.Name == "_") targetCh = _msg.Channel;
else targetCh = name.FindChannelIn(_guild);
if (targetCh == null) return FromError($"Unable to find channel '{name.Name}'.");
isUser = false;
} else if (name.Type == EntityType.User) {
if (name.Name == "_") targetCh = await _user.CreateDMChannelAsync();
else {
var searchedUser = name.FindUserIn(_guild);
if (searchedUser == null) return FromError($"Unable to find user '{name.Name}'.");
targetCh = await searchedUser.CreateDMChannelAsync();
}
isUser = true;
} else {
return FromError("Channel or user were not correctly set in configuration.");
}
if (targetCh == null) return FromError("Could not acquire target channel.");
await targetCh.SendMessageAsync(Utilities.ProcessTextTokens(param[1], _msg));
return FromSuccess($"Sent to {(isUser ? "user DM" : $"<#{targetCh.Id}>")}.");
}
private async Task<ResponseResult> CmdNote(string? parameter) {
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
var log = await _bot.AddUserNoteAsync(_guild, _user.Id, LogSource, parameter);
return FromSuccess($"Note \\#{log.LogId} logged for {_user}.");
}
private async Task<ResponseResult> CmdWarn(string? parameter) {
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
var (log, result) = await _bot.AddUserWarnAsync(_guild, _user.Id, LogSource, parameter);
var resultMsg = $"Warning \\#{log.LogId} logged for {_user}.";
if (result.Success) return FromSuccess(resultMsg);
else return FromError(resultMsg + " Failed to send DM.");
}
private async Task<ResponseResult> CmdTimeout(string? parameter) {
// parameters: (time in minutes) [reason]
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
var param = parameter.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (param.Length < 1) return FromError(ErrParamWrongAmount);
if (!int.TryParse(param[0], out var timemin)) {
return FromError($"Couldn't parse '{param[0]}' as amount of time in minutes.");
}
string? reason = null;
if (param.Length == 2) reason = param[1];
var result = await _bot.SetTimeoutAsync(_guild, LogSource, _user,
TimeSpan.FromMinutes(timemin), reason, _rule.NotifyUser);
if (result.ErrorForbidden) return FromError(Messages.ForbiddenGenericError);
if (result.ErrorNotFound) return FromError(ErrMissingUser);
if (result.Error != null) return FromError(result.Error.Message);
if (_rule.NotifyChannel) await _msg.Channel.SendMessageAsync(result.ToResultString());
return FromSuccess(result.Success ? null : "Unable to send notification DM.");
}
#endregion
#region Response reporting
private struct ResponseResult {
public bool Success;
public string? LogLine;
}
private static ResponseResult FromSuccess(string? logLine = null) => new() { Success = true, LogLine = logLine };
private static ResponseResult FromError(string? logLine = null) => new() { Success = false, LogLine = logLine };
#endregion
}

View file

@ -0,0 +1,51 @@
using RegexBot.Common;
using System.Collections.ObjectModel;
namespace RegexBot.Modules.VoiceRoleSync;
class ModuleConfig {
/// <summary>
/// Key = voice channel ID, Value = role ID.
/// </summary>
private readonly ReadOnlyDictionary<ulong, ulong> _values;
public int Count { get => _values.Count; }
public ModuleConfig(JObject config, SocketGuild g) {
// Configuration: Object with properties.
// Property name is a role entity name
// Value is a string or array of voice channel IDs.
var values = new Dictionary<ulong, ulong>();
foreach (var item in config.Properties()) {
EntityName name;
try {
name = new EntityName(item.Name, EntityType.Role);
} catch (FormatException) {
throw new ModuleLoadException($"'{item.Name}' is not specified as a role.");
}
var role = name.FindRoleIn(g) ?? throw new ModuleLoadException($"Unable to find role '{name}'.");
var channels = Utilities.LoadStringOrStringArray(item.Value);
if (channels.Count == 0) throw new ModuleLoadException($"One or more channels must be defined under '{name}'.");
foreach (var id in channels) {
if (!ulong.TryParse(id, out var channelId)) throw new ModuleLoadException("Voice channel IDs must be numeric.");
if (values.ContainsKey(channelId)) throw new ModuleLoadException($"'{channelId}' cannot be specified more than once.");
values.Add(channelId, role.Id);
}
}
_values = new(values);
}
public SocketRole? GetAssociatedRoleFor(SocketVoiceChannel voiceChannel) {
if (voiceChannel == null) return null;
if (_values.TryGetValue(voiceChannel.Id, out var roleId)) return voiceChannel.Guild.GetRole(roleId);
return null;
}
public IEnumerable<SocketRole> GetTrackedRoles(SocketGuild guild) {
var roles = _values.Select(v => v.Value).Distinct();
foreach (var id in roles) {
var r = guild.GetRole(id);
if (r != null) yield return r;
}
}
}

View file

@ -0,0 +1,55 @@
namespace RegexBot.Modules.VoiceRoleSync;
/// <summary>
/// Synchronizes a user's state in a voice channel with a role.
/// In other words: applies a role to a user entering a voice channel. Removes the role when exiting.
/// </summary>
[RegexbotModule]
internal class VoiceRoleSync : RegexbotModule {
public VoiceRoleSync(RegexbotClient bot) : base(bot) {
DiscordClient.UserVoiceStateUpdated += Client_UserVoiceStateUpdated;
}
private async Task Client_UserVoiceStateUpdated(SocketUser argUser, SocketVoiceState before, SocketVoiceState after) {
// Gather data.
if (argUser is not SocketGuildUser user) return; // not a guild user
var settings = GetGuildState<ModuleConfig>(user.Guild.Id);
if (settings == null) return; // not enabled here
async Task RemoveAllAssociatedRoles()
=> await user.RemoveRolesAsync(settings.GetTrackedRoles(user.Guild).Intersect(user.Roles),
new Discord.RequestOptions() { AuditLogReason = nameof(VoiceRoleSync) + ": No longer in associated voice channel." });
if (after.VoiceChannel == null) {
// Not in any voice channel. Remove all roles being tracked by this instance. Clear.
await RemoveAllAssociatedRoles();
} else {
// In a voice channel, and...
if (after.IsDeafened || after.IsSelfDeafened) {
// Is defeaned, which is like not being in a voice channel for our purposes. Clear.
await RemoveAllAssociatedRoles();
} else {
var targetRole = settings.GetAssociatedRoleFor(after.VoiceChannel);
if (targetRole == null) {
// In an untracked voice channel. Clear.
await RemoveAllAssociatedRoles();
} else {
// In a tracked voice channel: Clear all except target, add target if needed.
var toRemove = settings.GetTrackedRoles(user.Guild).Where(role => role.Id != targetRole.Id).Intersect(user.Roles);
if (toRemove.Any()) await user.RemoveRolesAsync(toRemove);
if (!user.Roles.Contains(targetRole)) await user.AddRoleAsync(targetRole,
new Discord.RequestOptions() { AuditLogReason = nameof(VoiceRoleSync) + ": Joined associated voice channel." });
}
}
}
}
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken? config) {
if (config == null) return Task.FromResult<object?>(null);
if (config.Type != JTokenType.Object)
throw new ModuleLoadException("Configuration for this section is invalid.");
var newconf = new ModuleConfig((JObject)config, Bot.DiscordClient.GetGuild(guildID));
Log(DiscordClient.GetGuild(guildID), $"Configured with {newconf.Count} pairing(s).");
return Task.FromResult<object?>(newconf);
}
}

64
Program.cs Normal file
View file

@ -0,0 +1,64 @@
global using Discord.WebSocket;
global using Newtonsoft.Json.Linq;
using Discord;
namespace RegexBot;
class Program {
/// <summary>
/// Timestamp specifying the date and time that the program began running.
/// </summary>
public static DateTimeOffset StartTime { get; private set; }
static RegexbotClient _main = null!;
static async Task Main() {
StartTime = DateTimeOffset.UtcNow;
Console.WriteLine("Bot start time: " + StartTime.ToString("u"));
Configuration cfg;
try {
cfg = new Configuration(); // Program may exit within here.
} catch (Exception ex) {
Console.WriteLine(ex.Message);
Environment.ExitCode = 1;
return;
}
// Configure Discord client
var client = new DiscordSocketClient(new DiscordSocketConfig() {
DefaultRetryMode = RetryMode.Retry502 | RetryMode.RetryTimeouts,
MessageCacheSize = 0, // using our own
LogLevel = LogSeverity.Info,
GatewayIntents = GatewayIntents.All & ~GatewayIntents.GuildPresences,
LogGatewayIntentWarnings = false,
AlwaysDownloadUsers = true
});
// Initialize services, load modules
_main = new RegexbotClient(cfg, client);
// Set up application close handler
Console.CancelKeyPress += Console_CancelKeyPress;
// Proceed to connect
await _main.DiscordClient.LoginAsync(TokenType.Bot, cfg.BotToken);
await _main.DiscordClient.StartAsync();
await Task.Delay(-1);
}
private static void Console_CancelKeyPress(object? sender, ConsoleCancelEventArgs e) {
e.Cancel = true;
_main._svcLogging.DoLog(nameof(RegexBot), "Shutting down.");
var finishingTasks = Task.Run(async () => {
// TODO periodic task service: stop processing, wait for all tasks to finish
// TODO notify services of shutdown
await _main.DiscordClient.StopAsync();
});
if (!finishingTasks.Wait(5000))
_main._svcLogging.DoLog(nameof(RegexBot), "Warning: Normal shutdown is taking too long. Exiting now.");
Environment.Exit(0);
}
}

View file

@ -1,12 +0,0 @@
# RegexBot
RegexBot is a standalone Discord moderation bot that makes use of the
[Discord.Net](https://github.com/RogueException/Discord.Net) library.
The goal of this project is to provide a bot that can truly fit your unique needs in managing Discord server. To that end, many aspects of the bot's behavior can be configured and fine-tuned, ensuring that the bot acts exactly as you want it to act.
Are you satisfied with your current bot but wish that you could change *that one thing* to better suit your needs? This project is an answer to that.
Granted, progress on this project so far has followed the needs of one Discord server that makes heavy use of it, so its current feature set may be limited. Feel free to send a pull request or submit an issue.
## Documentation
Documentation is available in the form of a number of pages hosted by Github Pages. See [this site](https://noikoio.github.io/RegexBot) or the [site's source directory](https://github.com/Noikoio/RegexBot/tree/master/docs) for more information.

30
Readme.md Normal file
View file

@ -0,0 +1,30 @@
# RegexBot
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/J3J65TW2E)
RegexBot is a Discord moderation bot framework of sorts, inspired by the terrible state of Discord moderation tools a few years ago
combined with my tendency to overengineer things until they into pseudo-libraries of their own right.
This bot includes a number of features which assist in handling the tedious details in a busy server with the goal of minimizing
the occurrence of hidden details, arbitrary restrictions, or annoyingly unmodifiable behavior. Its configuration allows for a very high
level of flexibility, ensuring that the bot behaves in accordance to the exact needs of your server without compromise.
### Features
* Create rules based on regular expression patterns
* Follow up with custom responses ranging from sending a DM to disciplinary action
* Create pattern-based triggers to provide information and fun to your users
* Adjustable rate limits per-trigger to prevent spam
* Specify multiple different responses to display at random when triggered
* Make things interesting by setting triggers that only activate at random
* Individual rules and triggers can be whitelisted or blacklisted per-user, per-channel, or per-role
* Exemptions to these filters can be applied for additional flexibility
* High detail logging and record-keeping prevents gaps in moderation that might occur with large public bots.
### Modules
As mentioned above, this bot also serves as a framework of sorts, allowing others to write their own modules and expand
the bot's feature set ever further. Its benefits are:
* Putting together disparate bot features under a common, consistent interface.
* Reducing duplicate code potentially leading to an inconsistent user experience.
* Versatile JSON-based configuration.
## User documentation
Coming soon?

32
RegexBot.csproj Normal file
View file

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>3.2.1</Version>
<Authors>NoiTheCat</Authors>
<Description>Advanced and flexible Discord moderation bot.</Description>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Discord.Net" Version="3.15.0" />
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql" Version="8.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
</ItemGroup>
</Project>

View file

@ -1,22 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.26430.6
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RegexBot", "RegexBot\RegexBot.csproj", "{DD16F03C-751C-463F-872F-1749124B7A39}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DD16F03C-751C-463F-872F-1749124B7A39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DD16F03C-751C-463F-872F-1749124B7A39}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DD16F03C-751C-463F-872F-1749124B7A39}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DD16F03C-751C-463F-872F-1749124B7A39}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

View file

@ -1,81 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Noikoio.RegexBot
{
/// <summary>
/// Base class for bot modules
/// </summary>
abstract class BotModule
{
private readonly DiscordSocketClient _client;
private readonly AsyncLogger _logger;
public string Name => this.GetType().Name;
protected DiscordSocketClient Client => _client;
public BotModule(DiscordSocketClient client)
{
_client = client;
_logger = Logger.GetLogger(this.Name);
}
/// <summary>
/// This method is called on each module when configuration is (re)loaded.
/// The module is expected to use this opportunity to set up an object that will hold state data
/// for a particular guild, using the incoming configuration object as needed in order to do so.
/// </summary>
/// <remarks>
/// Module code <i>should not</i> hold on state or configuration data on its own, but instead use
/// <see cref="GetState{T}(ulong)"/> to retrieve its state object. This is to provide the user
/// with the ability to maintain the current bot state in the event that a configuration reload fails.
/// </remarks>
/// <param name="configSection">
/// Configuration data for this module, for this guild. Is null if none was defined.
/// </param>
/// <returns>An object that may later be retrieved by <see cref="GetState{T}(ulong)"/>.</returns>
public virtual Task<object> CreateInstanceState(JToken configSection) => Task.FromResult<object>(null);
/// <summary>
/// Retrieves this module's relevant state data associated with the given Discord guild.
/// </summary>
/// <returns>
/// The stored state data, or null/default if none exists.
/// </returns>
protected T GetState<T>(ulong guildId)
{
// TODO investigate if locking may be necessary
var sc = RegexBot.Config.Servers.FirstOrDefault(g => g.Id == guildId);
if (sc == null) return default(T);
if (sc.ModuleConfigs.TryGetValue(this, out var item)) return (T)item;
else return default(T);
}
/// <summary>
/// Determines if the given message author or channel is in the server configuration's moderator list.
/// </summary>
protected bool IsModerator(ulong guildId, SocketMessage m)
{
var sc = RegexBot.Config.Servers.FirstOrDefault(g => g.Id == guildId);
if (sc == null)
{
throw new ArgumentException("There is no known configuration associated with the given Guild ID.");
}
return sc.Moderators.ExistsInList(m);
}
protected async Task Log(string text)
{
await _logger(text);
}
public sealed override bool Equals(object obj) => base.Equals(obj);
public sealed override int GetHashCode() => base.GetHashCode();
public sealed override string ToString() => base.ToString();
}
}

View file

@ -1,59 +0,0 @@
using Newtonsoft.Json.Linq;
using Npgsql;
using System;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.ConfigItem
{
class DatabaseConfig
{
private readonly string _host;
private readonly string _user;
private readonly string _pass;
private readonly string _dbname;
public DatabaseConfig(JToken ctok)
{
if (ctok == null || ctok.Type != JTokenType.Object)
{
throw new DatabaseConfigLoadException("");
}
var conf = (JObject)ctok;
_host = conf["hostname"]?.Value<string>() ?? "localhost"; // default to localhost
_user = conf["username"]?.Value<string>();
if (string.IsNullOrWhiteSpace(_user))
throw new DatabaseConfigLoadException("Value for username is not defined.");
_pass = conf["password"]?.Value<string>();
if (string.IsNullOrWhiteSpace(_pass))
throw new DatabaseConfigLoadException(
$"Value for password is not defined. {nameof(RegexBot)} only supports password authentication.");
_dbname = conf["database"]?.Value<string>();
if (string.IsNullOrWhiteSpace(_dbname))
throw new DatabaseConfigLoadException("Value for database name is not defined.");
}
internal async Task<NpgsqlConnection> GetOpenConnectionAsync()
{
var cs = new NpgsqlConnectionStringBuilder()
{
Host = _host,
Username = _user,
Password = _pass,
Database = _dbname
};
var db = new NpgsqlConnection(cs.ToString());
await db.OpenAsync();
return db;
}
internal class DatabaseConfigLoadException : Exception
{
public DatabaseConfigLoadException(string message) : base(message) { }
}
}
}

View file

@ -1,135 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace Noikoio.RegexBot.ConfigItem
{
/// <summary>
/// Represents a structure in bot configuration that contains a list of
/// channels, roles, and users.
/// </summary>
class EntityList
{
private readonly Dictionary<EntityType, EntityName[]> _innerList;
public IEnumerable<EntityName> Channels => _innerList[EntityType.Channel];
public IEnumerable<EntityName> Roles => _innerList[EntityType.Role];
public IEnumerable<EntityName> Users => _innerList[EntityType.User];
public EntityList() : this(null) { }
public EntityList(JToken config)
{
_innerList = new Dictionary<EntityType, EntityName[]>();
if (config == null)
{
foreach (EntityType t in Enum.GetValues(typeof(EntityType)))
{
_innerList.Add(t, new EntityName[0]);
}
}
else
{
foreach (EntityType t in Enum.GetValues(typeof(EntityType)))
{
string aname = Enum.GetName(typeof(EntityType), t).ToLower() + "s";
List<EntityName> items = new List<EntityName>();
JToken array = config[aname];
if (array != null)
{
foreach (var item in array) {
string input = item.Value<string>();
if (t == EntityType.User && input.StartsWith("@")) input = input.Substring(1);
if (t == EntityType.Channel && input.StartsWith("#")) input = input.Substring(1);
if (input.Length > 0) items.Add(new EntityName(input, t));
}
}
_innerList.Add(t, items.ToArray());
}
}
Debug.Assert(Channels != null && Roles != null && Users != null);
}
public override string ToString()
{
return $"List contains: "
+ $"{Channels.Count()} channel(s), "
+ $"{Roles.Count()} role(s), "
+ $"{Users.Count()} user(s)";
}
/// <summary>
/// Checks if the parameters of the given <see cref="SocketMessage"/> match with the entities
/// specified in this list.
/// </summary>
/// <param name="msg">An incoming message.</param>
/// <returns>
/// True if '<paramref name="msg"/>' occurred within a channel specified in this list,
/// or if the message author belongs to one or more roles in this list, or if the user itself
/// is defined within this list.
/// </returns>
public bool ExistsInList(SocketMessage msg)
{
var guildauthor = msg.Author as SocketGuildUser;
foreach (var item in this.Users)
{
if (!item.Id.HasValue)
{
if (guildauthor != null &&
string.Equals(item.Name, guildauthor.Nickname, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (string.Equals(item.Name, msg.Author.Username, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
else
{
if (item.Id.Value == msg.Author.Id) return true;
}
}
if (guildauthor != null)
{
foreach (var guildrole in guildauthor.Roles)
{
if (this.Roles.Any(listrole =>
{
if (listrole.Id.HasValue) return listrole.Id == guildrole.Id;
else return string.Equals(listrole.Name, guildrole.Name, StringComparison.OrdinalIgnoreCase);
}))
{
return true;
}
}
foreach (var listchannel in this.Channels)
{
if (listchannel.Id.HasValue && listchannel.Id == msg.Channel.Id ||
string.Equals(listchannel.Name, msg.Channel.Name, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
// No match.
return false;
}
/// <summary>
/// Determines if this is an empty list.
/// </summary>
public bool IsEmpty()
{
return Channels.Count() + Roles.Count() + Users.Count() == 0;
}
}
}

View file

@ -1,81 +0,0 @@
namespace Noikoio.RegexBot.ConfigItem
{
enum EntityType { Channel, Role, User }
/// <summary>
/// Used to join together an entity ID and its name when read from configuration.
/// In configuration, entities are fully specified with a prefix (if necessary), an ID, two colons, and a name.
/// An EntityName struct can have either an ID, Name, or both. It cannot have neither.
/// </summary>
struct EntityName
{
private readonly ulong? _id;
private readonly string _name;
private readonly EntityType _type;
public ulong? Id => _id;
public string Name => _name;
public EntityType Type => _type;
/// <summary>
/// Creates a new EntityItem instance
/// </summary>
/// <param name="input">Input text WITHOUT the leading prefix. It must be stripped beforehand.</param>
/// <param name="t">Type of this entity. Should be determined by the input prefix.</param>
public EntityName(string input, EntityType t)
{
_type = t;
// Check if input contains both ID and label
int separator = input.IndexOf("::");
if (separator != -1)
{
_name = input.Substring(separator + 2, input.Length - (separator + 2));
if (ulong.TryParse(input.Substring(0, separator), out var id))
{
_id = id;
}
else
{
// Failed to parse ID. Assuming the actual name includes our separator.
_id = null;
_name = input;
}
}
else
{
// Input is either only an ID or only a name
if (ulong.TryParse(input, out var id))
{
_id = id;
_name = null;
}
else
{
_name = input;
_id = null;
}
}
}
public override string ToString()
{
string prefix;
if (_type == EntityType.Channel) prefix = "#";
else if (_type == EntityType.User) prefix = "@";
else prefix = "";
if (_id.HasValue && _name != null)
{
return $"{prefix}{Id}::{Name}";
}
if (_id.HasValue)
{
return $"{prefix}{Id}";
}
return $"{prefix}{Name}";
}
}
}

View file

@ -1,91 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Text;
namespace Noikoio.RegexBot.ConfigItem
{
enum FilterType { None, Whitelist, Blacklist }
/// <summary>
/// Represents whitelist/blacklist configuration, including exemptions.
/// </summary>
struct FilterList
{
FilterType _type;
EntityList _filterList;
EntityList _exemptions;
public FilterType FilterMode => _type;
public EntityList FilterEntities => _filterList;
public EntityList FilterExemptions => _exemptions;
/// <summary>
/// Gets the
/// </summary>
/// <param name="conf">
/// A JSON object which presumably contains an array named "whitelist" or "blacklist",
/// and optionally one named "exempt".
/// </param>
/// <exception cref="RuleImportException">
/// Thrown if both "whitelist" and "blacklist" definitions were found, if
/// "exempt" was found without a corresponding "whitelist" or "blacklist",
/// or if there was an issue parsing an EntityList within these definitions.
/// </exception>
public FilterList(JObject conf)
{
_type = FilterType.None;
if (conf["whitelist"] != null) _type = FilterType.Whitelist;
if (conf["blacklist"] != null)
{
if (_type != FilterType.None)
throw new RuleImportException("Cannot have both 'whitelist' and 'blacklist' values defined.");
_type = FilterType.Blacklist;
}
if (_type == FilterType.None)
{
_filterList = null;
_exemptions = null;
if (conf["exempt"] != null)
throw new RuleImportException("Cannot have 'exempt' defined if no corresponding " +
"'whitelist' or 'blacklist' has been defined in the same section.");
}
else
{
_filterList = new EntityList(conf[_type == FilterType.Whitelist ? "whitelist" : "blacklist"]);
_exemptions = new EntityList(conf["exempt"]); // EntityList constructor checks for null value
}
}
/// <summary>
/// Determines if the parameters of '<paramref name="msg"/>' are a match with filtering
/// rules defined in this instance.
/// </summary>
/// <param name="msg">An incoming message.</param>
/// <returns>
/// True if the user or channel specified by '<paramref name="msg"/>' is filtered by
/// the configuration defined in this instance.
/// </returns>
public bool IsFiltered(SocketMessage msg)
{
if (FilterMode == FilterType.None) return false;
bool inFilter = FilterEntities.ExistsInList(msg);
if (FilterMode == FilterType.Whitelist)
{
if (!inFilter) return true;
return FilterExemptions.ExistsInList(msg);
}
else if (FilterMode == FilterType.Blacklist)
{
if (!inFilter) return false;
return !FilterExemptions.ExistsInList(msg);
}
throw new Exception("this shouldn't happen");
}
}
}

View file

@ -1,12 +0,0 @@
using System;
namespace Noikoio.RegexBot.ConfigItem
{
/// <summary>
/// Exception thrown during an attempt to read rule configuration.
/// </summary>
public class RuleImportException : Exception
{
public RuleImportException(string message) : base(message) { }
}
}

View file

@ -1,27 +0,0 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
namespace Noikoio.RegexBot.ConfigItem
{
/// <summary>
/// Represents known information about a Discord guild (server) and other associated data
/// </summary>
class ServerConfig
{
private readonly ulong _id;
private EntityList _moderators;
private ReadOnlyDictionary<BotModule, object> _modData;
public ulong? Id => _id;
public EntityList Moderators => _moderators;
public ReadOnlyDictionary<BotModule, object> ModuleConfigs => _modData;
public ServerConfig(ulong id, EntityList moderators, ReadOnlyDictionary<BotModule, object> modconf)
{
_id = id;
_moderators = moderators;
_modData = modconf;
Debug.Assert(_moderators != null && _modData != null);
}
}
}

View file

@ -1,216 +0,0 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Noikoio.RegexBot
{
/// <summary>
/// Configuration loader
/// </summary>
class Configuration
{
public const string LogPrefix = "Config";
private readonly RegexBot _bot;
private readonly string _configPath;
private DatabaseConfig _dbConfig;
private ServerConfig[] _servers;
// The following values do not change on reload:
private string _botToken;
private string _currentGame;
public string BotUserToken => _botToken;
public string CurrentGame => _currentGame;
public ServerConfig[] Servers => _servers;
public Configuration(RegexBot bot)
{
_bot = bot;
var dsc = Path.DirectorySeparatorChar;
_configPath = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location)
+ dsc + "settings.json";
}
private async Task<JObject> LoadFile()
{
var Log = Logger.GetLogger(LogPrefix);
JObject pcfg;
try
{
var ctxt = File.ReadAllText(_configPath);
pcfg = JObject.Parse(ctxt);
return pcfg;
}
catch (Exception ex) when (ex is DirectoryNotFoundException || ex is FileNotFoundException)
{
await Log("Config file not found! Check bot directory for settings.json file.");
return null;
}
catch (UnauthorizedAccessException)
{
await Log("Could not access config file. Check file permissions.");
return null;
}
catch (JsonReaderException jex)
{
await Log("Failed to parse JSON.");
await Log(jex.GetType().Name + " " + jex.Message);
return null;
}
}
/// <summary>
/// Loads essential, unchanging values needed for bot startup. Returns false on failure.
/// </summary>
internal bool LoadInitialConfig()
{
var lt = LoadFile();
lt.Wait();
JObject conf = lt.Result;
if (conf == null) return false;
var log = Logger.GetLogger(LogPrefix);
_botToken = conf["bot-token"]?.Value<string>();
if (String.IsNullOrWhiteSpace(_botToken))
{
log("Error: Bot token not defined. Cannot continue.").Wait();
return false;
}
_currentGame = conf["playing"]?.Value<string>();
// Database configuration:
// Either it exists or it doesn't. Read config, but also attempt to make a database connection
// right here, or else make it known that database support is disabled for this instance.
try
{
_dbConfig = new DatabaseConfig(conf["database"]);
var conn = _dbConfig.GetOpenConnectionAsync().GetAwaiter().GetResult();
conn.Dispose();
}
catch (DatabaseConfig.DatabaseConfigLoadException ex)
{
if (ex.Message == "") log("Database configuration not found.").Wait();
else log("Error within database config: " + ex.Message).Wait();
_dbConfig = null;
}
catch (Npgsql.NpgsqlException ex)
{
log("An error occurred while establishing initial database connection: " + ex.Message).Wait();
_dbConfig = null;
}
// Modules that will not enable due to lack of database access should say so in their constructors.
return true;
}
/// <summary>
/// Reloads the server portion of the configuration file.
/// </summary>
/// <returns>False on failure. Specific reasons will have been sent to log.</returns>
public async Task<bool> ReloadServerConfig()
{
var config = await LoadFile();
if (config == null) return false;
return await ProcessServerConfig(config);
}
/// <summary>
/// Converts a json object containing bot configuration into data usable by this program.
/// On success, updates the Servers values and returns true. Returns false on failure.
/// </summary>
private async Task<bool> ProcessServerConfig(JObject conf)
{
var Log = Logger.GetLogger(LogPrefix);
if (!conf["servers"].HasValues)
{
await Log("Error: No server configurations are defined.");
return false;
}
List<ServerConfig> newservers = new List<ServerConfig>();
await Log("Reading server configurations...");
foreach (JObject sconf in conf["servers"].Children<JObject>())
{
// Server name
//if (sconf["id"] == null || sconf["id"].Type != JTokenType.Integer))
if (sconf["id"] == null)
{
await Log("Error: Server ID is missing within definition.");
return false;
}
ulong sid = sconf["id"].Value<ulong>();
string sname = sconf["name"]?.Value<string>();
var SLog = Logger.GetLogger(LogPrefix + "/" + (sname ?? sid.ToString()));
// Load server moderator list
EntityList mods = new EntityList(sconf["moderators"]);
if (sconf["moderators"] != null) await SLog("Moderator " + mods.ToString());
// Set up module state / load configurations
Dictionary<BotModule, object> customConfs = new Dictionary<BotModule, object>();
foreach (var item in _bot.Modules)
{
var confSection = item.Name;
var section = sconf[confSection];
await SLog("Setting up " + item.Name);
object result;
try
{
result = await item.CreateInstanceState(section);
}
catch (RuleImportException ex)
{
await SLog($"{item.Name} failed to load configuration: " + ex.Message);
return false;
}
catch (Exception ex)
{
await SLog("Encountered unhandled exception:");
await SLog(ex.ToString());
return false;
}
customConfs.Add(item, result);
}
// Switch to new configuration
List<Tuple<Regex, string[]>> rulesfinal = new List<Tuple<Regex, string[]>>();
newservers.Add(new ServerConfig(sid, mods, new ReadOnlyDictionary<BotModule, object>(customConfs)));
}
_servers = newservers.ToArray();
return true;
}
/// <summary>
/// Gets a value stating if database access is available.
/// Specifically, indicates if <see cref="GetOpenDatabaseConnectionAsync"/> will return a non-null value.
/// </summary>
/// <remarks>
/// Ideally, this value remains constant on runtime. It does not take into account
/// the possibility of the database connection failing during the program's run time.
/// </remarks>
public bool DatabaseAvailable => _dbConfig != null;
/// <summary>
/// Gets an opened connection to the SQL database, if available.
/// </summary>
/// <returns>
/// An <see cref="Npgsql.NpgsqlConnection"/> in the opened state,
/// or null if an SQL database is not available.
/// </returns>
public Task<Npgsql.NpgsqlConnection> GetOpenDatabaseConnectionAsync() => _dbConfig?.GetOpenConnectionAsync();
}
}

View file

@ -1,154 +0,0 @@
using Discord.WebSocket;
using Npgsql;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Linq;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.EntityCache
{
class CacheChannel
{
readonly ulong _channelId;
readonly ulong _guildId;
readonly DateTimeOffset _cacheDate;
readonly string _channelName;
private CacheChannel(SocketGuildChannel c)
{
_channelId = c.Id;
_guildId = c.Guild.Id;
_cacheDate = DateTimeOffset.UtcNow;
_channelName = c.Name;
}
// Double-check SqlHelper if making changes to this constant
const string QueryColumns = "channel_id, guild_id, cache_date, channel_name";
private CacheChannel(DbDataReader r)
{
// Double-check ordinals if making changes to QueryColumns
unchecked
{
_channelId = (ulong)r.GetInt64(0);
_guildId = (ulong)r.GetInt64(1);
}
_cacheDate = r.GetDateTime(2).ToUniversalTime();
_channelName = r.GetString(3);
}
#region Queries
// Accessible by EntityCache. Documentation is there.
internal static async Task<CacheChannel> QueryAsync(DiscordSocketClient c, ulong guild, ulong channel)
{
// Local cache search
var lresult = LocalQueryAsync(c, guild, channel);
if (lresult != null) return lresult;
// Database cache search
var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
if (db == null) return null; // Database not available for query.
return await DbQueryAsync(db, guild, channel);
}
private static CacheChannel LocalQueryAsync(DiscordSocketClient c, ulong guild, ulong channel)
{
var ch = c.GetGuild(guild)?.GetChannel(channel);
if (ch == null) return null;
return new CacheChannel(ch);
}
private static async Task<CacheChannel> DbQueryAsync(NpgsqlConnection db, ulong guild, ulong channel)
{
using (db)
{
using (var c = db.CreateCommand())
{
c.CommandText = $"SELECT {QueryColumns} from {SqlHelper.TableTextChannel} WHERE "
+ "channel_id = @Cid AND guild_id = @Gid";
c.Parameters.Add("@Cid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)channel;
c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)guild;
c.Prepare();
using (var r = await c.ExecuteReaderAsync())
{
if (await r.ReadAsync())
{
return new CacheChannel(r);
}
else
{
return null;
}
}
}
}
}
// -----
// Accessible by EntityCache. Documentation is there.
internal static async Task<IEnumerable<CacheChannel>> QueryAsync(DiscordSocketClient c, ulong guild, string search)
{
// Is search just a number? Assume ID, pass it on to the correct place.
if (ulong.TryParse(search, out var presult))
{
var r = await QueryAsync(c, guild, presult);
if (r == null) return new CacheChannel[0];
else return new CacheChannel[] { r };
}
// Split leading # from name, if exists
if (search.Length > 0 && search[0] == '#') search = search.Substring(1);
// Local cache search
var lresult = LocalQueryAsync(c, guild, search);
if (lresult.Count() != 0) return lresult;
// Database cache search
var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
if (db == null) return new CacheChannel[0];
return await DbQueryAsync(db, guild, search);
}
private static IEnumerable<CacheChannel> LocalQueryAsync(DiscordSocketClient c, ulong guild, string search)
{
var g = c.GetGuild(guild);
if (g == null) return new CacheChannel[0];
var qresult = g.Channels
.Where(i => string.Equals(i.Name, search, StringComparison.InvariantCultureIgnoreCase));
var result = new List<CacheChannel>();
foreach (var item in qresult)
{
result.Add(new CacheChannel(item));
}
return result;
}
private static async Task<IEnumerable<CacheChannel>> DbQueryAsync(NpgsqlConnection db, ulong guild, string search)
{
var result = new List<CacheChannel>();
using (db)
{
using (var c = db.CreateCommand())
{
c.CommandText = $"SELECT {QueryColumns} FROM {SqlHelper.TableTextChannel} WHERE"
+ " name = lower(@NameSearch)" // all channel names presumed to be lowercase already
+ " ORDER BY cache_date desc, name";
c.Parameters.Add("@NameSearch", NpgsqlTypes.NpgsqlDbType.Text).Value = search;
c.Prepare();
using (var r = await c.ExecuteReaderAsync())
{
while (await r.ReadAsync())
{
result.Add(new CacheChannel(r));
}
}
}
}
return result;
}
#endregion
}
}

View file

@ -1,248 +0,0 @@
using Discord.WebSocket;
using Npgsql;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.EntityCache
{
/// <summary>
/// Representation of a cached user.
/// </summary>
class CacheUser
{
readonly ulong _userId;
readonly ulong _guildId;
readonly DateTimeOffset _cacheDate;
readonly DateTimeOffset _firstSeen;
readonly string _username;
readonly string _discriminator;
readonly string _nickname;
readonly string _avatarUrl;
/// <summary>
/// The cached user's ID (snowflake) value.
/// </summary>
public ulong UserId => _userId;
/// <summary>
/// The guild ID (snowflake) for which this user information corresponds to.
/// </summary>
public ulong GuildId => _guildId;
/// <summary>
/// Timestamp value for when this cache item was last updated, in universal time.
/// </summary>
public DateTimeOffset CacheDate => _cacheDate;
/// <summary>
/// Timestamp value for when this user was first seen by the bot, in universal time.
/// </summary>
public DateTimeOffset FirstSeenDate => _firstSeen;
/// <summary>
/// Display name, including discriminator. Shows the nickname, if available.
/// </summary>
public string DisplayName => (_nickname ?? _username) + "#" + _discriminator;
/// <summary>
/// String useful for tagging the user.
/// </summary>
public string Mention => $"<@{_userId}>";
/// <summary>
/// User's cached nickname in the guild. May be null.
/// </summary>
public string Nickname => _nickname;
/// <summary>
/// User's cached username.
/// </summary>
public string Username => _username;
/// <summary>
/// User's cached discriminator value.
/// </summary>
public string Discriminator => _discriminator;
/// <summary>
/// URL for user's last known avatar. May be null or invalid.
/// </summary>
public string AvatarUrl => _avatarUrl;
private CacheUser(SocketGuildUser u)
{
_userId = u.Id;
_guildId = u.Guild.Id;
_cacheDate = DateTime.UtcNow;
_username = u.Username;
_discriminator = u.Discriminator;
_nickname = u.Nickname;
_avatarUrl = u.GetAvatarUrl();
}
// Double-check SqlHelper if making changes to this constant
const string QueryColumns = "user_id, guild_id, first_seen, cache_date, username, discriminator, nickname, avatar_url";
private CacheUser(DbDataReader r)
{
// Double-check ordinals if making changes to QueryColumns
unchecked
{
// PostgreSQL does not support unsigned 64-bit numbers. Must convert.
_userId = (ulong)r.GetInt64(0);
_guildId = (ulong)r.GetInt64(1);
}
_firstSeen = r.GetDateTime(2).ToUniversalTime();
_cacheDate = r.GetDateTime(3).ToUniversalTime();
_username = r.GetString(4);
_discriminator = r.GetString(5);
_nickname = r.IsDBNull(6) ? null : r.GetString(6);
_avatarUrl = r.IsDBNull(7) ? null : r.GetString(7);
}
public override string ToString() => DisplayName;
#region Queries
// Accessible by EntityCache. Documentation is there.
internal static async Task<CacheUser> QueryAsync(DiscordSocketClient c, ulong guild, ulong user)
{
// Local cache search
var lresult = LocalQueryAsync(c, guild, user);
if (lresult != null) return lresult;
// Database cache search
var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
if (db == null) return null; // Database not available for query.
return await DbQueryAsync(db, guild, user);
}
private static CacheUser LocalQueryAsync(DiscordSocketClient c, ulong guild, ulong user)
{
var u = c.GetGuild(guild)?.GetUser(user);
if (u == null) return null;
return new CacheUser(u);
}
private static async Task<CacheUser> DbQueryAsync(NpgsqlConnection db, ulong guild, ulong user)
{
using (db)
{
using (var c = db.CreateCommand())
{
c.CommandText = $"SELECT {QueryColumns} FROM {SqlHelper.TableUser} WHERE "
+ "user_id = @Uid AND guild_id = @Gid";
c.Parameters.Add("@Uid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)user;
c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)guild;
c.Prepare();
using (var r = await c.ExecuteReaderAsync())
{
if (await r.ReadAsync())
{
return new CacheUser(r);
}
else
{
return null;
}
}
}
}
}
// -----
private static Regex DiscriminatorSearch = new Regex(@"(.+)#(\d{4}(?!\d))", RegexOptions.Compiled);
// Accessible by EntityCache. Documentation is there.
internal static async Task<IEnumerable<CacheUser>> QueryAsync(DiscordSocketClient c, ulong guild, string search)
{
// Is search just a number? Assume ID, pass it on to the correct place.
if (ulong.TryParse(search, out var presult))
{
var r = await QueryAsync(c, guild, presult);
if (r == null) return new CacheUser[0];
else return new CacheUser[] { r };
}
// Split name/discriminator
string name;
string disc;
var split = DiscriminatorSearch.Match(search);
if (split.Success)
{
name = split.Groups[1].Value;
disc = split.Groups[2].Value;
}
else
{
name = search;
disc = null;
}
// Strip leading @ from name, if any
if (name.Length > 0 && name[0] == '@') name = name.Substring(1);
// Local cache search
var lresult = LocalQueryAsync(c, guild, name, disc);
if (lresult.Count() != 0) return lresult;
// Database cache search
var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
if (db == null) return new CacheUser[0]; // Database not available for query.
return await DbQueryAsync(db, guild, name, disc);
}
private static IEnumerable<CacheUser> LocalQueryAsync(DiscordSocketClient c, ulong guild, string name, string disc)
{
var g = c.GetGuild(guild);
if (g == null) return new CacheUser[] { };
bool Filter(string iun, string inn, string idc)
{
// Same logic as in the SQL query in the method below this one
bool match =
string.Equals(iun, name, StringComparison.InvariantCultureIgnoreCase)
|| string.Equals(inn, name, StringComparison.InvariantCultureIgnoreCase);
if (match && disc != null)
match = idc.Equals(disc);
return match;
}
var qresult = g.Users.Where(i => Filter(i.Username, i.Nickname, i.Discriminator));
var result = new List<CacheUser>();
foreach (var item in qresult)
{
result.Add(new CacheUser(item));
}
return result;
}
private static async Task<IEnumerable<CacheUser>> DbQueryAsync(NpgsqlConnection db, ulong guild, string name, string disc)
{
var result = new List<CacheUser>();
using (db)
{
using (var c = db.CreateCommand())
{
c.CommandText = $"SELECT {QueryColumns} FROM {SqlHelper.TableUser} WHERE"
+ " ( lower(username) = lower(@NameSearch) OR lower(nickname) = lower(@NameSearch) )";
if (disc != null)
{
c.CommandText += " AND discriminator = @DiscSearch";
c.Parameters.Add("@DiscSearch", NpgsqlTypes.NpgsqlDbType.Text).Value = disc;
}
c.CommandText += " ORDER BY cache_date desc, username";
c.Parameters.Add("@NameSearch", NpgsqlTypes.NpgsqlDbType.Text).Value = name;
c.Prepare();
using (var r = await c.ExecuteReaderAsync())
{
while (await r.ReadAsync())
{
result.Add(new CacheUser(r));
}
}
}
}
return result;
}
#endregion
}
}

View file

@ -1,132 +0,0 @@
using Discord;
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using Npgsql;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.EntityCache
{
/// <summary>
/// Bot module portion of the entity cache. Caches information regarding all known guilds, channels, and users.
/// The function of this module should be transparent to the user, and thus no configuration is needed.
/// This module should be initialized BEFORE any other modules that make use of the entity cache.
/// </summary>
class ECModule : BotModule
{
public ECModule(DiscordSocketClient client) : base(client)
{
if (RegexBot.Config.DatabaseAvailable)
{
SqlHelper.CreateCacheTablesAsync().Wait();
client.GuildAvailable += Client_GuildAvailable;
client.GuildUpdated += Client_GuildUpdated;
client.GuildMemberUpdated += Client_GuildMemberUpdated;
client.UserJoined += Client_UserJoined;
client.UserLeft += Client_UserLeft;
client.ChannelCreated += Client_ChannelCreated;
client.ChannelUpdated += Client_ChannelUpdated;
}
else
{
Log("No database storage available.").Wait();
}
}
private async Task Client_ChannelUpdated(SocketChannel arg1, SocketChannel arg2)
{
if (arg2 is SocketGuildChannel ch)
await SqlHelper.UpdateGuildChannelAsync(ch);
}
private async Task Client_ChannelCreated(SocketChannel arg)
{
if (arg is SocketGuildChannel ch)
await SqlHelper.UpdateGuildChannelAsync(ch);
}
// Guild and guild member information has become available.
// This is a very expensive operation, especially when joining larger guilds.
private async Task Client_GuildAvailable(SocketGuild arg)
{
await Task.Run(async () =>
{
try
{
await SqlHelper.UpdateGuildAsync(arg);
await SqlHelper.UpdateGuildMemberAsync(arg.Users);
await SqlHelper.UpdateGuildChannelAsync(arg.Channels);
}
catch (NpgsqlException ex)
{
await Log($"SQL error in {nameof(Client_GuildAvailable)}: {ex.Message}");
}
});
}
// Guild information has changed
private async Task Client_GuildUpdated(SocketGuild arg1, SocketGuild arg2)
{
await Task.Run(async () =>
{
try
{
await SqlHelper.UpdateGuildAsync(arg2);
}
catch (NpgsqlException ex)
{
await Log($"SQL error in {nameof(Client_GuildUpdated)}: {ex.Message}");
}
});
}
// Guild member information has changed
private async Task Client_GuildMemberUpdated(Cacheable<SocketGuildUser, ulong> arg1, SocketGuildUser arg2)
{
await Task.Run(async () =>
{
try
{
await SqlHelper.UpdateGuildMemberAsync(arg2);
}
catch (NpgsqlException ex)
{
await Log($"SQL error in {nameof(Client_GuildMemberUpdated)}: {ex.Message}");
}
});
}
// A new guild member has appeared
private async Task Client_UserJoined(SocketGuildUser arg)
{
await Task.Run(async () =>
{
try
{
await SqlHelper.UpdateGuildMemberAsync(arg);
}
catch (NpgsqlException ex)
{
await Log($"SQL error in {nameof(Client_UserJoined)}: {ex.Message}");
}
});
}
// User left the guild. No new data, but gives an excuse to update the cache date.
private async Task Client_UserLeft(SocketGuild guild, SocketUser user)
{
await Task.Run(async () =>
{
try
{
await SqlHelper.UpdateGuildMemberAsync((SocketGuildUser)user);
}
catch (NpgsqlException ex)
{
await Log($"SQL error in {nameof(Client_UserLeft)}: {ex.Message}");
}
});
}
}
}

View file

@ -1,60 +0,0 @@
using Discord.WebSocket;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.EntityCache
{
/// <summary>
/// Static class for accessing the entity cache.
/// </summary>
static class EntityCache
{
/*
* The entity cache works by combining data known/cached by Discord.Net in addition to
* what has been stored in the database. If data does not exist in the former, it is
* retrieved from the latter.
* In either case, the resulting data is placed within a cache item object.
*/
static DiscordSocketClient _client;
internal static void SetClient(DiscordSocketClient c) => _client = _client ?? c;
/// <summary>
/// Attempts to query for an exact result with the given parameters.
/// Does not handle exceptions that may occur.
/// </summary>
/// <returns>Null on no result.</returns>
internal static Task<CacheUser> QueryUserAsync(ulong guild, ulong user)
=> CacheUser.QueryAsync(_client, guild, user);
/// <summary>
/// Attempts to look up the user given a search string.
/// This string looks up case-insensitive, exact matches of nicknames and usernames.
/// </summary>
/// <returns>
/// An <see cref="IEnumerable{T}"/> containing zero or more query results,
/// sorted by cache date from most to least recent.
/// </returns>
internal static Task<IEnumerable<CacheUser>> QueryUserAsync(ulong guild, string search)
=> CacheUser.QueryAsync(_client, guild, search);
/// <summary>
/// Attempts to query for an exact result with the given parameters.
/// Does not handle exceptions that may occur.
/// </summary>
/// <returns>Null on no result.</returns>
internal static Task<CacheChannel> QueryChannelAsync(ulong guild, ulong channel)
=> CacheChannel.QueryAsync(_client, guild, channel);
/// <summary>
/// Attempts to look up the channel given a search string.
/// This string looks up exact matches of the given name, regardless of if the channel has been deleted.
/// </summary>
/// <returns>
/// An <see cref="IEnumerable{T}"/> containing zero or more query results,
/// sorted by cache date from most to least recent.
/// </returns>
internal static Task<IEnumerable<CacheChannel>> QueryChannelAsync(ulong guild, string search)
=> CacheChannel.QueryAsync(_client, guild, search);
}
}

View file

@ -1,193 +0,0 @@
using Discord;
using Discord.WebSocket;
using NpgsqlTypes;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.EntityCache
{
/// <summary>
/// Helper methods for database operations.
/// Exceptions are not handled within methods of this class.
/// </summary>
static class SqlHelper
{
public const string TableGuild = "cache_guild";
public const string TableTextChannel = "cache_textchannel";
public const string TableUser = "cache_users";
// Reminder: Check Cache query methods if making changes to tables
internal static async Task CreateCacheTablesAsync()
{
var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
if (db == null) return;
using (db)
{
// Guild cache
using (var c = db.CreateCommand())
{
c.CommandText = "CREATE TABLE IF NOT EXISTS " + TableGuild + " ("
+ "guild_id bigint primary key, "
+ "cache_date timestamptz not null, "
+ "current_name text not null, "
+ "display_name text null"
+ ")";
await c.ExecuteNonQueryAsync();
}
// May not require other indexes. Add here if they become necessary.
// Text channel cache
using (var c = db.CreateCommand())
{
c.CommandText = "CREATE TABLE IF NOT EXISTS " + TableTextChannel + " ("
+ "channel_id bigint not null, "
+ $"guild_id bigint not null references {TableGuild}, "
+ "cache_date timestamptz not null, "
+ "channel_name text not null"
+ ")";
await c.ExecuteNonQueryAsync();
}
using (var c = db.CreateCommand())
{
// guild_id is a foreign key, and also one half of the primary key here
c.CommandText = "CREATE UNIQUE INDEX IF NOT EXISTS "
+ $"{TableTextChannel}_ck_idx on {TableTextChannel} (channel_id, guild_id)";
await c.ExecuteNonQueryAsync();
}
// As of the time of this commit, Discord doesn't allow any uppercase characters
// in channel names. No lowercase name index needed.
// User cache
using (var c = db.CreateCommand())
{
c.CommandText = "CREATE TABLE IF NOT EXISTS " + TableUser + " ("
+ "user_id bigint not null, "
+ $"guild_id bigint not null references {TableGuild}, "
+ "first_seen timestamptz not null default now(),"
+ "cache_date timestamptz not null, "
+ "username text not null, "
+ "discriminator text not null, "
+ "nickname text null, "
+ "avatar_url text null"
+ ")";
await c.ExecuteNonQueryAsync();
}
using (var c = db.CreateCommand())
{
// compound primary key
c.CommandText = "CREATE UNIQUE INDEX IF NOT EXISTS "
+ $"{TableUser}_ck_idx on {TableUser} (user_id, guild_id)";
await c.ExecuteNonQueryAsync();
}
using (var c = db.CreateCommand())
{
c.CommandText = "CREATE INDEX IF NOT EXISTS "
+ $"{TableUser}_usersearch_idx on {TableUser} (LOWER(username))";
await c.ExecuteNonQueryAsync();
}
}
}
#region Insertions and updates
internal static async Task UpdateGuildAsync(SocketGuild g)
{
var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
if (db == null) return;
using (db)
{
using (var c = db.CreateCommand())
{
c.CommandText = "INSERT INTO " + TableGuild + " (guild_id, cache_date, current_name) "
+ "VALUES (@GuildId, now(), @CurrentName) "
+ "ON CONFLICT (guild_id) DO UPDATE SET "
+ "current_name = EXCLUDED.current_name, cache_date = EXCLUDED.cache_date";
c.Parameters.Add("@GuildId", NpgsqlDbType.Bigint).Value = (long)g.Id;
c.Parameters.Add("@CurrentName", NpgsqlDbType.Text).Value = g.Name;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
internal static Task UpdateGuildMemberAsync(SocketGuildUser user)
=> UpdateGuildMemberAsync(new SocketGuildUser[] { user });
internal static async Task UpdateGuildMemberAsync(IEnumerable<SocketGuildUser> users)
{
var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
if (db == null) return;
using (db)
{
using (var c = db.CreateCommand())
{
c.CommandText = "INSERT INTO " + TableUser
+ " (user_id, guild_id, cache_date, username, discriminator, nickname, avatar_url)"
+ " VALUES (@Uid, @Gid, now(), @Uname, @Disc, @Nname, @Url) "
+ "ON CONFLICT (user_id, guild_id) DO UPDATE SET "
+ "cache_date = EXCLUDED.cache_date, username = EXCLUDED.username, "
+ "discriminator = EXCLUDED.discriminator, " // I've seen someone's discriminator change this one time...
+ "nickname = EXCLUDED.nickname, avatar_url = EXCLUDED.avatar_url";
var uid = c.Parameters.Add("@Uid", NpgsqlDbType.Bigint);
var gid = c.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
var uname = c.Parameters.Add("@Uname", NpgsqlDbType.Text);
var disc = c.Parameters.Add("@Disc", NpgsqlDbType.Text);
var nname = c.Parameters.Add("@Nname", NpgsqlDbType.Text);
var url = c.Parameters.Add("@Url", NpgsqlDbType.Text);
c.Prepare();
foreach (var item in users)
{
if (item.IsWebhook) continue;
uid.Value = (long)item.Id;
gid.Value = (long)item.Guild.Id;
uname.Value = item.Username;
disc.Value = item.Discriminator;
nname.Value = item.Nickname;
if (nname.Value == null) nname.Value = DBNull.Value; // why can't ?? work here?
url.Value = item.GetAvatarUrl();
if (url.Value == null) url.Value = DBNull.Value;
await c.ExecuteNonQueryAsync();
}
}
}
}
internal static Task UpdateGuildChannelAsync(SocketGuildChannel channel)
=> UpdateGuildChannelAsync(new SocketGuildChannel[] { channel });
internal static async Task UpdateGuildChannelAsync(IEnumerable<SocketGuildChannel> channels)
{
var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
if (db == null) return;
using (db)
{
using (var c = db.CreateCommand())
{
c.CommandText = "INSERT INTO " + TableTextChannel
+ " (channel_id, guild_id, cache_date, channel_name)"
+ " VALUES (@Cid, @Gid, now(), @Name) "
+ "ON CONFLICT (channel_id, guild_id) DO UPDATE SET "
+ "cache_date = EXCLUDED.cache_date, channel_name = EXCLUDED.channel_name";
var cid = c.Parameters.Add("@Cid", NpgsqlDbType.Bigint);
var gid = c.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
var cname = c.Parameters.Add("@Name", NpgsqlDbType.Text);
c.Prepare();
foreach (var item in channels)
{
if (!(item is ITextChannel ich)) continue;
cid.Value = (long)item.Id;
gid.Value = (long)item.Guild.Id;
cname.Value = item.Name;
await c.ExecuteNonQueryAsync();
}
}
}
}
#endregion
}
}

View file

@ -1,83 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Noikoio.RegexBot
{
/// <summary>
/// Logging helper class. Receives logging messages and handles them accordingly.
/// </summary>
class Logger
{
private static Logger _instance;
private readonly string _logBasePath;
private bool _fileLogEnabled;
private static readonly object FileLogLock = new object();
/// <summary>
/// Gets if the instance is logging all messages to a file.
/// </summary>
public bool FileLoggingEnabled => _fileLogEnabled;
private Logger()
{
// top level - determine path to use for logging and see if it's usable
var dc = Path.DirectorySeparatorChar;
_logBasePath = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location) + dc + "logs";
try
{
if (!Directory.Exists(_logBasePath)) Directory.CreateDirectory(_logBasePath);
_fileLogEnabled = true;
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
Console.Error.WriteLine("Unable to create log directory. File logging disabled.");
_fileLogEnabled = false;
}
}
/// <summary>
/// Requests a delegate to be used for sending log messages.
/// </summary>
/// <param name="prefix">String used to prefix log messages sent using the given delegate.</param>
/// <returns></returns>
public static AsyncLogger GetLogger(string prefix)
{
if (_instance == null) _instance = new Logger();
return (async delegate (string line) { await _instance.ProcessLog(prefix, line); });
}
protected Task ProcessLog(string source, string input)
{
var timestamp = DateTime.Now;
string filename = _logBasePath + Path.DirectorySeparatorChar + $"{timestamp:yyyy-MM}.log";
List<string> result = new List<string>();
foreach (var line in Regex.Split(input, "\r\n|\r|\n"))
{
string finalLine = $"{timestamp:u} [{source}] {line}";
result.Add(finalLine);
Console.WriteLine(finalLine);
}
if (FileLoggingEnabled)
{
try
{
lock (FileLogLock) File.AppendAllLines(filename, result);
}
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
{
Console.Error.WriteLine("Unable to write to log file. File logging disabled.");
_fileLogEnabled = false;
}
}
return Task.CompletedTask;
}
}
public delegate Task AsyncLogger(string prefix);
}

View file

@ -1,94 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.AutoMod
{
/// <summary>
/// Implements per-message regex matching and executes customizable responses.
/// The name RegexBot comes from the existence of this feature.
/// </summary>
/// <remarks>
/// Strictly for use as a moderation tool only. Triggers that simply reply to messages
/// should be implemented using <see cref="AutoRespond"/>.
/// </remarks>
class AutoMod : BotModule
{
public AutoMod(DiscordSocketClient client) : base(client)
{
client.MessageReceived += CMessageReceived;
client.MessageUpdated += CMessageUpdated;
}
public override async Task<object> CreateInstanceState(JToken configSection)
{
if (configSection == null) return null;
List<ConfigItem> rules = new List<ConfigItem>();
foreach (var def in configSection.Children<JProperty>())
{
string label = def.Name;
var rule = new ConfigItem(this, def);
rules.Add(rule);
}
if (rules.Count > 0)
await Log($"Loaded {rules.Count} rule(s) from configuration.");
return rules.AsReadOnly();
}
private async Task CMessageReceived(SocketMessage arg)
=> await ReceiveMessage(arg);
private async Task CMessageUpdated(Discord.Cacheable<Discord.IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3)
=> await ReceiveMessage(arg2);
/// <summary>
/// Does initial message checking before sending to further processing.
/// </summary>
private async Task ReceiveMessage(SocketMessage m)
{
// Determine if incoming channel is in a guild
var ch = m.Channel as SocketGuildChannel;
if (ch == null) return;
// Get rules
var rules = GetState<IEnumerable<ConfigItem>>(ch.Guild.Id);
if (rules == null) return;
foreach (var rule in rules)
{
// Checking for mod bypass here (ConfigItem.Match isn't able to access mod list)
bool isMod = IsModerator(ch.Guild.Id, m);
await Task.Run(async () => await ProcessMessage(m, rule, isMod));
}
}
/// <summary>
/// Checks if the incoming message matches the given rule, and executes responses if necessary.
/// </summary>
private async Task ProcessMessage(SocketMessage m, ConfigItem r, bool isMod)
{
if (!r.Match(m, isMod)) return;
// TODO make log optional; configurable
await Log($"{r} triggered by {m.Author} in {((SocketGuildChannel)m.Channel).Guild.Name}/#{m.Channel.Name}");
foreach (ResponseBase resp in r.Response)
{
try
{
await resp.Invoke(m);
}
catch (Exception ex)
{
await Log($"Encountered an error while processing '{resp.CmdArg0}'. Details follow:");
await Log(ex.ToString());
}
}
}
public new Task Log(string text) => base.Log(text);
public new DiscordSocketClient Client => base.Client;
}
}

View file

@ -1,213 +0,0 @@
using Discord;
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.AutoMod
{
/// <summary>
/// Representation of a single AutoMod rule.
/// Data stored within cannot be edited.
/// </summary>
class ConfigItem
{
readonly AutoMod _instance;
readonly string _label;
readonly IEnumerable<Regex> _regex;
readonly ICollection<ResponseBase> _responses;
readonly FilterList _filter;
readonly int _msgMinLength;
readonly int _msgMaxLength;
readonly bool _modBypass;
readonly bool _embedMode;
public string Label => _label;
public IEnumerable<Regex> Regex => _regex;
public ICollection<ResponseBase> Response => _responses;
public FilterList Filter => _filter;
public (int?, int?) MatchLengthMinMaxLimit => (_msgMinLength, _msgMaxLength);
public bool AllowsModBypass => _modBypass;
public bool MatchEmbed => _embedMode;
public DiscordSocketClient Discord => _instance.Client;
public Func<string, Task> Logger => _instance.Log;
/// <summary>
/// Creates a new Rule instance to represent the given configuration.
/// </summary>
public ConfigItem(AutoMod instance, JProperty definition)
{
_instance = instance;
_label = definition.Name;
var ruleconf = (JObject)definition.Value;
// TODO validation. does the above line even throw an exception in the right cases?
// and what about the label? does it make for a good name?
string errpfx = $" in definition for rule '{_label}'.";
// regex options
RegexOptions opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
// TODO consider adding an option to specify Singleline and Multiline matching
opts |= RegexOptions.Singleline;
// case sensitivity must be explicitly defined, else not case sensitive by default
bool? regexci = ruleconf["ignorecase"]?.Value<bool>();
opts |= RegexOptions.IgnoreCase;
if (regexci.HasValue && regexci.Value == false)
opts &= ~RegexOptions.IgnoreCase;
// regex
const string NoRegexError = "No regular expression patterns are defined";
var regexes = new List<Regex>();
var rxconf = ruleconf["regex"];
if (rxconf == null) throw new RuleImportException(NoRegexError + errpfx);
if (rxconf.Type == JTokenType.Array)
{
foreach (var input in rxconf.Values<string>())
{
try
{
Regex r = new Regex(input, opts);
regexes.Add(r);
}
catch (ArgumentException)
{
throw new RuleImportException(
$"Failed to parse regular expression pattern '{input}'{errpfx}");
}
}
}
else
{
string rxstr = rxconf.Value<string>();
try
{
Regex r = new Regex(rxstr, opts);
regexes.Add(r);
}
catch (Exception ex) when (ex is ArgumentException || ex is NullReferenceException)
{
throw new RuleImportException(
$"Failed to parse regular expression pattern '{rxstr}'{errpfx}");
}
}
if (regexes.Count == 0)
{
throw new RuleImportException(NoRegexError + errpfx);
}
_regex = regexes.ToArray();
// min/max length
try
{
_msgMinLength = ruleconf["min"]?.Value<int>() ?? -1;
_msgMaxLength = ruleconf["max"]?.Value<int>() ?? -1;
}
catch (FormatException)
{
throw new RuleImportException("Minimum/maximum values must be an integer.");
}
// responses
const string NoResponseError = "No responses have been defined";
var responsestrs = new List<string>();
var rsconf = ruleconf["response"];
if (rsconf == null) throw new RuleImportException(NoResponseError + errpfx);
try
{
if (rsconf.Type == JTokenType.Array)
{
_responses = ResponseBase.ReadConfiguration(this, rsconf.Values<string>());
}
else
{
_responses = ResponseBase.ReadConfiguration(this, new string[] { rsconf.Value<string>() });
}
}
catch (RuleImportException ex)
{
throw new RuleImportException(ex.Message + errpfx);
}
// whitelist/blacklist filtering
_filter = new FilterList(ruleconf);
// moderator bypass toggle - true by default, must be explicitly set to false
bool? bypass = ruleconf["AllowModBypass"]?.Value<bool>();
_modBypass = bypass.HasValue ? bypass.Value : true;
// embed matching mode
bool? embed = ruleconf["MatchEmbeds"]?.Value<bool>();
_embedMode = (embed.HasValue && embed == true);
}
/// <summary>
/// Checks given message to see if it matches this rule's constraints.
/// </summary>
/// <returns>If true, the rule's response(s) should be executed.</returns>
public bool Match(SocketMessage m, bool isMod)
{
// Regular or embed mode?
string msgcontent;
if (MatchEmbed) msgcontent = SerializeEmbed(m.Embeds);
else msgcontent = m.Content;
if (msgcontent == null) return false;
// Min/max length check
if (_msgMinLength != -1 && msgcontent.Length <= _msgMinLength) return false;
if (_msgMaxLength != -1 && msgcontent.Length >= _msgMaxLength) return false;
// Filter check
if (Filter.IsFiltered(m)) return false;
// Mod bypass check
if (AllowsModBypass && isMod) return false;
// Finally, regex checks
foreach (var regex in Regex)
{
if (regex.IsMatch(msgcontent)) return true;
}
return false;
}
private string SerializeEmbed(IReadOnlyCollection<Embed> e)
{
var text = new StringBuilder();
foreach (var item in e) text.AppendLine(SerializeEmbed(item));
return text.ToString();
}
/// <summary>
/// Converts an embed to a plain string for easier matching.
/// </summary>
private string SerializeEmbed(Embed e)
{
StringBuilder result = new StringBuilder();
if (e.Author.HasValue) result.AppendLine(e.Author.Value.Name ?? "" + e.Author.Value.Url ?? "");
if (!string.IsNullOrWhiteSpace(e.Title)) result.AppendLine(e.Title);
if (!string.IsNullOrWhiteSpace(e.Description)) result.AppendLine(e.Description);
foreach (var f in e.Fields)
{
if (!string.IsNullOrWhiteSpace(f.Name)) result.AppendLine(f.Name);
if (!string.IsNullOrWhiteSpace(f.Value)) result.AppendLine(f.Value);
}
if (e.Footer.HasValue)
{
result.AppendLine(e.Footer.Value.Text ?? "");
}
return result.ToString();
}
public override string ToString() => $"Rule '{Label}'";
}
}

View file

@ -1,171 +0,0 @@
using Discord;
using Discord.WebSocket;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.AutoMod
{
/// <summary>
/// Base class for all Response classes.
/// Contains helper methods for use by response code.
/// </summary>
[DebuggerDisplay("Response: {_cmdline}")]
abstract class ResponseBase
{
private readonly ConfigItem _rule;
private readonly string _cmdline;
protected ConfigItem Rule => _rule;
private DiscordSocketClient Client => _rule.Discord;
public string CmdLine => _cmdline;
public string CmdArg0 {
get {
int i = _cmdline.IndexOf(' ');
if (i != -1) return _cmdline.Substring(0, i);
return _cmdline;
}
}
/// <summary>
/// Deriving constructor should do validation of incoming <paramref name="cmdline"/>.
/// </summary>
public ResponseBase(ConfigItem rule, string cmdline)
{
_rule = rule;
_cmdline = cmdline;
}
public abstract Task Invoke(SocketMessage msg);
protected async Task Log(string text)
{
int dl = _cmdline.IndexOf(' ');
var prefix = _cmdline.Substring(0, dl);
await Rule.Logger(prefix + ": " + text);
}
#region Config loading
private static readonly ReadOnlyDictionary<string, Type> _commands =
new ReadOnlyDictionary<string, Type>(
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
// Define all accepted commands and their corresponding types here
{ "ban", typeof(Responses.Ban) },
{ "kick", typeof(Responses.Kick) },
{ "say", typeof(Responses.Say) },
{ "send", typeof(Responses.Say) },
{ "delete", typeof(Responses.Remove) },
{ "remove", typeof(Responses.Remove) },
{ "report", typeof(Responses.Report) },
{ "addrole", typeof(Responses.RoleManipulation) },
{ "grantrole", typeof(Responses.RoleManipulation) },
{ "delrole", typeof(Responses.RoleManipulation) },
{ "removerole", typeof(Responses.RoleManipulation) },
{ "revokerole", typeof(Responses.RoleManipulation) }
});
public static ResponseBase[] ReadConfiguration(ConfigItem r, IEnumerable<string> responses)
{
var result = new List<ResponseBase>();
foreach (var line in responses)
{
if (string.IsNullOrWhiteSpace(line))
throw new RuleImportException("Empty response line");
int i = line.IndexOf(' ');
string basecmd;
if (i != -1) basecmd = line.Substring(0, i);
else basecmd = line;
Type rt;
if (!_commands.TryGetValue(basecmd, out rt))
throw new RuleImportException($"'{basecmd}' is not a valid response");
var newresponse = Activator.CreateInstance(rt, r, line) as ResponseBase;
if (newresponse == null)
throw new Exception("An unknown error occurred when attempting to create a new Response object.");
result.Add(newresponse);
}
return result.ToArray();
}
#endregion
#region Helper methods
/// <summary>
/// Receives a string (beginning with @ or #) and returns an object
/// suitable for sending out messages
/// </summary>
protected async Task<IMessageChannel> GetMessageTargetAsync(string targetName, SocketMessage m)
{
const string AEShort = "Target name is too short.";
EntityType et;
if (targetName.Length <= 1) throw new ArgumentException(AEShort);
if (targetName[0] == '#') et = EntityType.Channel;
else if (targetName[0] == '@') et = EntityType.User;
else throw new ArgumentException("Target is not specified to be either a channel or user.");
targetName = targetName.Substring(1);
if (targetName == "_")
{
if (et == EntityType.Channel) return m.Channel;
else return await m.Author.CreateDMChannelAsync();
}
EntityName ei = new EntityName(targetName, et);
SocketGuild g = ((SocketGuildUser)m.Author).Guild;
if (et == EntityType.Channel)
{
if (targetName.Length < 2 || targetName.Length > 100)
throw new ArgumentException(AEShort);
foreach (var ch in g.TextChannels)
{
if (ei.Id.HasValue)
{
if (ei.Id.Value == ch.Id) return ch;
}
else
{
if (string.Equals(ei.Name, ch.Name, StringComparison.OrdinalIgnoreCase)) return ch;
}
}
}
else
{
if (ei.Id.HasValue)
{
// The easy way
return await Client.GetUser(ei.Id.Value).CreateDMChannelAsync();
}
// The hard way
foreach (var u in g.Users)
{
if (string.Equals(ei.Name, u.Username, StringComparison.OrdinalIgnoreCase) ||
string.Equals(ei.Name, u.Nickname, StringComparison.OrdinalIgnoreCase))
{
return await u.CreateDMChannelAsync();
}
}
}
return null;
}
protected string ProcessText(string input, SocketMessage m)
{
// Maybe in the future this will do more.
// For now, replaces all instances of @_ with the message sender.
return input
.Replace("@_", m.Author.Mention)
.Replace("@\\_", "@_");
}
#endregion
}
}

View file

@ -1,49 +0,0 @@
using Discord.WebSocket;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.AutoMod.Responses
{
/// <summary>
/// Bans the invoking user.
/// Parameters: ban [days = 0]
/// </summary>
class Ban : ResponseBase
{
readonly int _purgeDays;
public Ban(ConfigItem rule, string cmdline) : base(rule, cmdline)
{
var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (line.Length == 1)
{
_purgeDays = 0;
}
else if (line.Length == 2)
{
if (int.TryParse(line[1], out _purgeDays))
{
if (_purgeDays < 0 || _purgeDays > 7)
{
throw new RuleImportException("Parameter must be an integer between 0 and 7.");
}
}
else
{
throw new RuleImportException("Parameter must be an integer between 0 and 7.");
}
}
else
{
throw new RuleImportException("Incorrect number of parameters.");
}
}
public override async Task Invoke(SocketMessage msg)
{
var target = (SocketGuildUser)msg.Author;
await target.Guild.AddBanAsync(target, _purgeDays, $"Rule '{Rule.Label}'");
}
}
}

View file

@ -1,27 +0,0 @@
using Discord.WebSocket;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.AutoMod.Responses
{
/// <summary>
/// Kicks the invoking user.
/// Takes no parameters.
/// </summary>
class Kick : ResponseBase
{
public Kick(ConfigItem rule, string cmdline) : base(rule, cmdline)
{
// Throw exception if extra parameters found
if (cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Length > 1)
throw new RuleImportException("Incorrect number of parameters.");
}
public override async Task Invoke(SocketMessage msg)
{
var target = (SocketGuildUser)msg.Author;
await target.KickAsync($"Rule '{Rule.Label}'");
}
}
}

View file

@ -1,23 +0,0 @@
using Discord.WebSocket;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.AutoMod.Responses
{
/// <summary>
/// Removes the invoking message.
/// Takes no parameters.
/// </summary>
class Remove : ResponseBase
{
public Remove(ConfigItem rule, string cmdline) : base(rule, cmdline)
{
// Throw exception if extra parameters found
if (cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Length > 1)
throw new RuleImportException("Incorrect number of parameters.");
}
public override Task Invoke(SocketMessage msg) => msg.DeleteAsync();
}
}

View file

@ -1,110 +0,0 @@
using Discord;
using Discord.WebSocket;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Text;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.AutoMod.Responses
{
/// <summary>
/// Sends a summary of the invoking message, along with information
/// about the rule making use of this command, to the given target.
/// Parameters: report (target)
/// </summary>
class Report : ResponseBase
{
readonly string _target;
public Report(ConfigItem rule, string cmdline) : base(rule, cmdline)
{
var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (line.Length != 2) throw new RuleImportException("Incorrect number of parameters");
_target = line[1];
}
public override async Task Invoke(SocketMessage msg)
{
var target = await GetMessageTargetAsync(_target, msg);
if (target == null)
{
await Log("Error: Unable to resolve the given target.");
}
await target.SendMessageAsync("", embed: BuildReportEmbed(msg));
}
private Embed BuildReportEmbed(SocketMessage msg)
{
string invokeLine = msg.Content;
// Discord has a 2000 character limit per single message.
// Priority is to show as much of the offending line as possible, to a point.
const int DescriptionLengthMax = 1700; // leaving 300 buffer for embed formatting data
bool showResponseBody = true;
if (invokeLine.Length > DescriptionLengthMax)
{
// Do not attempt to show response body.
showResponseBody = false;
invokeLine = $"**Message length too long; showing first {DescriptionLengthMax} characters.**\n\n"
+ invokeLine.Substring(0, DescriptionLengthMax);
}
string responsebody = null;
if (showResponseBody)
{
// Write a summary of responses defined
var frb = new StringBuilder();
foreach (var item in Rule.Response)
{
frb.AppendLine("`" + item.CmdLine.Replace("\r", "").Replace("\n", "\\n") + "`");
}
responsebody = frb.ToString();
if (invokeLine.Length + responsebody.Length > DescriptionLengthMax)
{
// Still can't do it, so just don't.
responsebody = null;
}
}
var finalem = new EmbedBuilder()
{
Color = new Color(0xEDCE00), // configurable later?
Author = new EmbedAuthorBuilder()
{
Name = $"{msg.Author.Username}#{msg.Author.Discriminator} said:",
IconUrl = msg.Author.GetAvatarUrl()
},
Description = invokeLine,
Footer = new EmbedFooterBuilder()
{
Text = $"Rule '{Rule.Label}'",
IconUrl = Rule.Discord.CurrentUser.GetAvatarUrl()
},
Timestamp = msg.EditedTimestamp ?? msg.Timestamp
}.AddField(new EmbedFieldBuilder()
{
Name = "Context",
Value = $"Username: {msg.Author.Mention}\n"
+ $"Channel: <#{msg.Channel.Id}> #{msg.Channel.Name}\n"
+ $"Message ID: {msg.Id}"
});
if (responsebody != null)
{
finalem = finalem.AddField(new EmbedFieldBuilder()
{
Name = "Response:",
Value = responsebody.ToString()
});
}
return finalem.Build();
}
}
}

View file

@ -1,89 +0,0 @@
using Discord.WebSocket;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.AutoMod.Responses
{
/// <summary>
/// Manipulates a given user's role.
/// Parameters: (command) (target) (role ID)
/// </summary>
class RoleManipulation : ResponseBase
{
enum ManipulationType { None, Add, Remove }
readonly ManipulationType _action;
readonly string _target;
readonly EntityName _role;
public RoleManipulation(ConfigItem rule, string cmdline) : base(rule, cmdline)
{
var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (line.Length != 3)
throw new RuleImportException("Incorrect number of parameters.");
// Ensure the strings here match those in Response._commands
switch (line[0].ToLowerInvariant())
{
case "addrole":
case "grantrole":
_action = ManipulationType.Add;
break;
case "delrole":
case "removerole":
case "revokerole":
_action = ManipulationType.Remove;
break;
default:
_action = ManipulationType.None;
break;
}
if (_action == ManipulationType.None)
throw new RuleImportException("Command not defined. This is a bug.");
_target = line[1];
_role = new EntityName(line[2], EntityType.Role);
}
public override async Task Invoke(SocketMessage msg)
{
// Find role
SocketRole rtarget;
var g = ((SocketGuildUser)msg.Author).Guild;
if (_role.Id.HasValue) rtarget = g.GetRole(_role.Id.Value);
else rtarget = g.Roles.FirstOrDefault(r =>
string.Equals(r.Name, _role.Name, StringComparison.OrdinalIgnoreCase));
if (rtarget == null)
{
await Log("Error: Target role not found in server.");
return;
}
// Find user
SocketGuildUser utarget;
if (_target == "@_") utarget = (SocketGuildUser)msg.Author;
else
{
utarget = g.Users.FirstOrDefault(u =>
{
if (string.Equals(u.Nickname, _target, StringComparison.OrdinalIgnoreCase)) return true;
if (string.Equals(u.Username, _target, StringComparison.OrdinalIgnoreCase)) return true;
return false;
});
}
if (utarget == null)
{
await Log("Error: Target user not found in server.");
return;
}
// Do action
if (_action == ManipulationType.Add)
await utarget.AddRoleAsync(rtarget);
else if (_action == ManipulationType.Remove)
await utarget.RemoveRoleAsync(rtarget);
}
}
}

View file

@ -1,45 +0,0 @@
using Discord.WebSocket;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.AutoMod.Responses
{
/// <summary>
/// Sends a message to the given target.
/// Parameters: say (target) (message)
/// </summary>
class Say : ResponseBase
{
private readonly string _target;
private readonly string _payload;
public Say(ConfigItem rule, string cmdline) : base(rule, cmdline)
{
var line = cmdline.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
if (line.Length != 3) throw new RuleImportException("Incorrect number of parameters.");
// Very basic target verification. Could be improved?
if (line[1][0] != '@' && line[1][0] != '#')
throw new RuleImportException("The given target is not valid.");
_target = line[1];
_payload = line[2];
if (string.IsNullOrWhiteSpace(_payload))
throw new RuleImportException("Message parameter is blank or missing.");
}
public override async Task Invoke(SocketMessage msg)
{
// 
string reply = ProcessText(_payload, msg);
var target = await GetMessageTargetAsync(_target, msg);
if (target == null)
{
await Log("Error: Unable to resolve the given target.");
}
await target.SendMessageAsync(reply);
}
}
}

View file

@ -1,109 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.AutoRespond
{
/// <summary>
/// Similar to <see cref="AutoMod"/>, but lightweight.
/// Provides the capability to define autoresponses for fun or informational purposes.
/// <para>
/// The major differences between this and <see cref="AutoMod"/> include:
/// <list type="bullet">
/// <item><description>Does not listen for message edits.</description></item>
/// <item><description>Moderators are not exempt from any defined triggers.</description></item>
/// <item><description>Responses are limited to the invoking channel.</description></item>
/// <item><description>Per-channel rate limiting.</description></item>
/// </list>
/// </para>
/// </summary>
partial class AutoRespond : BotModule
{
#region BotModule implementation
public AutoRespond(DiscordSocketClient client) : base(client)
{
client.MessageReceived += Client_MessageReceived;
}
private async Task Client_MessageReceived(SocketMessage arg)
{
// Determine channel type - if not a guild channel, stop.
var ch = arg.Channel as SocketGuildChannel;
if (ch == null) return;
var defs = GetState<IEnumerable<ConfigItem>>(ch.Guild.Id);
if (defs == null) return;
foreach (var def in defs)
await Task.Run(async () => await ProcessMessage(arg, def));
}
public override async Task<object> CreateInstanceState(JToken configSection)
{
if (configSection == null) return null;
var responses = new List<ConfigItem>();
foreach (var def in configSection.Children<JProperty>())
{
// All validation is left to the constructor
var resp = new ConfigItem(def);
responses.Add(resp);
}
if (responses.Count > 0)
await Log($"Loaded {responses.Count} definition(s) from configuration.");
return responses.AsReadOnly();
}
#endregion
private async Task ProcessMessage(SocketMessage msg, ConfigItem def)
{
if (!def.Match(msg)) return;
await Log($"'{def.Label}' triggered by {msg.Author} in {((SocketGuildChannel)msg.Channel).Guild.Name}/#{msg.Channel.Name}");
var (type, text) = def.Response;
if (type == ConfigItem.ResponseType.Reply) await ProcessReply(msg, text);
else if (type == ConfigItem.ResponseType.Exec) await ProcessExec(msg, text);
}
private async Task ProcessReply(SocketMessage msg, string text)
{
await msg.Channel.SendMessageAsync(text);
}
private async Task ProcessExec(SocketMessage msg, string text)
{
string[] cmdline = text.Split(new char[] { ' ' }, 2);
ProcessStartInfo ps = new ProcessStartInfo()
{
FileName = cmdline[0],
Arguments = (cmdline.Length == 2 ? cmdline[1] : ""),
UseShellExecute = false, // ???
CreateNoWindow = true,
RedirectStandardOutput = true
};
using (Process p = Process.Start(ps))
{
p.WaitForExit(5000); // waiting at most 5 seconds
if (p.HasExited)
{
if (p.ExitCode != 0) await Log("exec: Process returned exit code " + p.ExitCode);
using (var stdout = p.StandardOutput)
{
var result = await stdout.ReadToEndAsync();
await msg.Channel.SendMessageAsync(result);
}
}
else
{
await Log("exec: Process is taking too long to exit. Killing process.");
p.Kill();
return;
}
}
}
}
}

View file

@ -1,179 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace Noikoio.RegexBot.Module.AutoRespond
{
/// <summary>
/// Represents a single autoresponse definition.
/// </summary>
class ConfigItem
{
public enum ResponseType { None, Exec, Reply }
private static Random ChangeRng = new Random();
ResponseType _rtype;
string _rbody;
private double _random;
public string Label { get; }
public IEnumerable<Regex> Regex { get; }
public (ResponseType, string) Response => (_rtype, _rbody);
public FilterList Filter { get; }
public RateLimitCache RateLimit { get; }
public double RandomChance => _random;
public ConfigItem(JProperty definition)
{
Label = definition.Name;
var data = (JObject)definition.Value;
// error postfix string
string errorpfx = $" in response definition for '{Label}'.";
// regex trigger
const string NoRegexError = "No regular expression patterns are defined";
var regexes = new List<Regex>();
const RegexOptions rxopts = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline;
var rxconf = data["regex"];
if (rxconf == null) throw new RuleImportException(NoRegexError + errorpfx);
if (rxconf.Type == JTokenType.Array)
{
foreach (var input in rxconf.Values<string>())
{
try
{
Regex r = new Regex(input, rxopts);
regexes.Add(r);
}
catch (ArgumentException)
{
throw new RuleImportException(
$"Failed to parse regular expression pattern '{input}'{errorpfx}");
}
}
}
else
{
string rxstr = rxconf.Value<string>();
try
{
Regex r = new Regex(rxstr, rxopts);
regexes.Add(r);
}
catch (Exception ex) when (ex is ArgumentException || ex is NullReferenceException)
{
throw new RuleImportException(
$"Failed to parse regular expression pattern '{rxstr}'{errorpfx}");
}
}
Regex = regexes.ToArray();
// response - defined in either "exec" or "reply", but not both
_rbody = null;
_rtype = ResponseType.None;
// exec response
string execstr = data["exec"]?.Value<string>();
if (!string.IsNullOrWhiteSpace(execstr))
{
_rbody = execstr;
_rtype = ResponseType.Exec;
}
// reply response
string replystr = data["reply"]?.Value<string>();
if (!string.IsNullOrWhiteSpace(replystr))
{
if (_rbody != null)
throw new RuleImportException("A value for both 'exec' and 'reply' is not allowed" + errorpfx);
_rbody = replystr;
_rtype = ResponseType.Reply;
}
if (_rbody == null)
throw new RuleImportException("A response value of either 'exec' or 'reply' was not defined" + errorpfx);
// ---
// whitelist/blacklist filtering
Filter = new FilterList(data);
// rate limiting
string rlstr = data["ratelimit"]?.Value<string>();
if (string.IsNullOrWhiteSpace(rlstr))
{
RateLimit = new RateLimitCache(RateLimitCache.DefaultTimeout);
}
else
{
if (uint.TryParse(rlstr, out var rlval))
{
RateLimit = new RateLimitCache(rlval);
}
else
{
throw new RuleImportException("Rate limit value is invalid" + errorpfx);
}
}
// random chance
string randstr = data["RandomChance"]?.Value<string>();
if (string.IsNullOrWhiteSpace(randstr))
{
_random = double.NaN;
}
else
{
if (!double.TryParse(randstr, out _random))
{
throw new RuleImportException("Random value is invalid (unable to parse)" + errorpfx);
}
if (_random > 1 || _random < 0)
{
throw new RuleImportException("Random value is invalid (not between 0 and 1)" + errorpfx);
}
}
}
/// <summary>
/// Checks given message to see if it matches this rule's constraints.
/// </summary>
/// <returns>If true, the rule's response(s) should be executed.</returns>
public bool Match(SocketMessage m)
{
// Filter check
if (Filter.IsFiltered(m)) return false;
// Match check
bool matchFound = false;
foreach (var item in Regex)
{
if (item.IsMatch(m.Content))
{
matchFound = true;
break;
}
}
if (!matchFound) return false;
// Rate limit check - currently per channel
if (!RateLimit.AllowUsage(m.Channel.Id)) return false;
// Random chance check
if (!double.IsNaN(RandomChance))
{
// Fail if randomly generated value is higher than the parameter
// Example: To fail a 75% chance, the check value must be between 0.75000...001 and 1.0.
var chk = ChangeRng.NextDouble();
if (chk > RandomChance) return false;
}
return true;
}
public override string ToString() => $"Autoresponse definition '{Label}'";
}
}

View file

@ -1,59 +0,0 @@
using System;
using System.Collections.Generic;
namespace Noikoio.RegexBot.Module.AutoRespond
{
/// <summary>
/// Stores rate limit settings and caches.
/// </summary>
class RateLimitCache
{
public const ushort DefaultTimeout = 20; // this is Skeeter's fault
private Dictionary<ulong, DateTime> _cache;
public uint Timeout { get; }
/// <summary>
/// Sets up a new instance of <see cref="RateLimitCache"/>.
/// </summary>
public RateLimitCache(uint timeout)
{
Timeout = timeout;
_cache = new Dictionary<ulong, DateTime>();
}
/// <summary>
/// Checks if a "usage" is allowed for the given value.
/// Items added to cache will be removed after the number of seconds specified in <see cref="Timeout"/>.
/// </summary>
/// <param name="id">The ID to add to the cache.</param>
/// <returns>True on success. False if the given ID already exists.</returns>
public bool AllowUsage(ulong id)
{
if (Timeout == 0) return true;
lock (this)
{
Clean();
if (_cache.ContainsKey(id)) return false;
_cache.Add(id, DateTime.Now);
}
return true;
}
private void Clean()
{
var now = DateTime.Now;
var clean = new Dictionary<ulong, DateTime>();
foreach (var kp in _cache)
{
if (kp.Value.AddSeconds(Timeout) > now)
{
// Copy items that have not yet timed out to the new dictionary
clean.Add(kp.Key, kp.Value);
}
}
_cache = clean;
}
}
}

View file

@ -1,50 +0,0 @@
using Discord;
using Discord.WebSocket;
using System.Text;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.DMLogger
{
/// <summary>
/// Listens for and logs direct messages sent to the bot.
/// The function of this module should be transparent to the user, and thus no configuration is needed.
/// </summary>
class DMLogger : BotModule
{
public DMLogger(DiscordSocketClient client) : base(client)
{
client.MessageReceived += Client_MessageReceived;
client.MessageUpdated += Client_MessageUpdated;
}
private async Task Client_MessageReceived(SocketMessage arg)
{
if (!(arg.Channel is IDMChannel)) return;
if (arg.Author.IsBot) return;
await ProcessMessage(arg, false);
}
private async Task Client_MessageUpdated(Cacheable<IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3)
{
if (!(arg2.Channel is IDMChannel)) return;
if (arg2.Author.IsBot) return;
await ProcessMessage(arg2, true);
}
private async Task ProcessMessage(SocketMessage arg, bool edited)
{
var result = new StringBuilder();
result.Append(arg.Author.ToString() + (edited ? "(edit) " : "") + ": ");
if (!string.IsNullOrWhiteSpace(arg.Content))
{
if (arg.Content.Contains("\n")) result.AppendLine(); // If multi-line, show sender on separate line
result.AppendLine(arg.Content);
}
foreach (var i in arg.Attachments) result.AppendLine($"[Attachment: {i.Url}]");
await Log(result.ToString().TrimEnd(new char[] { ' ', '\r', '\n' }));
}
}
}

View file

@ -1,184 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.EntryAutoRole
{
/// <summary>
/// Automatically sets a specified role
/// </summary>
class EntryAutoRole : BotModule
{
private List<AutoRoleEntry> _roleWaitlist;
private object _roleWaitLock = new object();
// TODO make use of this later if/when some shutdown handler gets added
// (else it continues running in debug after the client has been disposed)
private readonly CancellationTokenSource _workerCancel;
// Config:
// Role: string - Name or ID of the role to apply. Takes EntityName format.
// WaitTime: number - Amount of time in seconds to wait until applying the role to a new user.
public EntryAutoRole(DiscordSocketClient client) : base(client)
{
client.GuildAvailable += Client_GuildAvailable;
client.UserJoined += Client_UserJoined;
client.UserLeft += Client_UserLeft;
_roleWaitlist = new List<AutoRoleEntry>();
_workerCancel = new CancellationTokenSource();
Task.Factory.StartNew(Worker, _workerCancel.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
public override Task<object> CreateInstanceState(JToken configSection)
{
if (configSection == null) return Task.FromResult<object>(null);
if (configSection.Type != JTokenType.Object)
{
throw new RuleImportException("Configuration for this section is invalid.");
}
return Task.FromResult<object>(new ModuleConfig((JObject)configSection));
}
private Task Client_GuildAvailable(SocketGuild arg)
{
var conf = GetState<ModuleConfig>(arg.Id);
if (conf == null) return Task.CompletedTask;
SocketRole trole = GetRole(arg);
if (trole == null) return Task.CompletedTask;
lock (_roleWaitLock)
foreach (var item in arg.Users)
{
if (item.IsBot) continue;
if (item.IsWebhook) continue;
if (item.Roles.Contains(trole)) continue;
_roleWaitlist.Add(new AutoRoleEntry()
{
GuildId = arg.Id,
UserId = item.Id,
ExpireTime = DateTimeOffset.UtcNow.AddSeconds(conf.TimeDelay)
});
}
return Task.CompletedTask;
}
private Task Client_UserLeft(SocketGuild guild, SocketUser user)
{
if (GetState<object>(guild.Id) == null) return Task.CompletedTask;
lock (_roleWaitLock) _roleWaitlist.RemoveAll(m => m.GuildId == guild.Id && m.UserId == user.Id);
return Task.CompletedTask;
}
private Task Client_UserJoined(SocketGuildUser arg)
{
if (GetState<object>(arg.Guild.Id) == null) return Task.CompletedTask;
lock (_roleWaitLock) _roleWaitlist.Add(new AutoRoleEntry()
{
GuildId = arg.Guild.Id,
UserId = arg.Id,
ExpireTime = DateTimeOffset.UtcNow.AddSeconds((GetState<ModuleConfig>(arg.Guild.Id)).TimeDelay)
});
return Task.CompletedTask;
}
// can return null
private SocketRole GetRole(SocketGuild g)
{
var conf = GetState<ModuleConfig>(g.Id);
if (conf == null) return null;
var roleInfo = conf.Role;
if (roleInfo.Id.HasValue)
{
var result = g.GetRole(roleInfo.Id.Value);
if (result != null) return result;
}
else
{
foreach (var role in g.Roles)
if (string.Equals(roleInfo.Name, role.Name)) return role;
}
Log("Unable to find role in " + g.Name).Wait();
return null;
}
struct AutoRoleEntry
{
public ulong GuildId;
public ulong UserId;
public DateTimeOffset ExpireTime;
}
async Task Worker()
{
while (!_workerCancel.IsCancellationRequested)
{
await Task.Delay(5000);
AutoRoleEntry[] jobsList;
lock (_roleWaitLock)
{
var chk = DateTimeOffset.UtcNow;
// Attempt to avoid throttling: only 3 per run are processed
var jobs = _roleWaitlist.Where(i => chk > i.ExpireTime).Take(3);
jobsList = jobs.ToArray(); // force evaluation
// remove selected entries from current list
foreach (var item in jobsList)
{
_roleWaitlist.Remove(item);
}
}
// Temporary SocketRole cache. key = guild ID
Dictionary<ulong, SocketRole> cr = new Dictionary<ulong, SocketRole>();
foreach (var item in jobsList)
{
if (_workerCancel.IsCancellationRequested) return;
// do we have the guild?
var g = Client.GetGuild(item.GuildId);
if (g == null) continue; // bot probably left the guild
// do we have the user?
var u = g.GetUser(item.UserId);
if (u == null) continue; // user is probably gone
// do we have the role?
SocketRole r;
if (!cr.TryGetValue(g.Id, out r))
{
r = GetRole(g);
if (r != null) cr[g.Id] = r;
}
if (r == null)
{
await Log($"Skipping {g.Name}/{u.ToString()}");
await Log("Was the role renamed or deleted?");
}
// do the work
try
{
await u.AddRoleAsync(r);
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
await Log($"WARNING: Cannot set roles! Skipping {g.Name}/{u.ToString()}");
}
}
}
}
}
}
}

View file

@ -1,35 +0,0 @@
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
namespace Noikoio.RegexBot.Module.EntryAutoRole
{
class ModuleConfig
{
private EntityName _cfgRole;
private int _cfgTime;
public EntityName Role => _cfgRole;
public int TimeDelay => _cfgTime;
public ModuleConfig(JObject conf)
{
var cfgRole = conf["Role"]?.Value<string>();
if (string.IsNullOrWhiteSpace(cfgRole))
throw new RuleImportException("Role was not specified.");
_cfgRole = new EntityName(cfgRole, EntityType.Role);
var inTime = conf["WaitTime"]?.Value<string>();
if (string.IsNullOrWhiteSpace(inTime))
throw new RuleImportException("WaitTime was not specified.");
if (!int.TryParse(inTime, out _cfgTime))
{
throw new RuleImportException("WaitTime must be a numeric value.");
}
if (_cfgTime < 0)
{
throw new RuleImportException("WaitTime must be a positive integer.");
}
}
}
}

View file

@ -1,228 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModCommands.Commands
{
// Ban and kick commands are highly similar in implementation, and thus are handled in a single class.
class BanKick : Command
{
protected enum CommandMode { Ban, Kick }
private readonly CommandMode _mode;
private readonly bool _forceReason;
private readonly int _purgeDays;
private readonly string _successMsg;
private readonly string _notifyMsg;
// Configuration:
// "forcereason" - boolean; Force a reason to be given. Defaults to false.
// "purgedays" - integer; Number of days of target's post history to delete, if banning.
// Must be between 0-7 inclusive. Defaults to 0.
// "successmsg" - Message to display on command success. Overrides default.
// "notifymsg" - Message to send to the target user being acted upon. Default message is used
// if the value is not specified. If a blank value is given, the feature is disabled.
// Takes the special values $s for server name and $r for reason text.
protected BanKick(ModCommands l, string label, JObject conf, CommandMode mode) : base(l, label, conf)
{
_mode = mode;
_forceReason = conf["forcereason"]?.Value<bool>() ?? false;
_purgeDays = conf["purgedays"]?.Value<int>() ?? 0;
if (_mode == CommandMode.Ban && (_purgeDays > 7 || _purgeDays < 0))
{
throw new RuleImportException("The value of 'purgedays' must be between 0 and 7.");
}
_successMsg = conf["successmsg"]?.Value<string>();
if (conf["notifymsg"] == null)
{
// Message not specified - use default
_notifyMsg = string.Format(NotifyDefault, mode == CommandMode.Ban ? "banned" : "kicked");
}
else
{
string val = conf["notifymsg"].Value<string>();
if (string.IsNullOrWhiteSpace(val)) _notifyMsg = null; // empty value - disable message
else _notifyMsg = val;
}
// Building usage message here
DefaultUsageMsg = $"{this.Trigger} [user or user ID] " + (_forceReason ? "[reason]" : "*[reason]*") + "\n"
+ "Removes the given user from this server"
+ (_mode == CommandMode.Ban ? " and prevents the user from rejoining" : "") + ". "
+ (_forceReason ? "L" : "Optionally l") + "ogs the reason for the "
+ (_mode == CommandMode.Ban ? "ban" : "kick") + " to the Audit Log.";
if (_purgeDays > 0)
DefaultUsageMsg += $"\nAdditionally removes the user's post history for the last {_purgeDays} day(s).";
}
#region Strings
const string FailPrefix = ":x: **Failed to {0} user:** ";
const string Fail404 = "The specified user is no longer in the server.";
const string NotifyDefault = "You have been {0} from $s for the following reason:\n$r";
const string NotifyReasonNone = "No reason specified.";
const string NotifyFailed = "\n(User was unable to receive notification message.)";
const string ReasonRequired = ":x: **You must specify a reason.**";
const string TargetNotFound = ":x: **Unable to determine the target user.**";
#endregion
// Usage: (command) (mention) (reason)
public override async Task Invoke(SocketGuild g, SocketMessage msg)
{
string[] line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
string targetstr;
string reason;
if (line.Length < 2)
{
await SendUsageMessageAsync(msg.Channel, null);
return;
}
targetstr = line[1];
if (line.Length == 3)
{
// Reason given - keep it
reason = line[2];
}
else
{
// No reason given
if (_forceReason)
{
await SendUsageMessageAsync(msg.Channel, ReasonRequired);
return;
}
reason = null;
}
// Retrieve target user
var (targetId, targetData) = await GetUserDataFromString(g.Id, targetstr);
if (targetId == 1)
{
await msg.Channel.SendMessageAsync(
string.Format(FailPrefix, (_mode == CommandMode.Ban ? "ban" : "kick")) + FailDefault);
return;
}
if (targetId == 0)
{
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
SocketGuildUser targetobj = g.GetUser(targetId);
string targetdisp;
if (targetData != null)
targetdisp = $"{targetData.Username}#{targetData.Discriminator}";
else
targetdisp = $"ID {targetId}";
if (_mode == CommandMode.Kick && targetobj == null)
{
// Can't kick without obtaining the user object
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
// Checks for existing (found) users:
if (targetobj != null)
{
// Bot check
if (targetobj.IsBot)
{
await SendUsageMessageAsync(msg.Channel, ":x: I will not do that. Please kick bots manually.");
return;
}
// Hierarchy check
if ((msg.Author as SocketGuildUser).Hierarchy <= targetobj.Hierarchy)
{
// Block kick attempts if the invoking user is at or above the target in role hierarchy
await SendUsageMessageAsync(msg.Channel, ":x: You are not allowed to kick this user.");
return;
}
}
// Send out message
var notifyTask = SendNotificationMessage(targetobj, reason);
// Do the action
try
{
string reasonlog = $"Invoked by {msg.Author}.";
if (reason != null) reasonlog += $" Reason: {reason}";
await notifyTask;
#if !DEBUG
if (_mode == CommandMode.Ban) await g.AddBanAsync(targetId, _purgeDays, reasonlog);
else await targetobj.KickAsync(reasonlog);
#else
#warning "Actual kick/ban action is DISABLED during debug."
#endif
string resultmsg = BuildSuccessMessage(targetdisp);
if (notifyTask.Result == false) resultmsg += NotifyFailed;
await msg.Channel.SendMessageAsync(resultmsg);
}
catch (Discord.Net.HttpException ex)
{
string err = string.Format(FailPrefix, (_mode == CommandMode.Ban ? "ban" : "kick"));
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
await msg.Channel.SendMessageAsync(err + Fail403);
}
else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound)
{
await msg.Channel.SendMessageAsync(err + Fail404);
}
else
{
await msg.Channel.SendMessageAsync(err + FailDefault);
await Log(ex.ToString());
}
}
}
// Returns true on message send success
private async Task<bool> SendNotificationMessage(SocketGuildUser target, string reason)
{
if (_notifyMsg == null) return true;
if (target == null) return false;
var ch = await target.CreateDMChannelAsync();
string outresult = _notifyMsg;
outresult = outresult.Replace("$s", target.Guild.Name);
outresult = outresult.Replace("$r", reason ?? NotifyReasonNone);
try
{
await ch.SendMessageAsync(outresult);
}
catch (Discord.Net.HttpException ex)
{
await Log("Failed to send out notification to target over DM: "
+ Enum.GetName(typeof(System.Net.HttpStatusCode), ex.HttpCode));
return false;
}
return true;
}
private string BuildSuccessMessage(string targetstr)
{
const string defaultmsgBan = ":white_check_mark: Banned user **$target**.";
const string defaultmsgKick = ":white_check_mark: Kicked user **$target**.";
string msg = _successMsg ?? (_mode == CommandMode.Ban ? defaultmsgBan : defaultmsgKick);
return msg.Replace("$target", targetstr);
}
}
class Ban : BanKick
{
public Ban(ModCommands l, string label, JObject conf)
: base(l, label, conf, CommandMode.Ban) { }
}
class Kick : BanKick
{
public Kick(ModCommands l, string label, JObject conf)
: base(l, label, conf, CommandMode.Kick) { }
}
}

View file

@ -1,25 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModCommands.Commands
{
class ConfReload : Command
{
// No configuration.
public ConfReload(ModCommands l, string label, JObject conf) : base(l, label, conf) { }
// Usage: (command)
public override async Task Invoke(SocketGuild g, SocketMessage msg)
{
bool status = await RegexBot.Config.ReloadServerConfig();
string res;
if (status) res = ":white_check_mark: Configuration reloaded with no issues. Check the console to verify.";
else res = ":x: Reload failed. Check the console.";
await msg.Channel.SendMessageAsync(res);
}
// Crazy idea: somehow redirect all logging messages created from invoking config reloading
// and pass them onto the invoking channel.
}
}

View file

@ -1,133 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModCommands.Commands
{
// Role adding and removing is largely the same, and thus are handled in a single class.
class RoleManipulation : Command
{
protected enum CommandMode { Add, Del }
private readonly CommandMode _mode;
private readonly EntityName _role;
private readonly string _successMsg;
// Configuration:
// "role" - string; The given role that applies to this command.
// "successmsg" - string; Messages to display on command success. Overrides default.
protected RoleManipulation(ModCommands l, string label, JObject conf, CommandMode mode) : base(l, label, conf)
{
_mode = mode;
var rolestr = conf["role"]?.Value<string>();
if (string.IsNullOrWhiteSpace(rolestr)) throw new RuleImportException("Role must be provided.");
_role = new EntityName(rolestr, EntityType.Role);
_successMsg = conf["successmsg"]?.Value<string>();
DefaultUsageMsg = $"{this.Trigger} [user or user ID]\n"
+ (_mode == CommandMode.Add ? "Adds" : "Removes") + " the specified role "
+ (_mode == CommandMode.Add ? "to" : "from") + " the given user.";
}
#region Strings
const string FailPrefix = ":x: **Failed to apply role change:** ";
const string TargetNotFound = ":x: **Unable to determine the target user.**";
const string RoleNotFound = ":x: **Failed to determine the specified role for this command.**";
const string Success = ":white_check_mark: Successfully {0} role for **{1}**.";
#endregion
public override async Task Invoke(SocketGuild g, SocketMessage msg)
{
string[] line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
string targetstr;
if (line.Length < 2)
{
await SendUsageMessageAsync(msg.Channel, null);
return;
}
targetstr = line[1];
// Retrieve target user
var (targetId, targetData) = await GetUserDataFromString(g.Id, targetstr);
if (targetId == 1)
{
await msg.Channel.SendMessageAsync(FailPrefix + FailDefault);
return;
}
if (targetId == 0)
{
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
string targetdisp;
if (targetData != null)
targetdisp = $"{targetData.Username}#{targetData.Discriminator}";
else
targetdisp = $"ID {targetId}";
// Determine role
SocketRole cmdRole;
if (_role.Id.HasValue)
{
cmdRole = g.GetRole(_role.Id.Value);
}
else
{
var res = g.Roles.Where(rn =>
string.Equals(rn.Name, _role.Name, StringComparison.InvariantCultureIgnoreCase))
.FirstOrDefault();
if (res == null)
{
await msg.Channel.SendMessageAsync(RoleNotFound);
await Log(RoleNotFound);
return;
}
cmdRole = res;
}
// Do the action
try
{
var u = g.GetUser(targetId);
if (_mode == CommandMode.Add)
await u.AddRoleAsync(cmdRole);
else
await u.RemoveRoleAsync(cmdRole);
await msg.Channel.SendMessageAsync(BuildSuccessMessage(targetdisp));
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
await msg.Channel.SendMessageAsync(FailPrefix + Fail403);
}
else
{
await msg.Channel.SendMessageAsync(FailPrefix + FailDefault);
await Log(ex.ToString());
}
}
}
private string BuildSuccessMessage(string targetstr)
{
const string defaultmsg = ":white_check_mark: Successfully {0} role for **$target**.";
string msg = _successMsg ?? string.Format(defaultmsg, _mode == CommandMode.Add ? "set" : "unset");
return msg.Replace("$target", targetstr);
}
}
class RoleAdd : RoleManipulation
{
public RoleAdd(ModCommands l, string label, JObject conf) : base(l, label, conf, CommandMode.Add) { }
}
class RoleDel : RoleManipulation
{
public RoleDel(ModCommands l, string label, JObject conf) : base(l, label, conf, CommandMode.Del) { }
}
}

View file

@ -1,70 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModCommands.Commands
{
class Say : Command
{
// No configuration at the moment.
// TODO: Whitelist/blacklist - to limit which channels it can "say" into
public Say(ModCommands l, string label, JObject conf) : base(l, label, conf) {
DefaultUsageMsg = $"{this.Trigger} [channel] [message]\n"
+ "Displays the given message exactly as specified to the given channel.";
}
#region Strings
const string ChannelRequired = ":x: You must specify a channel.";
const string MessageRequired = ":x: You must specify a message.";
const string TargetNotFound = ":x: Unable to find given channel.";
#endregion
public override async Task Invoke(SocketGuild g, SocketMessage msg)
{
string[] line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
if (line.Length <= 1)
{
await SendUsageMessageAsync(msg.Channel, ChannelRequired);
return;
}
if (line.Length <= 2 || string.IsNullOrWhiteSpace(line[2]))
{
await SendUsageMessageAsync(msg.Channel, MessageRequired);
return;
}
var ch = GetTextChannelFromString(g, line[1]);
if (ch == null) await SendUsageMessageAsync(msg.Channel, TargetNotFound);
await ch.SendMessageAsync(line[2]);
}
private SocketTextChannel GetTextChannelFromString(SocketGuild g, string input)
{
// Method 1: Check for channel mention
// Note: SocketGuild.GetTextChannel(ulong) returns null if no match.
var m = ChannelMention.Match(input);
if (m.Success)
{
ulong channelId = ulong.Parse(m.Groups["snowflake"].Value);
return g.GetTextChannel(channelId);
}
// Method 2: Check if specified in string, scan manually
if (input.StartsWith('#'))
{
input = input.Substring(1);
if (input.Length <= 0) return null;
foreach (var c in g.Channels)
{
if (string.Equals(c.Name, input, StringComparison.InvariantCultureIgnoreCase))
{
return c as SocketTextChannel;
}
}
}
return null;
}
}
}

View file

@ -1,80 +0,0 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModCommands.Commands
{
class Unban : Command
{
// No configuration.
// TODO bring in some options from BanKick. Particularly custom success msg.
// TODO when ModLogs fully implemented, add a reason?
public Unban(ModCommands l, string label, JObject conf) : base(l, label, conf) {
DefaultUsageMsg = $"{this.Trigger} [user or user ID]\n"
+ "Unbans the given user, allowing them to rejoin the server.";
}
#region Strings
const string FailPrefix = ":x: **Unable to unban:** ";
protected const string Fail404 = "The specified user does not exist or is not in the ban list.";
const string TargetNotFound = ":x: **Unable to determine the target user.**";
const string Success = ":white_check_mark: Unbanned user **{0}**.";
#endregion
// Usage: (command) (user query)
public override async Task Invoke(SocketGuild g, SocketMessage msg)
{
string[] line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
string targetstr;
if (line.Length < 2)
{
await SendUsageMessageAsync(msg.Channel, null);
return;
}
targetstr = line[1];
// Retrieve target user
var (targetId, targetData) = await GetUserDataFromString(g.Id, targetstr);
if (targetId == 1)
{
await msg.Channel.SendMessageAsync(FailPrefix + FailDefault);
return;
}
if (targetId == 0)
{
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
string targetdisp;
if (targetData != null)
targetdisp = $"{targetData.Username}#{targetData.Discriminator}";
else
targetdisp = $"ID {targetId}";
// Do the action
try
{
await g.RemoveBanAsync(targetId);
await msg.Channel.SendMessageAsync(string.Format(Success, targetdisp));
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
await msg.Channel.SendMessageAsync(FailPrefix + Fail403);
}
else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound)
{
await msg.Channel.SendMessageAsync(FailPrefix + Fail404);
}
else
{
await msg.Channel.SendMessageAsync(FailPrefix + FailDefault);
await Log(ex.ToString());
}
}
}
}
}

View file

@ -1,161 +0,0 @@
using Discord;
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModCommands.Commands
{
/// <summary>
/// Base class for a command within the module.
/// After implementing, don't forget to add a reference to
/// <see cref="CreateInstance(ModCommands, JProperty)"/>.
/// </summary>
[DebuggerDisplay("Command def: {Label}")]
abstract class Command
{
private readonly ModCommands _mod;
private readonly string _label;
private readonly string _command;
protected ModCommands Module => _mod;
public string Label => _label;
public string Trigger => _command;
public Command(ModCommands l, string label, JObject conf)
{
_mod = l;
_label = label;
_command = conf["command"].Value<string>();
}
public abstract Task Invoke(SocketGuild g, SocketMessage msg);
protected Task Log(string text)
{
return _mod.Log($"{Label}: {text}");
}
#region Config loading
private static readonly ReadOnlyDictionary<string, Type> _commands =
new ReadOnlyDictionary<string, Type>(
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
// Define all command types and their corresponding Types here
{ "ban", typeof(Ban) },
{ "confreload", typeof(ConfReload) },
{ "kick", typeof(Kick) },
{ "say", typeof(Say) },
{ "unban", typeof(Unban) },
{ "addrole", typeof(RoleAdd) },
{ "delrole", typeof(RoleDel) }
});
public static Command CreateInstance(ModCommands root, JProperty def)
{
string label = def.Name;
if (string.IsNullOrWhiteSpace(label)) throw new RuleImportException("Label cannot be blank.");
var definition = (JObject)def.Value;
string cmdinvoke = definition["command"]?.Value<string>();
if (string.IsNullOrWhiteSpace(cmdinvoke))
throw new RuleImportException($"{label}: 'command' value was not specified.");
if (cmdinvoke.Contains(" "))
throw new RuleImportException($"{label}: 'command' must not contain spaces.");
string ctypestr = definition["type"]?.Value<string>();
if (string.IsNullOrWhiteSpace(ctypestr))
throw new RuleImportException($"Value 'type' must be specified in definition for '{label}'.");
if (_commands.TryGetValue(ctypestr, out Type ctype))
{
try
{
return (Command)Activator.CreateInstance(ctype, root, label, definition);
}
catch (TargetInvocationException ex)
{
if (ex.InnerException is RuleImportException)
throw new RuleImportException($"Error in configuration for command '{label}': {ex.InnerException.Message}");
else throw;
}
}
else
{
throw new RuleImportException($"The given 'type' value is invalid in definition for '{label}'.");
}
}
#endregion
#region Helper methods and common values
protected static readonly Regex UserMention = new Regex(@"<@!?(?<snowflake>\d+)>", RegexOptions.Compiled);
protected static readonly Regex RoleMention = new Regex(@"<@?&(?<snowflake>\d+)>", RegexOptions.Compiled);
protected static readonly Regex ChannelMention = new Regex(@"<#(?<snowflake>\d+)>", RegexOptions.Compiled);
protected static readonly Regex EmojiMatch = new Regex(@"<:(?<name>[A-Za-z0-9_]{2,}):(?<ID>\d+)>", RegexOptions.Compiled);
protected const string Fail403 = "I do not have the required permissions to perform that action.";
protected const string FailDefault = "An unknown error occurred. Notify the bot operator.";
protected string DefaultUsageMsg { get; set; }
/// <summary>
/// Sends out the default usage message (<see cref="DefaultUsageMsg"/>) within an embed.
/// An optional message can be included, for uses such as notifying users of incorrect usage.
/// </summary>
/// <param name="target">Target channel for sending the message.</param>
/// <param name="message">The message to send alongside the default usage message.</param>
protected async Task SendUsageMessageAsync(ISocketMessageChannel target, string message = null)
{
if (DefaultUsageMsg == null)
throw new InvalidOperationException("DefaultUsage was not defined.");
var usageEmbed = new EmbedBuilder()
{
Title = "Usage",
Description = DefaultUsageMsg
};
await target.SendMessageAsync(message ?? "", embed: usageEmbed.Build());
}
/// <summary>
/// Helper method for turning input into user data. Only returns the first cache result.
/// </summary>
/// <returns>
/// First value: 0 for no data, 1 for no data + exception.
/// May return a partial result: a valid ulong value but no CacheUser.
/// </returns>
protected async Task<(ulong, EntityCache.CacheUser)> GetUserDataFromString(ulong guild, string input)
{
ulong uid;
EntityCache.CacheUser cdata = null;
// If input is a mention, isolate the ID value
Match m = UserMention.Match(input);
if (m.Success) input = m.Groups["snowflake"].Value;
// Attempt to turn the input into a ulong
try { uid = ulong.Parse(input); }
catch (FormatException) { uid = 0; }
// EntityCache lookup
try
{
cdata = (await EntityCache.EntityCache.QueryUserAsync(guild, input))
.FirstOrDefault();
if (cdata != null) uid = cdata.UserId;
}
catch (Npgsql.NpgsqlException ex)
{
await Log("A databasae error occurred during user lookup: " + ex.Message);
if (uid == 0) uid = 1;
}
return (uid, cdata);
}
#endregion
}
}

View file

@ -1,42 +0,0 @@
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using Noikoio.RegexBot.Module.ModCommands.Commands;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace Noikoio.RegexBot.Module.ModCommands
{
/// <summary>
/// Contains a server's ModCommands configuration.
/// </summary>
class ConfigItem
{
private readonly ReadOnlyDictionary<string, Command> _cmdInstances;
public ReadOnlyDictionary<string, Command> Commands => _cmdInstances;
public ConfigItem(ModCommands instance, JToken inconf)
{
if (inconf.Type != JTokenType.Object)
{
throw new RuleImportException("Configuration for this section is invalid.");
}
// Command instance creation
var commands = new Dictionary<string, Command>(StringComparer.OrdinalIgnoreCase);
foreach (var def in inconf.Children<JProperty>())
{
string label = def.Name;
var cmd = Command.CreateInstance(instance, def);
if (commands.ContainsKey(cmd.Trigger))
throw new RuleImportException(
$"{label}: 'command' value must not be equal to that of another definition. " +
$"Given value is being used for \"{commands[cmd.Trigger].Label}\".");
commands.Add(cmd.Trigger, cmd);
}
_cmdInstances = new ReadOnlyDictionary<string, Command>(commands);
}
}
}

View file

@ -1,86 +0,0 @@
using Discord;
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModCommands
{
/// <summary>
/// This class manages reading configuration and creating instances based on it.
/// It processes input and looks for messages that intend to invoke commands defined in configuration.
/// </summary>
/// <remarks>
/// Discord.Net has its own recommended way of implementing commands, but it's not exactly
/// done in a way that would easily allow for flexibility and modifications during runtime.
/// Thus, reinventing the wheel right here.
/// </remarks>
class ModCommands : BotModule
{
public ModCommands(DiscordSocketClient client) : base(client)
{
client.MessageReceived += Client_MessageReceived;
}
private async Task Client_MessageReceived(SocketMessage arg)
{
// Always ignore these
if (arg.Author.IsBot || arg.Author.IsWebhook) return;
if (arg.Channel is IGuildChannel) await CommandCheckInvoke(arg);
}
public override async Task<object> CreateInstanceState(JToken configSection)
{
if (configSection == null) return null;
// Constructor throws exception on config errors
var conf = new ConfigItem(this, configSection);
// Log results
if (conf.Commands.Count > 0)
await Log(conf.Commands.Count + " command definition(s) loaded.");
return conf;
}
public new Task Log(string text) => base.Log(text);
private async Task CommandCheckInvoke(SocketMessage arg)
{
SocketGuild g = ((SocketGuildUser)arg.Author).Guild;
// Get guild config
ServerConfig sc = RegexBot.Config.Servers.FirstOrDefault(s => s.Id == g.Id);
if (sc == null) return;
// Disregard if not a bot moderator
if (!sc.Moderators.ExistsInList(arg)) return;
// Disregard if the message contains a newline character
if (arg.Content.Contains("\n")) return;
// Check for and invoke command
string cmdchk;
int spc = arg.Content.IndexOf(' ');
if (spc != -1) cmdchk = arg.Content.Substring(0, spc);
else cmdchk = arg.Content;
if (GetState<ConfigItem>(g.Id).Commands.TryGetValue(cmdchk, out var c))
{
try
{
await c.Invoke(g, arg);
// TODO Custom invocation log messages? Not by the user, but by the command.
await Log($"{g.Name}/#{arg.Channel.Name}: {arg.Author} invoked {arg.Content}");
}
catch (Exception ex)
{
await Log($"Encountered an unhandled exception while processing '{c.Label}'. Details follow:");
await Log(ex.ToString());
}
}
}
}
}

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