Compare commits

...

No commits in common. "master" and "web-pages" have entirely different histories.

96 changed files with 311 additions and 11646 deletions

File diff suppressed because it is too large Load diff

2
.github/CODEOWNERS vendored
View file

@ -1,2 +0,0 @@
* @TeamOctolings/octobot
/docs/ @TeamOctolings/octobot-docs

View file

@ -1,43 +0,0 @@
name: Bug Report
description: Create a report to help us improve
labels: [ "type: bug" ]
body:
- type: markdown
attributes:
value: |
We welcome bug reports! Please see our [contribution guidelines](docs/CONTRIBUTING.md#reporting-bugs) for more information on writing a good bug report. This template will help us gather the information we need to start the triage process.
- type: textarea
id: background
attributes:
label: Description
description: Please share a clear and concise description of the problem.
placeholder: Description
validations:
required: true
- type: textarea
id: expected-vs-actual-behavior
attributes:
label: Expected vs. Actual Behavior
description: |
Provide a description of the expected behavior compared to the actual behavior.
placeholder: Expected vs. Actual Behavior
validations:
required: true
- type: textarea
id: repro-steps
attributes:
label: Reproduction Steps
description: |
Please include minimal steps to reproduce the problem if possible. E.g.: the smallest possible command/action sequence. If possible include text as text rather than screenshots (so it shows up in searches).
placeholder: Minimal Reproduction
validations:
required: true
- type: textarea
id: other-info
attributes:
label: Other Information
description: |
If you have an idea where the problem might lie, let us know that here. Please include any pointers to code, relevant changes, or related issues you know of.
placeholder: Other Information
validations:
required: false

View file

@ -1 +0,0 @@
blank_issues_enabled: true

View file

@ -1,29 +0,0 @@
name: Feature Request
description: Create a request for a feature you would like
labels: [ "type: feature" ]
body:
- type: textarea
id: background
attributes:
label: Description
description: Please share a clear and concise description of the feature you want.
placeholder: Description
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed Solution
description: Please describe the solution you would like.
placeholder: Proposed Solution
validations:
required: true
- type: textarea
id: other-info
attributes:
label: Other Information
description: |
Please add any other context or screenshots about the feature request here.
placeholder: Other Information
validations:
required: false

View file

@ -1,41 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
allow:
# Allow both direct and indirect updates for all packages
- dependency-type: "all"
labels:
- "type: change"
- "area: build/ci"
# For all packages, ignore all patch updates
ignore:
- dependency-name: "*"
update-types: [ "version-update:semver-patch" ]
- package-ecosystem: "nuget" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
allow:
# Allow both direct and indirect updates for all packages
- dependency-type: "all"
labels:
- "type: change"
- "area: build/ci"
groups:
remora:
patterns:
- "Remora.Discord.*"
# For all packages, ignore all patch updates
ignore:
- dependency-name: "GitInfo"
- dependency-name: "*"
update-types: [ "version-update:semver-patch" ]

24
.github/labels.yml vendored
View file

@ -1,24 +0,0 @@
XS:
name: size/XS
lines: 0
color: 3CBF00
S:
name: size/S
lines: 20
color: 5D9801
M:
name: size/M
lines: 100
color: 7F7203
L:
name: size/L
lines: 200
color: A14C05
XL:
name: size/XL
lines: 1000
color: C32607
XXL:
name: size/XXL
lines: 2000
color: E50009

View file

@ -1,36 +0,0 @@
name: "ReSharper"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
pull_request:
branches: [ "master" ]
merge_group:
types: [ checks_requested ]
jobs:
inspect-code:
name: Inspect code
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: ReSharper CLI InspectCode
uses: muno92/resharper_inspectcode@1.13.0
with:
solutionPath: ./Octobot.sln
ignoreIssueType: InvertIf, ConvertIfStatementToSwitchStatement, ConvertToPrimaryConstructor
extensions: ReSharperPlugin.CognitiveComplexity
solutionWideAnalysis: true

View file

@ -1,87 +0,0 @@
name: "Publish and deploy"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [ "master", "deploy-test" ]
jobs:
upload-image:
name: Upload Octobot Docker image
runs-on: ubuntu-latest
permissions:
packages: write
environment: production
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{vars.NAMESPACE}}/${{vars.IMAGE_NAME}}:latest
build-args: |
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
PUBLISH_OPTIONS=${{vars.PUBLISH_OPTIONS}}
update-production:
name: Update Octobot on production
runs-on: ubuntu-latest
environment: production
needs: upload-image
steps:
- name: Copy SSH key
run: |
install -m 600 -D /dev/null ~/.ssh/id_ed25519
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
shell: bash
env:
SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}}
- name: Generate SSH known hosts file
run: |
ssh-keyscan -H -p $SSH_PORT $SSH_HOST > ~/.ssh/known_hosts
shell: bash
env:
SSH_HOST: ${{secrets.SSH_HOST}}
SSH_PORT: ${{secrets.SSH_PORT}}
- name: Stop currently running instance
run: |
ssh -p $SSH_PORT $SSH_USER@$SSH_HOST $STOP_COMMAND
shell: bash
env:
SSH_PORT: ${{secrets.SSH_PORT}}
SSH_USER: ${{secrets.SSH_USER}}
SSH_HOST: ${{secrets.SSH_HOST}}
STOP_COMMAND: ${{vars.STOP_COMMAND}}
- name: Update Docker image
run: |
ssh -p $SSH_PORT $SSH_USER@$SSH_HOST docker pull ghcr.io/$NAMESPACE/$IMAGE_NAME:latest
shell: bash
env:
SSH_PORT: ${{secrets.SSH_PORT}}
SSH_USER: ${{secrets.SSH_USER}}
SSH_HOST: ${{secrets.SSH_HOST}}
NAMESPACE: ${{vars.NAMESPACE}}
IMAGE_NAME: ${{vars.IMAGE_NAME}}
- name: Start new instance
run: |
ssh -p $SSH_PORT $SSH_USER@$SSH_HOST $START_COMMAND
shell: bash
env:
SSH_PORT: ${{secrets.SSH_PORT}}
SSH_USER: ${{secrets.SSH_USER}}
SSH_HOST: ${{secrets.SSH_HOST}}
START_COMMAND: ${{vars.START_COMMAND}}

10
.gitignore vendored
View file

@ -1,11 +1 @@
.idea/ .idea/
*.user
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
/.vs/
GuildData/
Logs/
compose.yaml

View file

@ -1,22 +0,0 @@
M:System.Object.Equals(System.Object)~System.Boolean;Don't use object.Equals. Use IEquatable<T> or EqualityComparer<T>.Default instead.
M:System.ValueType.Equals(System.Object)~System.Boolean;Don't use object.Equals(Fallbacks to ValueType). Use IEquatable<T> or EqualityComparer<T>.Default instead.
M:System.Nullable`1.Equals(System.Object)~System.Boolean;Use == instead.
T:System.IComparable;Don't use non-generic IComparable. Use generic version instead.
M:System.Guid.#ctor;Probably meaning to use Guid.NewGuid() instead. If actually wanting empty, use Guid.Empty.
M:System.Threading.Tasks.Task.Wait();Don't use Task.Wait.
P:System.Threading.Tasks.Task`1.Result;Don't use Task.Result.
M:System.Threading.ManualResetEventSlim.Wait();Specify a timeout to avoid waiting forever.
M:System.Char.ToLower(System.Char);char.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture.
M:System.Char.ToUpper(System.Char);char.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture.
M:System.String.ToLower();string.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString.
M:System.String.ToUpper();string.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString.
M:Humanizer.InflectorExtensions.Pascalize(System.String);Humanizer's .Pascalize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToPascalCase() instead.
M:Humanizer.InflectorExtensions.Camelize(System.String);Humanizer's .Camelize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToCamelCase() instead.
M:Humanizer.InflectorExtensions.Underscore(System.String);Humanizer's .Underscore() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToSnakeCase() instead.
M:Humanizer.InflectorExtensions.Kebaberize(System.String);Humanizer's .Kebaberize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToKebabCase() instead.
P:System.DateTime.Now;Use System.DateTime.UtcNow instead.
P:System.DateTimeOffset.Now;Use System.DateTimeOffset.UtcNow instead.
P:System.DateTimeOffset.DateTime;Use System.DateTimeOffset.UtcDateTime instead.
M:System.IO.File.OpenWrite(System.String);File.OpenWrite(string) does not clear the file before writing to it. Use File.Create(string) instead.
M:System.Threading.Thread.Sleep(System.Int32);Use Task.Delay(int, CancellationToken) instead.
M:System.Threading.Thread.Sleep(System.TimeSpan);Use Task.Delay(TimeSpan, CancellationToken) instead.

View file

@ -1,15 +0,0 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0@sha256:7d24e90a392e88eb56093e4eb325ff883ad609382a55d42f17fd557b997022ca AS build-env
WORKDIR /Octobot
# Copy everything
COPY . ./
# Load build argument with publish options
ARG PUBLISH_OPTIONS="-c Release"
# Build and publish a release
RUN dotnet publish ./TeamOctolings.Octobot $PUBLISH_OPTIONS -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/runtime:9.0@sha256:1e5eb0ed94ca96a34a914456db80e48bd1bb7bc3e3c8eda5e2c3d89c153c3081
WORKDIR /Octobot
COPY --from=build-env /Octobot/out .
ENTRYPOINT ["./TeamOctolings.Octobot"]

661
LICENSE
View file

@ -1,661 +0,0 @@
 GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<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 Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View file

@ -1,16 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamOctolings.Octobot", "TeamOctolings.Octobot\TeamOctolings.Octobot.csproj", "{A1679BA2-3A36-4D98-80C0-EEE771398FBD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1679BA2-3A36-4D98-80C0-EEE771398FBD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View file

@ -1,8 +0,0 @@
namespace TeamOctolings.Octobot.Attributes;
/// <summary>
/// Any property marked with <see cref="StaticCallersOnlyAttribute"/> should only be accessed by static methods.
/// Such properties may be used to provide dependencies where it is not possible to acquire them through normal means.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class StaticCallersOnlyAttribute : Attribute;

View file

@ -1,20 +0,0 @@
namespace TeamOctolings.Octobot;
public static class BuildInfo
{
public const string WebsiteUrl = "https://teamoctolings.github.io/Octobot";
private const string RepositoryUrl = "https://github.com/TeamOctolings/Octobot";
public const string IssuesUrl = $"{RepositoryUrl}/issues";
public const string WikiUrl = $"{RepositoryUrl}/wiki";
private const string Commit = ThisAssembly.Git.Commit;
private const string Branch = ThisAssembly.Git.Branch;
public static bool IsDirty => ThisAssembly.Git.IsDirty;
public static string Version => IsDirty ? $"{Branch}-{Commit}-dirty" : $"{Branch}-{Commit}";
}

View file

@ -1,19 +0,0 @@
using System.Drawing;
namespace TeamOctolings.Octobot;
/// <summary>
/// Contains all colors used in embeds.
/// </summary>
public static class ColorsList
{
public static readonly Color Default = Color.Gray;
public static readonly Color Red = Color.Firebrick;
public static readonly Color Green = Color.PaleGreen;
public static readonly Color Yellow = Color.Gold;
public static readonly Color Blue = Color.RoyalBlue;
public static readonly Color Magenta = Color.Orchid;
public static readonly Color Cyan = Color.LightSkyBlue;
public static readonly Color Black = Color.Black;
public static readonly Color White = Color.WhiteSmoke;
}

View file

@ -1,137 +0,0 @@
using System.ComponentModel;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the command to show information about this bot: /about.
/// </summary>
[UsedImplicitly]
public sealed class AboutCommandGroup : CommandGroup
{
private static readonly (string Username, Snowflake Id)[] Developers =
[
("Octol1ttle", new Snowflake(504343489664909322)),
("mctaylors", new Snowflake(326642240229474304)),
("neroduckale", new Snowflake(474943797063843851))
];
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
public AboutCommandGroup(
ICommandContext context, GuildDataService guildData,
IFeedbackService feedback, IDiscordRestUserAPI userApi,
IDiscordRestGuildAPI guildApi)
{
_context = context;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
_guildApi = guildApi;
}
/// <summary>
/// A slash command that shows information about this bot.
/// </summary>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("about")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[Description("Shows Octobot's developers")]
[UsedImplicitly]
public async Task<Result> ExecuteAboutAsync()
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var cfg = await _guildData.GetSettings(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(cfg);
return await SendAboutBotAsync(bot, guildId, CancellationToken);
}
private async Task<Result> SendAboutBotAsync(IUser bot, Snowflake guildId, CancellationToken ct = default)
{
var builder = new StringBuilder().Append("### ").AppendLine(Messages.AboutTitleDevelopers);
foreach (var dev in Developers)
{
var guildMemberResult = await _guildApi.GetGuildMemberAsync(
guildId, dev.Id, ct);
var tag = guildMemberResult.IsSuccess
? $"<@{dev.Id}>"
: Markdown.Hyperlink($"@{dev.Username}", $"https://github.com/{dev.Username}");
builder.AppendBulletPointLine($"{tag} — {$"AboutDeveloper@{dev.Username}".Localized()}");
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.AboutBot, bot.Username), bot)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan)
.WithImageUrl("https://raw.githubusercontent.com/TeamOctolings/Octobot/HEAD/docs/octobot-banner.png")
.WithFooter(string.Format(Messages.Version, BuildInfo.Version))
.Build();
var repositoryButton = new ButtonComponent(
ButtonComponentStyle.Link,
Messages.ButtonOpenWebsite,
new PartialEmoji(Name: "\ud83c\udf10"), // 'GLOBE WITH MERIDIANS' (U+1F310)
URL: BuildInfo.WebsiteUrl
);
var wikiButton = new ButtonComponent(
ButtonComponentStyle.Link,
Messages.ButtonOpenWiki,
new PartialEmoji(Name: "\ud83d\udcd6"), // 'OPEN BOOK' (U+1F4D6)
URL: BuildInfo.WikiUrl
);
var issuesButton = new ButtonComponent(
ButtonComponentStyle.Link,
BuildInfo.IsDirty
? Messages.ButtonDirty
: Messages.ButtonReportIssue,
new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0)
URL: BuildInfo.IssuesUrl,
IsDisabled: BuildInfo.IsDirty
);
return await _feedback.SendContextualEmbedResultAsync(embed,
new FeedbackMessageOptions(MessageComponents: new[]
{
new ActionRowComponent([repositoryButton, wikiButton, issuesButton])
}), ct);
}
}

View file

@ -1,300 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Parsers;
using TeamOctolings.Octobot.Services;
using TeamOctolings.Octobot.Services.Update;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles commands related to ban management: /ban and /unban.
/// </summary>
[UsedImplicitly]
public sealed class BanCommandGroup : CommandGroup
{
private readonly AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public BanCommandGroup(AccessControlService access, IDiscordRestChannelAPI channelApi, ICommandContext context,
IFeedbackService feedback, IDiscordRestGuildAPI guildApi, GuildDataService guildData,
IDiscordRestUserAPI userApi, Utility utility)
{
_access = access;
_channelApi = channelApi;
_context = context;
_feedback = feedback;
_guildApi = guildApi;
_guildData = guildData;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that bans a Discord user with the specified reason.
/// </summary>
/// <param name="target">The user to ban.</param>
/// <param name="duration">The duration for this ban. The user will be automatically unbanned after this duration.</param>
/// <param name="reason">
/// The reason for this ban. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.CreateGuildBanAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user
/// was banned and vice versa.
/// </returns>
/// <seealso cref="ExecuteUnban" />
[Command("ban", "бан")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
[Description("Ban user")]
[UsedImplicitly]
public async Task<Result> ExecuteBanAsync(
[Description("User to ban")] IUser target,
[Description("Ban reason")] [MaxLength(256)]
string reason,
[Description("Ban duration (e.g. 1h30m)")]
string? duration = null)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending error messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild))
{
return ResultExtensions.FromError(guildResult);
}
var data = await _guildData.GetData(guild.ID, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
if (duration is null)
{
return await BanUserAsync(executor, target, reason, null, guild, data, channelId, bot,
CancellationToken);
}
var parseResult = TimeSpanParser.TryParse(duration);
if (!parseResult.IsDefined(out var timeSpan))
{
var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken);
}
return await BanUserAsync(executor, target, reason, timeSpan, guild, data, channelId, bot, CancellationToken);
}
private async Task<Result> BanUserAsync(
IUser executor, IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data,
Snowflake channelId,
IUser bot, CancellationToken ct = default)
{
var existingBanResult = await _guildApi.GetGuildBanAsync(guild.ID, target.ID, ct);
if (existingBanResult.IsDefined())
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserAlreadyBanned, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var interactionResult
= await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Ban", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct);
}
var builder =
new StringBuilder().AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason));
if (duration is not null)
{
builder.AppendBulletPoint(
string.Format(
Messages.DescriptionActionExpiresAt,
Markdown.Timestamp(DateTimeOffset.UtcNow.Add(duration.Value))));
}
var title = string.Format(Messages.UserBanned, target.GetTag());
var description = builder.ToString();
var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct);
if (dmChannelResult.IsDefined(out var dmChannel))
{
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YouWereBanned)
.WithDescription(description)
.WithActionFooter(executor)
.WithCurrentTimestamp()
.WithColour(ColorsList.Red)
.Build();
await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct);
}
var memberData = data.GetOrCreateMemberData(target.ID);
memberData.BannedUntil
= duration is not null ? DateTimeOffset.UtcNow.Add(duration.Value) : DateTimeOffset.MaxValue;
var banResult = await _guildApi.CreateGuildBanAsync(
guild.ID, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(),
ct: ct);
if (!banResult.IsSuccess)
{
memberData.BannedUntil = null;
return ResultExtensions.FromError(banResult);
}
memberData.Roles.Clear();
var embed = new EmbedBuilder().WithSmallTitle(
title, target)
.WithColour(ColorsList.Green).Build();
_utility.LogAction(
data.Settings, channelId, executor, title, description, target, ColorsList.Red, ct: ct);
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that unbans a Discord user with the specified reason.
/// </summary>
/// <param name="target">The user to unban.</param>
/// <param name="reason">
/// The reason for this unban. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.RemoveGuildBanAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user
/// was unbanned and vice versa.
/// </returns>
/// <seealso cref="ExecuteBanAsync" />
/// <seealso cref="MemberUpdateService.TickMemberDataAsync" />
[Command("unban")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
[Description("Unban user")]
[UsedImplicitly]
public async Task<Result> ExecuteUnban(
[Description("User to unban")] IUser target,
[Description("Unban reason")] [MaxLength(256)]
string reason)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending error messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
// Needed to get the tag and avatar
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await UnbanUserAsync(executor, target, reason, guildId, data, channelId, bot, CancellationToken);
}
private async Task<Result> UnbanUserAsync(
IUser executor, IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId,
IUser bot, CancellationToken ct = default)
{
var existingBanResult = await _guildApi.GetGuildBanAsync(guildId, target.ID, ct);
if (!existingBanResult.IsDefined())
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserNotBanned, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct);
}
var unbanResult = await _guildApi.RemoveGuildBanAsync(
guildId, target.ID, $"({executor.GetTag()}) {reason}".EncodeHeader(),
ct);
if (!unbanResult.IsSuccess)
{
return ResultExtensions.FromError(unbanResult);
}
data.GetOrCreateMemberData(target.ID).BannedUntil = null;
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnbanned, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
var title = string.Format(Messages.UserUnbanned, target.GetTag());
var description =
new StringBuilder().AppendBulletPoint(string.Format(Messages.DescriptionActionReason, reason));
_utility.LogAction(
data.Settings, channelId, executor, title, description.ToString(), target, ColorsList.Green, ct: ct);
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -1,170 +0,0 @@
using System.ComponentModel;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the command to clear messages in a channel: /clear.
/// </summary>
[UsedImplicitly]
public sealed class ClearCommandGroup : CommandGroup
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public ClearCommandGroup(
IDiscordRestChannelAPI channelApi, ICommandContext context, GuildDataService guildData,
IFeedbackService feedback, IDiscordRestUserAPI userApi, Utility utility)
{
_channelApi = channelApi;
_context = context;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that clears messages in the channel it was executed, optionally filtering by message author.
/// </summary>
/// <param name="amount">The amount of messages to clear.</param>
/// <param name="author">The user whose messages will be cleared.</param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that any messages
/// were cleared and vice versa.
/// </returns>
[Command("clear", "очистить")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.ManageMessages)]
[Description("Remove multiple messages")]
[UsedImplicitly]
public async Task<Result> ExecuteClear(
[Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)]
int amount,
[Description("Ignore messages except from the specified author")]
IUser? author = null)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var messagesResult = await _channelApi.GetChannelMessagesAsync(
channelId, limit: amount + 1, ct: CancellationToken);
if (!messagesResult.IsDefined(out var messages))
{
return ResultExtensions.FromError(messagesResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await ClearMessagesAsync(executor, author, data, channelId, messages, bot, CancellationToken);
}
private async Task<Result> ClearMessagesAsync(
IUser executor, IUser? author, GuildData data, Snowflake channelId, IReadOnlyList<IMessage> messages, IUser bot,
CancellationToken ct = default)
{
var idList = new List<Snowflake>(messages.Count);
var logEntries = new List<ClearedMessageEntry> { new() };
var currentLogEntry = 0;
for (var i = messages.Count - 1; i >= 1; i--) // '>= 1' to skip last message ('Octobot is thinking...')
{
var message = messages[i];
if (author is not null && message.Author.ID != author.ID)
{
continue;
}
idList.Add(message.ID);
var entry = logEntries[currentLogEntry];
var str = $"{string.Format(Messages.MessageFrom, Mention.User(message.Author))}\n{message.Content.InBlockCode()}";
if (entry.Builder.Length + str.Length > EmbedConstants.MaxDescriptionLength)
{
logEntries.Add(entry = new ClearedMessageEntry());
currentLogEntry++;
}
entry.Builder.Append(str);
entry.DeletedCount++;
}
if (idList.Count == 0)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.NoMessagesToClear, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var title = author is not null
? string.Format(Messages.MessagesClearedFiltered, idList.Count.ToString(), author.GetTag())
: string.Format(Messages.MessagesCleared, idList.Count.ToString());
var deleteResult = await _channelApi.BulkDeleteMessagesAsync(
channelId, idList, executor.GetTag().EncodeHeader(), ct);
if (!deleteResult.IsSuccess)
{
return ResultExtensions.FromError(deleteResult);
}
foreach (var log in logEntries)
{
_utility.LogAction(
data.Settings, channelId, executor, author is not null
? string.Format(Messages.MessagesClearedFiltered, log.DeletedCount.ToString(), author.GetTag())
: string.Format(Messages.MessagesCleared, log.DeletedCount.ToString()),
log.Builder.ToString(), bot, ColorsList.Red, false, ct);
}
var embed = new EmbedBuilder().WithSmallTitle(title, bot)
.WithColour(ColorsList.Green).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private sealed class ClearedMessageEntry
{
public StringBuilder Builder { get; } = new();
public int DeletedCount { get; set; }
}
}

View file

@ -1,88 +0,0 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Commands.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.Commands.Events;
/// <summary>
/// Handles error logging for slash command groups.
/// </summary>
[UsedImplicitly]
public sealed class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
{
private readonly IFeedbackService _feedback;
private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger;
private readonly IDiscordRestUserAPI _userApi;
public ErrorLoggingPostExecutionEvent(ILogger<ErrorLoggingPostExecutionEvent> logger, IFeedbackService feedback,
IDiscordRestUserAPI userApi)
{
_logger = logger;
_feedback = feedback;
_userApi = userApi;
}
/// <summary>
/// Logs a warning using the injected <see cref="ILogger" /> if the <paramref name="commandResult" /> has not
/// succeeded.
/// </summary>
/// <param name="context">The context of the slash command.</param>
/// <param name="commandResult">The result whose success is checked.</param>
/// <param name="ct">The cancellation token for this operation. Unused.</param>
/// <returns>A result which has succeeded.</returns>
public async Task<Result> AfterExecutionAsync(
ICommandContext context, IResult commandResult, CancellationToken ct = default)
{
_logger.LogResult(commandResult, $"Error in slash command execution for /{context.Command.Command.Node.Key}.");
var result = commandResult;
while (result.Inner is not null)
{
result = result.Inner;
}
if (result.IsSuccess)
{
return Result.Success;
}
var botResult = await _userApi.GetCurrentUserAsync(ct);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var embed = new EmbedBuilder().WithSmallTitle(Messages.CommandExecutionFailed, bot)
.WithDescription(Markdown.InlineCode(result.Error.Message))
.WithFooter(Messages.ContactDevelopers)
.WithColour(ColorsList.Red)
.Build();
var issuesButton = new ButtonComponent(
ButtonComponentStyle.Link,
BuildInfo.IsDirty
? Messages.ButtonDirty
: Messages.ButtonReportIssue,
new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0)
URL: BuildInfo.IssuesUrl,
IsDisabled: BuildInfo.IsDirty
);
return ResultExtensions.FromError(await _feedback.SendContextualEmbedResultAsync(embed,
new FeedbackMessageOptions(MessageComponents: new[]
{
new ActionRowComponent([issuesButton])
}), ct)
);
}
}

View file

@ -1,38 +0,0 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Services;
using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.Commands.Events;
/// <summary>
/// Handles error logging for slash commands that couldn't be successfully prepared.
/// </summary>
[UsedImplicitly]
public sealed class LoggingPreparationErrorEvent : IPreparationErrorEvent
{
private readonly ILogger<LoggingPreparationErrorEvent> _logger;
public LoggingPreparationErrorEvent(ILogger<LoggingPreparationErrorEvent> logger)
{
_logger = logger;
}
/// <summary>
/// Logs a warning using the injected <see cref="ILogger" /> if the <paramref name="preparationResult" /> has not
/// succeeded.
/// </summary>
/// <param name="context">The context of the slash command. Unused.</param>
/// <param name="preparationResult">The result whose success is checked.</param>
/// <param name="ct">The cancellation token for this operation. Unused.</param>
/// <returns>A result which has succeeded.</returns>
public Task<Result> PreparationFailed(
IOperationContext context, IResult preparationResult, CancellationToken ct = default)
{
_logger.LogResult(preparationResult, "Error in slash command preparation.");
return Task.FromResult(Result.Success);
}
}

View file

@ -1,329 +0,0 @@
using System.ComponentModel;
using System.Drawing;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles info commands: /userinfo, /guildinfo.
/// </summary>
[UsedImplicitly]
public sealed class InfoCommandGroup : CommandGroup
{
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
public InfoCommandGroup(
ICommandContext context, IFeedbackService feedback,
GuildDataService guildData, IDiscordRestGuildAPI guildApi,
IDiscordRestUserAPI userApi)
{
_context = context;
_guildData = guildData;
_feedback = feedback;
_guildApi = guildApi;
_userApi = userApi;
}
/// <summary>
/// A slash command that shows information about user.
/// </summary>
/// <remarks>
/// Information in the output:
/// <list type="bullet">
/// <item>Display name</item>
/// <item>Discord user since</item>
/// <item>Guild nickname</item>
/// <item>Guild member since</item>
/// <item>Nitro booster since</item>
/// <item>Guild roles</item>
/// <item>Active mute information</item>
/// <item>Active ban information</item>
/// <item>Is on guild status</item>
/// </list>
/// </remarks>
/// <param name="target">The user to show info about.</param>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("userinfo")]
[DiscordDefaultDMPermission(false)]
[Description("Shows info about user")]
[UsedImplicitly]
public async Task<Result> ExecuteUserInfoAsync(
[Description("User to show info about")]
IUser? target = null)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await ShowUserInfoAsync(target ?? executor, bot, data, guildId, CancellationToken);
}
private async Task<Result> ShowUserInfoAsync(
IUser target, IUser bot, GuildData data, Snowflake guildId, CancellationToken ct = default)
{
var builder = new StringBuilder().AppendLine($"### <@{target.ID}>");
if (target.GlobalName.IsDefined(out var globalName))
{
builder.AppendBulletPointLine(Messages.UserInfoDisplayName)
.AppendLine(Markdown.InlineCode(globalName));
}
builder.AppendBulletPointLine(Messages.UserInfoDiscordUserSince)
.AppendLine(Markdown.Timestamp(target.ID.Timestamp));
var memberData = data.GetOrCreateMemberData(target.ID);
var embedColor = ColorsList.Cyan;
var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, ct);
DateTimeOffset? communicationDisabledUntil = null;
if (guildMemberResult.IsDefined(out var guildMember))
{
communicationDisabledUntil = guildMember.CommunicationDisabledUntil.OrDefault(null);
embedColor = AppendGuildInformation(embedColor, guildMember, builder);
}
var wasMuted = (memberData.MutedUntil is not null && DateTimeOffset.UtcNow <= memberData.MutedUntil) ||
communicationDisabledUntil is not null;
var wasBanned = memberData.BannedUntil is not null;
var wasKicked = memberData.Kicked;
if (wasMuted || wasBanned || wasKicked)
{
builder.Append("### ")
.AppendLine(Markdown.Bold(Messages.UserInfoPunishments));
embedColor = AppendPunishmentsInformation(wasMuted, wasKicked, wasBanned, memberData,
builder, embedColor, communicationDisabledUntil);
}
if (!guildMemberResult.IsSuccess && !wasBanned)
{
builder.Append("### ")
.AppendLine(Markdown.Bold(Messages.UserInfoNotOnGuild));
embedColor = ColorsList.Default;
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.InformationAbout, target.GetTag()), bot)
.WithDescription(builder.ToString())
.WithColour(embedColor)
.WithLargeUserAvatar(target)
.WithFooter($"ID: {target.ID.ToString()}")
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private static Color AppendPunishmentsInformation(bool wasMuted, bool wasKicked, bool wasBanned,
MemberData memberData, StringBuilder builder, Color embedColor, DateTimeOffset? communicationDisabledUntil)
{
if (wasMuted)
{
AppendMuteInformation(memberData, communicationDisabledUntil, builder);
embedColor = ColorsList.Red;
}
if (wasKicked)
{
builder.AppendBulletPointLine(Messages.UserInfoKicked);
}
if (wasBanned)
{
AppendBanInformation(memberData, builder);
embedColor = ColorsList.Black;
}
return embedColor;
}
private static Color AppendGuildInformation(Color color, IGuildMember guildMember, StringBuilder builder)
{
if (guildMember.Nickname.IsDefined(out var nickname))
{
builder.AppendBulletPointLine(Messages.UserInfoGuildNickname)
.AppendLine(Markdown.InlineCode(nickname));
}
builder.AppendBulletPointLine(Messages.UserInfoGuildMemberSince)
.AppendLine(Markdown.Timestamp(guildMember.JoinedAt));
if (guildMember.PremiumSince.IsDefined(out var premiumSince))
{
builder.AppendBulletPointLine(Messages.UserInfoGuildMemberPremiumSince)
.AppendLine(Markdown.Timestamp(premiumSince.Value));
color = ColorsList.Magenta;
}
if (guildMember.Roles.Count > 0)
{
builder.AppendBulletPointLine(Messages.UserInfoGuildRoles);
for (var i = 0; i < guildMember.Roles.Count - 1; i++)
{
builder.Append($"<@&{guildMember.Roles[i]}>, ");
}
builder.AppendLine($"<@&{guildMember.Roles[^1]}>");
}
return color;
}
private static void AppendBanInformation(MemberData memberData, StringBuilder builder)
{
if (memberData.BannedUntil < DateTimeOffset.MaxValue)
{
builder.AppendBulletPointLine(Messages.UserInfoBanned)
.AppendSubBulletPointLine(string.Format(
Messages.DescriptionActionExpiresAt, Markdown.Timestamp(memberData.BannedUntil.Value)));
return;
}
builder.AppendBulletPointLine(Messages.UserInfoBannedPermanently);
}
private static void AppendMuteInformation(
MemberData memberData, DateTimeOffset? communicationDisabledUntil, StringBuilder builder)
{
builder.AppendBulletPointLine(Messages.UserInfoMuted);
if (memberData.MutedUntil is not null && DateTimeOffset.UtcNow <= memberData.MutedUntil)
{
builder.AppendSubBulletPointLine(Messages.UserInfoMutedByMuteRole)
.AppendSubBulletPointLine(string.Format(
Messages.DescriptionActionExpiresAt, Markdown.Timestamp(memberData.MutedUntil.Value)));
}
if (communicationDisabledUntil is not null)
{
builder.AppendSubBulletPointLine(Messages.UserInfoMutedByTimeout)
.AppendSubBulletPointLine(string.Format(
Messages.DescriptionActionExpiresAt, Markdown.Timestamp(communicationDisabledUntil.Value)));
}
}
/// <summary>
/// A slash command that shows guild information.
/// </summary>
/// <remarks>
/// Information in the output:
/// <list type="bullet">
/// <item>Guild description</item>
/// <item>Creation date</item>
/// <item>Guild's language</item>
/// <item>Guild's owner</item>
/// <item>Boost level</item>
/// <item>Boost count</item>
/// </list>
/// </remarks>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("guildinfo")]
[DiscordDefaultDMPermission(false)]
[Description("Shows info about current guild")]
[UsedImplicitly]
public async Task<Result> ExecuteGuildInfoAsync()
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild))
{
return ResultExtensions.FromError(guildResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await ShowGuildInfoAsync(bot, guild, CancellationToken);
}
private Task<Result> ShowGuildInfoAsync(IUser bot, IGuild guild, CancellationToken ct = default)
{
var description = new StringBuilder().AppendLine($"## {guild.Name}");
if (guild.Description is not null)
{
description.AppendBulletPointLine(Messages.GuildInfoDescription)
.AppendLine(Markdown.InlineCode(guild.Description));
}
description.AppendBulletPointLine(Messages.GuildInfoCreatedAt)
.AppendLine(Markdown.Timestamp(guild.ID.Timestamp))
.AppendBulletPointLine(Messages.GuildInfoOwner)
.AppendLine(Mention.User(guild.OwnerID));
var embedColor = ColorsList.Cyan;
if (guild.PremiumTier > PremiumTier.None)
{
description.Append("### ").AppendLine(Messages.GuildInfoServerBoost)
.AppendBulletPoint(Messages.GuildInfoBoostTier)
.Append(": ").AppendLine(Markdown.InlineCode(guild.PremiumTier.ToString()))
.AppendBulletPoint(Messages.GuildInfoBoostCount)
.Append(": ").AppendLine(Markdown.InlineCode(guild.PremiumSubscriptionCount.ToString()));
embedColor = ColorsList.Magenta;
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.InformationAbout, guild.Name), bot)
.WithDescription(description.ToString())
.WithColour(embedColor)
.WithLargeGuildIcon(guild)
.WithGuildBanner(guild)
.WithFooter($"ID: {guild.ID.ToString()}")
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -1,174 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the command to kick members of a guild: /kick.
/// </summary>
[UsedImplicitly]
public sealed class KickCommandGroup : CommandGroup
{
private readonly AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public KickCommandGroup(AccessControlService access, IDiscordRestChannelAPI channelApi, ICommandContext context,
IFeedbackService feedback, IDiscordRestGuildAPI guildApi, GuildDataService guildData,
IDiscordRestUserAPI userApi, Utility utility)
{
_access = access;
_channelApi = channelApi;
_context = context;
_feedback = feedback;
_guildApi = guildApi;
_guildData = guildData;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that kicks a Discord member with the specified reason.
/// </summary>
/// <param name="target">The member to kick.</param>
/// <param name="reason">
/// The reason for this kick. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.RemoveGuildMemberAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member
/// was kicked and vice versa.
/// </returns>
[Command("kick", "кик")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.KickMembers)]
[Description("Kick member")]
[UsedImplicitly]
public async Task<Result> ExecuteKick(
[Description("Member to kick")] IUser target,
[Description("Kick reason")] [MaxLength(256)]
string reason)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending error messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild))
{
return ResultExtensions.FromError(guildResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken);
if (!memberResult.IsSuccess)
{
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: CancellationToken);
}
return await KickUserAsync(executor, target, reason, guild, channelId, data, bot, CancellationToken);
}
private async Task<Result> KickUserAsync(
IUser executor, IUser target, string reason, IGuild guild, Snowflake channelId, GuildData data, IUser bot,
CancellationToken ct = default)
{
var interactionResult
= await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Kick", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct);
if (dmChannelResult.IsDefined(out var dmChannel))
{
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YouWereKicked)
.WithDescription(
MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason)))
.WithActionFooter(executor)
.WithCurrentTimestamp()
.WithColour(ColorsList.Red)
.Build();
await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct);
}
var memberData = data.GetOrCreateMemberData(target.ID);
memberData.Kicked = true;
var kickResult = await _guildApi.RemoveGuildMemberAsync(
guild.ID, target.ID, $"({executor.GetTag()}) {reason}".EncodeHeader(),
ct);
if (!kickResult.IsSuccess)
{
memberData.Kicked = false;
return ResultExtensions.FromError(kickResult);
}
memberData.Roles.Clear();
var title = string.Format(Messages.UserKicked, target.GetTag());
var description = MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason));
_utility.LogAction(
data.Settings, channelId, executor, title, description, target, ColorsList.Red, ct: ct);
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserKicked, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -1,388 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Parsers;
using TeamOctolings.Octobot.Services;
using TeamOctolings.Octobot.Services.Update;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles commands related to mute management: /mute and /unmute.
/// </summary>
[UsedImplicitly]
public sealed class MuteCommandGroup : CommandGroup
{
private readonly AccessControlService _access;
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public MuteCommandGroup(AccessControlService access, ICommandContext context, IFeedbackService feedback,
IDiscordRestGuildAPI guildApi, GuildDataService guildData, IDiscordRestUserAPI userApi, Utility utility)
{
_access = access;
_context = context;
_feedback = feedback;
_guildApi = guildApi;
_guildData = guildData;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that mutes a Discord member with the specified reason.
/// </summary>
/// <param name="target">The member to mute.</param>
/// <param name="stringDuration">The duration for this mute. The member will be automatically unmuted after this duration.</param>
/// <param name="reason">
/// The reason for this mute. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member
/// was muted and vice versa.
/// </returns>
/// <seealso cref="ExecuteUnmute" />
[Command("mute", "мут")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
[Description("Mute member")]
[UsedImplicitly]
public async Task<Result> ExecuteMute(
[Description("Member to mute")] IUser target,
[Description("Mute reason")] [MaxLength(256)]
string reason,
[Description("Mute duration (e.g. 1h30m)")] [Option("duration")]
string stringDuration)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending error messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken);
if (!memberResult.IsSuccess)
{
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: CancellationToken);
}
var parseResult = TimeSpanParser.TryParse(stringDuration);
if (!parseResult.IsDefined(out var duration))
{
var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken);
}
return await MuteUserAsync(executor, target, reason, duration, guildId, data, channelId, bot,
CancellationToken);
}
private async Task<Result> MuteUserAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data,
Snowflake channelId, IUser bot, CancellationToken ct = default)
{
var interactionResult
= await _access.CheckInteractionsAsync(
guildId, executor.ID, target.ID, "Mute", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var until = DateTimeOffset.UtcNow.Add(duration); // >:)
var muteMethodResult =
await SelectMuteMethodAsync(executor, target, reason, duration, guildId, data, bot, until, ct);
if (!muteMethodResult.IsSuccess)
{
return ResultExtensions.FromError(muteMethodResult);
}
var title = string.Format(Messages.UserMuted, target.GetTag());
var description = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason))
.AppendBulletPoint(string.Format(
Messages.DescriptionActionExpiresAt, Markdown.Timestamp(until))).ToString();
_utility.LogAction(
data.Settings, channelId, executor, title, description, target, ColorsList.Red, ct: ct);
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserMuted, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private async Task<Result> SelectMuteMethodAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data,
IUser bot, DateTimeOffset until, CancellationToken ct = default)
{
var muteRole = GuildSettings.MuteRole.Get(data.Settings);
if (muteRole.Empty())
{
var timeoutResult = await TimeoutUserAsync(executor, target, reason, duration, guildId, bot, until, ct);
return timeoutResult;
}
var muteRoleResult = await RoleMuteUserAsync(executor, target, reason, guildId, data, until, muteRole, ct);
return muteRoleResult;
}
private async Task<Result> RoleMuteUserAsync(
IUser executor, IUser target, string reason, Snowflake guildId, GuildData data,
DateTimeOffset until, Snowflake muteRole, CancellationToken ct = default)
{
var assignRoles = new List<Snowflake> { muteRole };
var memberData = data.GetOrCreateMemberData(target.ID);
if (!GuildSettings.RemoveRolesOnMute.Get(data.Settings))
{
assignRoles.AddRange(memberData.Roles.ConvertAll(r => r.ToSnowflake()));
}
var muteResult = await _guildApi.ModifyGuildMemberAsync(
guildId, target.ID, roles: assignRoles,
reason: $"({executor.GetTag()}) {reason}".EncodeHeader(), ct: ct);
if (muteResult.IsSuccess)
{
memberData.MutedUntil = until;
}
return muteResult;
}
private async Task<Result> TimeoutUserAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId,
IUser bot, DateTimeOffset until, CancellationToken ct = default)
{
if (duration.TotalDays >= 28)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.BotCannotMuteTarget, bot)
.WithDescription(Messages.DurationRequiredForTimeOuts)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var muteResult = await _guildApi.ModifyGuildMemberAsync(
guildId, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(),
communicationDisabledUntil: until, ct: ct);
return muteResult;
}
/// <summary>
/// A slash command that unmutes a Discord member with the specified reason.
/// </summary>
/// <param name="target">The member to unmute.</param>
/// <param name="reason">
/// The reason for this unmute. Must be encoded with <see cref="StringExtensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member
/// was unmuted and vice versa.
/// </returns>
/// <seealso cref="ExecuteMute" />
/// <seealso cref="MemberUpdateService.TickMemberDataAsync" />
[Command("unmute", "размут")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
[Description("Unmute member")]
[UsedImplicitly]
public async Task<Result> ExecuteUnmute(
[Description("Member to unmute")] IUser target,
[Description("Unmute reason")] [MaxLength(256)]
string reason)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
// The bot's avatar is used when sending error messages
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
// Needed to get the tag and avatar
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, CancellationToken);
if (!memberResult.IsSuccess)
{
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: CancellationToken);
}
return await RemoveMuteAsync(executor, target, reason, guildId, data, channelId, bot, CancellationToken);
}
private async Task<Result> RemoveMuteAsync(
IUser executor, IUser target, string reason, Snowflake guildId, GuildData data, Snowflake channelId,
IUser bot, CancellationToken ct = default)
{
var interactionResult
= await _access.CheckInteractionsAsync(
guildId, executor.ID, target.ID, "Unmute", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, target.ID, ct);
DateTimeOffset? communicationDisabledUntil = null;
if (guildMemberResult.IsDefined(out var guildMember))
{
communicationDisabledUntil = guildMember.CommunicationDisabledUntil.OrDefault(null);
}
var memberData = data.GetOrCreateMemberData(target.ID);
var wasMuted = memberData.MutedUntil is not null || communicationDisabledUntil is not null;
if (!wasMuted)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserNotMuted, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var removeMuteRoleAsync =
await RemoveMuteRoleAsync(executor, target, reason, guildId, memberData, CancellationToken);
if (!removeMuteRoleAsync.IsSuccess)
{
return ResultExtensions.FromError(removeMuteRoleAsync);
}
var removeTimeoutResult =
await RemoveTimeoutAsync(executor, target, reason, guildId, communicationDisabledUntil, CancellationToken);
if (!removeTimeoutResult.IsSuccess)
{
return ResultExtensions.FromError(removeTimeoutResult);
}
var title = string.Format(Messages.UserUnmuted, target.GetTag());
var description = MarkdownExtensions.BulletPoint(string.Format(Messages.DescriptionActionReason, reason));
_utility.LogAction(
data.Settings, channelId, executor, title, description, target, ColorsList.Green, ct: ct);
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnmuted, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private async Task<Result> RemoveMuteRoleAsync(
IUser executor, IUser target, string reason, Snowflake guildId, MemberData memberData,
CancellationToken ct = default)
{
if (memberData.MutedUntil is null)
{
return Result.Success;
}
var unmuteResult = await _guildApi.ModifyGuildMemberAsync(
guildId, target.ID, roles: memberData.Roles.ConvertAll(r => r.ToSnowflake()),
reason: $"({executor.GetTag()}) {reason}".EncodeHeader(), ct: ct);
if (unmuteResult.IsSuccess)
{
memberData.MutedUntil = null;
}
return unmuteResult;
}
private async Task<Result> RemoveTimeoutAsync(
IUser executor, IUser target, string reason, Snowflake guildId, DateTimeOffset? communicationDisabledUntil,
CancellationToken ct = default)
{
if (communicationDisabledUntil is null)
{
return Result.Success;
}
var unmuteResult = await _guildApi.ModifyGuildMemberAsync(
guildId, target.ID, reason: $"({executor.GetTag()}) {reason}".EncodeHeader(),
communicationDisabledUntil: null, ct: ct);
return unmuteResult;
}
}

View file

@ -1,102 +0,0 @@
using System.ComponentModel;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping
/// </summary>
[UsedImplicitly]
public sealed class PingCommandGroup : CommandGroup
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly DiscordGatewayClient _client;
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
public PingCommandGroup(
IDiscordRestChannelAPI channelApi, ICommandContext context, DiscordGatewayClient client,
GuildDataService guildData, IFeedbackService feedback, IDiscordRestUserAPI userApi)
{
_channelApi = channelApi;
_context = context;
_client = client;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
}
/// <summary>
/// A slash command that shows time taken for the gateway to respond to the last heartbeat.
/// </summary>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("ping", "пинг")]
[Description("Get bot latency")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[UsedImplicitly]
public async Task<Result> ExecutePingAsync()
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var cfg = await _guildData.GetSettings(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(cfg);
return await SendLatencyAsync(channelId, bot, CancellationToken);
}
private async Task<Result> SendLatencyAsync(
Snowflake channelId, IUser bot, CancellationToken ct = default)
{
var latency = _client.Latency.TotalMilliseconds;
if (latency is 0)
{
// No heartbeat has occurred, estimate latency from local time and "Octobot is thinking..." message
var lastMessageResult = await _channelApi.GetChannelMessagesAsync(
channelId, limit: 1, ct: ct);
if (!lastMessageResult.IsDefined(out var lastMessage))
{
return ResultExtensions.FromError(lastMessageResult);
}
latency = DateTimeOffset.UtcNow.Subtract(lastMessage.Single().Timestamp).TotalMilliseconds;
}
var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot)
.WithTitle($"Generic{Random.Shared.Next(1, 4)}".Localized())
.WithDescription($"{latency:F0}{Messages.Milliseconds}")
.WithColour(latency < 250 ? ColorsList.Green : latency < 500 ? ColorsList.Yellow : ColorsList.Red)
.WithCurrentTimestamp()
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -1,382 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Parsers;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles commands to manage reminders: /remind, /listremind, /delremind
/// </summary>
[UsedImplicitly]
public sealed class RemindCommandGroup : CommandGroup
{
private readonly IInteractionCommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestInteractionAPI _interactionApi;
private readonly IDiscordRestUserAPI _userApi;
public RemindCommandGroup(
IInteractionCommandContext context, GuildDataService guildData, IFeedbackService feedback,
IDiscordRestUserAPI userApi, IDiscordRestInteractionAPI interactionApi)
{
_context = context;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
_interactionApi = interactionApi;
}
/// <summary>
/// A slash command that lists reminders of the user that called it.
/// </summary>
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("listremind")]
[Description("List your reminders")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[UsedImplicitly]
public async Task<Result> ExecuteListReminderAsync()
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await ListRemindersAsync(data.GetOrCreateMemberData(executorId), guildId, executor, bot, CancellationToken);
}
private Task<Result> ListRemindersAsync(MemberData data, Snowflake guildId, IUser executor, IUser bot, CancellationToken ct = default)
{
if (data.Reminders.Count == 0)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.NoRemindersFound, bot)
.WithColour(ColorsList.Red)
.Build();
return _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var builder = new StringBuilder();
for (var i = 0; i < data.Reminders.Count; i++)
{
var reminder = data.Reminders[i];
builder.AppendBulletPointLine(string.Format(Messages.ReminderPosition, Markdown.InlineCode((i + 1).ToString())))
.AppendSubBulletPointLine(string.Format(Messages.ReminderText, reminder.Text))
.AppendSubBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At)))
.AppendSubBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage, $"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}"));
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderList, executor.GetTag()), executor)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan)
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that schedules a reminder with the specified text.
/// </summary>
/// <param name="timeSpanString">The period of time which must pass before the reminder will be sent.</param>
/// <param name="text">The text of the reminder.</param>
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("remind")]
[Description("Create a reminder")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[UsedImplicitly]
public async Task<Result> ExecuteReminderAsync(
[Description("After what period of time mention the reminder (e.g. 1h30m)")]
[Option("in")]
string timeSpanString,
[Description("Reminder text")] [MaxLength(512)]
string text)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var parseResult = TimeSpanParser.TryParse(timeSpanString);
if (!parseResult.IsDefined(out var timeSpan))
{
var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken);
}
return await AddReminderAsync(timeSpan, text, data, channelId, executor, CancellationToken);
}
private async Task<Result> AddReminderAsync(TimeSpan timeSpan, string text, GuildData data,
Snowflake channelId, IUser executor, CancellationToken ct = default)
{
var memberData = data.GetOrCreateMemberData(executor.ID);
var remindAt = DateTimeOffset.UtcNow.Add(timeSpan);
var responseResult = await _interactionApi.GetOriginalInteractionResponseAsync(_context.Interaction.ApplicationID, _context.Interaction.Token, ct);
if (!responseResult.IsDefined(out var response))
{
return (Result)responseResult;
}
memberData.Reminders.Add(
new Reminder
{
At = remindAt,
ChannelId = channelId.Value,
Text = text,
MessageId = response.ID.Value
});
var builder = new StringBuilder()
.AppendLine(MarkdownExtensions.Quote(text))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt)));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderCreated, executor.GetTag()), executor)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Green)
.WithFooter(string.Format(Messages.ReminderPosition, memberData.Reminders.Count))
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
public enum Parameters
{
[UsedImplicitly] Time,
[UsedImplicitly] Text
}
/// <summary>
/// A slash command that edits a scheduled reminder using the specified text or time.
/// </summary>
/// <param name="position">The list position of the reminder to edit.</param>
/// <param name="parameter">The reminder's parameter to edit.</param>
/// <param name="value">The new value for the reminder as a text or time.</param>
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("editremind")]
[Description("Edit a reminder")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[UsedImplicitly]
public async Task<Result> ExecuteEditReminderAsync(
[Description("Position in list")] [MinValue(1)]
int position,
[Description("Parameter to edit")] Parameters parameter,
[Description("Parameter's new value")] string value)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var memberData = data.GetOrCreateMemberData(executor.ID);
if (parameter is Parameters.Time)
{
return await EditReminderTimeAsync(position - 1, value, memberData, bot, executor, CancellationToken);
}
return await EditReminderTextAsync(position - 1, value, memberData, bot, executor, CancellationToken);
}
private async Task<Result> EditReminderTimeAsync(int index, string value, MemberData data,
IUser bot, IUser executor, CancellationToken ct = default)
{
if (index >= data.Reminders.Count)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.InvalidReminderPosition, bot)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var parseResult = TimeSpanParser.TryParse(value);
if (!parseResult.IsDefined(out var timeSpan))
{
var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var oldReminder = data.Reminders[index];
var remindAt = DateTimeOffset.UtcNow.Add(timeSpan);
data.Reminders.Add(oldReminder with { At = remindAt });
data.Reminders.RemoveAt(index);
var builder = new StringBuilder()
.AppendLine(MarkdownExtensions.Quote(oldReminder.Text))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt)));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderEdited, executor.GetTag()), executor)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan)
.WithFooter(string.Format(Messages.ReminderPosition, data.Reminders.Count))
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private async Task<Result> EditReminderTextAsync(int index, string value, MemberData data,
IUser bot, IUser executor, CancellationToken ct = default)
{
if (index >= data.Reminders.Count)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.InvalidReminderPosition, bot)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var oldReminder = data.Reminders[index];
data.Reminders.Add(oldReminder with { Text = value });
data.Reminders.RemoveAt(index);
var builder = new StringBuilder()
.AppendLine(MarkdownExtensions.Quote(value))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(oldReminder.At)));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderEdited, executor.GetTag()), executor)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan)
.WithFooter(string.Format(Messages.ReminderPosition, data.Reminders.Count))
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that deletes a reminder using its list position.
/// </summary>
/// <param name="position">The list position of the reminder to delete.</param>
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("delremind")]
[Description("Delete one of your reminders")]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[UsedImplicitly]
public async Task<Result> ExecuteDeleteReminderAsync(
[Description("Position in list")] [MinValue(1)]
int position)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await DeleteReminderAsync(data.GetOrCreateMemberData(executorId), position - 1, bot, CancellationToken);
}
private Task<Result> DeleteReminderAsync(MemberData data, int index, IUser bot,
CancellationToken ct = default)
{
if (index >= data.Reminders.Count)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.InvalidReminderPosition, bot)
.WithColour(ColorsList.Red)
.Build();
return _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var reminder = data.Reminders[index];
var description = new StringBuilder()
.AppendLine(MarkdownExtensions.Quote(reminder.Text))
.AppendBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At)));
data.Reminders.RemoveAt(index);
var embed = new EmbedBuilder().WithSmallTitle(Messages.ReminderDeleted, bot)
.WithDescription(description.ToString())
.WithColour(ColorsList.Green)
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -1,330 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Json.Nodes;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Data.Options;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the commands to list and modify per-guild settings: /settings and /settings list.
/// </summary>
[UsedImplicitly]
public sealed class SettingsCommandGroup : CommandGroup
{
/// <summary>
/// Represents all options as an array of objects implementing <see cref="IGuildOption" />.
/// </summary>
/// <remarks>
/// WARNING: If you update this array in any way, you must also update <see cref="AllOptionsEnum" /> and make sure
/// that the orders match.
/// </remarks>
private static readonly IGuildOption[] AllOptions =
[
GuildSettings.Language,
GuildSettings.WelcomeMessage,
GuildSettings.LeaveMessage,
GuildSettings.ReceiveStartupMessages,
GuildSettings.RemoveRolesOnMute,
GuildSettings.ReturnRolesOnRejoin,
GuildSettings.AutoStartEvents,
GuildSettings.RenameHoistedUsers,
GuildSettings.PublicFeedbackChannel,
GuildSettings.PrivateFeedbackChannel,
GuildSettings.WelcomeMessagesChannel,
GuildSettings.EventNotificationChannel,
GuildSettings.DefaultRole,
GuildSettings.MuteRole,
GuildSettings.ModeratorRole,
GuildSettings.EventNotificationRole,
GuildSettings.EventEarlyNotificationOffset
];
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public SettingsCommandGroup(
ICommandContext context, GuildDataService guildData,
IFeedbackService feedback, IDiscordRestUserAPI userApi, Utility utility)
{
_context = context;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that sends a page from the list of current GuildSettings.
/// </summary>
/// <param name="page">The number of the page to send.</param>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("listsettings")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageGuild)]
[Description("Shows settings list for this server")]
[UsedImplicitly]
public async Task<Result> ExecuteListSettingsAsync(
[Description("Settings list page")] [MinValue(1)]
int page)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var cfg = await _guildData.GetSettings(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(cfg);
return await SendSettingsListAsync(cfg, bot, page, CancellationToken);
}
private Task<Result> SendSettingsListAsync(JsonNode cfg, IUser bot, int page,
CancellationToken ct = default)
{
var description = new StringBuilder();
var footer = new StringBuilder();
const int optionsPerPage = 10;
var totalPages = (AllOptions.Length + optionsPerPage - 1) / optionsPerPage;
var lastOptionOnPage = Math.Min(optionsPerPage * page, AllOptions.Length);
var firstOptionOnPage = optionsPerPage * page - optionsPerPage;
if (firstOptionOnPage >= AllOptions.Length)
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(Messages.PageNotFound, bot)
.WithDescription(string.Format(Messages.PagesAllowed, Markdown.Bold(totalPages.ToString())))
.WithColour(ColorsList.Red)
.Build();
return _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct);
}
footer.Append($"{Messages.Page} {page}/{totalPages} ");
for (var i = 0; i < totalPages; i++)
{
footer.Append(i + 1 == page ? "●" : "○");
}
for (var i = firstOptionOnPage; i < lastOptionOnPage; i++)
{
var optionName = AllOptions[i].Name;
var optionValue = AllOptions[i].Display(cfg);
description.AppendBulletPointLine($"Settings{optionName}".Localized())
.AppendSubBulletPoint(Markdown.InlineCode(optionName))
.Append(": ").AppendLine(optionValue);
}
var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingsListTitle, bot)
.WithDescription(description.ToString())
.WithColour(ColorsList.Default)
.WithFooter(footer.ToString())
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that modifies per-guild GuildSettings.
/// </summary>
/// <param name="setting">The setting to modify.</param>
/// <param name="value">The new value of the setting.</param>
/// <returns>A feedback sending result which may or may not have succeeded.</returns>
[Command("editsettings")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageGuild)]
[Description("Change settings for this server")]
[UsedImplicitly]
public async Task<Result> ExecuteEditSettingsAsync(
[Description("The setting whose value you want to change")]
AllOptionsEnum setting,
[Description("Setting value")] [MaxLength(512)]
string value)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await EditSettingAsync(AllOptions[(int)setting], value, data, channelId, executor, bot,
CancellationToken);
}
private async Task<Result> EditSettingAsync(
IGuildOption option, string value, GuildData data, Snowflake channelId, IUser executor, IUser bot,
CancellationToken ct = default)
{
var equalsResult = option.ValueEquals(data.Settings, value);
if (!equalsResult.IsSuccess)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, bot)
.WithDescription(equalsResult.Error.Message)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
if (equalsResult.Entity)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, bot)
.WithDescription(Messages.SettingValueEquals)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var setResult = option.Set(data.Settings, value);
if (!setResult.IsSuccess)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, bot)
.WithDescription(setResult.Error.Message)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var builder = new StringBuilder();
builder.Append(Markdown.InlineCode(option.Name))
.Append($" {Messages.SettingIsNow} ")
.Append(option.Display(data.Settings));
var title = Messages.SettingSuccessfullyChanged;
var description = builder.ToString();
_utility.LogAction(
data.Settings, channelId, executor, title, description, bot, ColorsList.Magenta, false, ct);
var embed = new EmbedBuilder().WithSmallTitle(title, bot)
.WithDescription(description)
.WithColour(ColorsList.Green)
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that resets per-guild GuildSettings.
/// </summary>
/// <param name="setting">The setting to reset.</param>
/// <returns>A feedback sending result which may have succeeded.</returns>
[Command("resetsettings")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageGuild)]
[Description("Reset settings for this guild")]
[UsedImplicitly]
public async Task<Result> ExecuteResetSettingsAsync(
[Description("Setting to reset")] AllOptionsEnum? setting = null)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var cfg = await _guildData.GetSettings(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(cfg);
if (setting is not null)
{
return await ResetSingleSettingAsync(cfg, bot, AllOptions[(int)setting], CancellationToken);
}
return await ResetAllSettingsAsync(cfg, bot, CancellationToken);
}
private async Task<Result> ResetSingleSettingAsync(JsonNode cfg, IUser bot,
IGuildOption option, CancellationToken ct = default)
{
var resetResult = option.Reset(cfg);
if (!resetResult.IsSuccess)
{
return ResultExtensions.FromError(resetResult);
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.SingleSettingReset, option.Name), bot)
.WithColour(ColorsList.Green)
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private async Task<Result> ResetAllSettingsAsync(JsonNode cfg, IUser bot,
CancellationToken ct = default)
{
var failedResults = new List<Result>();
foreach (var resetResult in AllOptions.Select(option => option.Reset(cfg)))
{
failedResults.AddIfFailed(resetResult);
}
if (failedResults.Count is not 0)
{
return failedResults.AggregateErrors();
}
var embed = new EmbedBuilder().WithSmallTitle(Messages.AllSettingsReset, bot)
.WithColour(ColorsList.Green)
.Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -1,272 +0,0 @@
using System.ComponentModel;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Parsers;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles tool commands: /random, /timestamp, /8ball.
/// </summary>
[UsedImplicitly]
public sealed class ToolsCommandGroup : CommandGroup
{
private static readonly TimestampStyle[] AllStyles =
[
TimestampStyle.ShortDate,
TimestampStyle.LongDate,
TimestampStyle.ShortTime,
TimestampStyle.LongTime,
TimestampStyle.ShortDateTime,
TimestampStyle.LongDateTime,
TimestampStyle.RelativeTime
];
private static readonly string[] AnswerTypes =
[
"Positive", "Questionable", "Neutral", "Negative"
];
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
public ToolsCommandGroup(
ICommandContext context, IFeedbackService feedback,
GuildDataService guildData, IDiscordRestUserAPI userApi)
{
_context = context;
_guildData = guildData;
_feedback = feedback;
_userApi = userApi;
}
/// <summary>
/// A slash command that generates a random number using maximum and minimum numbers.
/// </summary>
/// <param name="first">The first number used for randomization.</param>
/// <param name="second">The second number used for randomization. Default value: 0</param>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("random")]
[DiscordDefaultDMPermission(false)]
[Description("Generates a random number")]
[UsedImplicitly]
public async Task<Result> ExecuteRandomAsync(
[Description("First number")] long first,
[Description("Second number (Default: 0)")]
long? second = null)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await SendRandomNumberAsync(first, second, executor, CancellationToken);
}
private Task<Result> SendRandomNumberAsync(long first, long? secondNullable,
IUser executor, CancellationToken ct = default)
{
const long secondDefault = 0;
var second = secondNullable ?? secondDefault;
var min = Math.Min(first, second);
var max = Math.Max(first, second);
var i = Random.Shared.NextInt64(min, max + 1);
var description = new StringBuilder().Append("# ").Append(i);
description.AppendLine().AppendBulletPoint(string.Format(
Messages.RandomMin, Markdown.InlineCode(min.ToString())));
if (secondNullable is null && first >= secondDefault)
{
description.Append(' ').Append(Messages.Default);
}
description.AppendLine().AppendBulletPoint(string.Format(
Messages.RandomMax, Markdown.InlineCode(max.ToString())));
if (secondNullable is null && first < secondDefault)
{
description.Append(' ').Append(Messages.Default);
}
var embedColor = ColorsList.Blue;
if (secondNullable is not null && min == max)
{
description.AppendLine().Append(Markdown.Italicise(Messages.RandomMinMaxSame));
embedColor = ColorsList.Red;
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.RandomTitle, executor.GetTag()), executor)
.WithDescription(description.ToString())
.WithColour(embedColor)
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that shows the current timestamp with an optional offset in all styles supported by Discord.
/// </summary>
/// <param name="stringOffset">The offset for the current timestamp.</param>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("timestamp")]
[DiscordDefaultDMPermission(false)]
[Description("Shows a timestamp in all styles")]
[UsedImplicitly]
public async Task<Result> ExecuteTimestampAsync(
[Description("Offset from current time")] [Option("offset")]
string? stringOffset = null)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return ResultExtensions.FromError(executorResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
if (stringOffset is null)
{
return await SendTimestampAsync(null, executor, CancellationToken);
}
var parseResult = TimeSpanParser.TryParse(stringOffset);
if (!parseResult.IsDefined(out var offset))
{
var failedEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.InvalidTimeSpan, bot)
.WithDescription(Messages.TimeSpanExample)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: CancellationToken);
}
return await SendTimestampAsync(offset, executor, CancellationToken);
}
private Task<Result> SendTimestampAsync(TimeSpan? offset, IUser executor, CancellationToken ct = default)
{
var timestamp = DateTimeOffset.UtcNow.Add(offset ?? TimeSpan.Zero).ToUnixTimeSeconds();
var description = new StringBuilder().Append("# ").AppendLine(timestamp.ToString());
if (offset is not null)
{
description.AppendLine(string.Format(
Messages.TimestampOffset, Markdown.InlineCode(offset.ToString() ?? string.Empty))).AppendLine();
}
foreach (var markdownTimestamp in AllStyles.Select(style => Markdown.Timestamp(timestamp, style)))
{
description.AppendBulletPoint(Markdown.InlineCode(markdownTimestamp))
.Append(" → ").AppendLine(markdownTimestamp);
}
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.TimestampTitle, executor.GetTag()), executor)
.WithDescription(description.ToString())
.WithColour(ColorsList.Blue)
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <summary>
/// A slash command that shows a random answer from the Magic 8-Ball.
/// </summary>
/// <param name="question">Unused input.</param>
/// <remarks>
/// The 8-Ball answers were taken from <a href="https://en.wikipedia.org/wiki/Magic_8_Ball#Possible_answers">Wikipedia</a>.
/// </remarks>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("8ball")]
[DiscordDefaultDMPermission(false)]
[Description("Ask the Magic 8-Ball a question")]
[UsedImplicitly]
public async Task<Result> ExecuteEightBallAsync(
// let the user think he's actually asking the ball a question
[Description("Question to ask")] string question)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
var data = await _guildData.GetData(guildId, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
return await AnswerEightBallAsync(bot, CancellationToken);
}
private Task<Result> AnswerEightBallAsync(IUser bot, CancellationToken ct = default)
{
var typeNumber = Random.Shared.Next(0, 4);
var embedColor = typeNumber switch
{
0 => ColorsList.Blue,
1 => ColorsList.Green,
2 => ColorsList.Yellow,
3 => ColorsList.Red,
_ => throw new ArgumentOutOfRangeException(null, nameof(typeNumber))
};
var answer = $"EightBall{AnswerTypes[typeNumber]}{Random.Shared.Next(1, 6)}".Localized();
var embed = new EmbedBuilder().WithSmallTitle(answer, bot)
.WithColour(embedColor)
.Build();
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -1,47 +0,0 @@
using System.Text.Json.Nodes;
using Remora.Rest.Core;
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Stores information about a guild. This information is not accessible via the Discord API.
/// </summary>
/// <remarks>This information is stored on disk as a JSON file.</remarks>
public sealed class GuildData
{
public readonly Dictionary<ulong, MemberData> MemberData;
public readonly string MemberDataPath;
public readonly Dictionary<ulong, ScheduledEventData> ScheduledEvents;
public readonly string ScheduledEventsPath;
public readonly JsonNode Settings;
public readonly string SettingsPath;
public readonly bool DataLoadFailed;
public GuildData(
JsonNode settings, string settingsPath,
Dictionary<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath,
Dictionary<ulong, MemberData> memberData, string memberDataPath, bool dataLoadFailed)
{
Settings = settings;
SettingsPath = settingsPath;
ScheduledEvents = scheduledEvents;
ScheduledEventsPath = scheduledEventsPath;
MemberData = memberData;
MemberDataPath = memberDataPath;
DataLoadFailed = dataLoadFailed;
}
public MemberData GetOrCreateMemberData(Snowflake memberId)
{
if (MemberData.TryGetValue(memberId.Value, out var existing))
{
return existing;
}
var newData = new MemberData(memberId.Value);
MemberData.Add(memberId.Value, newData);
return newData;
}
}

View file

@ -1,87 +0,0 @@
using Remora.Discord.API.Abstractions.Objects;
using TeamOctolings.Octobot.Data.Options;
using TeamOctolings.Octobot.Responders;
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Contains all per-guild settings that can be set by a member
/// with <see cref="DiscordPermission.ManageGuild" /> using the /settings command
/// </summary>
public static class GuildSettings
{
public static readonly LanguageOption Language = new("Language", "en");
/// <summary>
/// Controls what message should be sent in <see cref="PublicFeedbackChannel" /> when a new member joins the guild.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>No message will be sent if set to "off", "disable" or "disabled".</item>
/// <item><see cref="Messages.DefaultWelcomeMessage" /> will be sent if set to "default" or "reset".</item>
/// </list>
/// </remarks>
/// <seealso cref="GuildMemberJoinedResponder" />
public static readonly GuildOption<string> WelcomeMessage = new("WelcomeMessage", "default");
/// <summary>
/// Controls what message should be sent in <see cref="PublicFeedbackChannel" /> when a member leaves the guild.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>No message will be sent if set to "off", "disable" or "disabled".</item>
/// <item><see cref="Messages.DefaultLeaveMessage" /> will be sent if set to "default" or "reset".</item>
/// </list>
/// </remarks>
/// <seealso cref="GuildMemberLeftResponder" />
public static readonly GuildOption<string> LeaveMessage = new("LeaveMessage", "default");
/// <summary>
/// Controls whether or not the <see cref="Messages.Ready" /> message should be sent
/// in <see cref="PrivateFeedbackChannel" /> on startup.
/// </summary>
/// <seealso cref="GuildLoadedResponder" />
public static readonly BoolOption ReceiveStartupMessages = new("ReceiveStartupMessages", false);
public static readonly BoolOption RemoveRolesOnMute = new("RemoveRolesOnMute", false);
/// <summary>
/// Controls whether or not a guild member's roles are returned if he/she leaves and then joins back.
/// </summary>
/// <remarks>Roles will not be returned if the member left the guild because of /ban or /kick.</remarks>
public static readonly BoolOption ReturnRolesOnRejoin = new("ReturnRolesOnRejoin", false);
public static readonly BoolOption AutoStartEvents = new("AutoStartEvents", false);
/// <summary>
/// Controls whether or not users who try to hoist themselves should be renamed.
/// </summary>
public static readonly BoolOption RenameHoistedUsers = new("RenameHoistedUsers", false);
/// <summary>
/// Controls what channel should all public messages be sent to.
/// </summary>
public static readonly SnowflakeOption PublicFeedbackChannel = new("PublicFeedbackChannel");
/// <summary>
/// Controls what channel should all private, moderator-only messages be sent to.
/// </summary>
public static readonly SnowflakeOption PrivateFeedbackChannel = new("PrivateFeedbackChannel");
/// <summary>
/// Controls what channel should welcome messages be sent to.
/// </summary>
public static readonly SnowflakeOption WelcomeMessagesChannel = new("WelcomeMessagesChannel");
public static readonly SnowflakeOption EventNotificationChannel = new("EventNotificationChannel");
public static readonly SnowflakeOption DefaultRole = new("DefaultRole");
public static readonly SnowflakeOption MuteRole = new("MuteRole");
public static readonly SnowflakeOption ModeratorRole = new("ModeratorRole");
public static readonly SnowflakeOption EventNotificationRole = new("EventNotificationRole");
/// <summary>
/// Controls the amount of time before a scheduled event to send a reminder in <see cref="EventNotificationChannel" />.
/// </summary>
public static readonly TimeSpanOption EventEarlyNotificationOffset = new(
"EventEarlyNotificationOffset", TimeSpan.Zero);
}

View file

@ -1,23 +0,0 @@
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Stores information about a member
/// </summary>
public sealed class MemberData
{
public MemberData(ulong id, List<Reminder>? reminders = null)
{
Id = id;
if (reminders is not null)
{
Reminders = reminders;
}
}
public ulong Id { get; }
public DateTimeOffset? BannedUntil { get; set; }
public DateTimeOffset? MutedUntil { get; set; }
public bool Kicked { get; set; }
public List<ulong> Roles { get; set; } = [];
public List<Reminder> Reminders { get; } = [];
}

View file

@ -1,32 +0,0 @@
using JetBrains.Annotations;
using TeamOctolings.Octobot.Commands;
namespace TeamOctolings.Octobot.Data.Options;
/// <summary>
/// Represents all options as enums.
/// </summary>
/// <remarks>
/// WARNING: This enum is order-dependent! It's values are used as indexes for
/// <see cref="SettingsCommandGroup.AllOptions" />.
/// </remarks>
public enum AllOptionsEnum
{
[UsedImplicitly] Language,
[UsedImplicitly] WelcomeMessage,
[UsedImplicitly] LeaveMessage,
[UsedImplicitly] ReceiveStartupMessages,
[UsedImplicitly] RemoveRolesOnMute,
[UsedImplicitly] ReturnRolesOnRejoin,
[UsedImplicitly] AutoStartEvents,
[UsedImplicitly] RenameHoistedUsers,
[UsedImplicitly] PublicFeedbackChannel,
[UsedImplicitly] PrivateFeedbackChannel,
[UsedImplicitly] WelcomeMessagesChannel,
[UsedImplicitly] EventNotificationChannel,
[UsedImplicitly] DefaultRole,
[UsedImplicitly] MuteRole,
[UsedImplicitly] ModeratorRole,
[UsedImplicitly] EventNotificationRole,
[UsedImplicitly] EventEarlyNotificationOffset
}

View file

@ -1,51 +0,0 @@
using System.Text.Json.Nodes;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
public sealed class BoolOption : GuildOption<bool>
{
public BoolOption(string name, bool defaultValue) : base(name, defaultValue) { }
public override string Display(JsonNode settings)
{
return Get(settings) ? Messages.Yes : Messages.No;
}
public override Result<bool> ValueEquals(JsonNode settings, string value)
{
if (!TryParseBool(value, out var boolean))
{
return new ArgumentInvalidError(nameof(value), Messages.InvalidSettingValue);
}
return Value(settings).Equals(boolean.ToString());
}
public override Result Set(JsonNode settings, string from)
{
if (!TryParseBool(from, out var value))
{
return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue);
}
settings[Name] = value;
return Result.Success;
}
private static bool TryParseBool(string from, out bool value)
{
value = false;
switch (from.ToLowerInvariant())
{
case "true" or "1" or "y" or "yes" or "д" or "да":
value = true;
return true;
case "false" or "0" or "n" or "no" or "н" or "не" or "нет" or "нъет":
value = false;
return true;
default:
return false;
}
}
}

View file

@ -1,67 +0,0 @@
using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
/// <summary>
/// Represents a per-guild option.
/// </summary>
/// <typeparam name="T">The type of the option.</typeparam>
public class GuildOption<T> : IGuildOption
where T : notnull
{
protected readonly T DefaultValue;
public GuildOption(string name, T defaultValue)
{
Name = name;
DefaultValue = defaultValue;
}
public string Name { get; }
protected virtual string Value(JsonNode settings)
{
return Get(settings).ToString() ?? throw new InvalidOperationException();
}
public virtual string Display(JsonNode settings)
{
return Markdown.InlineCode(Value(settings));
}
public virtual Result<bool> ValueEquals(JsonNode settings, string value)
{
return Value(settings).Equals(value);
}
/// <summary>
/// Sets the value of the option from a <see cref="string" /> to the provided JsonNode.
/// </summary>
/// <param name="settings">The <see cref="JsonNode" /> to set the value to.</param>
/// <param name="from">The string from which the new value of the option will be parsed.</param>
/// <returns>A value setting result which may or may not have succeeded.</returns>
public virtual Result Set(JsonNode settings, string from)
{
settings[Name] = from;
return Result.Success;
}
public Result Reset(JsonNode settings)
{
settings[Name] = null;
return Result.Success;
}
/// <summary>
/// Gets the value of the option from the provided <paramref name="settings" />.
/// </summary>
/// <param name="settings">The <see cref="JsonNode" /> to get the value from.</param>
/// <returns>The value of the option.</returns>
public virtual T Get(JsonNode settings)
{
var property = settings[Name];
return property != null ? property.GetValue<T>() : DefaultValue;
}
}

View file

@ -1,13 +0,0 @@
using System.Text.Json.Nodes;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
public interface IGuildOption
{
string Name { get; }
string Display(JsonNode settings);
Result<bool> ValueEquals(JsonNode settings, string value);
Result Set(JsonNode settings, string from);
Result Reset(JsonNode settings);
}

View file

@ -1,37 +0,0 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
/// <inheritdoc />
public sealed class LanguageOption : GuildOption<CultureInfo>
{
private static readonly Dictionary<string, CultureInfo> CultureInfoCache = new()
{
{ "en", new CultureInfo("en-US") },
{ "ru", new CultureInfo("ru-RU") }
};
public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { }
protected override string Value(JsonNode settings)
{
return settings[Name]?.GetValue<string>() ?? "en";
}
/// <inheritdoc />
public override CultureInfo Get(JsonNode settings)
{
var property = settings[Name];
return property != null ? CultureInfoCache[property.GetValue<string>()] : DefaultValue;
}
/// <inheritdoc />
public override Result Set(JsonNode settings, string from)
{
return CultureInfoCache.ContainsKey(from.ToLowerInvariant())
? base.Set(settings, from.ToLowerInvariant())
: new ArgumentInvalidError(nameof(from), Messages.LanguageNotSupported);
}
}

View file

@ -1,40 +0,0 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.Data.Options;
public sealed partial class SnowflakeOption : GuildOption<Snowflake>
{
public SnowflakeOption(string name) : base(name, 0UL.ToSnowflake()) { }
public override string Display(JsonNode settings)
{
return Name.EndsWith("Channel", StringComparison.Ordinal)
? Mention.Channel(Get(settings))
: Mention.Role(Get(settings));
}
public override Snowflake Get(JsonNode settings)
{
var property = settings[Name];
return property != null ? property.GetValue<ulong>().ToSnowflake() : DefaultValue;
}
public override Result Set(JsonNode settings, string from)
{
if (!ulong.TryParse(NonNumbers().Replace(from, ""), out var parsed))
{
return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue);
}
settings[Name] = parsed;
return Result.Success;
}
[GeneratedRegex("[^0-9]")]
private static partial Regex NonNumbers();
}

View file

@ -1,37 +0,0 @@
using System.Text.Json.Nodes;
using Remora.Results;
using TeamOctolings.Octobot.Parsers;
namespace TeamOctolings.Octobot.Data.Options;
public sealed class TimeSpanOption : GuildOption<TimeSpan>
{
public TimeSpanOption(string name, TimeSpan defaultValue) : base(name, defaultValue) { }
public override Result<bool> ValueEquals(JsonNode settings, string value)
{
if (!TimeSpanParser.TryParse(value).IsDefined(out var span))
{
return new ArgumentInvalidError(nameof(value), Messages.InvalidSettingValue);
}
return Value(settings).Equals(span.ToString());
}
public override TimeSpan Get(JsonNode settings)
{
var property = settings[Name];
return property != null ? TimeSpanParser.TryParse(property.GetValue<string>()).Entity : DefaultValue;
}
public override Result Set(JsonNode settings, string from)
{
if (!TimeSpanParser.TryParse(from).IsDefined(out var span))
{
return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue);
}
settings[Name] = span.ToString();
return Result.Success;
}
}

View file

@ -1,9 +0,0 @@
namespace TeamOctolings.Octobot.Data;
public sealed record Reminder
{
public required DateTimeOffset At { get; init; }
public required string Text { get; init; }
public required ulong ChannelId { get; init; }
public required ulong MessageId { get; init; }
}

View file

@ -1,41 +0,0 @@
using System.Text.Json.Serialization;
using Remora.Discord.API.Abstractions.Objects;
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Stores information about scheduled events. This information is not provided by the Discord API.
/// </summary>
/// <remarks>This information is stored on disk as a JSON file.</remarks>
public sealed class ScheduledEventData
{
public ScheduledEventData(ulong id, string name, DateTimeOffset scheduledStartTime,
GuildScheduledEventStatus status)
{
Id = id;
Name = name;
ScheduledStartTime = scheduledStartTime;
Status = status;
}
[JsonConstructor]
public ScheduledEventData(ulong id, string name, bool earlyNotificationSent, DateTimeOffset scheduledStartTime,
DateTimeOffset? actualStartTime, GuildScheduledEventStatus? status, bool scheduleOnStatusUpdated)
{
Id = id;
Name = name;
EarlyNotificationSent = earlyNotificationSent;
ScheduledStartTime = scheduledStartTime;
ActualStartTime = actualStartTime;
Status = status;
ScheduleOnStatusUpdated = scheduleOnStatusUpdated;
}
public ulong Id { get; }
public string Name { get; set; }
public bool EarlyNotificationSent { get; set; }
public DateTimeOffset ScheduledStartTime { get; set; }
public DateTimeOffset? ActualStartTime { get; set; }
public GuildScheduledEventStatus? Status { get; set; }
public bool ScheduleOnStatusUpdated { get; set; } = true;
}

View file

@ -1,30 +0,0 @@
using OneOf;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Rest.Core;
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class ChannelApiExtensions
{
public static async Task<Result> CreateMessageWithEmbedResultAsync(this IDiscordRestChannelAPI channelApi,
Snowflake channelId, Optional<string> message = default, Optional<string> nonce = default,
Optional<bool> isTextToSpeech = default, Optional<Result<Embed>> embedResult = default,
Optional<IAllowedMentions> allowedMentions = default, Optional<IMessageReference> messageReference = default,
Optional<IReadOnlyList<IMessageComponent>> components = default,
Optional<IReadOnlyList<Snowflake>> stickerIds = default,
Optional<IReadOnlyList<OneOf<FileData, IPartialAttachment>>> attachments = default,
Optional<MessageFlags> flags = default, Optional<bool> enforceNonce = default,
Optional<IPollCreateRequest> poll = default, CancellationToken ct = default)
{
if (!embedResult.IsDefined() || !embedResult.Value.IsDefined(out var embed))
{
return ResultExtensions.FromError(embedResult.Value);
}
return (Result)await channelApi.CreateMessageAsync(channelId, message, nonce, isTextToSpeech, new[] { embed },
allowedMentions, messageReference, components, stickerIds, attachments, flags, enforceNonce, poll, ct);
}
}

View file

@ -1,40 +0,0 @@
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class CollectionExtensions
{
public static TResult? MaxOrDefault<TSource, TResult>(
this IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
var list = source.ToList();
return list.Count > 0 ? list.Max(selector) : default;
}
public static void AddIfFailed(this List<Result> list, Result result)
{
if (!result.IsSuccess)
{
list.Add(result);
}
}
/// <summary>
/// Return an appropriate result for a list of failed results. The list must only contain failed results.
/// </summary>
/// <param name="list">The list of failed results.</param>
/// <returns>
/// A successful result if the list is empty, the only Result in the list, or <see cref="AggregateError" />
/// containing all results from the list.
/// </returns>
/// <exception cref="InvalidOperationException"></exception>
public static Result AggregateErrors(this List<Result> list)
{
return list.Count switch
{
0 => Result.Success,
1 => list[0],
_ => new AggregateError(list.Cast<IResult>().ToArray())
};
}
}

View file

@ -1,19 +0,0 @@
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Extensions;
using Remora.Rest.Core;
namespace TeamOctolings.Octobot.Extensions;
public static class CommandContextExtensions
{
public static bool TryGetContextIDs(
this ICommandContext context, out Snowflake guildId,
out Snowflake channelId, out Snowflake executorId)
{
channelId = default;
executorId = default;
return context.TryGetGuildID(out guildId)
&& context.TryGetChannelID(out channelId)
&& context.TryGetUserID(out executorId);
}
}

View file

@ -1,31 +0,0 @@
using System.Text;
using DiffPlex.DiffBuilder.Model;
namespace TeamOctolings.Octobot.Extensions;
public static class DiffPaneModelExtensions
{
public static string AsMarkdown(this DiffPaneModel model)
{
var builder = new StringBuilder();
foreach (var line in model.Lines)
{
if (line.Type is ChangeType.Deleted)
{
builder.Append("-- ");
}
if (line.Type is ChangeType.Inserted)
{
builder.Append("++ ");
}
if (line.Type is not ChangeType.Imaginary)
{
builder.AppendLine(line.Text.SanitizeForDiffBlock());
}
}
return builder.ToString().InBlockCode("diff");
}
}

View file

@ -1,149 +0,0 @@
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Rest.Core;
namespace TeamOctolings.Octobot.Extensions;
public static class EmbedBuilderExtensions
{
/// <summary>
/// Adds a footer representing that an action was performed by a <paramref name="user" />.
/// </summary>
/// <param name="builder">The builder to add the footer to.</param>
/// <param name="user">The user that performed the action whose tag and avatar to use.</param>
/// <returns>The builder with the added footer.</returns>
public static EmbedBuilder WithActionFooter(this EmbedBuilder builder, IUser user)
{
var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256);
var avatarUrl = avatarUrlResult.IsSuccess
? avatarUrlResult.Entity.AbsoluteUri
: CDN.GetDefaultUserAvatarUrl(user, imageSize: 256).Entity.AbsoluteUri;
return builder.WithFooter(
new EmbedFooter($"{Messages.IssuedBy}:\n{user.GetTag()}", avatarUrl));
}
/// <summary>
/// Adds a title using the author field, making it smaller than using the title field.
/// </summary>
/// <param name="builder">The builder to add the small title to.</param>
/// <param name="text">The text of the small title.</param>
/// <param name="avatarSource">The user whose avatar to use in the small title.</param>
/// <returns>The builder with the added small title in the author field.</returns>
public static EmbedBuilder WithSmallTitle(
this EmbedBuilder builder, string text, IUser? avatarSource = null)
{
Uri? avatarUrl = null;
if (avatarSource is not null)
{
var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256);
avatarUrl = avatarUrlResult.IsSuccess
? avatarUrlResult.Entity
: CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity;
}
builder.Author = new EmbedAuthorBuilder(text, iconUrl: avatarUrl?.AbsoluteUri);
return builder;
}
/// <summary>
/// Adds a user avatar in the thumbnail field.
/// </summary>
/// <param name="builder">The builder to add the thumbnail to.</param>
/// <param name="avatarSource">The user whose avatar to use in the thumbnail field.</param>
/// <returns>The builder with the added avatar in the thumbnail field.</returns>
public static EmbedBuilder WithLargeUserAvatar(
this EmbedBuilder builder, IUser avatarSource)
{
var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256);
var avatarUrl = avatarUrlResult.IsSuccess
? avatarUrlResult.Entity
: CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity;
return builder.WithThumbnailUrl(avatarUrl.AbsoluteUri);
}
/// <summary>
/// Adds a guild icon in the thumbnail field.
/// </summary>
/// <param name="builder">The builder to add the thumbnail to.</param>
/// <param name="iconSource">The guild whose icon to use in the thumbnail field.</param>
/// <returns>The builder with the added icon in the thumbnail field.</returns>
public static EmbedBuilder WithLargeGuildIcon(
this EmbedBuilder builder, IGuild iconSource)
{
var iconUrlResult = CDN.GetGuildIconUrl(iconSource, imageSize: 256);
return iconUrlResult.IsSuccess
? builder.WithThumbnailUrl(iconUrlResult.Entity.AbsoluteUri)
: builder;
}
/// <summary>
/// Adds a guild banner in the image field.
/// </summary>
/// <param name="builder">The builder to add the image to.</param>
/// <param name="bannerSource">The guild whose banner to use in the image field.</param>
/// <returns>The builder with the added banner in the image field.</returns>
public static EmbedBuilder WithGuildBanner(
this EmbedBuilder builder, IGuild bannerSource)
{
return bannerSource.Banner is not null
? builder.WithImageUrl(CDN.GetGuildBannerUrl(bannerSource).Entity.AbsoluteUri)
: builder;
}
/// <summary>
/// Adds a footer representing that the action was performed in the <paramref name="guild" />.
/// </summary>
/// <param name="builder">The builder to add the footer to.</param>
/// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added footer.</returns>
public static EmbedBuilder WithGuildFooter(this EmbedBuilder builder, IGuild guild)
{
var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256);
var iconUrl = iconUrlResult.IsSuccess
? iconUrlResult.Entity.AbsoluteUri
: default(Optional<string>);
return builder.WithFooter(new EmbedFooter(guild.Name, iconUrl));
}
/// <summary>
/// Adds a title representing that the action happened in the <paramref name="guild" />.
/// </summary>
/// <param name="builder">The builder to add the title to.</param>
/// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added title.</returns>
public static EmbedBuilder WithGuildTitle(this EmbedBuilder builder, IGuild guild)
{
var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256);
var iconUrl = iconUrlResult.IsSuccess
? iconUrlResult.Entity.AbsoluteUri
: null;
builder.Author = new EmbedAuthorBuilder(guild.Name, iconUrl: iconUrl);
return builder;
}
/// <summary>
/// Adds a scheduled event's cover image.
/// </summary>
/// <param name="builder">The builder to add the image to.</param>
/// <param name="eventId">The ID of the scheduled event whose image to use.</param>
/// <param name="imageHashOptional">The Optional containing the image hash.</param>
/// <returns>The builder with the added cover image.</returns>
public static EmbedBuilder WithEventCover(
this EmbedBuilder builder, Snowflake eventId, Optional<IImageHash?> imageHashOptional)
{
if (!imageHashOptional.IsDefined(out var imageHash))
{
return builder;
}
var iconUrlResult = CDN.GetGuildScheduledEventCoverUrl(eventId, imageHash, imageSize: 1024);
return iconUrlResult.IsDefined(out var iconUrl) ? builder.WithImageUrl(iconUrl.AbsoluteUri) : builder;
}
}

View file

@ -1,21 +0,0 @@
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class FeedbackServiceExtensions
{
public static async Task<Result> SendContextualEmbedResultAsync(
this IFeedbackService feedback, Result<Embed> embedResult,
FeedbackMessageOptions? options = null, CancellationToken ct = default)
{
if (!embedResult.IsDefined(out var embed))
{
return ResultExtensions.FromError(embedResult);
}
return (Result)await feedback.SendContextualEmbedAsync(embed, options, ct);
}
}

View file

@ -1,28 +0,0 @@
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class GuildScheduledEventExtensions
{
public static Result TryGetExternalEventData(this IGuildScheduledEvent scheduledEvent, out DateTimeOffset endTime,
out string? location)
{
endTime = default;
location = null;
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
{
return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata));
}
if (!metadata.Location.IsDefined(out location))
{
return new ArgumentNullError(nameof(metadata.Location));
}
return scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out endTime)
? Result.Success
: new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime));
}
}

View file

@ -1,40 +0,0 @@
using Microsoft.Extensions.Logging;
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class LoggerExtensions
{
/// <summary>
/// Checks if the <paramref name="result" /> has failed due to an error that has resulted from neither invalid user
/// input nor the execution environment and logs the error using the provided <paramref name="logger" />.
/// </summary>
/// <remarks>
/// This has special behavior for <see cref="ExceptionError" /> - its exception will be passed to the
/// <paramref name="logger" />
/// </remarks>
/// <param name="logger">The logger to use.</param>
/// <param name="result">The Result whose error check.</param>
/// <param name="message">The message to use if this result has failed.</param>
public static void LogResult(this ILogger logger, IResult result, string? message = "")
{
if (result.IsSuccess)
{
return;
}
if (result.Error is ExceptionError exe)
{
if (exe.Exception is OperationCanceledException)
{
return;
}
logger.LogError(exe.Exception, "{ErrorMessage}", message);
return;
}
logger.LogWarning("{UserMessage}{NewLine}{ResultErrorMessage}", message, Environment.NewLine,
result.Error.Message);
}
}

View file

@ -1,28 +0,0 @@
namespace TeamOctolings.Octobot.Extensions;
public static class MarkdownExtensions
{
/// <summary>
/// Formats a string to use Markdown Bullet formatting.
/// </summary>
/// <param name="text">The input text to format.</param>
/// <returns>
/// A markdown-formatted bullet string.
/// </returns>
public static string BulletPoint(string text)
{
return $"- {text}";
}
/// <summary>
/// Formats a string to use Markdown Quote formatting.
/// </summary>
/// <param name="text">The input text to format.</param>
/// <returns>
/// A markdown-formatted quote string.
/// </returns>
public static string Quote(string text)
{
return $"> {text}";
}
}

View file

@ -1,65 +0,0 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Remora.Results;
namespace TeamOctolings.Octobot.Extensions;
public static class ResultExtensions
{
public static Result FromError(Result result)
{
LogResultStackTrace(result);
return result;
}
public static Result FromError<T>(Result<T> result)
{
var casted = (Result)result;
LogResultStackTrace(casted);
return casted;
}
private static void LogResultStackTrace(Result result)
{
if (result.IsSuccess || result.Error is ExceptionError { Exception: OperationCanceledException })
{
return;
}
if (Utility.StaticLogger is null)
{
throw new InvalidOperationException();
}
Utility.StaticLogger.LogError("{ErrorType}: {ErrorMessage}{NewLine}{StackTrace}",
result.Error.GetType().FullName, result.Error.Message, Environment.NewLine, ConstructStackTrace());
var inner = result.Inner;
while (inner is { IsSuccess: false })
{
Utility.StaticLogger.LogError("Caused by: {ResultType}: {ResultMessage}",
inner.Error.GetType().FullName, inner.Error.Message);
inner = inner.Inner;
}
}
private static string ConstructStackTrace()
{
var stackArray = new StackTrace(3, true).ToString().Split(Environment.NewLine).ToList();
for (var i = stackArray.Count - 1; i >= 0; i--)
{
var frame = stackArray[i];
var trimmed = frame.TrimStart();
if (trimmed.StartsWith("at System.Threading", StringComparison.Ordinal)
|| trimmed.StartsWith("at System.Runtime.CompilerServices", StringComparison.Ordinal))
{
stackArray.RemoveAt(i);
}
}
return string.Join(Environment.NewLine, stackArray);
}
}

View file

@ -1,32 +0,0 @@
using Remora.Rest.Core;
namespace TeamOctolings.Octobot.Extensions;
public static class SnowflakeExtensions
{
/// <summary>
/// Checks whether this Snowflake has any value set.
/// </summary>
/// <param name="snowflake">The Snowflake to check.</param>
/// <returns>true if the Snowflake has no value set or it's set to 0, false otherwise.</returns>
public static bool Empty(this Snowflake snowflake)
{
return snowflake.Value is 0;
}
/// <summary>
/// Checks whether this snowflake is empty (see <see cref="Empty" />) or it's equal to
/// <paramref name="anotherSnowflake" />
/// </summary>
/// <param name="snowflake">The Snowflake to check for emptiness</param>
/// <param name="anotherSnowflake">The Snowflake to check for equality with <paramref name="snowflake" />.</param>
/// <returns>
/// true if <paramref name="snowflake" /> is empty or is equal to <paramref name="anotherSnowflake" />, false
/// otherwise.
/// </returns>
/// <seealso cref="Empty" />
public static bool EmptyOrEqualTo(this Snowflake snowflake, Snowflake anotherSnowflake)
{
return snowflake.Empty() || snowflake == anotherSnowflake;
}
}

View file

@ -1,62 +0,0 @@
using System.Text;
namespace TeamOctolings.Octobot.Extensions;
public static class StringBuilderExtensions
{
/// <summary>
/// Appends the input string with Markdown Bullet formatting to the specified <see cref="StringBuilder" /> object.
/// </summary>
/// <param name="builder">The <see cref="StringBuilder" /> object.</param>
/// <param name="value">The string to append with bullet point.</param>
/// <returns>
/// The builder with the appended string with Markdown Bullet formatting.
/// </returns>
public static StringBuilder AppendBulletPoint(this StringBuilder builder, string? value)
{
return builder.Append("- ").Append(value);
}
/// <summary>
/// Appends the input string with Markdown Sub-Bullet formatting to the specified <see cref="StringBuilder" /> object.
/// </summary>
/// <param name="builder">The <see cref="StringBuilder" /> object.</param>
/// <param name="value">The string to append with sub-bullet point.</param>
/// <returns>
/// The builder with the appended string with Markdown Sub-Bullet formatting.
/// </returns>
public static StringBuilder AppendSubBulletPoint(this StringBuilder builder, string? value)
{
return builder.Append(" - ").Append(value);
}
/// <summary>
/// Appends the input string with Markdown Bullet formatting followed by
/// the default line terminator to the end of specified <see cref="StringBuilder" /> object.
/// </summary>
/// <param name="builder">The <see cref="StringBuilder" /> object.</param>
/// <param name="value">The string to append with bullet point.</param>
/// <returns>
/// The builder with the appended string with Markdown Bullet formatting
/// and default line terminator at the end.
/// </returns>
public static StringBuilder AppendBulletPointLine(this StringBuilder builder, string? value)
{
return builder.Append("- ").AppendLine(value);
}
/// <summary>
/// Appends the input string with Markdown Sub-Bullet formatting followed by
/// the default line terminator to the end of specified <see cref="StringBuilder" /> object.
/// </summary>
/// <param name="builder">The <see cref="StringBuilder" /> object.</param>
/// <param name="value">The string to append with sub-bullet point.</param>
/// <returns>
/// The builder with the appended string with Markdown Sub-Bullet formatting
/// and default line terminator at the end.
/// </returns>
public static StringBuilder AppendSubBulletPointLine(this StringBuilder builder, string? value)
{
return builder.Append(" - ").AppendLine(value);
}
}

View file

@ -1,66 +0,0 @@
using System.Net;
using Remora.Discord.Extensions.Formatting;
namespace TeamOctolings.Octobot.Extensions;
public static class StringExtensions
{
private const string ZeroWidthSpace = "";
/// <summary>
/// Sanitizes a string for use in <see cref="Markdown.BlockCode(string)" /> by inserting zero-width spaces in between
/// symbols used to format the string with block code.
/// </summary>
/// <param name="s">The string to sanitize.</param>
/// <returns>The sanitized string that can be safely used in <see cref="Markdown.BlockCode(string)" />.</returns>
private static string SanitizeForBlockCode(this string s)
{
return s.Replace("```", $"{ZeroWidthSpace}`{ZeroWidthSpace}`{ZeroWidthSpace}`{ZeroWidthSpace}");
}
/// <summary>
/// Sanitizes a string for use in <see cref="Markdown.BlockCode(string, string)" /> when "language" is "diff" by
/// prepending a zero-width space before the input string to prevent Discord from applying syntax highlighting.
/// </summary>
/// <remarks>This does not call <see cref="SanitizeForBlockCode"/>, you have to do so yourself if needed.</remarks>
/// <param name="s">The string to sanitize.</param>
/// <returns>The sanitized string that can be safely used in <see cref="Markdown.BlockCode(string, string)" /> with "diff" as the language.</returns>
public static string SanitizeForDiffBlock(this string s)
{
return $"{ZeroWidthSpace}{s}";
}
/// <summary>
/// Sanitizes a string (see <see cref="SanitizeForBlockCode" />) and formats the string to use Markdown Block Code
/// formatting with a specified
/// language for syntax highlighting.
/// </summary>
/// <param name="s">The string to sanitize and format.</param>
/// <param name="language"></param>
/// <returns>
/// The sanitized string formatted to use Markdown Block Code with a specified
/// language for syntax highlighting.
/// </returns>
public static string InBlockCode(this string s, string language = "")
{
s = s.SanitizeForBlockCode();
return
$"```{language}\n{s.SanitizeForBlockCode()}{(s.EndsWith('`') || string.IsNullOrWhiteSpace(s) ? " " : "")}```";
}
public static string Localized(this string key)
{
return Messages.ResourceManager.GetString(key, Messages.Culture) ?? key;
}
/// <summary>
/// Encodes a string to allow its transmission in request headers.
/// </summary>
/// <remarks>Used when encountering "Request headers must contain only ASCII characters".</remarks>
/// <param name="s">The string to encode.</param>
/// <returns>An encoded string with spaces kept intact.</returns>
public static string EncodeHeader(this string s)
{
return WebUtility.UrlEncode(s).Replace('+', ' ');
}
}

View file

@ -1,12 +0,0 @@
using Remora.Discord.API;
using Remora.Rest.Core;
namespace TeamOctolings.Octobot.Extensions;
public static class UInt64Extensions
{
public static Snowflake ToSnowflake(this ulong id)
{
return DiscordSnowflake.New(id);
}
}

View file

@ -1,11 +0,0 @@
using Remora.Discord.API.Abstractions.Objects;
namespace TeamOctolings.Octobot.Extensions;
public static class UserExtensions
{
public static string GetTag(this IUser user)
{
return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}";
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,687 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader><resheader name="version">2.0</resheader><resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader><resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader><data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data><data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data><data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"><value>[base64 mime encoded serialized .NET Framework object]</value></data><data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"><value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value><comment>This is a comment</comment></data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="root" xmlns="">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
</resheader>
<data name="Ready" xml:space="preserve">
<value>I'm ready!</value>
</data>
<data name="CachedMessageDeleted" xml:space="preserve">
<value>Deleted message by {0}:</value>
</data>
<data name="CachedMessageEdited" xml:space="preserve">
<value>Edited message by {0}:</value>
</data>
<data name="DefaultWelcomeMessage" xml:space="preserve">
<value>{0}, welcome to {1}</value>
</data>
<data name="Generic1" xml:space="preserve">
<value>Veemo!</value>
</data>
<data name="Generic2" xml:space="preserve">
<value>Woomy!</value>
</data>
<data name="Generic3" xml:space="preserve">
<value>Ngyes!</value>
</data>
<data name="YouWereBanned" xml:space="preserve">
<value>You were banned</value>
</data>
<data name="PunishmentExpired" xml:space="preserve">
<value>Punishment expired</value>
</data>
<data name="YouWereKicked" xml:space="preserve">
<value>You were kicked</value>
</data>
<data name="Milliseconds" xml:space="preserve">
<value>ms</value>
</data>
<data name="ChannelNotSpecified" xml:space="preserve">
<value>Not specified</value>
</data>
<data name="RoleNotSpecified" xml:space="preserve">
<value>Not specified</value>
</data>
<data name="SettingsLanguage" xml:space="preserve">
<value>Language</value>
</data>
<data name="SettingsPrefix" xml:space="preserve">
<value>Prefix</value>
</data>
<data name="SettingsRemoveRolesOnMute" xml:space="preserve">
<value>Remove roles on mute</value>
</data>
<data name="SettingsSendWelcomeMessages" xml:space="preserve">
<value>Send welcome messages</value>
</data>
<data name="SettingsMuteRole" xml:space="preserve">
<value>Mute role</value>
</data>
<data name="LanguageNotSupported" xml:space="preserve">
<value>Language not supported!</value>
</data>
<data name="Yes" xml:space="preserve">
<value>Yes</value>
</data>
<data name="No" xml:space="preserve">
<value>No</value>
</data>
<data name="UserNotBanned" xml:space="preserve">
<value>This user is not banned!</value>
</data>
<data name="MemberNotMuted" xml:space="preserve">
<value>Member not muted!</value>
</data>
<data name="SettingsWelcomeMessage" xml:space="preserve">
<value>Welcome message</value>
</data>
<data name="UserBanned" xml:space="preserve">
<value>{0} was banned</value>
</data>
<data name="SettingsReceiveStartupMessages" xml:space="preserve">
<value>Receive startup messages</value>
</data>
<data name="InvalidSettingValue" xml:space="preserve">
<value>Invalid setting value specified!</value>
</data>
<data name="DurationRequiredForTimeOuts" xml:space="preserve">
<value>I cannot mute someone for more than 28 days using timeouts! Either specify a duration shorter than 28 days, or set a mute role in settings</value>
</data>
<data name="CannotTimeOutBot" xml:space="preserve">
<value>I cannot use time-outs on other bots! Try to set a mute role in settings</value>
</data>
<data name="SettingsEventNotificationRole" xml:space="preserve">
<value>Role for event creation notifications</value>
</data>
<data name="SettingsEventNotificationChannel" xml:space="preserve">
<value>Channel for event notifications</value>
</data>
<data name="SettingsEventStartedReceivers" xml:space="preserve">
<value>Event start notifications receivers</value>
</data>
<data name="EventStarted" xml:space="preserve">
<value>Event "{0}" started</value>
</data>
<data name="EventCancelled" xml:space="preserve">
<value>Event "{0}" is cancelled!</value>
</data>
<data name="EventCompleted" xml:space="preserve">
<value>Event "{0}" has completed!</value>
</data>
<data name="MessagesCleared" xml:space="preserve">
<value>Cleared {0} messages</value>
</data>
<data name="SettingsNothingChanged" xml:space="preserve">
<value>Nothing changed! `{0}` is already set to {1}</value>
</data>
<data name="SettingNotDefined" xml:space="preserve">
<value>Not specified</value>
</data>
<data name="MissingUser" xml:space="preserve">
<value>You need to specify a user!</value>
</data>
<data name="UserCannotBanMembers" xml:space="preserve">
<value>You cannot ban users from this guild!</value>
</data>
<data name="UserCannotManageMessages" xml:space="preserve">
<value>You cannot manage messages in this guild!</value>
</data>
<data name="UserCannotKickMembers" xml:space="preserve">
<value>You cannot kick members from this guild!</value>
</data>
<data name="UserCannotMuteMembers" xml:space="preserve">
<value>You cannot mute members in this guild!</value>
</data>
<data name="UserCannotUnmuteMembers" xml:space="preserve">
<value>You cannot unmute members in this guild!</value>
</data>
<data name="UserCannotManageGuild" xml:space="preserve">
<value>You cannot manage this guild!</value>
</data>
<data name="BotCannotBanMembers" xml:space="preserve">
<value>I cannot ban users from this guild!</value>
</data>
<data name="BotCannotManageMessages" xml:space="preserve">
<value>I cannot manage messages in this guild!</value>
</data>
<data name="BotCannotKickMembers" xml:space="preserve">
<value>I cannot kick members from this guild!</value>
</data>
<data name="BotCannotModerateMembers" xml:space="preserve">
<value>I cannot moderate members in this guild!</value>
</data>
<data name="BotCannotManageGuild" xml:space="preserve">
<value>I cannot manage this guild!</value>
</data>
<data name="UserCannotBanOwner" xml:space="preserve">
<value>You cannot ban the owner of this guild!</value>
</data>
<data name="UserCannotBanThemselves" xml:space="preserve">
<value>You cannot ban yourself!</value>
</data>
<data name="UserCannotBanBot" xml:space="preserve">
<value>You cannot ban me!</value>
</data>
<data name="BotCannotBanTarget" xml:space="preserve">
<value>I cannot ban this user!</value>
</data>
<data name="UserCannotBanTarget" xml:space="preserve">
<value>You cannot ban this user!</value>
</data>
<data name="UserCannotKickOwner" xml:space="preserve">
<value>You cannot kick the owner of this guild!</value>
</data>
<data name="UserCannotKickThemselves" xml:space="preserve">
<value>You cannot kick yourself!</value>
</data>
<data name="UserCannotKickBot" xml:space="preserve">
<value>You cannot kick me!</value>
</data>
<data name="BotCannotKickTarget" xml:space="preserve">
<value>I cannot kick this member!</value>
</data>
<data name="UserCannotKickTarget" xml:space="preserve">
<value>You cannot kick this member!</value>
</data>
<data name="UserCannotMuteOwner" xml:space="preserve">
<value>You cannot mute the owner of this guild!</value>
</data>
<data name="UserCannotMuteThemselves" xml:space="preserve">
<value>You cannot mute yourself!</value>
</data>
<data name="UserCannotMuteBot" xml:space="preserve">
<value>You cannot mute me!</value>
</data>
<data name="BotCannotMuteTarget" xml:space="preserve">
<value>I cannot mute this member!</value>
</data>
<data name="UserCannotMuteTarget" xml:space="preserve">
<value>You cannot mute this member!</value>
</data>
<data name="UserCannotUnmuteOwner" xml:space="preserve">
<value>You don't need to unmute the owner of this guild!</value>
</data>
<data name="UserCannotUnmuteThemselves" xml:space="preserve">
<value>You are muted!</value>
</data>
<data name="UserCannotUnmuteBot" xml:space="preserve">
<value>...</value>
</data>
<data name="BotCannotUnmuteTarget" xml:space="preserve">
<value>I cannot unmute this member!</value>
</data>
<data name="UserCannotUnmuteTarget" xml:space="preserve">
<value>You cannot unmute this user!</value>
</data>
<data name="EventEarlyNotification" xml:space="preserve">
<value>Event "{0}" will start {1}!</value>
</data>
<data name="SettingsEventEarlyNotificationOffset" xml:space="preserve">
<value>Early event start notification offset</value>
</data>
<data name="UserNotFound" xml:space="preserve">
<value>I could not find this user in any guild I'm a member of! Check if the ID is correct and that the user was on this server no longer than 30 days ago</value>
</data>
<data name="SettingsDefaultRole" xml:space="preserve">
<value>Default role</value>
</data>
<data name="SettingsPublicFeedbackChannel" xml:space="preserve">
<value>Channel for public notifications</value>
</data>
<data name="SettingsPrivateFeedbackChannel" xml:space="preserve">
<value>Channel for private notifications</value>
</data>
<data name="SettingsReturnRolesOnRejoin" xml:space="preserve">
<value>Return roles on rejoin</value>
</data>
<data name="SettingsAutoStartEvents" xml:space="preserve">
<value>Automatically start scheduled events</value>
</data>
<data name="IssuedBy" xml:space="preserve">
<value>Issued by</value>
</data>
<data name="EventCreatedTitle" xml:space="preserve">
<value>{0} has created a new event:</value>
</data>
<data name="DescriptionLocalEventCreated" xml:space="preserve">
<value>The event will start at {0} in {1}</value>
</data>
<data name="DescriptionExternalEventCreated" xml:space="preserve">
<value>The event will start at {0} until {1} in {2}</value>
</data>
<data name="ButtonOpenEventInfo" xml:space="preserve">
<value>Open Event Info</value>
</data>
<data name="EventDuration" xml:space="preserve">
<value>The event has lasted for `{0}`</value>
</data>
<data name="DescriptionLocalEventStarted" xml:space="preserve">
<value>The event is happening at {0}</value>
</data>
<data name="DescriptionExternalEventStarted" xml:space="preserve">
<value>The event is happening at {0} until {1}</value>
</data>
<data name="UserAlreadyBanned" xml:space="preserve">
<value>This user is already banned!</value>
</data>
<data name="UserUnbanned" xml:space="preserve">
<value>{0} was unbanned</value>
</data>
<data name="UserMuted" xml:space="preserve">
<value>{0} was muted</value>
</data>
<data name="UserUnmuted" xml:space="preserve">
<value>{0} was unmuted</value>
</data>
<data name="UserNotMuted" xml:space="preserve">
<value>This member is not muted!</value>
</data>
<data name="UserNotFoundShort" xml:space="preserve">
<value>I could not find this user!</value>
</data>
<data name="UserKicked" xml:space="preserve">
<value>{0} was kicked</value>
</data>
<data name="DescriptionActionReason" xml:space="preserve">
<value>Reason: {0}</value>
</data>
<data name="DescriptionActionExpiresAt" xml:space="preserve">
<value>Expires at: {0}</value>
</data>
<data name="UserAlreadyMuted" xml:space="preserve">
<value>This user is already muted!</value>
</data>
<data name="MessageFrom" xml:space="preserve">
<value>From {0}:</value>
</data>
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>Developers:</value>
</data>
<data name="ButtonOpenWebsite" xml:space="preserve">
<value>Open Website</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>About {0}</value>
</data>
<data name="AboutDeveloper@mctaylors" xml:space="preserve">
<value>developer &amp; designer, Octobot's Wiki creator</value>
</data>
<data name="AboutDeveloper@Octol1ttle" xml:space="preserve">
<value>main developer</value>
</data>
<data name="AboutDeveloper@neroduckale" xml:space="preserve">
<value>developer</value>
</data>
<data name="ReminderCreated" xml:space="preserve">
<value>Reminder for {0} created</value>
</data>
<data name="Reminder" xml:space="preserve">
<value>Reminder for {0}</value>
</data>
<data name="DescriptionReminder" xml:space="preserve">
<value>You asked me to remind you {0}</value>
</data>
<data name="SettingsListTitle" xml:space="preserve">
<value>Octobot's Settings</value>
</data>
<data name="SettingSuccessfullyChanged" xml:space="preserve">
<value>Setting successfully changed</value>
</data>
<data name="SettingNotChanged" xml:space="preserve">
<value>Setting not changed</value>
</data>
<data name="SettingIsNow" xml:space="preserve">
<value>is now</value>
</data>
<data name="SettingsRenameHoistedUsers" xml:space="preserve">
<value>Rename members who attempt to hoist themselves</value>
</data>
<data name="Page" xml:space="preserve">
<value>Page</value>
</data>
<data name="PageNotFound" xml:space="preserve">
<value>Page not found!</value>
</data>
<data name="PagesAllowed" xml:space="preserve">
<value>There are {0} total pages</value>
</data>
<data name="Next" xml:space="preserve">
<value>Next</value>
</data>
<data name="Previous" xml:space="preserve">
<value>Previous</value>
</data>
<data name="ReminderList" xml:space="preserve">
<value>{0}'s reminders</value>
</data>
<data name="InvalidReminderPosition" xml:space="preserve">
<value>There's no reminder in this position!</value>
</data>
<data name="ReminderDeleted" xml:space="preserve">
<value>Reminder deleted</value>
</data>
<data name="NoRemindersFound" xml:space="preserve">
<value>You don't have any reminders created!</value>
</data>
<data name="SingleSettingReset" xml:space="preserve">
<value>Setting {0} reset</value>
</data>
<data name="AllSettingsReset" xml:space="preserve">
<value>All settings have been reset</value>
</data>
<data name="DescriptionActionJumpToMessage" xml:space="preserve">
<value>Jump to message: {0}</value>
</data>
<data name="DescriptionActionJumpToChannel" xml:space="preserve">
<value>Jump to channel: {0}</value>
</data>
<data name="ReminderPosition" xml:space="preserve">
<value>Position in list: {0}</value>
</data>
<data name="ReminderTime" xml:space="preserve">
<value>Reminder send time: {0}</value>
</data>
<data name="ReminderText" xml:space="preserve">
<value>Reminder text: {0}</value>
</data>
<data name="UserInfoDisplayName" xml:space="preserve">
<value>Display name</value>
</data>
<data name="InformationAbout" xml:space="preserve">
<value>Information about {0}</value>
</data>
<data name="UserInfoMuted" xml:space="preserve">
<value>Muted</value>
</data>
<data name="UserInfoDiscordUserSince" xml:space="preserve">
<value>Discord user since</value>
</data>
<data name="UserInfoBanned" xml:space="preserve">
<value>Banned</value>
</data>
<data name="UserInfoPunishments" xml:space="preserve">
<value>Punishments</value>
</data>
<data name="UserInfoBannedPermanently" xml:space="preserve">
<value>Banned permanently</value>
</data>
<data name="UserInfoNotOnGuild" xml:space="preserve">
<value>Not in the guild</value>
</data>
<data name="UserInfoMutedByTimeout" xml:space="preserve">
<value>Muted by timeout</value>
</data>
<data name="UserInfoMutedByMuteRole" xml:space="preserve">
<value>Muted by mute role</value>
</data>
<data name="UserInfoGuildMemberSince" xml:space="preserve">
<value>Guild member since</value>
</data>
<data name="UserInfoGuildNickname" xml:space="preserve">
<value>Nickname</value>
</data>
<data name="UserInfoGuildRoles" xml:space="preserve">
<value>Roles</value>
</data>
<data name="UserInfoGuildMemberPremiumSince" xml:space="preserve">
<value>Nitro booster since</value>
</data>
<data name="RandomTitle" xml:space="preserve">
<value>Random number for {0} is:</value>
</data>
<data name="RandomMinMaxSame" xml:space="preserve">
<value>Isn't it obvious?</value>
</data>
<data name="RandomMin" xml:space="preserve">
<value>Minimum number: {0}</value>
</data>
<data name="RandomMax" xml:space="preserve">
<value>Maximum number: {0}</value>
</data>
<data name="Default" xml:space="preserve">
<value>(default)</value>
</data>
<data name="TimestampTitle" xml:space="preserve">
<value>Timestamp for {0}:</value>
</data>
<data name="TimestampOffset" xml:space="preserve">
<value>Offset: {0}</value>
</data>
<data name="GuildInfoDescription" xml:space="preserve">
<value>Guild description</value>
</data>
<data name="GuildInfoCreatedAt" xml:space="preserve">
<value>Creation date</value>
</data>
<data name="GuildInfoOwner" xml:space="preserve">
<value>Guild owner</value>
</data>
<data name="GuildInfoServerBoost" xml:space="preserve">
<value>Server Boost</value>
</data>
<data name="GuildInfoBoostTier" xml:space="preserve">
<value>Boost level</value>
</data>
<data name="GuildInfoBoostCount" xml:space="preserve">
<value>Boost count</value>
</data>
<data name="NoMessagesToClear" xml:space="preserve">
<value>There are no messages matching your filter!</value>
</data>
<data name="MessagesClearedFiltered" xml:space="preserve">
<value>Cleared {0} messages from {1}</value>
</data>
<data name="DataLoadFailedTitle" xml:space="preserve">
<value>An error occurred during guild data load.</value>
</data>
<data name="DataLoadFailedDescription" xml:space="preserve">
<value>This will lead to unexpected behavior. Data will no longer be saved</value>
</data>
<data name="CommandExecutionFailed" xml:space="preserve">
<value>An error occurred during command execution, try again later.</value>
</data>
<data name="ContactDevelopers" xml:space="preserve">
<value>Contact the developers if the problem occurs again.</value>
</data>
<data name="ButtonReportIssue" xml:space="preserve">
<value>Report an issue</value>
</data>
<data name="DefaultLeaveMessage" xml:space="preserve">
<value>See you soon, {0}!</value>
</data>
<data name="SettingsLeaveMessage" xml:space="preserve">
<value>Leave message</value>
</data>
<data name="InvalidTimeSpan" xml:space="preserve">
<value>Time specified incorrectly!</value>
</data>
<data name="UserInfoKicked" xml:space="preserve">
<value>Kicked</value>
</data>
<data name="ReminderEdited" xml:space="preserve">
<value>Reminder edited</value>
</data>
<data name="EightBallPositive1" xml:space="preserve">
<value>It is certain</value>
</data>
<data name="EightBallPositive2" xml:space="preserve">
<value>It is decidedly so</value>
</data>
<data name="EightBallPositive3" xml:space="preserve">
<value>Without a doubt</value>
</data>
<data name="EightBallPositive4" xml:space="preserve">
<value>Yes — definitely</value>
</data>
<data name="EightBallPositive5" xml:space="preserve">
<value>You may rely on it</value>
</data>
<data name="EightBallQuestionable1" xml:space="preserve">
<value>As I see it, yes</value>
</data>
<data name="EightBallQuestionable2" xml:space="preserve">
<value>Most likely</value>
</data>
<data name="EightBallQuestionable3" xml:space="preserve">
<value>Outlook good</value>
</data>
<data name="EightBallQuestionable4" xml:space="preserve">
<value>Signs point to yes</value>
</data>
<data name="EightBallQuestionable5" xml:space="preserve">
<value>Yes</value>
</data>
<data name="EightBallNeutral1" xml:space="preserve">
<value>Reply hazy, try again</value>
</data>
<data name="EightBallNeutral2" xml:space="preserve">
<value>Ask again later</value>
</data>
<data name="EightBallNeutral3" xml:space="preserve">
<value>Better not tell you now</value>
</data>
<data name="EightBallNeutral4" xml:space="preserve">
<value>Cannot predict now</value>
</data>
<data name="EightBallNeutral5" xml:space="preserve">
<value>Concentrate and ask again</value>
</data>
<data name="EightBallNegative1" xml:space="preserve">
<value>Dont count on it</value>
</data>
<data name="EightBallNegative2" xml:space="preserve">
<value>My reply is no</value>
</data>
<data name="EightBallNegative3" xml:space="preserve">
<value>My sources say no</value>
</data>
<data name="EightBallNegative4" xml:space="preserve">
<value>Outlook not so good</value>
</data>
<data name="EightBallNegative5" xml:space="preserve">
<value>Very doubtful</value>
</data>
<data name="TimeSpanExample" xml:space="preserve">
<value>Example of a valid input: `1h30m`</value>
</data>
<data name="Version" xml:space="preserve">
<value>Version: {0}</value>
</data>
<data name="SettingsWelcomeMessagesChannel" xml:space="preserve">
<value>Welcome messages channel</value>
</data>
<data name="ButtonDirty" xml:space="preserve">
<value>Can't report an issue in the development version</value>
</data>
<data name="ButtonOpenWiki" xml:space="preserve">
<value>Open Octobot's Wiki</value>
</data>
<data name="SettingsModeratorRole" xml:space="preserve">
<value>Moderator role</value>
</data>
<data name="SettingValueEquals" xml:space="preserve">
<value>The setting value is the same as the input value.</value>
</data>
</root>

View file

@ -1,687 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader><resheader name="version">2.0</resheader><resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader><resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader><data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data><data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data><data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"><value>[base64 mime encoded serialized .NET Framework object]</value></data><data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"><value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value><comment>This is a comment</comment></data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata" id="root" xmlns="">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
</resheader>
<data name="Ready" xml:space="preserve">
<value>Я запустился!</value>
</data>
<data name="CachedMessageDeleted" xml:space="preserve">
<value>Сообщение {0} удалено:</value>
</data>
<data name="CachedMessageEdited" xml:space="preserve">
<value>Сообщение {0} отредактировано:</value>
</data>
<data name="DefaultWelcomeMessage" xml:space="preserve">
<value>{0}, добро пожаловать на сервер {1}</value>
</data>
<data name="Generic1" xml:space="preserve">
<value>Виимо!</value>
</data>
<data name="Generic2" xml:space="preserve">
<value>Вууми!</value>
</data>
<data name="Generic3" xml:space="preserve">
<value>Нгьес!</value>
</data>
<data name="PunishmentExpired" xml:space="preserve">
<value>Время наказания истекло</value>
</data>
<data name="YouWereKicked" xml:space="preserve">
<value>Вы были выгнаны</value>
</data>
<data name="Milliseconds" xml:space="preserve">
<value>мс</value>
</data>
<data name="ChannelNotSpecified" xml:space="preserve">
<value>Не указан</value>
</data>
<data name="RoleNotSpecified" xml:space="preserve">
<value>Не указана</value>
</data>
<data name="SettingsLanguage" xml:space="preserve">
<value>Язык</value>
</data>
<data name="SettingsPrefix" xml:space="preserve">
<value>Префикс</value>
</data>
<data name="SettingsRemoveRolesOnMute" xml:space="preserve">
<value>Удалять роли при муте</value>
</data>
<data name="SettingsSendWelcomeMessages" xml:space="preserve">
<value>Отправлять приветствия</value>
</data>
<data name="SettingsMuteRole" xml:space="preserve">
<value>Роль мута</value>
</data>
<data name="LanguageNotSupported" xml:space="preserve">
<value>Язык не поддерживается! </value>
</data>
<data name="Yes" xml:space="preserve">
<value>Да</value>
</data>
<data name="No" xml:space="preserve">
<value>Нет</value>
</data>
<data name="UserNotBanned" xml:space="preserve">
<value>Этот пользователь не забанен!</value>
</data>
<data name="MemberNotMuted" xml:space="preserve">
<value>Участник не заглушен!</value>
</data>
<data name="SettingsWelcomeMessage" xml:space="preserve">
<value>Приветствие</value>
</data>
<data name="UserBanned" xml:space="preserve">
<value>{0} был(-а) забанен(-а)</value>
</data>
<data name="SettingsReceiveStartupMessages" xml:space="preserve">
<value>Получать сообщения о запуске</value>
</data>
<data name="InvalidSettingValue" xml:space="preserve">
<value>Указано недействительное значение для настройки!</value>
</data>
<data name="DurationRequiredForTimeOuts" xml:space="preserve">
<value>Я не могу заглушить кого-то на более чем 28 дней, используя тайм-ауты! Или укажи продолжительность менее 28 дней, или установи роль мута в настройках</value>
</data>
<data name="CannotTimeOutBot" xml:space="preserve">
<value>Я не могу использовать тайм-ауты на других ботах! Попробуй указать роль мута в настройках</value>
</data>
<data name="SettingsEventNotificationRole" xml:space="preserve">
<value>Роль для уведомлений о создании событий</value>
</data>
<data name="SettingsEventNotificationChannel" xml:space="preserve">
<value>Канал для уведомлений о событиях</value>
</data>
<data name="SettingsEventStartedReceivers" xml:space="preserve">
<value>Получатели уведомлений о начале событий</value>
</data>
<data name="EventStarted" xml:space="preserve">
<value>Событие "{0}" началось</value>
</data>
<data name="EventCancelled" xml:space="preserve">
<value>Событие "{0}" отменено!</value>
</data>
<data name="EventCompleted" xml:space="preserve">
<value>Событие "{0}" завершено!</value>
</data>
<data name="MessagesCleared" xml:space="preserve">
<value>Очищено {0} сообщений</value>
</data>
<data name="SettingsNothingChanged" xml:space="preserve">
<value>Ничего не изменилось! Значение настройки `{0}` уже {1}</value>
</data>
<data name="SettingNotDefined" xml:space="preserve">
<value>Не указано</value>
</data>
<data name="MissingUser" xml:space="preserve">
<value>Надо указать пользователя!</value>
</data>
<data name="UserCannotBanMembers" xml:space="preserve">
<value>Ты не можешь банить пользователей на этом сервере!</value>
</data>
<data name="UserCannotManageMessages" xml:space="preserve">
<value>Ты не можешь управлять сообщениями этого сервера!</value>
</data>
<data name="UserCannotKickMembers" xml:space="preserve">
<value>Ты не можешь выгонять участников с этого сервера!</value>
</data>
<data name="UserCannotMuteMembers" xml:space="preserve">
<value>Ты не можешь глушить участников этого сервера!</value>
</data>
<data name="UserCannotUnmuteMembers" xml:space="preserve">
<value>Ты не можешь разглушать участников этого сервера!</value>
</data>
<data name="UserCannotManageGuild" xml:space="preserve">
<value>Ты не можешь настраивать этот сервер!</value>
</data>
<data name="BotCannotBanMembers" xml:space="preserve">
<value>Я не могу банить пользователей на этом сервере!</value>
</data>
<data name="BotCannotManageMessages" xml:space="preserve">
<value>Я не могу управлять сообщениями этого сервера!</value>
</data>
<data name="BotCannotKickMembers" xml:space="preserve">
<value>Я не могу выгонять участников с этого сервера!</value>
</data>
<data name="BotCannotModerateMembers" xml:space="preserve">
<value>Я не могу модерировать участников этого сервера!</value>
</data>
<data name="BotCannotManageGuild" xml:space="preserve">
<value>Я не могу настраивать этот сервер!</value>
</data>
<data name="UserCannotBanBot" xml:space="preserve">
<value>Ты не можешь меня забанить!</value>
</data>
<data name="UserCannotBanOwner" xml:space="preserve">
<value>Ты не можешь забанить владельца этого сервера!</value>
</data>
<data name="UserCannotBanTarget" xml:space="preserve">
<value>Ты не можешь забанить этого участника!</value>
</data>
<data name="UserCannotBanThemselves" xml:space="preserve">
<value>Ты не можешь себя забанить!</value>
</data>
<data name="BotCannotBanTarget" xml:space="preserve">
<value>Я не могу забанить этого пользователя!</value>
</data>
<data name="UserCannotKickOwner" xml:space="preserve">
<value>Ты не можешь выгнать владельца этого сервера!</value>
</data>
<data name="UserCannotKickThemselves" xml:space="preserve">
<value>Ты не можешь себя выгнать!</value>
</data>
<data name="UserCannotKickBot" xml:space="preserve">
<value>Ты не можешь меня выгнать!</value>
</data>
<data name="BotCannotKickTarget" xml:space="preserve">
<value>Я не могу выгнать этого участника</value>
</data>
<data name="UserCannotKickTarget" xml:space="preserve">
<value>Ты не можешь выгнать этого участника!</value>
</data>
<data name="UserCannotMuteOwner" xml:space="preserve">
<value>Ты не можешь заглушить владельца этого сервера!</value>
</data>
<data name="UserCannotMuteThemselves" xml:space="preserve">
<value>Ты не можешь себя заглушить!</value>
</data>
<data name="UserCannotMuteBot" xml:space="preserve">
<value>Ты не можешь заглушить меня!</value>
</data>
<data name="BotCannotMuteTarget" xml:space="preserve">
<value>Я не могу заглушить этого пользователя!</value>
</data>
<data name="UserCannotMuteTarget" xml:space="preserve">
<value>Ты не можешь заглушить этого участника!</value>
</data>
<data name="UserCannotUnmuteOwner" xml:space="preserve">
<value>Тебе не надо возвращать из мута владельца этого сервера!</value>
</data>
<data name="UserCannotUnmuteThemselves" xml:space="preserve">
<value>Ты заглушен!</value>
</data>
<data name="UserCannotUnmuteBot" xml:space="preserve">
<value>... </value>
</data>
<data name="UserCannotUnmuteTarget" xml:space="preserve">
<value>Ты не можешь вернуть из мута этого пользователя!</value>
</data>
<data name="BotCannotUnmuteTarget" xml:space="preserve">
<value>Я не могу вернуть из мута этого пользователя!</value>
</data>
<data name="EventEarlyNotification" xml:space="preserve">
<value>Событие "{0}" начнется {1}!</value>
</data>
<data name="SettingsEventEarlyNotificationOffset" xml:space="preserve">
<value>Офсет отправки преждевременного уведомления о начале события</value>
</data>
<data name="UserNotFound" xml:space="preserve">
<value>Я не смог найти этого пользователя ни в одном из серверов, в которых я есть. Проверь правильность ID и нахождение пользователя на этом сервере максимум 30 дней назад</value>
</data>
<data name="SettingsDefaultRole" xml:space="preserve">
<value>Роль по умолчанию</value>
</data>
<data name="SettingsPublicFeedbackChannel" xml:space="preserve">
<value>Канал для публичных уведомлений</value>
</data>
<data name="SettingsPrivateFeedbackChannel" xml:space="preserve">
<value>Канал для приватных уведомлений</value>
</data>
<data name="SettingsReturnRolesOnRejoin" xml:space="preserve">
<value>Возвращать роли при перезаходе</value>
</data>
<data name="SettingsAutoStartEvents" xml:space="preserve">
<value>Автоматически начинать события</value>
</data>
<data name="IssuedBy" xml:space="preserve">
<value>Ответственный</value>
</data>
<data name="EventCreatedTitle" xml:space="preserve">
<value>{0} создаёт новое событие:</value>
</data>
<data name="DescriptionLocalEventCreated" xml:space="preserve">
<value>Событие пройдёт {0} в канале {1}</value>
</data>
<data name="DescriptionExternalEventCreated" xml:space="preserve">
<value>Событие пройдёт с {0} до {1} в {2}</value>
</data>
<data name="ButtonOpenEventInfo" xml:space="preserve">
<value>Открыть сведения о событии</value>
</data>
<data name="EventDuration" xml:space="preserve">
<value>Событие длилось `{0}`</value>
</data>
<data name="DescriptionLocalEventStarted" xml:space="preserve">
<value>Событие происходит в {0}</value>
</data>
<data name="DescriptionExternalEventStarted" xml:space="preserve">
<value>Событие происходит в {0} до {1}</value>
</data>
<data name="UserAlreadyBanned" xml:space="preserve">
<value>Этот пользователь уже забанен!</value>
</data>
<data name="UserUnbanned" xml:space="preserve">
<value>{0} был(-а) разбанен(-а)</value>
</data>
<data name="UserMuted" xml:space="preserve">
<value>{0} был(-а) заглушен(-а)</value>
</data>
<data name="UserNotMuted" xml:space="preserve">
<value>Этот участник не заглушен!</value>
</data>
<data name="UserUnmuted" xml:space="preserve">
<value>{0} был(-а) разглушен(-а)</value>
</data>
<data name="UserNotFoundShort" xml:space="preserve">
<value>Я не смог найти этого пользователя!</value>
</data>
<data name="UserKicked" xml:space="preserve">
<value>{0} был(-а) выгнан(-а)</value>
</data>
<data name="DescriptionActionReason" xml:space="preserve">
<value>Причина: {0}</value>
</data>
<data name="DescriptionActionExpiresAt" xml:space="preserve">
<value>Закончится: {0}</value>
</data>
<data name="UserAlreadyMuted" xml:space="preserve">
<value>Этот пользователь уже в муте!</value>
</data>
<data name="YouWereBanned" xml:space="preserve">
<value>Вы были забанены</value>
</data>
<data name="MessageFrom" xml:space="preserve">
<value>От {0}:</value>
</data>
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>Разработчики:</value>
</data>
<data name="ButtonOpenWebsite" xml:space="preserve">
<value>Открыть веб-сайт</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>О боте {0}</value>
</data>
<data name="AboutDeveloper@neroduckale" xml:space="preserve">
<value>разработчик</value>
</data>
<data name="AboutDeveloper@Octol1ttle" xml:space="preserve">
<value>основной разработчик</value>
</data>
<data name="AboutDeveloper@mctaylors" xml:space="preserve">
<value>разработчик и дизайнер, создатель Octobot's Wiki</value>
</data>
<data name="ReminderCreated" xml:space="preserve">
<value>Напоминание для {0} создано</value>
</data>
<data name="Reminder" xml:space="preserve">
<value>Напоминание для {0}</value>
</data>
<data name="DescriptionReminder" xml:space="preserve">
<value>Вы просили напомнить вам {0}</value>
</data>
<data name="SettingsListTitle" xml:space="preserve">
<value>Настройки Octobot</value>
</data>
<data name="SettingSuccessfullyChanged" xml:space="preserve">
<value>Настройка успешно изменена</value>
</data>
<data name="SettingNotChanged" xml:space="preserve">
<value>Настройка не редактирована</value>
</data>
<data name="SettingIsNow" xml:space="preserve">
<value>теперь имеет значение</value>
</data>
<data name="SettingsRenameHoistedUsers" xml:space="preserve">
<value>Переименовывать участников, которые пытаются поднять себя</value>
</data>
<data name="Page" xml:space="preserve">
<value>Страница</value>
</data>
<data name="PageNotFound" xml:space="preserve">
<value>Страница не найдена!</value>
</data>
<data name="PagesAllowed" xml:space="preserve">
<value>Всего есть {0} страниц(-ы)</value>
</data>
<data name="Next" xml:space="preserve">
<value>Далее</value>
</data>
<data name="Previous" xml:space="preserve">
<value>Назад</value>
</data>
<data name="ReminderList" xml:space="preserve">
<value>Напоминания {0}</value>
</data>
<data name="InvalidReminderPosition" xml:space="preserve">
<value>У тебя нет напоминания на этой позиции!</value>
</data>
<data name="ReminderDeleted" xml:space="preserve">
<value>Напоминание удалено</value>
</data>
<data name="NoRemindersFound" xml:space="preserve">
<value>У вас нет созданных напоминаний!</value>
</data>
<data name="SingleSettingReset" xml:space="preserve">
<value>Настройка {0} сброшена</value>
</data>
<data name="AllSettingsReset" xml:space="preserve">
<value>Все настройки были сброшены</value>
</data>
<data name="DescriptionActionJumpToMessage" xml:space="preserve">
<value>Перейти к сообщению: {0}</value>
</data>
<data name="DescriptionActionJumpToChannel" xml:space="preserve">
<value>Перейти к каналу: {0}</value>
</data>
<data name="ReminderPosition" xml:space="preserve">
<value>Позиция в списке: {0}</value>
</data>
<data name="ReminderTime" xml:space="preserve">
<value>Время отправки напоминания: {0}</value>
</data>
<data name="ReminderText" xml:space="preserve">
<value>Текст напоминания: {0}</value>
</data>
<data name="UserInfoDisplayName" xml:space="preserve">
<value>Отображаемое имя</value>
</data>
<data name="InformationAbout" xml:space="preserve">
<value>Информация о {0}</value>
</data>
<data name="UserInfoMuted" xml:space="preserve">
<value>Заглушен</value>
</data>
<data name="UserInfoDiscordUserSince" xml:space="preserve">
<value>Вступил в Discord</value>
</data>
<data name="UserInfoBanned" xml:space="preserve">
<value>Забанен</value>
</data>
<data name="UserInfoPunishments" xml:space="preserve">
<value>Наказания</value>
</data>
<data name="UserInfoBannedPermanently" xml:space="preserve">
<value>Забанен навсегда</value>
</data>
<data name="UserInfoNotOnGuild" xml:space="preserve">
<value>Не на сервере</value>
</data>
<data name="UserInfoMutedByTimeout" xml:space="preserve">
<value>Заглушен с помощью тайм-аута</value>
</data>
<data name="UserInfoMutedByMuteRole" xml:space="preserve">
<value>Заглушен с помощью роли мута</value>
</data>
<data name="UserInfoGuildMemberSince" xml:space="preserve">
<value>Вступил на сервер</value>
</data>
<data name="UserInfoGuildNickname" xml:space="preserve">
<value>Никнейм</value>
</data>
<data name="UserInfoGuildRoles" xml:space="preserve">
<value>Роли</value>
</data>
<data name="UserInfoGuildMemberPremiumSince" xml:space="preserve">
<value>Начал бустить сервер</value>
</data>
<data name="RandomTitle" xml:space="preserve">
<value>Случайное число для {0}:</value>
</data>
<data name="RandomMinMaxSame" xml:space="preserve">
<value>Разве это не очевидно?</value>
</data>
<data name="RandomMax" xml:space="preserve">
<value>Максимальное число: {0}</value>
</data>
<data name="RandomMin" xml:space="preserve">
<value>Минимальное число: {0}</value>
</data>
<data name="Default" xml:space="preserve">
<value>(по умолчанию)</value>
</data>
<data name="TimestampTitle" xml:space="preserve">
<value>Временная метка для {0}:</value>
</data>
<data name="TimestampOffset" xml:space="preserve">
<value>Офсет: {0}</value>
</data>
<data name="GuildInfoDescription" xml:space="preserve">
<value>Описание сервера</value>
</data>
<data name="GuildInfoCreatedAt" xml:space="preserve">
<value>Дата создания</value>
</data>
<data name="GuildInfoOwner" xml:space="preserve">
<value>Владелец сервера</value>
</data>
<data name="GuildInfoServerBoost" xml:space="preserve">
<value>Буст сервера</value>
</data>
<data name="GuildInfoBoostTier" xml:space="preserve">
<value>Уровень буста</value>
</data>
<data name="GuildInfoBoostCount" xml:space="preserve">
<value>Количество бустов</value>
</data>
<data name="NoMessagesToClear" xml:space="preserve">
<value>Нет сообщений, которые подходят под твой фильтр!</value>
</data>
<data name="MessagesClearedFiltered" xml:space="preserve">
<value>Очищено {0} сообщений от {1}</value>
</data>
<data name="DataLoadFailedTitle" xml:space="preserve">
<value>Произошла ошибка при загрузке данных сервера.</value>
</data>
<data name="DataLoadFailedDescription" xml:space="preserve">
<value>Это может привести к неожиданному поведению. Данные больше не будут сохраняться.</value>
</data>
<data name="CommandExecutionFailed" xml:space="preserve">
<value>Произошла ошибка при выполнении команды, повтори попытку позже.</value>
</data>
<data name="ContactDevelopers" xml:space="preserve">
<value>Обратись к разработчикам, если проблема возникнет снова.</value>
</data>
<data name="ButtonReportIssue" xml:space="preserve">
<value>Сообщить о проблеме</value>
</data>
<data name="DefaultLeaveMessage" xml:space="preserve">
<value>До скорой встречи, {0}!</value>
</data>
<data name="SettingsLeaveMessage" xml:space="preserve">
<value>Сообщение о выходе</value>
</data>
<data name="InvalidTimeSpan" xml:space="preserve">
<value>Неправильно указано время!</value>
</data>
<data name="UserInfoKicked" xml:space="preserve">
<value>Выгнан</value>
</data>
<data name="ReminderEdited" xml:space="preserve">
<value>Напоминание отредактировано</value>
</data>
<data name="EightBallPositive1" xml:space="preserve">
<value>Бесспорно</value>
</data>
<data name="EightBallPositive2" xml:space="preserve">
<value>Предрешено</value>
</data>
<data name="EightBallPositive3" xml:space="preserve">
<value>Никаких сомнений</value>
</data>
<data name="EightBallPositive4" xml:space="preserve">
<value>Определённо да</value>
</data>
<data name="EightBallPositive5" xml:space="preserve">
<value>Можешь быть уверен в этом</value>
</data>
<data name="EightBallQuestionable1" xml:space="preserve">
<value>Мне кажется — «да»</value>
</data>
<data name="EightBallQuestionable2" xml:space="preserve">
<value>Вероятнее всего</value>
</data>
<data name="EightBallQuestionable3" xml:space="preserve">
<value>Хорошие перспективы</value>
</data>
<data name="EightBallQuestionable4" xml:space="preserve">
<value>Знаки говорят — «да»</value>
</data>
<data name="EightBallQuestionable5" xml:space="preserve">
<value>Да</value>
</data>
<data name="EightBallNeutral1" xml:space="preserve">
<value>Пока не ясно, попробуй снова</value>
</data>
<data name="EightBallNeutral2" xml:space="preserve">
<value>Спроси позже</value>
</data>
<data name="EightBallNeutral3" xml:space="preserve">
<value>Лучше не рассказывать</value>
</data>
<data name="EightBallNeutral4" xml:space="preserve">
<value>Сейчас нельзя предсказать</value>
</data>
<data name="EightBallNeutral5" xml:space="preserve">
<value>Сконцентрируйся и спроси снова</value>
</data>
<data name="EightBallNegative1" xml:space="preserve">
<value>Даже не думай</value>
</data>
<data name="EightBallNegative2" xml:space="preserve">
<value>Мой ответ — «нет»</value>
</data>
<data name="EightBallNegative3" xml:space="preserve">
<value>По моим данным — «нет»</value>
</data>
<data name="EightBallNegative4" xml:space="preserve">
<value>Перспективы не очень хорошие</value>
</data>
<data name="EightBallNegative5" xml:space="preserve">
<value>Весьма сомнительно</value>
</data>
<data name="TimeSpanExample" xml:space="preserve">
<value>Пример правильного ввода: `1ч30м`</value>
</data>
<data name="Version" xml:space="preserve">
<value>Версия: {0}</value>
</data>
<data name="SettingsWelcomeMessagesChannel" xml:space="preserve">
<value>Канал для приветствий</value>
</data>
<data name="ButtonDirty" xml:space="preserve">
<value>Нельзя сообщить о проблеме в версии под разработкой</value>
</data>
<data name="ButtonOpenWiki" xml:space="preserve">
<value>Открыть Octobot's Wiki</value>
</data>
<data name="SettingsModeratorRole" xml:space="preserve">
<value>Роль модератора</value>
</data>
<data name="SettingValueEquals" xml:space="preserve">
<value>Значение настройки такое же, как и вводное значение.</value>
</data>
</root>

View file

@ -1,78 +0,0 @@
using System.Globalization;
using System.Text.RegularExpressions;
using JetBrains.Annotations;
using Remora.Commands.Parsers;
using Remora.Results;
namespace TeamOctolings.Octobot.Parsers;
/// <summary>
/// Parses <see cref="TimeSpan"/>s.
/// </summary>
[PublicAPI]
public partial class TimeSpanParser : AbstractTypeParser<TimeSpan>
{
private static readonly Regex Pattern = ParseRegex();
/// <summary>
/// Parses a <see cref="TimeSpan"/> from the <paramref name="timeSpanString"/>.
/// </summary>
/// <returns>
/// The parsed <see cref="TimeSpan"/>, or <see cref="ArgumentInvalidError"/> if parsing failed.
/// </returns>
public static Result<TimeSpan> TryParse(string timeSpanString)
{
if (timeSpanString.StartsWith('-'))
{
return new ArgumentInvalidError(nameof(timeSpanString), "TimeSpans cannot be negative.");
}
if (TimeSpan.TryParse(timeSpanString, DateTimeFormatInfo.InvariantInfo, out var parsedTimeSpan))
{
return parsedTimeSpan;
}
var matches = ParseRegex().Matches(timeSpanString);
return matches.Count > 0
? ParseFromRegex(matches)
: new ArgumentInvalidError(nameof(timeSpanString), "The regex did not produce any matches.");
}
private static Result<TimeSpan> ParseFromRegex(MatchCollection matches)
{
var timeSpan = TimeSpan.Zero;
foreach (var groups in matches.Select(match => match.Groups
.Cast<Group>()
.Where(g => g.Success)
.Skip(1)
.Select(g => (g.Name, g.Value))))
{
foreach ((var key, var groupValue) in groups)
{
if (!int.TryParse(groupValue, out var parsedIntegerValue))
{
return new ArgumentInvalidError(nameof(groupValue), "The input value was not an integer.");
}
var now = DateTimeOffset.UtcNow;
timeSpan += key switch
{
"Years" => now.AddYears(parsedIntegerValue) - now,
"Months" => now.AddMonths(parsedIntegerValue) - now,
"Weeks" => TimeSpan.FromDays(parsedIntegerValue * 7),
"Days" => TimeSpan.FromDays(parsedIntegerValue),
"Hours" => TimeSpan.FromHours(parsedIntegerValue),
"Minutes" => TimeSpan.FromMinutes(parsedIntegerValue),
"Seconds" => TimeSpan.FromSeconds(parsedIntegerValue),
_ => throw new ArgumentOutOfRangeException(key)
};
}
}
return timeSpan;
}
[GeneratedRegex("(?<Years>\\d+(?=y|л|г))|(?<Months>\\d+(?=mo|мес))|(?<Weeks>\\d+(?=w|н|нед))|(?<Days>\\d+(?=d|д|дн))|(?<Hours>\\d+(?=h|ч))|(?<Minutes>\\d+(?=m|min|мин|м))|(?<Seconds>\\d+(?=s|sec|с|сек))")]
private static partial Regex ParseRegex();
}

View file

@ -1,95 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Gateway.Commands;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.Caching.Extensions;
using Remora.Discord.Caching.Services;
using Remora.Discord.Commands.Extensions;
using Remora.Discord.Commands.Services;
using Remora.Discord.Extensions.Extensions;
using Remora.Discord.Gateway;
using Remora.Discord.Hosting.Extensions;
using Serilog.Extensions.Logging;
using TeamOctolings.Octobot.Commands.Events;
using TeamOctolings.Octobot.Services;
using TeamOctolings.Octobot.Services.Update;
namespace TeamOctolings.Octobot;
public sealed class Program
{
public static async Task Main(string[] args)
{
var host = CreateHostBuilder(args).UseConsoleLifetime().Build();
var services = host.Services;
Utility.StaticLogger = services.GetRequiredService<ILogger<Program>>();
var slashService = services.GetRequiredService<SlashService>();
// Providing a guild ID to this call will result in command duplicates!
// To get rid of them, provide the ID of the guild containing duplicates,
// comment out calls to WithCommandGroup in CreateHostBuilder
// then launch the bot again and remove the guild ID
await slashService.UpdateSlashCommandsAsync();
await host.RunAsync();
}
private static IHostBuilder CreateHostBuilder(string[] args)
{
return Host.CreateDefaultBuilder(args)
.AddDiscordService(services =>
{
var configuration = services.GetRequiredService<IConfiguration>();
return configuration.GetValue<string?>("BOT_TOKEN")
?? throw new InvalidOperationException(
"No bot token has been provided. Set the "
+ "BOT_TOKEN environment variable to a valid token.");
}
).ConfigureServices((_, services) =>
{
services.Configure<DiscordGatewayClientOptions>(options =>
{
options.Intents |= GatewayIntents.MessageContents
| GatewayIntents.GuildMembers
| GatewayIntents.GuildPresences
| GatewayIntents.GuildScheduledEvents;
});
services.Configure<CacheSettings>(cSettings =>
{
cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1));
cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30));
cSettings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7));
cSettings.SetSlidingExpiration<IMessage>(TimeSpan.FromDays(7));
});
services.AddTransient<IConfigurationBuilder, ConfigurationBuilder>()
// Init
.AddDiscordCaching()
.AddDiscordCommands(true, false)
.AddRespondersFromAssembly(typeof(Program).Assembly)
.AddCommandGroupsFromAssembly(typeof(Program).Assembly)
// Slash command event handlers
.AddPreparationErrorEvent<LoggingPreparationErrorEvent>()
.AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>()
// Services
.AddSingleton<AccessControlService>()
.AddSingleton<GuildDataService>()
.AddSingleton<Utility>()
.AddHostedService<GuildDataService>(provider => provider.GetRequiredService<GuildDataService>())
.AddHostedService<MemberUpdateService>()
.AddHostedService<ScheduledEventUpdateService>()
.AddHostedService<SongUpdateService>();
}
).ConfigureLogging(c => c.AddConsole()
.AddFile("Logs/Octobot-{Date}.log",
outputTemplate: "{Timestamp:o} [{Level:u4}] {Message} {NewLine}{Exception}")
.AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
.AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
.AddFilter<SerilogLoggerProvider>("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
.AddFilter<SerilogLoggerProvider>("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
);
}
}

View file

@ -1,125 +0,0 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Gateway.Events;
using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles sending a <see cref="Ready" /> message to a guild that has just initialized if that guild
/// has <see cref="GuildSettings.ReceiveStartupMessages" /> enabled
/// </summary>
[UsedImplicitly]
public sealed class GuildLoadedResponder : IResponder<IGuildCreate>
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _guildData;
private readonly ILogger<GuildLoadedResponder> _logger;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public GuildLoadedResponder(
IDiscordRestChannelAPI channelApi, GuildDataService guildData, ILogger<GuildLoadedResponder> logger,
IDiscordRestUserAPI userApi, Utility utility)
{
_channelApi = channelApi;
_guildData = guildData;
_logger = logger;
_userApi = userApi;
_utility = utility;
}
public async Task<Result> RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.Guild.IsT0) // Guild is not IAvailableGuild
{
return Result.Success;
}
var guild = gatewayEvent.Guild.AsT0;
var data = await _guildData.GetData(guild.ID, ct);
var cfg = data.Settings;
foreach (var member in guild.Members.Where(m => m.User.HasValue))
{
data.GetOrCreateMemberData(member.User.Value.ID);
}
var botResult = await _userApi.GetCurrentUserAsync(ct);
if (!botResult.IsDefined(out var bot))
{
return ResultExtensions.FromError(botResult);
}
if (data.DataLoadFailed)
{
return await SendDataLoadFailed(guild, data, bot, ct);
}
var ownerResult = await _userApi.GetUserAsync(guild.OwnerID, ct);
if (!ownerResult.IsDefined(out var owner))
{
return ResultExtensions.FromError(ownerResult);
}
_logger.LogInformation("Loaded guild \"{Name}\" ({ID}) owned by {Owner} ({OwnerID}) with {MemberCount} members",
guild.Name, guild.ID, owner.GetTag(), owner.ID, guild.MemberCount);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty()
|| !GuildSettings.ReceiveStartupMessages.Get(cfg))
{
return Result.Success;
}
Messages.Culture = GuildSettings.Language.Get(cfg);
var i = Random.Shared.Next(1, 4);
var embed = new EmbedBuilder().WithSmallTitle(bot.GetTag(), bot)
.WithTitle($"Generic{i}".Localized())
.WithDescription(Messages.Ready)
.WithCurrentTimestamp()
.WithColour(ColorsList.Blue)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, ct: ct);
}
private async Task<Result> SendDataLoadFailed(IGuild guild, GuildData data, IUser bot, CancellationToken ct = default)
{
var channelResult = await _utility.GetEmergencyFeedbackChannel(guild, data, ct);
if (!channelResult.IsDefined(out var channel))
{
return ResultExtensions.FromError(channelResult);
}
var errorEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.DataLoadFailedTitle, bot)
.WithDescription(Messages.DataLoadFailedDescription)
.WithFooter(Messages.ContactDevelopers)
.WithColour(ColorsList.Red)
.Build();
var issuesButton = new ButtonComponent(
ButtonComponentStyle.Link,
BuildInfo.IsDirty
? Messages.ButtonDirty
: Messages.ButtonReportIssue,
new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0)
URL: BuildInfo.IssuesUrl,
IsDisabled: BuildInfo.IsDirty
);
return await _channelApi.CreateMessageWithEmbedResultAsync(channel, embedResult: errorEmbed,
components: new[] { new ActionRowComponent([issuesButton]) }, ct: ct);
}
}

View file

@ -1,106 +0,0 @@
using System.Text.Json.Nodes;
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles sending a guild's <see cref="GuildSettings.WelcomeMessage" /> if one is set.
/// If <see cref="GuildSettings.ReturnRolesOnRejoin" /> is enabled, roles will be returned.
/// </summary>
/// <seealso cref="GuildSettings.WelcomeMessage" />
[UsedImplicitly]
public sealed class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
public GuildMemberJoinedResponder(
IDiscordRestChannelAPI channelApi, GuildDataService guildData, IDiscordRestGuildAPI guildApi)
{
_channelApi = channelApi;
_guildData = guildData;
_guildApi = guildApi;
}
public async Task<Result> RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.User.IsDefined(out var user))
{
return new ArgumentNullError(nameof(gatewayEvent.User));
}
var data = await _guildData.GetData(gatewayEvent.GuildID, ct);
var cfg = data.Settings;
var memberData = data.GetOrCreateMemberData(user.ID);
memberData.Kicked = false;
var returnRolesResult = await TryReturnRolesAsync(cfg, memberData, gatewayEvent.GuildID, user.ID, ct);
if (!returnRolesResult.IsSuccess)
{
return ResultExtensions.FromError(returnRolesResult);
}
if (GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty()
|| GuildSettings.WelcomeMessage.Get(cfg) is "off" or "disable" or "disabled")
{
return Result.Success;
}
Messages.Culture = GuildSettings.Language.Get(cfg);
var welcomeMessage = GuildSettings.WelcomeMessage.Get(cfg) is "default" or "reset"
? Messages.DefaultWelcomeMessage
: GuildSettings.WelcomeMessage.Get(cfg);
var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct);
if (!guildResult.IsDefined(out var guild))
{
return ResultExtensions.FromError(guildResult);
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user)
.WithGuildFooter(guild)
.WithTimestamp(gatewayEvent.JoinedAt)
.WithColour(ColorsList.Green)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.WelcomeMessagesChannel.Get(cfg), embedResult: embed,
allowedMentions: Utility.NoMentions, ct: ct);
}
private async Task<Result> TryReturnRolesAsync(
JsonNode cfg, MemberData memberData, Snowflake guildId, Snowflake userId, CancellationToken ct = default)
{
if (!GuildSettings.ReturnRolesOnRejoin.Get(cfg))
{
return Result.Success;
}
var assignRoles = new List<Snowflake>();
if (memberData.MutedUntil is null || !GuildSettings.RemoveRolesOnMute.Get(cfg))
{
assignRoles.AddRange(memberData.Roles.ConvertAll(r => r.ToSnowflake()));
}
if (memberData.MutedUntil is not null)
{
assignRoles.Add(GuildSettings.MuteRole.Get(cfg));
}
return await _guildApi.ModifyGuildMemberAsync(
guildId, userId, roles: assignRoles, ct: ct);
}
}

View file

@ -1,68 +0,0 @@
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles sending a guild's <see cref="GuildSettings.LeaveMessage" /> if one is set.
/// </summary>
/// <seealso cref="GuildSettings.LeaveMessage" />
[UsedImplicitly]
public sealed class GuildMemberLeftResponder : IResponder<IGuildMemberRemove>
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
public GuildMemberLeftResponder(
IDiscordRestChannelAPI channelApi, GuildDataService guildData, IDiscordRestGuildAPI guildApi)
{
_channelApi = channelApi;
_guildData = guildData;
_guildApi = guildApi;
}
public async Task<Result> RespondAsync(IGuildMemberRemove gatewayEvent, CancellationToken ct = default)
{
var user = gatewayEvent.User;
var data = await _guildData.GetData(gatewayEvent.GuildID, ct);
var cfg = data.Settings;
var memberData = data.GetOrCreateMemberData(user.ID);
if (memberData.BannedUntil is not null || memberData.Kicked
|| GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty()
|| GuildSettings.LeaveMessage.Get(cfg) is "off" or "disable" or "disabled")
{
return Result.Success;
}
Messages.Culture = GuildSettings.Language.Get(cfg);
var leaveMessage = GuildSettings.LeaveMessage.Get(cfg) is "default" or "reset"
? Messages.DefaultLeaveMessage
: GuildSettings.LeaveMessage.Get(cfg);
var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct);
if (!guildResult.IsDefined(out var guild))
{
return ResultExtensions.FromError(guildResult);
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(leaveMessage, user.GetTag(), guild.Name), user)
.WithGuildFooter(guild)
.WithTimestamp(DateTimeOffset.UtcNow)
.WithColour(ColorsList.Black)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.WelcomeMessagesChannel.Get(cfg), embedResult: embed,
allowedMentions: Utility.NoMentions, ct: ct);
}
}

View file

@ -1,38 +0,0 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles removing guild ID from <see cref="GuildData" /> if the guild becomes unavailable.
/// </summary>
[UsedImplicitly]
public sealed class GuildUnloadedResponder : IResponder<IGuildDelete>
{
private readonly GuildDataService _guildData;
private readonly ILogger<GuildUnloadedResponder> _logger;
public GuildUnloadedResponder(
GuildDataService guildData, ILogger<GuildUnloadedResponder> logger)
{
_guildData = guildData;
_logger = logger;
}
public Task<Result> RespondAsync(IGuildDelete gatewayEvent, CancellationToken ct = default)
{
var guildId = gatewayEvent.ID;
var isDataRemoved = _guildData.UnloadGuildData(guildId);
if (isDataRemoved)
{
_logger.LogInformation("Unloaded guild {GuildId}", guildId);
}
return Task.FromResult(Result.Success);
}
}

View file

@ -1,107 +0,0 @@
using System.Text;
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles logging the contents of a deleted message and the user who deleted the message
/// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set.
/// </summary>
[UsedImplicitly]
public sealed class MessageDeletedResponder : IResponder<IMessageDelete>
{
private readonly IDiscordRestAuditLogAPI _auditLogApi;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
public MessageDeletedResponder(
IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi,
GuildDataService guildData, IDiscordRestUserAPI userApi)
{
_auditLogApi = auditLogApi;
_channelApi = channelApi;
_guildData = guildData;
_userApi = userApi;
}
public async Task<Result> RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.GuildID.IsDefined(out var guildId))
{
return Result.Success;
}
var cfg = await _guildData.GetSettings(guildId, ct);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{
return Result.Success;
}
var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
if (!messageResult.IsDefined(out var message))
{
return ResultExtensions.FromError(messageResult);
}
if (string.IsNullOrWhiteSpace(message.Content))
{
return Result.Success;
}
var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync(
guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct);
if (!auditLogResult.IsDefined(out var auditLogPage))
{
return ResultExtensions.FromError(auditLogResult);
}
var deleterResult = Result<IUser>.FromSuccess(message.Author);
var auditLog = auditLogPage.AuditLogEntries.SingleOrDefault();
if (auditLog is { UserID: not null }
&& auditLog.Options.Value.ChannelID == gatewayEvent.ChannelID
&& DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2)
{
deleterResult = await _userApi.GetUserAsync(auditLog.UserID.Value, ct);
}
if (!deleterResult.IsDefined(out var deleter))
{
return ResultExtensions.FromError(deleterResult);
}
Messages.Culture = GuildSettings.Language.Get(cfg);
var builder = new StringBuilder()
.AppendLine(message.Content.InBlockCode())
.AppendLine(
string.Format(Messages.DescriptionActionJumpToChannel, Mention.Channel(gatewayEvent.ChannelID))
);
var embed = new EmbedBuilder()
.WithSmallTitle(
string.Format(
Messages.CachedMessageDeleted,
message.Author.GetTag()), message.Author)
.WithDescription(builder.ToString())
.WithActionFooter(deleter)
.WithTimestamp(message.Timestamp)
.WithColour(ColorsList.Red)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed,
allowedMentions: Utility.NoMentions, ct: ct);
}
}

View file

@ -1,98 +0,0 @@
using System.Text;
using DiffPlex.DiffBuilder;
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Caching;
using Remora.Discord.Caching.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles logging the difference between an edited message's old and new content
/// to a guild's <see cref="GuildSettings.PrivateFeedbackChannel" /> if one is set.
/// </summary>
[UsedImplicitly]
public sealed class MessageEditedResponder : IResponder<IMessageUpdate>
{
private readonly CacheService _cacheService;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _guildData;
public MessageEditedResponder(
CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService guildData)
{
_cacheService = cacheService;
_channelApi = channelApi;
_guildData = guildData;
}
public async Task<Result> RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.GuildID.IsDefined(out var guildId)
|| !gatewayEvent.EditedTimestamp.HasValue
|| gatewayEvent.Author.IsBot.OrDefault(false))
{
return Result.Success;
}
var cfg = await _guildData.GetSettings(guildId, ct);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{
return Result.Success;
}
var cacheKey = new KeyHelpers.MessageCacheKey(gatewayEvent.ChannelID, gatewayEvent.ID);
var messageResult = await _cacheService.TryGetValueAsync<IMessage>(
cacheKey, ct);
if (!messageResult.IsDefined(out var message))
{
_ = _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
return Result.Success;
}
if (message.Content == gatewayEvent.Content)
{
return Result.Success;
}
// Custom event responders are called earlier than responders responsible for message caching
// This means that subsequent edit logs may contain the wrong content
// We can work around this by evicting the message from the cache
await _cacheService.EvictAsync<IMessage>(cacheKey, ct);
// However, since we evicted the message, subsequent edits won't have a cached instance to work with
// Getting the message will put it back in the cache, resolving all issues
// We don't need to await this since the result is not needed
// NOTE: Because this is not awaited, there may be a race condition depending on how fast clients are able to edit their messages
// NOTE: Awaiting this might not even solve this if the same responder is called asynchronously
_ = _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
var diff = InlineDiffBuilder.Diff(message.Content, gatewayEvent.Content);
Messages.Culture = GuildSettings.Language.Get(cfg);
var builder = new StringBuilder()
.AppendLine(diff.AsMarkdown())
.AppendLine(string.Format(Messages.DescriptionActionJumpToMessage,
$"https://discord.com/channels/{guildId}/{gatewayEvent.ChannelID}/{gatewayEvent.ID}")
);
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author)
.WithDescription(builder.ToString())
.WithTimestamp(gatewayEvent.EditedTimestamp.Value)
.WithColour(ColorsList.Yellow)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed,
allowedMentions: Utility.NoMentions, ct: ct);
}
}

View file

@ -1,39 +0,0 @@
using JetBrains.Annotations;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core;
using Remora.Results;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles sending replies to easter egg messages.
/// </summary>
[UsedImplicitly]
public sealed class MessageCreateResponder : IResponder<IMessageCreate>
{
private readonly IDiscordRestChannelAPI _channelApi;
public MessageCreateResponder(IDiscordRestChannelAPI channelApi)
{
_channelApi = channelApi;
}
public Task<Result> RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default)
{
_ = _channelApi.CreateMessageAsync(
gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content.ToLowerInvariant() switch
{
"whoami" => "`nobody`",
"сука !!" => "`root`",
"воооо" => "`removing /...`",
"пон" => "https://i.ibb.co/Kw6QVcw/parry.jpg",
"++++" => "#",
"осу" => "https://github.com/ppy/osu",
"лан" => "https://i.ibb.co/VYH2QLc/lan.jpg",
_ => default(Optional<string>)
});
return Task.FromResult(Result.Success);
}
}

View file

@ -1,142 +0,0 @@
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.Services;
public sealed class AccessControlService
{
private readonly GuildDataService _data;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly IDiscordRestUserAPI _userApi;
public AccessControlService(GuildDataService data, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi)
{
_data = data;
_guildApi = guildApi;
_userApi = userApi;
}
private static bool CheckPermission(IEnumerable<IRole> roles, GuildData data, MemberData memberData,
DiscordPermission permission)
{
var moderatorRole = GuildSettings.ModeratorRole.Get(data.Settings);
if (!moderatorRole.Empty() && memberData.Roles.Contains(moderatorRole.Value))
{
return true;
}
return roles
.Where(r => memberData.Roles.Contains(r.ID.Value))
.Any(r =>
r.Permissions.HasPermission(permission)
);
}
/// <summary>
/// Checks whether or not a member can interact with another member
/// </summary>
/// <param name="guildId">The ID of the guild in which an operation is being performed.</param>
/// <param name="interacterId">The executor of the operation.</param>
/// <param name="targetId">The target of the operation.</param>
/// <param name="action">The operation.</param>
/// <param name="ct">The cancellation token for this operation.</param>
/// <returns>
/// <list type="bullet">
/// <item>A result which has succeeded with a null string if the member can interact with the target.</item>
/// <item>
/// A result which has succeeded with a non-null string containing the error message if the member cannot
/// interact with the target.
/// </item>
/// <item>A result which has failed if an error occurred during the execution of this method.</item>
/// </list>
/// </returns>
public async Task<Result<string?>> CheckInteractionsAsync(
Snowflake guildId, Snowflake? interacterId, Snowflake targetId, string action, CancellationToken ct = default)
{
if (interacterId == targetId)
{
return Result<string?>.FromSuccess($"UserCannot{action}Themselves".Localized());
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct);
if (!guildResult.IsDefined(out var guild))
{
return Result<string?>.FromError(guildResult);
}
if (interacterId == guild.OwnerID)
{
return Result<string?>.FromSuccess(null);
}
var botResult = await _userApi.GetCurrentUserAsync(ct);
if (!botResult.IsDefined(out var bot))
{
return Result<string?>.FromError(botResult);
}
var rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct);
if (!rolesResult.IsDefined(out var roles))
{
return Result<string?>.FromError(rolesResult);
}
var data = await _data.GetData(guildId, ct);
var targetData = data.GetOrCreateMemberData(targetId);
var botData = data.GetOrCreateMemberData(bot.ID);
if (interacterId is null)
{
return CheckInteractions(action, guild, roles, targetData, botData, botData);
}
var interacterData = data.GetOrCreateMemberData(interacterId.Value);
var hasPermission = CheckPermission(roles, data, interacterData,
action switch
{
"Ban" => DiscordPermission.BanMembers,
"Kick" => DiscordPermission.KickMembers,
"Mute" or "Unmute" => DiscordPermission.ModerateMembers,
_ => throw new Exception()
});
return hasPermission
? CheckInteractions(action, guild, roles, targetData, botData, interacterData)
: Result<string?>.FromSuccess($"UserCannot{action}Members".Localized());
}
private static Result<string?> CheckInteractions(
string action, IGuild guild, IReadOnlyList<IRole> roles, MemberData targetData, MemberData botData,
MemberData interacterData)
{
if (botData.Id == targetData.Id)
{
return Result<string?>.FromSuccess($"UserCannot{action}Bot".Localized());
}
if (targetData.Id == guild.OwnerID)
{
return Result<string?>.FromSuccess($"UserCannot{action}Owner".Localized());
}
var targetRoles = roles.Where(r => targetData.Roles.Contains(r.ID.Value)).ToList();
var botRoles = roles.Where(r => botData.Roles.Contains(r.ID.Value));
var targetBotRoleDiff = targetRoles.MaxOrDefault(r => r.Position) - botRoles.MaxOrDefault(r => r.Position);
if (targetBotRoleDiff >= 0)
{
return Result<string?>.FromSuccess($"BotCannot{action}Target".Localized());
}
var interacterRoles = roles.Where(r => interacterData.Roles.Contains(r.ID.Value));
var targetInteracterRoleDiff
= targetRoles.MaxOrDefault(r => r.Position) - interacterRoles.MaxOrDefault(r => r.Position);
return targetInteracterRoleDiff < 0
? Result<string?>.FromSuccess(null)
: Result<string?>.FromSuccess($"UserCannot{action}Target".Localized());
}
}

View file

@ -1,297 +0,0 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Remora.Rest.Core;
using TeamOctolings.Octobot.Data;
namespace TeamOctolings.Octobot.Services;
/// <summary>
/// Handles saving, loading, initializing and providing <see cref="GuildData" />.
/// </summary>
public sealed class GuildDataService : BackgroundService
{
private readonly ConcurrentDictionary<Snowflake, GuildData> _datas = new();
private readonly ILogger<GuildDataService> _logger;
public GuildDataService(ILogger<GuildDataService> logger)
{
_logger = logger;
}
public override Task StopAsync(CancellationToken ct)
{
base.StopAsync(ct);
return SaveAsync(ct);
}
private Task SaveAsync(CancellationToken ct = default)
{
var tasks = new List<Task>();
var datas = _datas.Values.ToArray();
foreach (var data in datas.Where(data => !data.DataLoadFailed))
{
tasks.Add(SerializeObjectSafelyAsync(data.Settings, data.SettingsPath, ct));
tasks.Add(SerializeObjectSafelyAsync(data.ScheduledEvents, data.ScheduledEventsPath, ct));
var memberDatas = data.MemberData.Values.ToArray();
tasks.AddRange(memberDatas.Select(memberData =>
SerializeObjectSafelyAsync(memberData, $"{data.MemberDataPath}/{memberData.Id}.json", ct)));
}
return Task.WhenAll(tasks);
}
private static async Task SerializeObjectSafelyAsync<T>(T obj, string path, CancellationToken ct = default)
{
var tempFilePath = path + ".tmp";
await using (var tempFileStream = File.Create(tempFilePath))
{
await JsonSerializer.SerializeAsync(tempFileStream, obj, cancellationToken: ct);
}
File.Copy(tempFilePath, path, true);
File.Delete(tempFilePath);
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(ct))
{
await SaveAsync(ct);
}
}
public async Task<GuildData> GetData(Snowflake guildId, CancellationToken ct = default)
{
return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct);
}
private async Task<GuildData> InitializeData(Snowflake guildId, CancellationToken ct = default)
{
var path = $"GuildData/{guildId}";
var memberDataPath = $"{path}/MemberData";
var settingsPath = $"{path}/Settings.json";
var scheduledEventsPath = $"{path}/ScheduledEvents.json";
MigrateDataDirectory(guildId, path);
Directory.CreateDirectory(path);
var dataLoadFailed = false;
var jsonSettings = await LoadGuildSettings(settingsPath, ct);
if (jsonSettings is not null)
{
FixJsonSettings(jsonSettings);
}
else
{
dataLoadFailed = true;
}
var events = await LoadScheduledEvents(scheduledEventsPath, ct);
if (events is null)
{
dataLoadFailed = true;
}
var memberData = new Dictionary<ulong, MemberData>();
foreach (var dataFileInfo in Directory.CreateDirectory(memberDataPath).GetFiles()
.Where(dataFileInfo =>
!memberData.ContainsKey(
ulong.Parse(dataFileInfo.Name.Replace(".json", "").Replace(".tmp", "")))))
{
var data = await LoadMemberData(dataFileInfo, memberDataPath, true, ct);
if (data == null)
{
dataLoadFailed = true;
continue;
}
memberData.TryAdd(data.Id, data);
}
var finalData = new GuildData(
jsonSettings ?? new JsonObject(), settingsPath,
events ?? new Dictionary<ulong, ScheduledEventData>(), scheduledEventsPath,
memberData, memberDataPath,
dataLoadFailed);
_datas.TryAdd(guildId, finalData);
return finalData;
}
private async Task<MemberData?> LoadMemberData(FileInfo dataFileInfo, string memberDataPath, bool loadTmp,
CancellationToken ct = default)
{
MemberData? data;
var temporaryPath = $"{dataFileInfo.FullName}.tmp";
var usedInfo = loadTmp && File.Exists(temporaryPath) ? new FileInfo(temporaryPath) : dataFileInfo;
var isTmp = usedInfo.Extension is ".tmp";
try
{
await using var dataStream = usedInfo.OpenRead();
data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct);
if (isTmp)
{
usedInfo.CopyTo(usedInfo.FullName.Replace(".tmp", ""), true);
usedInfo.Delete();
}
}
catch (Exception e)
{
if (isTmp)
{
_logger.LogWarning(e,
"Unable to load temporary member data file, deleting: {MemberDataPath}/{FileName}", memberDataPath,
usedInfo.Name);
usedInfo.Delete();
return await LoadMemberData(dataFileInfo, memberDataPath, false, ct);
}
_logger.LogError(e, "Member data load failed: {MemberDataPath}/{FileName}", memberDataPath,
usedInfo.Name);
return null;
}
return data;
}
private async Task<Dictionary<ulong, ScheduledEventData>?> LoadScheduledEvents(string scheduledEventsPath,
CancellationToken ct = default)
{
var tempScheduledEventsPath = $"{scheduledEventsPath}.tmp";
if (!File.Exists(scheduledEventsPath) && !File.Exists(tempScheduledEventsPath))
{
return new Dictionary<ulong, ScheduledEventData>();
}
if (File.Exists(tempScheduledEventsPath))
{
_logger.LogWarning("Found temporary scheduled events file, will try to parse and copy to main: ${Path}",
tempScheduledEventsPath);
try
{
await using var tempEventsStream = File.OpenRead(tempScheduledEventsPath);
var events = await JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
tempEventsStream, cancellationToken: ct);
File.Copy(tempScheduledEventsPath, scheduledEventsPath, true);
File.Delete(tempScheduledEventsPath);
_logger.LogInformation("Successfully loaded temporary scheduled events file: ${Path}",
tempScheduledEventsPath);
return events;
}
catch (Exception e)
{
_logger.LogError(e, "Unable to load temporary scheduled events file: {Path}, deleting",
tempScheduledEventsPath);
File.Delete(tempScheduledEventsPath);
}
}
try
{
await using var eventsStream = File.OpenRead(scheduledEventsPath);
return await JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
eventsStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Guild scheduled events load failed: {Path}", scheduledEventsPath);
return null;
}
}
private async Task<JsonNode?> LoadGuildSettings(string settingsPath, CancellationToken ct = default)
{
var tempSettingsPath = $"{settingsPath}.tmp";
if (!File.Exists(settingsPath) && !File.Exists(tempSettingsPath))
{
return new JsonObject();
}
if (File.Exists(tempSettingsPath))
{
_logger.LogWarning("Found temporary settings file, will try to parse and copy to main: ${Path}",
tempSettingsPath);
try
{
await using var tempSettingsStream = File.OpenRead(tempSettingsPath);
var jsonSettings = await JsonNode.ParseAsync(tempSettingsStream, cancellationToken: ct);
File.Copy(tempSettingsPath, settingsPath, true);
File.Delete(tempSettingsPath);
_logger.LogInformation("Successfully loaded temporary settings file: ${Path}", tempSettingsPath);
return jsonSettings;
}
catch (Exception e)
{
_logger.LogError(e, "Unable to load temporary settings file: {Path}, deleting", tempSettingsPath);
File.Delete(tempSettingsPath);
}
}
try
{
await using var settingsStream = File.OpenRead(settingsPath);
return await JsonNode.ParseAsync(settingsStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Guild settings load failed: {Path}", settingsPath);
return null;
}
}
private void MigrateDataDirectory(Snowflake guildId, string newPath)
{
var oldPath = $"{guildId}";
if (Directory.Exists(oldPath))
{
Directory.CreateDirectory($"{newPath}/..");
Directory.Move(oldPath, newPath);
_logger.LogInformation("Moved guild data to separate folder: \"{OldPath}\" -> \"{NewPath}\"", oldPath,
newPath);
}
}
private static void FixJsonSettings(JsonNode settings)
{
var language = settings[GuildSettings.Language.Name]?.GetValue<string>();
if (language is "mctaylors-ru")
{
settings[GuildSettings.Language.Name] = "ru";
}
}
public async Task<JsonNode> GetSettings(Snowflake guildId, CancellationToken ct = default)
{
return (await GetData(guildId, ct)).Settings;
}
public ICollection<Snowflake> GetGuildIds()
{
return _datas.Keys;
}
public bool UnloadGuildData(Snowflake id)
{
return _datas.TryRemove(id, out _);
}
}

View file

@ -1,257 +0,0 @@
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.Services.Update;
public sealed partial class MemberUpdateService : BackgroundService
{
private static readonly string[] GenericNicknames =
[
"Albatross", "Alpha", "Anchor", "Banjo", "Bell", "Beta", "Blackbird", "Bulldog", "Canary",
"Cat", "Calf", "Cyclone", "Daisy", "Dalmatian", "Dart", "Delta", "Diamond", "Donkey", "Duck",
"Emu", "Eclipse", "Flamingo", "Flute", "Frog", "Goose", "Hatchet", "Heron", "Husky", "Hurricane",
"Iceberg", "Iguana", "Kiwi", "Kite", "Lamb", "Lily", "Macaw", "Manatee", "Maple", "Mask",
"Nautilus", "Ostrich", "Octopus", "Pelican", "Puffin", "Pyramid", "Rattle", "Robin", "Rose",
"Salmon", "Seal", "Shark", "Sheep", "Snake", "Sonar", "Stump", "Sparrow", "Toaster", "Toucan",
"Torus", "Violet", "Vortex", "Vulture", "Wagon", "Whale", "Woodpecker", "Zebra", "Zigzag"
];
private readonly AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly ILogger<MemberUpdateService> _logger;
public MemberUpdateService(AccessControlService access, IDiscordRestChannelAPI channelApi,
IDiscordRestGuildAPI guildApi, GuildDataService guildData, ILogger<MemberUpdateService> logger)
{
_access = access;
_channelApi = channelApi;
_guildApi = guildApi;
_guildData = guildData;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
var tasks = new List<Task>();
while (await timer.WaitForNextTickAsync(ct))
{
var guildIds = _guildData.GetGuildIds();
tasks.AddRange(guildIds.Select(async id =>
{
var tickResult = await TickMemberDatasAsync(id, ct);
_logger.LogResult(tickResult, $"Error in member data update for guild {id}.");
}));
await Task.WhenAll(tasks);
tasks.Clear();
}
}
private async Task<Result> TickMemberDatasAsync(Snowflake guildId, CancellationToken ct = default)
{
var guildData = await _guildData.GetData(guildId, ct);
var defaultRole = GuildSettings.DefaultRole.Get(guildData.Settings);
var failedResults = new List<Result>();
var memberDatas = guildData.MemberData.Values.ToArray();
foreach (var data in memberDatas)
{
var tickResult = await TickMemberDataAsync(guildId, guildData, defaultRole, data, ct);
failedResults.AddIfFailed(tickResult);
}
return failedResults.AggregateErrors();
}
private async Task<Result> TickMemberDataAsync(Snowflake guildId, GuildData guildData, Snowflake defaultRole,
MemberData data,
CancellationToken ct = default)
{
var failedResults = new List<Result>();
var id = data.Id.ToSnowflake();
var autoUnbanResult = await TryAutoUnbanAsync(guildId, id, data, ct);
failedResults.AddIfFailed(autoUnbanResult);
var guildMemberResult = await _guildApi.GetGuildMemberAsync(guildId, id, ct);
if (!guildMemberResult.IsDefined(out var guildMember))
{
return failedResults.AggregateErrors();
}
var interactionResult
= await _access.CheckInteractionsAsync(guildId, null, id, "Update", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
var canInteract = interactionResult.Entity is null;
if (data.MutedUntil is null)
{
data.Roles = guildMember.Roles.ToList().ConvertAll(r => r.Value);
}
if (!guildMember.User.IsDefined(out var user))
{
failedResults.AddIfFailed(new ArgumentNullError(nameof(guildMember.User)));
return failedResults.AggregateErrors();
}
for (var i = data.Reminders.Count - 1; i >= 0; i--)
{
var reminderTickResult = await TickReminderAsync(data.Reminders[i], user, data, guildId, ct);
failedResults.AddIfFailed(reminderTickResult);
}
if (!canInteract)
{
return Result.Success;
}
var autoUnmuteResult = await TryAutoUnmuteAsync(guildId, id, data, ct);
failedResults.AddIfFailed(autoUnmuteResult);
if (!defaultRole.Empty() && !data.Roles.Contains(defaultRole.Value))
{
var addResult = await _guildApi.AddGuildMemberRoleAsync(
guildId, id, defaultRole, ct: ct);
failedResults.AddIfFailed(addResult);
}
if (GuildSettings.RenameHoistedUsers.Get(guildData.Settings))
{
var filterResult = await FilterNicknameAsync(guildId, user, guildMember, ct);
failedResults.AddIfFailed(filterResult);
}
return failedResults.AggregateErrors();
}
private async Task<Result> TryAutoUnbanAsync(
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct = default)
{
if (data.BannedUntil is null || DateTimeOffset.UtcNow <= data.BannedUntil)
{
return Result.Success;
}
var existingBanResult = await _guildApi.GetGuildBanAsync(guildId, id, ct);
if (!existingBanResult.IsDefined())
{
data.BannedUntil = null;
return Result.Success;
}
var unbanResult = await _guildApi.RemoveGuildBanAsync(
guildId, id, Messages.PunishmentExpired.EncodeHeader(), ct);
if (unbanResult.IsSuccess)
{
data.BannedUntil = null;
}
return unbanResult;
}
private async Task<Result> TryAutoUnmuteAsync(
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct = default)
{
if (data.MutedUntil is null || DateTimeOffset.UtcNow <= data.MutedUntil)
{
return Result.Success;
}
var unmuteResult = await _guildApi.ModifyGuildMemberAsync(
guildId, id, roles: data.Roles.ConvertAll(r => r.ToSnowflake()),
reason: Messages.PunishmentExpired.EncodeHeader(), ct: ct);
if (unmuteResult.IsSuccess)
{
data.MutedUntil = null;
}
return unmuteResult;
}
private async Task<Result> FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member,
CancellationToken ct = default)
{
var currentNickname = member.Nickname.IsDefined(out var nickname)
? nickname
: user.GlobalName.OrDefault(user.Username);
var characterList = currentNickname.ToList();
var usernameChanged = false;
foreach (var character in currentNickname)
{
if (IllegalChars().IsMatch(character.ToString()))
{
characterList.Remove(character);
usernameChanged = true;
continue;
}
break;
}
if (!usernameChanged)
{
return Result.Success;
}
var newNickname = string.Concat(characterList.ToArray());
return await _guildApi.ModifyGuildMemberAsync(
guildId, user.ID,
!string.IsNullOrWhiteSpace(newNickname)
? newNickname
: GenericNicknames[Random.Shared.Next(GenericNicknames.Length)],
ct: ct);
}
[GeneratedRegex("[^0-9A-Za-zА-Яа-яЁё]")]
private static partial Regex IllegalChars();
private async Task<Result> TickReminderAsync(Reminder reminder, IUser user, MemberData data, Snowflake guildId,
CancellationToken ct = default)
{
if (DateTimeOffset.UtcNow < reminder.At)
{
return Result.Success;
}
var builder = new StringBuilder()
.AppendLine(MarkdownExtensions.Quote(reminder.Text))
.AppendBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage,
$"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}"));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.Reminder, user.GetTag()), user)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Magenta)
.Build();
var messageResult = await _channelApi.CreateMessageWithEmbedResultAsync(
reminder.ChannelId.ToSnowflake(), Mention.User(user), embedResult: embed, ct: ct);
if (!messageResult.IsSuccess)
{
return ResultExtensions.FromError(messageResult);
}
data.Reminders.Remove(reminder);
return Result.Success;
}
}

View file

@ -1,434 +0,0 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot.Services.Update;
public sealed class ScheduledEventUpdateService : BackgroundService
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
private readonly GuildDataService _guildData;
private readonly ILogger<ScheduledEventUpdateService> _logger;
private readonly Utility _utility;
public ScheduledEventUpdateService(IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi,
GuildDataService guildData, ILogger<ScheduledEventUpdateService> logger, Utility utility)
{
_channelApi = channelApi;
_eventApi = eventApi;
_guildData = guildData;
_logger = logger;
_utility = utility;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(ct))
{
var guildIds = _guildData.GetGuildIds();
foreach (var id in guildIds)
{
var tickResult = await TickScheduledEventsAsync(id, ct);
_logger.LogResult(tickResult, $"Error in scheduled events update for guild {id}.");
}
}
}
private async Task<Result> TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct = default)
{
var failedResults = new List<Result>();
var data = await _guildData.GetData(guildId, ct);
var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct);
if (!eventsResult.IsDefined(out var events))
{
return ResultExtensions.FromError(eventsResult);
}
SyncScheduledEvents(data, events);
foreach (var storedEvent in data.ScheduledEvents.Values)
{
var scheduledEvent = TryGetScheduledEvent(events, storedEvent.Id);
if (!scheduledEvent.IsSuccess)
{
storedEvent.ScheduleOnStatusUpdated = true;
storedEvent.Status = storedEvent.ActualStartTime != null
? GuildScheduledEventStatus.Completed
: GuildScheduledEventStatus.Canceled;
}
if (!storedEvent.ScheduleOnStatusUpdated)
{
var tickResult =
await TickScheduledEventAsync(guildId, data, scheduledEvent.Entity, storedEvent, ct);
failedResults.AddIfFailed(tickResult);
continue;
}
var statusUpdatedResponseResult = storedEvent.Status switch
{
GuildScheduledEventStatus.Scheduled =>
await SendScheduledEventCreatedMessage(scheduledEvent.Entity, data.Settings, ct),
GuildScheduledEventStatus.Canceled =>
await SendScheduledEventCancelledMessage(storedEvent, data, ct),
GuildScheduledEventStatus.Active =>
await SendScheduledEventStartedMessage(scheduledEvent.Entity, data, ct),
GuildScheduledEventStatus.Completed =>
await SendScheduledEventCompletedMessage(storedEvent, data, ct),
_ => new ArgumentOutOfRangeError(nameof(storedEvent.Status))
};
if (statusUpdatedResponseResult.IsSuccess)
{
storedEvent.ScheduleOnStatusUpdated = false;
}
failedResults.AddIfFailed(statusUpdatedResponseResult);
}
return failedResults.AggregateErrors();
}
private static void SyncScheduledEvents(GuildData data, IEnumerable<IGuildScheduledEvent> events)
{
foreach (var @event in events)
{
if (!data.ScheduledEvents.TryGetValue(@event.ID.Value, out var eventData))
{
data.ScheduledEvents.Add(@event.ID.Value,
new ScheduledEventData(@event.ID.Value, @event.Name, @event.ScheduledStartTime, @event.Status));
continue;
}
eventData.Name = @event.Name;
eventData.ScheduledStartTime = @event.ScheduledStartTime;
if (!eventData.ScheduleOnStatusUpdated)
{
eventData.ScheduleOnStatusUpdated = eventData.Status != @event.Status;
}
eventData.Status = @event.Status;
}
}
private static Result<IGuildScheduledEvent> TryGetScheduledEvent(IEnumerable<IGuildScheduledEvent> from, ulong id)
{
var filtered = from.Where(schEvent => schEvent.ID == id);
var filteredArray = filtered.ToArray();
return filteredArray.Length > 0
? Result<IGuildScheduledEvent>.FromSuccess(filteredArray.Single())
: new NotFoundError();
}
private async Task<Result> TickScheduledEventAsync(
Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData,
CancellationToken ct = default)
{
if (GuildSettings.AutoStartEvents.Get(data.Settings)
&& DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime
&& scheduledEvent.Status is not GuildScheduledEventStatus.Active)
{
return await AutoStartEventAsync(guildId, scheduledEvent, ct);
}
var offset = GuildSettings.EventEarlyNotificationOffset.Get(data.Settings);
if (offset == TimeSpan.Zero
|| eventData.EarlyNotificationSent
|| DateTimeOffset.UtcNow < scheduledEvent.ScheduledStartTime - offset)
{
return Result.Success;
}
var sendResult = await SendEarlyEventNotificationAsync(scheduledEvent, data, ct);
if (sendResult.IsSuccess)
{
eventData.EarlyNotificationSent = true;
}
return sendResult;
}
private async Task<Result> AutoStartEventAsync(
Snowflake guildId, IGuildScheduledEvent scheduledEvent, CancellationToken ct = default)
{
return (Result)await _eventApi.ModifyGuildScheduledEventAsync(
guildId, scheduledEvent.ID,
status: GuildScheduledEventStatus.Active, ct: ct);
}
/// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildSettings.EventNotificationRole" /> if one is
/// set,
/// when a scheduled event is created
/// in a guild's <see cref="GuildSettings.EventNotificationChannel" /> if one is set.
/// </summary>
/// <param name="scheduledEvent">The scheduled event that has just been created.</param>
/// <param name="settings">The settings of the guild containing the scheduled event.</param>
/// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A notification sending result which may or may not have succeeded.</returns>
private async Task<Result> SendScheduledEventCreatedMessage(
IGuildScheduledEvent scheduledEvent, JsonNode settings, CancellationToken ct = default)
{
if (GuildSettings.EventNotificationChannel.Get(settings).Empty())
{
return Result.Success;
}
if (!scheduledEvent.Creator.IsDefined(out var creator))
{
return new ArgumentNullError(nameof(scheduledEvent.Creator));
}
var eventDescription = scheduledEvent.Description.IsDefined(out var description)
? description
: string.Empty;
var embedDescriptionResult = scheduledEvent.EntityType switch
{
GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice =>
GetLocalEventCreatedEmbedDescription(scheduledEvent, eventDescription),
GuildScheduledEventEntityType.External => GetExternalScheduledEventCreatedEmbedDescription(
scheduledEvent, eventDescription),
_ => new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))
};
if (!embedDescriptionResult.IsDefined(out var embedDescription))
{
return ResultExtensions.FromError(embedDescriptionResult);
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator)
.WithTitle(Markdown.Sanitize(scheduledEvent.Name))
.WithDescription(embedDescription)
.WithEventCover(scheduledEvent.ID, scheduledEvent.Image)
.WithCurrentTimestamp()
.WithColour(ColorsList.White)
.Build();
var roleMention = !GuildSettings.EventNotificationRole.Get(settings).Empty()
? Mention.Role(GuildSettings.EventNotificationRole.Get(settings))
: string.Empty;
var button = new ButtonComponent(
ButtonComponentStyle.Link,
Messages.ButtonOpenEventInfo,
new PartialEmoji(Name: "\ud83d\udccb"), // 'CLIPBOARD' (U+1F4CB)
URL: $"https://discord.com/events/{scheduledEvent.GuildID}/{scheduledEvent.ID}"
);
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.EventNotificationChannel.Get(settings), roleMention, embedResult: embed,
components: new[] { new ActionRowComponent([button]) }, ct: ct);
}
private static Result<string> GetExternalScheduledEventCreatedEmbedDescription(
IGuildScheduledEvent scheduledEvent, string eventDescription)
{
var dataResult = scheduledEvent.TryGetExternalEventData(out var endTime, out var location);
if (!dataResult.IsSuccess)
{
return Result<string>.FromError(dataResult);
}
return $"{eventDescription}\n\n{Markdown.BlockQuote(
string.Format(
Messages.DescriptionExternalEventCreated,
Markdown.Timestamp(scheduledEvent.ScheduledStartTime),
Markdown.Timestamp(endTime),
Markdown.InlineCode(location ?? string.Empty)
))}";
}
private static Result<string> GetLocalEventCreatedEmbedDescription(
IGuildScheduledEvent scheduledEvent, string eventDescription)
{
if (scheduledEvent.ChannelID is null)
{
return new ArgumentNullError(nameof(scheduledEvent.ChannelID));
}
return $"{eventDescription}\n\n{Markdown.BlockQuote(
string.Format(
Messages.DescriptionLocalEventCreated,
Markdown.Timestamp(scheduledEvent.ScheduledStartTime),
Mention.Channel(scheduledEvent.ChannelID.Value)
))}";
}
/// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildSettings.EventNotificationRole" /> and event
/// subscribers,
/// when a scheduled event has started or completed
/// in a guild's <see cref="GuildSettings.EventNotificationChannel" /> if one is set.
/// </summary>
/// <param name="scheduledEvent">The scheduled event that is about to start, has started or completed.</param>
/// <param name="data">The data for the guild containing the scheduled event.</param>
/// <param name="ct">The cancellation token for this operation</param>
/// <returns>A reminder/notification sending result which may or may not have succeeded.</returns>
private async Task<Result> SendScheduledEventStartedMessage(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default)
{
data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow;
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
return Result.Success;
}
var embedDescriptionResult = scheduledEvent.EntityType switch
{
GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice =>
GetLocalEventStartedEmbedDescription(scheduledEvent),
GuildScheduledEventEntityType.External => GetExternalEventStartedEmbedDescription(scheduledEvent),
_ => new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType))
};
var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data, ct);
if (!contentResult.IsDefined(out var content))
{
return ResultExtensions.FromError(contentResult);
}
if (!embedDescriptionResult.IsDefined(out var embedDescription))
{
return ResultExtensions.FromError(embedDescriptionResult);
}
var startedEmbed = new EmbedBuilder()
.WithTitle(string.Format(Messages.EventStarted, Markdown.Sanitize(scheduledEvent.Name)))
.WithDescription(embedDescription)
.WithColour(ColorsList.Green)
.WithCurrentTimestamp()
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
content, embedResult: startedEmbed, ct: ct);
}
private async Task<Result> SendScheduledEventCompletedMessage(ScheduledEventData eventData, GuildData data,
CancellationToken ct = default)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
data.ScheduledEvents.Remove(eventData.Id);
return Result.Success;
}
var completedEmbed = new EmbedBuilder()
.WithTitle(string.Format(Messages.EventCompleted, Markdown.Sanitize(eventData.Name)))
.WithDescription(
string.Format(
Messages.EventDuration,
DateTimeOffset.UtcNow.Subtract(
eventData.ActualStartTime
?? eventData.ScheduledStartTime).ToString()))
.WithColour(ColorsList.Black)
.WithCurrentTimestamp()
.Build();
var createResult = await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
embedResult: completedEmbed, ct: ct);
if (createResult.IsSuccess)
{
data.ScheduledEvents.Remove(eventData.Id);
}
return createResult;
}
private async Task<Result> SendScheduledEventCancelledMessage(ScheduledEventData eventData, GuildData data,
CancellationToken ct = default)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
data.ScheduledEvents.Remove(eventData.Id);
return Result.Success;
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCancelled, Markdown.Sanitize(eventData.Name)))
.WithDescription(":(")
.WithColour(ColorsList.Red)
.WithCurrentTimestamp()
.Build();
var createResult = await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings), embedResult: embed, ct: ct);
if (createResult.IsSuccess)
{
data.ScheduledEvents.Remove(eventData.Id);
}
return createResult;
}
private static Result<string> GetLocalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent)
{
if (scheduledEvent.ChannelID is null)
{
return new ArgumentNullError(nameof(scheduledEvent.ChannelID));
}
return string.Format(
Messages.DescriptionLocalEventStarted,
Mention.Channel(scheduledEvent.ChannelID.Value)
);
}
private static Result<string> GetExternalEventStartedEmbedDescription(IGuildScheduledEvent scheduledEvent)
{
var dataResult = scheduledEvent.TryGetExternalEventData(out var endTime, out var location);
if (!dataResult.IsSuccess)
{
return Result<string>.FromError(dataResult);
}
return string.Format(
Messages.DescriptionExternalEventStarted,
Markdown.InlineCode(location ?? string.Empty),
Markdown.Timestamp(endTime)
);
}
private async Task<Result> SendEarlyEventNotificationAsync(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
return Result.Success;
}
var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data, ct);
if (!contentResult.IsDefined(out var content))
{
return ResultExtensions.FromError(contentResult);
}
var earlyResult = new EmbedBuilder()
.WithDescription(
string.Format(Messages.EventEarlyNotification, Markdown.Sanitize(scheduledEvent.Name),
Markdown.Timestamp(scheduledEvent.ScheduledStartTime, TimestampStyle.RelativeTime)))
.WithColour(ColorsList.Default)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.EventNotificationChannel.Get(data.Settings),
content,
embedResult: earlyResult, ct: ct);
}
}

View file

@ -1,95 +0,0 @@
using Microsoft.Extensions.Hosting;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Gateway.Commands;
using Remora.Discord.API.Objects;
using Remora.Discord.Gateway;
namespace TeamOctolings.Octobot.Services.Update;
public sealed class SongUpdateService : BackgroundService
{
private static readonly (string Author, string Name, TimeSpan Duration)[] SongList =
[
("Yoko & the Gold Bazookas", "Rockagilly Blues", new TimeSpan(0, 2, 52)),
("Deep Cut", "Big Betrayal", new TimeSpan(0, 5, 55)),
("Squid Sisters", "Tomorrow's Nostalgia Today", new TimeSpan(0, 3, 7)),
("Deep Cut", "Anarchy Rainbow", new TimeSpan(0, 3, 20)),
("Squid Sisters feat. Ian BGM", "Liquid Sunshine", new TimeSpan(0, 2, 37)),
("Damp Socks feat. Off the Hook", "Candy-Coated Rocks", new TimeSpan(0, 2, 58)),
("H2Whoa", "Aquasonic", new TimeSpan(0, 2, 51)),
("Yoko & the Gold Bazookas", "Ska-BLAM", new TimeSpan(0, 2, 57)),
("Off the Hook", "Muck Warfare", new TimeSpan(0, 3, 20)),
("Off the Hook", "Acid Hues", new TimeSpan(0, 3, 15)),
("Off the Hook", "Shark Bytes", new TimeSpan(0, 3, 34)),
("Squid Sisters", "Calamari Inkantation", new TimeSpan(0, 2, 14)),
("Squid Sisters", "Ink Me Up", new TimeSpan(0, 2, 13)),
("Chirpy Chips", "No Quarters", new TimeSpan(0, 2, 36)),
("Chirpy Chips", "Shellfie", new TimeSpan(0, 2, 1)),
("Dedf1sh", "#11 above", new TimeSpan(0, 2, 10)),
("Callie", "Bomb Rush Blush", new TimeSpan(0, 2, 18)),
("Turquoise October", "Octoling Rendezvous", new TimeSpan(0, 1, 57)),
("Damp Socks feat. Off the Hook", "Tentacle to the Metal", new TimeSpan(0, 2, 51)),
("Off the Hook feat. Dedf1sh", "Spectrum Obligato ~ Ebb & Flow (Out of Order)", new TimeSpan(0, 4, 30)),
("Dedf1sh feat. Off the Hook", "#47 onward", new TimeSpan(0, 4, 40)),
("Free Association", "EchΘ Θnslaught", new TimeSpan(0, 2, 52)),
("Off the Hook", "Short Order", new TimeSpan(0, 3, 36)),
("Deep Cut", "Fins in the Air", new TimeSpan(0, 3, 1))
];
private static readonly (string Author, string Name, TimeSpan Duration)[] SpecialSongList =
[
("Squid Sisters", "Maritime Memory", new TimeSpan(0, 2, 47))
];
private readonly List<Activity> _activityList = [new("with Remora.Discord", ActivityType.Game)];
private readonly DiscordGatewayClient _client;
private readonly GuildDataService _guildData;
private uint _nextSongIndex;
public SongUpdateService(DiscordGatewayClient client, GuildDataService guildData)
{
_client = client;
_guildData = guildData;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (_guildData.GetGuildIds().Count is 0)
{
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
while (!ct.IsCancellationRequested)
{
var nextSong = NextSong();
_activityList[0] = new Activity($"{nextSong.Name} / {nextSong.Author}",
ActivityType.Listening);
_client.SubmitCommand(
new UpdatePresence(
UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList));
await Task.Delay(nextSong.Duration, ct);
}
}
private (string Author, string Name, TimeSpan Duration) NextSong()
{
var today = DateTime.Today;
// Discontinuation of Online Services for Nintendo Wii U
if (today.Day is 8 or 9 && today.Month is 4)
{
return SpecialSongList[0]; // Maritime Memory / Squid Sisters
}
var nextSong = SongList[_nextSongIndex];
_nextSongIndex++;
if (_nextSongIndex >= SongList.Length)
{
_nextSongIndex = 0;
}
return nextSong;
}
}

View file

@ -1,46 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
<Title>Octobot</Title>
<Authors>Octol1ttle, mctaylors, neroduckale</Authors>
<Copyright>AGPLv3</Copyright>
<PackageProjectUrl>https://github.com/TeamOctolings/Octobot</PackageProjectUrl>
<PackageLicenseUrl>https://github.com/TeamOctolings/Octobot/blob/master/LICENSE</PackageLicenseUrl>
<RepositoryUrl>https://github.com/TeamOctolings/Octobot</RepositoryUrl>
<RepositoryType>github</RepositoryType>
<Company>TeamOctolings</Company>
<NeutralLanguage>en</NeutralLanguage>
<Description>A general-purpose Discord bot for moderation written in C#</Description>
<ApplicationIcon>../docs/octobot.ico</ApplicationIcon>
<GitVersion>false</GitVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DiffPlex" Version="1.7.2" />
<PackageReference Include="GitInfo" Version="3.3.5" />
<PackageReference Include="Humanizer.Core.ru" Version="2.14.1" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0"/>
<PackageReference Include="Remora.Commands" Version="11.0.1"/>
<PackageReference Include="Remora.Discord.Caching" Version="40.0.0" />
<PackageReference Include="Remora.Discord.Extensions" Version="6.0.0"/>
<PackageReference Include="Remora.Discord.Hosting" Version="7.0.0" />
<PackageReference Include="Remora.Discord.Interactivity" Version="6.0.0"/>
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Messages.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Messages.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="..\CodeAnalysis\BannedSymbols.txt" />
</ItemGroup>
</Project>

View file

@ -1,153 +0,0 @@
using System.Drawing;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Attributes;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace TeamOctolings.Octobot;
/// <summary>
/// Provides utility methods that cannot be transformed to extension methods because they require usage
/// of some Discord APIs.
/// </summary>
public sealed class Utility
{
public static readonly AllowedMentions NoMentions = new(
Array.Empty<MentionType>(), Array.Empty<Snowflake>(), Array.Empty<Snowflake>());
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
private readonly IDiscordRestGuildAPI _guildApi;
public Utility(
IDiscordRestChannelAPI channelApi, IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi)
{
_channelApi = channelApi;
_eventApi = eventApi;
_guildApi = guildApi;
}
[StaticCallersOnly]
public static ILogger<Program>? StaticLogger { get; set; }
/// <summary>
/// Gets the string mentioning the <see cref="GuildSettings.EventNotificationRole" /> and event subscribers related to
/// a scheduled
/// event.
/// </summary>
/// <param name="scheduledEvent">
/// The scheduled event whose subscribers will be mentioned.
/// </param>
/// <param name="data">The data of the guild containing the scheduled event.</param>
/// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A result containing the string which may or may not have succeeded.</returns>
public async Task<Result<string>> GetEventNotificationMentions(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default)
{
var builder = new StringBuilder();
var role = GuildSettings.EventNotificationRole.Get(data.Settings);
var subscribersResult = await _eventApi.GetGuildScheduledEventUsersAsync(
scheduledEvent.GuildID, scheduledEvent.ID, ct: ct);
if (!subscribersResult.IsDefined(out var subscribers))
{
return Result<string>.FromError(subscribersResult);
}
if (!role.Empty())
{
builder.Append($"{Mention.Role(role)} ");
}
builder = subscribers.Where(subscriber =>
!data.GetOrCreateMemberData(subscriber.User.ID).Roles.Contains(role.Value))
.Aggregate(builder, (current, subscriber) => current.Append($"{Mention.User(subscriber.User)} "));
return builder.ToString();
}
/// <summary>
/// Logs an action in the <see cref="GuildSettings.PublicFeedbackChannel" /> and
/// <see cref="GuildSettings.PrivateFeedbackChannel" />.
/// </summary>
/// <param name="cfg">The guild configuration.</param>
/// <param name="channelId">The ID of the channel where the action was executed.</param>
/// <param name="user">The user who performed the action.</param>
/// <param name="title">The title for the embed.</param>
/// <param name="description">The description of the embed.</param>
/// <param name="avatar">The user whose avatar will be displayed next to the <paramref name="title" /> of the embed.</param>
/// <param name="color">The color of the embed.</param>
/// <param name="isPublic">
/// Whether or not the embed should be sent in <see cref="GuildSettings.PublicFeedbackChannel" />
/// </param>
/// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A result which has succeeded.</returns>
public void LogAction(
JsonNode cfg, Snowflake channelId, IUser user, string title, string description, IUser avatar,
Color color, bool isPublic = true, CancellationToken ct = default)
{
var publicChannel = GuildSettings.PublicFeedbackChannel.Get(cfg);
var privateChannel = GuildSettings.PrivateFeedbackChannel.Get(cfg);
if (GuildSettings.PublicFeedbackChannel.Get(cfg).EmptyOrEqualTo(channelId)
&& GuildSettings.PrivateFeedbackChannel.Get(cfg).EmptyOrEqualTo(channelId))
{
return;
}
var logEmbed = new EmbedBuilder().WithSmallTitle(title, avatar)
.WithDescription(description)
.WithActionFooter(user)
.WithCurrentTimestamp()
.WithColour(color)
.Build();
// Not awaiting to reduce response time
if (isPublic && publicChannel != channelId)
{
_ = _channelApi.CreateMessageWithEmbedResultAsync(
publicChannel, embedResult: logEmbed,
ct: ct);
}
if (privateChannel != publicChannel
&& privateChannel != channelId)
{
_ = _channelApi.CreateMessageWithEmbedResultAsync(
privateChannel, embedResult: logEmbed,
ct: ct);
}
}
public async Task<Result<Snowflake>> GetEmergencyFeedbackChannel(IGuild guild, GuildData data, CancellationToken ct = default)
{
var privateFeedback = GuildSettings.PrivateFeedbackChannel.Get(data.Settings);
if (!privateFeedback.Empty())
{
return privateFeedback;
}
var publicFeedback = GuildSettings.PublicFeedbackChannel.Get(data.Settings);
if (!publicFeedback.Empty())
{
return publicFeedback;
}
if (guild.SystemChannelID.AsOptional().IsDefined(out var systemChannel))
{
return systemChannel;
}
var channelsResult = await _guildApi.GetGuildChannelsAsync(guild.ID, ct);
return channelsResult.IsDefined(out var channels)
? channels[0].ID
: Result<Snowflake>.FromError(channelsResult);
}
}

11
assets/css/fonts.css Normal file
View file

@ -0,0 +1,11 @@
@font-face {
font-family: 'BlitzBold';
font-weight: normal;
src: url(../woff2/BlitzBold.woff2);
}
@font-face {
font-family: 'BlitzMain';
font-weight: normal;
src: url(../woff2/BlitzMain.woff2);
}

196
assets/css/styles.css Normal file
View file

@ -0,0 +1,196 @@
/*
Octobot for Discord. Made by mctaylors.
Inspired by splatoon3.ink.
*/
@import url(fonts.css);
:root {
color: #eee;
background-color: #000;
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAOIAAADiBAMAAAChPgbkAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAqUExURRcXF0dwTBUVFRYWFgAAABoaGhkZGRUVFRgYGAAAAAAAAB4eHiQkJBISEkRDNy8AAAAOdFJOUxYAGBcDEwoMFQUBEQcOBwHpdgAAAjdJREFUeNrt271NBDEUReGnjUlGIoGICCKkbYIKqIASqIEm6IGMDiiBjtj5XXvGz9k9Cde5dfTFvo6Xc6TnacjP/Xd+7/TbuRi3r52b752bXw/5xedecaCRMdDIGGjkpQgjL0UYORZZ5FhkkVMRRU5FFDkXSeRcJJFLEUQuRRC5FjnkWuSQWxFDbkUMeS1SyGuRQhZFCFkUIWRZZJBlkUFWRQRZFRFkXSSQdZFA7ooAclcEkPuiHrkv6pGHohx5KMqRx6IaeSyqkY2iGNkoipGtohbZKmqRzaIU2SxKke2iEtkuKpFJUYhMikJkVtQhs6IOmRZlyLQoQ+ZFFTIvqpCdogjZKYqQvaIGGW80Mh5pZJxoZBhppJFGGmmkkUYaaaSRRhpppJFGGmmkkUYaaeR/Rp5pZHzQyPikkXFHI2OgkTHQyBho5KUII8f3RxY5Flnk9MaKIqciipzfkUnkXCSRy1s5iFyKIHLdA3DItcght80DhtyKGPK666CQ1yKFLLYrELIoQshyn8MgyyKDrDZICLIqIsh6Z0Ug6yKB3G3JAOSuCCD3ezk9cl/UIw+bQDnyUJQjj7tHNfJYVCMb204xslEUI1v7VS2yVdQimxtdKbJZlCLbO2Qlsl1UIpOttRCZFIXIbE+uQ2ZFHTLdzMuQaVGGzP8FqJB5UYXs/H0QITtFEbL3v0OD7BU1yPihkXFDIwNHhpFGGmmkkUYaaaSRRhpppJFGGmmkkUYaaaSRRhoJIgNH/gHUGcb6oO4YfwAAAABJRU5ErkJggg==");
font-family: BlitzMain, sans-serif;
}
a, a:visited {
color: chartreuse;
}
a:hover {
color: aquamarine;
}
a:active {
color: darkcyan;
}
a.alternative {
text-decoration: none;
}
.highlight {
font-family: BlitzBold, sans-serif;
font-size: 32px;
}
.header {
font-size: 24px;
padding: 16px;
position: fixed;
width: calc(100% - 48px);
z-index: 10;
}
.header > .left {
float: left;
}
.header > .right {
float: right;
}
.header > .left img {
margin: 0 8px;
height: 64px;
}
.header > .right .social img {
height: 32px;
width: 32px;
border: #999 1px solid;
border-radius: 16px;
background-color: #0009;
padding: 8px;
transition: 200ms;
}
.header > .right .social img:hover {
border-color: #eee;
}
.content {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 32px 64px;
padding: 100px 16px 80px;
}
.content > .card {
mask-image: url("../svg/card-header.svg");
mask-size: 2000px auto;
mask-position: top;
background-image: url("../png/tapes-transparent.png");
background-size: contain;
width: 480px;
min-height: 520px;
padding: 48px 16px 8px;
border-radius: 16px;
}
.content > .card.first {
background-color: #1bbeab; /* Splatoon 3 TurquoisePink Alpha */
rotate: -2deg;
}
.content > .card.second {
background-color: #c43a6e; /* Splatoon 3 TurquoisePink Bravo */
rotate: 2deg;
}
.content > .card * {
text-align: left;
}
.content > .card span {
line-height: 1em;
filter: drop-shadow(1px 1px #000);
}
.content > .card > .title {
margin: 8px 0;
}
.content > .card > .title > * {
vertical-align: middle;
}
.content > .card > .title > span {
font-size: 24px;
}
.content > .card > .title > img {
height: 32px;
width: 32px;
}
.content > .card > .frame {
padding: 4px 8px 8px;
border-radius: 8px;
background-color: #0009;
backdrop-filter: blur(4px);
}
.content > .card > .frame > ul {
padding: 0 0 0 24px;
}
.invite {
margin-top: 8px;
padding: 12px 0;
width: 100%;
font-family: BlitzBold, sans-serif;
font-size: 20px;
color: white;
background-color: #4d5058;
border-radius: 4px;
border: 0;
display: flex;
justify-content: center;
gap: 8px;
transition: 200ms;
}
.invite:hover {
background-color: #6d6f78;
cursor: pointer;
}
.invite:active {
background-color: #80848e;
}
.invite > img {
height: 24px;
width: 24px;
}
.invite > span {
filter: none !important;
}
.invite > * {
vertical-align: middle;
}
.footer {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
padding: 8px;
color: #999;
font-size: 14px;
text-align: center;
background-color: #0009;
backdrop-filter: blur(4px);
}
.footer img {
vertical-align: sub;
}
.splatoon {
height: 24px;
filter: brightness(75%);
}

View file

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
assets/png/octo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#fff"><path d="M440-440v120q0 17 11.5 28.5T480-280q17 0 28.5-11.5T520-320v-120h120q17 0 28.5-11.5T680-480q0-17-11.5-28.5T640-520H520v-120q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640v120H320q-17 0-28.5 11.5T280-480q0 17 11.5 28.5T320-440h120Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>

After

Width:  |  Height:  |  Size: 540 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 2000 100%" xmlns="http://www.w3.org/2000/svg" xmlns:bx="https://boxy-svg.com">
<path d="M 1029.96 23.774 C 1029.761 26.218 1027.719 28.101 1025.267 28.102 L 975.732 28.102 C 975.601 28.102 975.477 28.075 975.348 28.064 C 972.917 27.866 971 25.859 971 23.384 C 971.004 20.775 973.123 18.664 975.732 18.668 L 993.936 18.668 C 993.93 18.562 993.919 18.458 993.919 18.353 C 993.919 14.901 996.727 12.103 1000.189 12.103 L 1000.809 12.103 C 1002.796 12.102 1004.666 13.045 1005.847 14.643 C 1006.648 15.714 1007.08 17.016 1007.08 18.353 C 1007.08 18.459 1007.07 18.563 1007.064 18.668 L 1025.267 18.668 C 1027.881 18.668 1030 20.779 1030 23.385 C 1030 23.518 1029.971 23.643 1029.96 23.774 M 2000 0 L 0 0 L 0 4000 L 2000 4000 L 2000 0 Z" fill-rule="evenodd" bx:origin="0.5 0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 841 B

View file

@ -0,0 +1 @@
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 960 B

1
assets/svg/splatoon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Binary file not shown.

View file

@ -1,17 +0,0 @@
services:
octobot:
container_name: octobot
build:
context: .
args:
- PUBLISH_OPTIONS
environment:
- BOT_TOKEN
volumes:
- guild-data:/Octobot/GuildData
- logs:/Octobot/Logs
restart: unless-stopped
volumes:
guild-data:
logs:

View file

@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement via the "Report Content" feature or via email at
l1ttleofficial@outlook.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View file

@ -1,68 +0,0 @@
# Contributing Guidelines
Thank you for showing interest in the development of Octobot. We aim to provide a good collaborating environment for
everyone involved, and as such have decided to list some of the most important things to keep in mind in the process.
Before starting, please read our [Code of Conduct](CODE_OF_CONDUCT.md)
## Reporting bugs
A **bug** is a situation in which there is something clearly wrong with the bot. Examples of applicable bug reports are:
- The bot doesn't reply to a command
- The bot sends the same message twice
- The bot takes a long time to a respond if I use this specific command
- An embed the bot sent has incorrect information in it
To track bug reports, we primarily use GitHub **issues**. When opening an issue, please keep in mind the following:
- Before opening the issue, please search for any similar existing issues using the text search bar and the issue
labels. This includes both open and closed issues (we may have already fixed something, but the fix hasn't yet been
released).
- When opening the issue, please fill out as much of the issue template as you can. In particular, please make sure to
include console output and screenshots as much as possible.
- We may ask you for follow-up information to reproduce or debug the problem. Please look out for this and provide
follow-up info if we request it.
## Submitting pull requests
While pull requests from unaffiliated contributors are welcome, please note that the core team *may* be focused on
internal issues that haven't been published to the issue tracker yet. Reviewing PRs is done on a best-effort basis, so
please be aware that it may take a while before a core maintainer gets around to review your change.
The [issue tracker](https://github.com/TeamOctolings/Octobot/issues) should provide plenty of issues to start with.
Make sure to check that an issue you're planning to resolve does not already have people working on it and that there
are no PRs associated with it
In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't
seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the
correct direction on how to address it.
If you'd like to propose a subjective change to one of the UI/UX aspects of the bot, or there is a bigger task you'd
like to work on, but there is no corresponding issue yet for it, **please open an issue first** to avoid wasted effort.
Aside from the above, below is a brief checklist of things to watch out when you're preparing your code changes:
- Make sure you're comfortable with the principles of object-oriented programming, the syntax of C\# and your
development environment.
- Make sure you are familiar with [git](https://git-scm.com/)
and [the pull request workflow](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests).
- Please do not make code changes via the GitHub web interface.
- Please make sure your development environment respects the .editorconfig file present in the repository. Our code
style differs from most C\# projects and is closer to something you see in Java projects.
- Please test your changes. We expect most new features and bugfixes to be tested in an environment similar to
production.
After you're done with your changes and you wish to open the PR, please observe the following recommendations:
- Please submit the pull request from
a [topic branch](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows#_topic_branch) (not `master`), and
keep the *Allow edits from maintainers* check box selected, so that we can push fixes to your PR if necessary.
- Please avoid pushing untested or incomplete code.
- Please do not force-push or rebase unless we ask you to.
- Please do not merge `master` continually if there are no conflicts to resolve. We will do this for you when the change
is ready for merge.
We are highly committed to quality when it comes to Octobot. This means that contributions from less experienced
community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never
conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please
consider our comments and requests a learning experience.

View file

@ -1,47 +0,0 @@
<p align="center">
<img src="octobot-banner.png" alt="Octobot banner"/>
</p>
<a href="https://github.com/TeamOctolings/Octobot/blob/master/LICENSE"><img src="https://img.shields.io/github/license/TeamOctolings/Octobot?logo=git"></img></a>
<a href="https://github.com/Remora/Remora.Discord"><img src="https://img.shields.io/badge/powered_by-Remora.Discord-blue"></img></a>
<a href="https://github.com/TeamOctolings/Octobot/commit/master"><img src="https://img.shields.io/github/last-commit/TeamOctolings/Octobot?logo=github"></img></a>
Veemo! I'm a general-purpose bot for moderation (formerly known as Boyfriend) written by [Team Octolings](https://github.com/TeamOctolings) in C# and Remora.Discord
## Features
* Banning, muting, kicking, etc.
* Reminding you about something if you wish
* Reminding everyone about that new event you made
* Renaming those annoying self-hoisting members
* Log everything from joining the server to deleting messages
* Listen to Inkantation!
*...a-a-and more!*
## Building Octobot
Check out the Octobot's Wiki for details.
| [Windows](https://github.com/TeamOctolings/Octobot/wiki/Installing-Windows) | [Linux/macOS](https://github.com/TeamOctolings/Octobot/wiki/Installing-Unix) |
| --- | --- |
## Contributing
When it comes to contributing to the project, the two main things you can do to help out are reporting issues and
submitting pull requests. Please refer to the [contributing guidelines](CONTRIBUTING.md) to understand how to help in
the most effective way possible.
## Special Thanks
![JetBrains Logo (Main) logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg)
[JetBrains](https://www.jetbrains.com/), creators of [ReSharper](https://www.jetbrains.com/resharper)
and [Rider](https://www.jetbrains.com/rider), supports Octobot with one of
their [Open Source Licenses](https://jb.gg/OpenSourceSupport).
Rider is the recommended IDE when working with Octobot, and everyone on the Octobot team uses it.
Additionally, ReSharper command-line tools made by JetBrains are used for status checks on pull requests to ensure code
quality even when not using ReSharper or Rider.
#
<sup>Not an official Splatoon™ product. We are in no way affiliated with or endorsed by Nintendo Company, or other rightsholders.</sup>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

97
index.html Normal file
View file

@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="initial-scale=0.75 user-scalable=no"/>
<meta property="og:site_name" content="TeamOctolings/Octobot"/>
<meta property="og:title" content="Octobot for Discord"/>
<meta property="og:description" content="A general-purpose Discord bot for moderation written by Team Octolings in C# and Remora.Discord"/>
<meta property="og:image" content="assets/png/octo.png"/>
<link rel="stylesheet" href="assets/css/styles.css"/>
<link rel="icon" href="assets/ico/octobot.ico"/>
<title>Octobot for Discord</title>
</head>
<body>
<div class="header">
<div class="left">
<img src="assets/png/octobot-web-logo.png" alt="Octobot Web logo" draggable="false"/>
</div>
<div class="right">
<div class="social">
<a href="https://github.com/TeamOctolings/Octobot">
<img src="assets/svg/github-mark-white.svg" alt="GitHub logo" draggable="false"/>
</a>
</div>
</div>
</div>
<div class="content">
<div class="first card">
<div class="title">
<img src="assets/png/octo.png" alt="Octobot icon" draggable="false"/>
<span class="highlight">Veemo!</span>
</div>
<div class="frame">
<span>I'm a general-purpose Discord bot for moderation written by Team Octolings in C# and Remora.Discord!</span>
</div>
<div class="title">
<img src="assets/png/mem-cake-sardinium.png" alt="Mem Cake (Sardinium)" draggable="false"/>
<span class="highlight">Features</span>
</div>
<div class="frame">
<ul>
<li>Banning, muting, kicking, etc.</li>
<li>Reminding you about something if you wish</li>
<li>Reminding everyone about that new event you made</li>
<li>Renaming those annoying self-hoisting members</li>
<li>Log everything from joining the server to deleting messages</li>
<li>Listen to Inkantation!</li>
...a-a-and more!
</ul>
</div>
<a class="alternative"
href="https://discord.com/oauth2/authorize?client_id=855023234407333888&permissions=1383382133894&scope=applications.commands%20bot">
<button class="invite">
<img src="assets/svg/add-circle-white.svg" alt="Add icon"/>
<span>Add App</span>
</button>
</a>
</div>
<div class="second card">
<div class="title">
<img src="assets/png/mem-cake-octoling.png" alt="Mem Cake (Rival Octoling)" draggable="false"/>
<span class="highlight">Bug Report / Feature Request</span>
</div>
<div class="frame">
<span>If you find some bug or want some new feature in Octobot, you can always use the Issues menu in our GitHub repository.</span>
<ul>
<li><a href="https://github.com/TeamOctolings/Octobot/issues">Open GitHub Issues</a></li>
<li><a
href="https://github.com/TeamOctolings/Octobot/issues/new?assignees=&labels=type%3A+bug&projects=&template=bug-report.yml">Report
a bug</a></li>
<li><a
href="https://github.com/TeamOctolings/Octobot/issues/new?assignees=&labels=type%3A+feature&projects=&template=feature-request.yml">Request
a feature</a></li>
</ul>
</div>
<div class="title">
<img src="assets/png/mem-cake-mole.png" alt="Mem Cake (Mole)" draggable="false"/>
<span class="highlight">Building Octobot</span>
</div>
<div class="frame">
<span>Want to make your own Octobot with, for example, even more features? Then, Octobot's Wiki is at your service!</span>
<ul>
<li><a href="https://github.com/TeamOctolings/Octobot/wiki/Installing-Windows">Building for Windows</a></li>
<li><a href="https://github.com/TeamOctolings/Octobot/wiki/Installing-Unix">Building for Linux/macOS</a></li>
</ul>
</div>
</div>
</div>
<div class="footer">
<span>Not an official <img class="splatoon" src="assets/svg/splatoon.svg" alt="Splatoon™"/> product. We are in no way affiliated with or endorsed by Nintendo Company, or other rightsholders.</span>
<a href="https://github.com/TeamOctolings/Octobot/commit/master"><img
src="https://img.shields.io/github/last-commit/TeamOctolings/Octobot?logo=github" alt="Last commit"/></a>
</div>
</body>
</html>