Compare commits

...
This repository has been archived on 2025-06-20. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.

40 commits

Author SHA1 Message Date
dependabot[bot]
5a351cbd97
Bump muno92/resharper_inspectcode from 1.12.3 to 1.13.0 (#348) 2025-05-19 08:33:14 +00:00
dependabot[bot]
f3330c47cc
Bump Remora.Discord with 5 updates (#346)
Bumps the remora group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [Remora.Discord.Caching](https://github.com/Remora/Remora.Discord) |
`39.0.0` | `40.0.0` |
| [Remora.Commands](https://github.com/Remora/Remora.Commands) |
`10.0.6` | `11.0.1` |
| [Remora.Discord.Extensions](https://github.com/Remora/Remora.Discord)
| `5.3.6` | `6.0.0` |
|
[Remora.Discord.Interactivity](https://github.com/Remora/Remora.Discord)
| `5.0.0` | `6.0.0` |
| [Remora.Discord.Hosting](https://github.com/Remora/Remora.Discord) |
`6.0.10` | `7.0.0` |

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Octol1ttle <l1ttleofficial@outlook.com>
2025-02-17 14:50:08 +05:00
4785d162a2
Handle temporary files being present when loading guild data (#345)
This PR fixes catastrophic guild data loading errors that appear when
there are lingering temporary files. In normal operation, temporary
files are deleted as soon as they are copied to the main file. It is
also expected that temporary files are valid JSON files.

However, due to a yesterday's DoS attack, something™️ happened and a
bunch of empty temporary files got written to disk. When Octobot
recovered from the attack, it was unable to load any guild data because
of the temporary files.

This PR addresses this issue by changing the data loading logic:
1) Check if there's a temporary file. If it exists, try loading it.
2) If it is successfully loaded, move the temp file to the main file and
resume operation as normal
3) If it could not be loaded, try loading the main file
4) If it is successfully loaded, delete the temporary file and resume
operation as normal
5) If it is not, throw an error (like before)

This PR was tested on production data and managed to load every guild
without errors.

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2025-02-03 16:58:57 +05:00
bf818401d8
Bump TargetFramework from 8.0 to 9.0 (#344)
Simple and self-explanatory.

Closes #342

---------

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-12-01 19:52:47 +03:00
5c235b9f98
Downgrade GitInfo from 3.5.0 to 3.3.5 (#343)
This pull request downgrades GitInfo from 3.5.0 to 3.3.5 due to a
decision from the maintainers to lock features behind a paywall.

![image](https://github.com/user-attachments/assets/f90eba84-1a1e-43eb-950e-e233a02feb9a)
2024-12-01 19:48:25 +03:00
dependabot[bot]
ed298202fc
Bump JetBrains.Annotations from 2024.2.0 to 2024.3.0 (#339) 2024-11-15 14:41:36 +00:00
dependabot[bot]
9fc97bc908
Bump GitInfo from 3.3.5 to 3.5.0 (#340) 2024-11-15 14:35:06 +00:00
bfee889149
ssh-keyscan is a dumb command (#338)
Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-10-28 08:04:08 +03:00
8a42ecd1ab
Retrieve SSH port from environment secrets (#337)
Fixes current deployment failure due to use of non-standard SSH port on
our production host.

`ssh-keyscan` command was moved out to its own step to help
troubleshooting in the future.

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-10-28 07:51:24 +03:00
84dc248038
Add Dockerfile and compose.yaml (#335)
This PR adds Dockerfile, to run Octobot within a Docker container, and
compose.yaml, to run the Octobot container along with any services that
the user may require.

---------

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
Co-authored-by: mctaylors <cantsendmails@mctaylors.ru>
2024-10-27 22:31:04 +05:00
9e44d49039
Update dependencies (#334)
this is supposed to be Dependabot's job, but ig something happened to it

Bumps `JetBrains.Annotations` from `2023.3.0` to `2024.2.0`
<details><summary>Changelog</summary>
• Added DefaultEqualityUsageAttribute for equality members usage
analysis.
• MustDisposeResourceAttribute is now allowed on struct types.
• Added ability to specify the description for UsedImplicitlyAttribute
(new 'Reason' property).
• Added copyright information to nuspec.
</details>

Bumps `Remora.Commands` from `10.0.5` to `10.0.6`
<details><summary>Changelog</summary>
Upgrade Remora.Sdk.
           Upgrade nuget packages.
</details>

Bumps `Remora.Discord.Extensions` from `5.3.5` to `5.3.6`
Bumps `Remora.Discord.Interactivity` from `4.5.4` to `5.0.0`
<details><summary>Changelog</summary>
Update dependencies.
BREAKING: Rework deletion logic for data leases to prevent deadlocks.
</details>

The breaking change in Remora should not affect us.

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-10-03 21:18:25 +03:00
dependabot[bot]
450f1da54d
Bump muno92/resharper_inspectcode from 1.11.12 to 1.12.0 (#332) 2024-09-23 18:59:32 +00:00
8028d47ba1
Fix redundant type specification (#333)
This PR fixes failing checks for #332
2024-09-23 23:55:58 +05:00
086cb672f0
Fix wrong private key file name (#330)
The key used for deploy is Ed25519, not RSA

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-08-25 16:03:31 +05:00
0fc53990b9
Update song list with new Splatoon™ soundtracks (#266)
Changes in the list:
1. Fly Octo Fly ~ Ebb & Flow (Octo) / Off the Hook → Spectrum Obligato ~
Ebb & Flow (Out of Order) / Off the Hook feat. Dedf1sh
2. `#47` onward / Dedf1sh feat. Off the Hook
3. EchΘ Θnslaught / Free Association
4. Short Order / Off the Hook
5. Fins in the Air / Deep Cut

Signed-off-by: Macintxsh <95250141+mctaylors@users.noreply.github.com>
2024-08-25 07:11:54 +00:00
afd0141c13
/about: Replace repo link with website link (#328)
A some sort of UX change. Repository link will be still accessible from
the website.
2024-08-24 15:50:32 +00:00
d1133124b8
Apply new inspection fixes (#329)
Rider and R# 2024.2 have introduced new inspections, causing current
builds to fail. This PR applies fixes for issues caught by these
inspections.
2024-08-24 20:48:47 +05:00
e457b4609e
Don't log stack traces for cancelled operations (#327)
This PR fixes an issue where the `LogResultStackTrace` method would log
stack traces for results that encountered an error due to a cancelled
operation/task. The `LoggerExtensions` class already skipped
`TaskCanceledException`s, but didn't skip `OperationCanceledException`s
(which is a parent of `TaskCanceledException`).

The patch specifically does not affect *inner* results which are
canceled. Skipping logging these could hide the true cause of an error
which appears important
2024-07-31 23:57:21 +05:00
d6d2660fb0
Show an error when entering the same value from the settings (#326)
Closes #324
2024-07-31 14:13:29 +05:00
07e8784d2e
Redesign reminder-related commands (#321)
In this PR, I redesigned the reminder-related commands and they will now
have a quote block instead of a inline code block (to avoid some visual
bugs). Except /listremind. It just has the inline code block removed.

![image](https://github.com/TeamOctolings/Octobot/assets/95250141/3521af97-ee11-405f-8cc2-7bf9a747e757)
2024-07-03 17:12:32 +00:00
930b7ca6ed
Bump InspectCode from 1.11.10 to 1.11.12 (#323)
I <3 breaking changes.
2024-07-03 22:09:39 +05:00
a0e7b3a611
Always default cancellation tokens (#319)
This PR makes sure that a cancellation token is never *required* to use
an `async` method. This does not affect user experience in any way, only
code quality.

---------

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-06-25 15:09:45 +05:00
a953053f1d
Handle audit log entries for message deletion being empty (#317)
In order to determine who deleted a message, Octobot fetches the audit
log with a filter on the action "Message Delete", gets the latest entry
and uses its author if the timestamps roughly match. However, if the
filter returns no entries (as in, no message deletions are present in
the audit log), `Single()` will throw an exception with the message
`Sequence contains no elements`. To fix this, this PR replaces
`Single()` with `SingleOrDefault()` and adds a null-check on `auditLog`
in the form of a pattern access

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-06-21 19:28:29 +00:00
2b0c4b62d3
README: Refer to the Octobot's Wiki in Building Octobot (#316)
Signed-off-by: Fakeintxsh <95250141+mctaylors@users.noreply.github.com>
2024-06-11 11:28:06 +05:00
29a1eb9869
Add description to /clear's author to clarify its functionality (#315)
Closes #314

What? The title speaks for itself.
2024-05-31 21:46:12 +05:00
daef4f1d48
Upgrade NuGet dependencies (#313)
Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-05-30 15:32:27 +05:00
ea9302e185
Use MemberData roles when checking permissions & interactions (#312)
Closes #311 

This change fixes unexpected behavior when a member's Discord roles get
desynchronized with their MemberData roles (e.g. when a member gets
role-muted). In addition this results in less API requests being made
when there are cache misses (commands should execute faster)

---------

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-05-23 18:21:52 +05:00
d03e2504fc
Seal implicitly used classes (#309)
Apparently the `[UsedImplicitly]` annotation suppresses the "Class has
no inheritors and can be marked sealed" warning. Cool to know.

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-05-23 17:47:51 +05:00
ebcdcb35f7
Separate /*info commands from ToolsCommandGroup (#308)
who tf thought that putting 1234915912 methods responsible for 23981
commands in a single class was a good idea???????

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-05-18 21:12:38 +05:00
793afd0e06
Apply official naming guidelines to Octobot (#306)
1. The root namespace was changed from `Octobot` to
`TeamOctolings.Octobot`:
> DO prefix namespace names with a company name to prevent namespaces
from different companies from having the same name.
2. `Octobot.cs` was renamed to `Program.cs`:
> DO NOT use the same name for a namespace and a type in that namespace.
3. `IOption`, `Option` were renamed to `IGuildOption` and `GuildOption`
respectively:
> DO NOT introduce generic type names such as Element, Node, Log, and
Message.
4. `Utility` was moved out of the `Services` namespace. It didn't belong
there anyway
5. `Program` static fields were moved to `Utility`
6. Localisation files were moved back to the project source files. Looks
like this fixed `Message.Designer.cs` code generation

---------

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-05-16 20:34:26 +05:00
19fadead91
Enable result stacktraces in release mode (#305)
Originally I did not enable because "stack traces are expensive to
retrieve", but let's be honest, who cares, this is a Discord bot,
there's no such thing as "good performance"
2024-05-13 18:10:41 +05:00
2502beb5df
Dependabot: ignore patch updates (#304)
This PR *should* disable creating Dependabot PRs for patch updates.
These updates often don't contain significant changes and only clutter
the PR feed in addition to taking the maintainers' time
2024-05-13 12:43:59 +00:00
dependabot[bot]
3029089385
Bump muno92/resharper_inspectcode from 1.11.8 to 1.11.10 (#302) 2024-05-13 12:41:55 +00:00
508edcbd68
Don't hardcode logo's link in /about (#301)
In this PR, I made the logo link in /about use HEAD instead of hardcoded
branch.

---------

Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
Co-authored-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-04-08 14:02:31 +03:00
defa3c2e4a
Listen to Maritime Memory on Wii U Discontinuation Day (#299)
Due to the shutdown of Wii U online services on April 8 at 23:00 UTC
(which affects Splatoon for Wii U), I'm opening a PR to memorialize
Splatoon multiplayer on Wii U by replacing bot music with Maritime
Memory on April 8-9.

Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
2024-04-07 13:51:28 +03:00
d3053d87e8
Remove mctaylors' version of the Russian language (#297)
295 PR/issues ~(not 300, however)~ or ~1.5 years ago, I made #2, the
Russian language replacement aka mctaylors-ru. This was my first
contribution to the Octobot project (formerly known as Boyfriend). This
was to add some sort of unique, unusual feature to Octobot, which
doesn't have any moderator bots. Everyone loved the language. But it
just became difficult to maintain. I certainly don't want to get rid of
it, but it leaves me no other choice. This isn't a joke or anything like
that. I'm tired of maintaining it. And I'm sure the other contributors
are too.

This PR removes the mctaylors-ru language.

---------

Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
Co-authored-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-04-01 22:20:41 +03:00
96680d3beb
Make the logo in /about independent of image hosting (#296)
PR's name speaks for itself. It might also be useful to update the logo
more easily.

---------

Signed-off-by: Macintxsh <95250141+mctaylors@users.noreply.github.com>
Co-authored-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-04-01 12:59:19 +00:00
70fccf8335
Use unicode codes instead of emojis (#295)
This change was made to avoid using emoji in the code, which may not
render correctly depending on the IDE and/or operating system.
2024-04-01 15:57:49 +03:00
9429dfe8d8
Fix "No operation context has been set for this scope." crash on startup (#293)
Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
2024-03-26 15:35:31 +05:00
dependabot[bot]
cccc4d6205
Bump muno92/resharper_inspectcode from 1.11.7 to 1.11.8 (#292)
Bumps
[muno92/resharper_inspectcode](https://github.com/muno92/resharper_inspectcode)
from 1.11.7 to 1.11.8.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/muno92/resharper_inspectcode/releases">muno92/resharper_inspectcode's
releases</a>.</em></p>
<blockquote>
<h2>1.11.8</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<ul>
<li>the input <code>cacheHome</code> should be <code>cachesHome</code>
[sic] by <a href="https://github.com/n0099"><code>@​n0099</code></a> in
<a
href="https://redirect.github.com/muno92/resharper_inspectcode/pull/458">muno92/resharper_inspectcode#458</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/n0099"><code>@​n0099</code></a> made
their first contribution in <a
href="https://redirect.github.com/muno92/resharper_inspectcode/pull/458">muno92/resharper_inspectcode#458</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/muno92/resharper_inspectcode/compare/1.11.7...1.11.8">https://github.com/muno92/resharper_inspectcode/compare/1.11.7...1.11.8</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/muno92/resharper_inspectcode/blob/main/CHANGELOG.md">muno92/resharper_inspectcode's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/muno92/resharper_inspectcode/compare/1.11.7...1.11.8">1.11.8</a>
- 2024-03-23</h2>
<ul>
<li>the input <code>cacheHome</code> should be <code>cachesHome</code>
[sic] by <a href="https://github.com/n0099"><code>@​n0099</code></a> in
<a
href="https://redirect.github.com/muno92/resharper_inspectcode/pull/458">muno92/resharper_inspectcode#458</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="1aa09d0e3f"><code>1aa09d0</code></a>
Merge pull request <a
href="https://redirect.github.com/muno92/resharper_inspectcode/issues/459">#459</a>
from muno92/tagpr-from-1.11.7</li>
<li><a
href="b5d9f05274"><code>b5d9f05</code></a>
Compile</li>
<li><a
href="cf28f1048a"><code>cf28f10</code></a>
[tagpr] update CHANGELOG.md</li>
<li><a
href="d41935b2e5"><code>d41935b</code></a>
[tagpr] prepare for the next release</li>
<li><a
href="5685c9cc42"><code>5685c9c</code></a>
Merge pull request <a
href="https://redirect.github.com/muno92/resharper_inspectcode/issues/458">#458</a>
from n0099/main</li>
<li><a
href="d433d7b7fd"><code>d433d7b</code></a>
Update README.md</li>
<li>See full diff in <a
href="https://github.com/muno92/resharper_inspectcode/compare/1.11.7...1.11.8">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=muno92/resharper_inspectcode&package-manager=github_actions&previous-version=1.11.7&new-version=1.11.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-25 10:47:05 +03:00
72 changed files with 1396 additions and 1835 deletions

View file

@ -15,6 +15,10 @@ updates:
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
@ -30,3 +34,8 @@ updates:
remora:
patterns:
- "Remora.Discord.*"
# For all packages, ignore all patch updates
ignore:
- dependency-name: "GitInfo"
- dependency-name: "*"
update-types: [ "version-update:semver-patch" ]

View file

@ -22,8 +22,13 @@ jobs:
- 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.11.7
uses: muno92/resharper_inspectcode@1.13.0
with:
solutionPath: ./Octobot.sln
ignoreIssueType: InvertIf, ConvertIfStatementToSwitchStatement, ConvertToPrimaryConstructor

View file

@ -5,60 +5,83 @@ concurrency:
on:
push:
branches: [ "master" ]
branches: [ "master", "deploy-test" ]
jobs:
upload-solution:
name: Upload Octobot to production
upload-image:
name: Upload Octobot Docker image
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
packages: write
environment: production
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish solution
run: dotnet publish $PUBLISH_FLAGS
env:
PUBLISH_FLAGS: ${{vars.PUBLISH_FLAGS}}
- 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}}
- name: Setup SSH key
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_rsa
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
ssh-keyscan -H $SSH_HOST > ~/.ssh/known_hosts
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}}
SSH_HOST: ${{secrets.SSH_HOST}}
- name: Stop currently running instance
- name: Generate SSH known hosts file
run: |
ssh $SSH_USER@$SSH_HOST $STOP_COMMAND
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: Upload published solution
- name: Update Docker image
run: |
scp -r $UPLOAD_FROM $SSH_USER@$SSH_HOST:$UPLOAD_TO
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}}
UPLOAD_FROM: ${{vars.UPLOAD_FROM}}
UPLOAD_TO: ${{vars.UPLOAD_TO}}
NAMESPACE: ${{vars.NAMESPACE}}
IMAGE_NAME: ${{vars.IMAGE_NAME}}
- name: Start new instance
run: |
ssh $SSH_USER@$SSH_HOST $START_COMMAND
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}}

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ riderModule.iml
/.vs/
GuildData/
Logs/
compose.yaml

15
Dockerfile Normal file
View file

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

View file

@ -1,6 +1,6 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Octobot", "Octobot.csproj", "{9CA7A44F-167C-46D4-923D-88CE71044144}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamOctolings.Octobot", "TeamOctolings.Octobot\TeamOctolings.Octobot.csproj", "{A1679BA2-3A36-4D98-80C0-EEE771398FBD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -8,9 +8,9 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9CA7A44F-167C-46D4-923D-88CE71044144}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9CA7A44F-167C-46D4-923D-88CE71044144}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9CA7A44F-167C-46D4-923D-88CE71044144}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9CA7A44F-167C-46D4-923D-88CE71044144}.Release|Any CPU.Build.0 = Release|Any CPU
{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,4 +1,4 @@
namespace Octobot.Attributes;
namespace TeamOctolings.Octobot.Attributes;
/// <summary>
/// Any property marked with <see cref="StaticCallersOnlyAttribute"/> should only be accessed by static methods.

View file

@ -1,8 +1,10 @@
namespace Octobot;
namespace TeamOctolings.Octobot;
public static class BuildInfo
{
public const string RepositoryUrl = "https://github.com/TeamOctolings/Octobot";
public const string WebsiteUrl = "https://teamoctolings.github.io/Octobot";
private const string RepositoryUrl = "https://github.com/TeamOctolings/Octobot";
public const string IssuesUrl = $"{RepositoryUrl}/issues";

View file

@ -1,6 +1,6 @@
using System.Drawing;
namespace Octobot;
namespace TeamOctolings.Octobot;
/// <summary>
/// Contains all colors used in embeds.

View file

@ -1,9 +1,6 @@
using System.ComponentModel;
using System.Text;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
@ -18,14 +15,17 @@ 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 Octobot.Commands;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the command to show information about this bot: /about.
/// </summary>
[UsedImplicitly]
public class AboutCommandGroup : CommandGroup
public sealed class AboutCommandGroup : CommandGroup
{
private static readonly (string Username, Snowflake Id)[] Developers =
[
@ -36,9 +36,9 @@ public class AboutCommandGroup : CommandGroup
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly IDiscordRestGuildAPI _guildApi;
public AboutCommandGroup(
ICommandContext context, GuildDataService guildData,
@ -100,21 +100,21 @@ public class AboutCommandGroup : CommandGroup
.WithSmallTitle(string.Format(Messages.AboutBot, bot.Username), bot)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan)
.WithImageUrl("https://i.ibb.co/fS6wZhh/octobot-banner.png")
.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.ButtonOpenRepository,
new PartialEmoji(Name: "🌐"),
URL: BuildInfo.RepositoryUrl
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: "📖"),
new PartialEmoji(Name: "\ud83d\udcd6"), // 'OPEN BOOK' (U+1F4D6)
URL: BuildInfo.WikiUrl
);
@ -123,7 +123,7 @@ public class AboutCommandGroup : CommandGroup
BuildInfo.IsDirty
? Messages.ButtonDirty
: Messages.ButtonReportIssue,
new PartialEmoji(Name: "⚠️"),
new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0)
URL: BuildInfo.IssuesUrl,
IsDisabled: BuildInfo.IsDirty
);
@ -131,7 +131,7 @@ public class AboutCommandGroup : CommandGroup
return await _feedback.SendContextualEmbedResultAsync(embed,
new FeedbackMessageOptions(MessageComponents: new[]
{
new ActionRowComponent(new[] { repositoryButton, wikiButton, issuesButton })
new ActionRowComponent([repositoryButton, wikiButton, issuesButton])
}), ct);
}
}

View file

@ -2,11 +2,6 @@ using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Parsers;
using Octobot.Services;
using Octobot.Services.Update;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
@ -19,14 +14,19 @@ 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 Octobot.Commands;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles commands related to ban management: /ban and /unban.
/// </summary>
[UsedImplicitly]
public class BanCommandGroup : CommandGroup
public sealed class BanCommandGroup : CommandGroup
{
private readonly AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi;
@ -62,7 +62,7 @@ public class BanCommandGroup : CommandGroup
/// </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.
/// was banned and vice versa.
/// </returns>
/// <seealso cref="ExecuteUnban" />
[Command("ban", "бан")]
@ -219,7 +219,7 @@ public class BanCommandGroup : CommandGroup
/// </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.
/// was unbanned and vice versa.
/// </returns>
/// <seealso cref="ExecuteBanAsync" />
/// <seealso cref="MemberUpdateService.TickMemberDataAsync" />

View file

@ -1,9 +1,6 @@
using System.ComponentModel;
using System.Text;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
@ -16,14 +13,17 @@ 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 Octobot.Commands;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the command to clear messages in a channel: /clear.
/// </summary>
[UsedImplicitly]
public class ClearCommandGroup : CommandGroup
public sealed class ClearCommandGroup : CommandGroup
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
@ -51,7 +51,7 @@ public class ClearCommandGroup : CommandGroup
/// <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.
/// were cleared and vice versa.
/// </returns>
[Command("clear", "очистить")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
@ -64,6 +64,7 @@ public class ClearCommandGroup : CommandGroup
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))

View file

@ -1,6 +1,5 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Octobot.Extensions;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
@ -11,14 +10,15 @@ using Remora.Discord.Commands.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace Octobot.Commands.Events;
namespace TeamOctolings.Octobot.Commands.Events;
/// <summary>
/// Handles error logging for slash command groups.
/// </summary>
[UsedImplicitly]
public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
public sealed class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
{
private readonly IFeedbackService _feedback;
private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger;
@ -73,7 +73,7 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
BuildInfo.IsDirty
? Messages.ButtonDirty
: Messages.ButtonReportIssue,
new PartialEmoji(Name: "⚠️"),
new PartialEmoji(Name: "\u26a0\ufe0f"), // 'WARNING SIGN' (U+26A0)
URL: BuildInfo.IssuesUrl,
IsDisabled: BuildInfo.IsDirty
);
@ -81,7 +81,7 @@ public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
return ResultExtensions.FromError(await _feedback.SendContextualEmbedResultAsync(embed,
new FeedbackMessageOptions(MessageComponents: new[]
{
new ActionRowComponent(new[] { issuesButton })
new ActionRowComponent([issuesButton])
}), ct)
);
}

View file

@ -1,17 +1,17 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Octobot.Extensions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Services;
using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace Octobot.Commands.Events;
namespace TeamOctolings.Octobot.Commands.Events;
/// <summary>
/// Handles error logging for slash commands that couldn't be successfully prepared.
/// </summary>
[UsedImplicitly]
public class LoggingPreparationErrorEvent : IPreparationErrorEvent
public sealed class LoggingPreparationErrorEvent : IPreparationErrorEvent
{
private readonly ILogger<LoggingPreparationErrorEvent> _logger;

View file

@ -2,10 +2,6 @@ using System.ComponentModel;
using System.Drawing;
using System.Text;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Parsers;
using Octobot.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
@ -17,14 +13,17 @@ 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 Octobot.Commands;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles tool commands: /userinfo, /guildinfo, /random, /timestamp, /8ball.
/// Handles info commands: /userinfo, /guildinfo.
/// </summary>
[UsedImplicitly]
public class ToolsCommandGroup : CommandGroup
public sealed class InfoCommandGroup : CommandGroup
{
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
@ -32,7 +31,7 @@ public class ToolsCommandGroup : CommandGroup
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
public ToolsCommandGroup(
public InfoCommandGroup(
ICommandContext context, IFeedbackService feedback,
GuildDataService guildData, IDiscordRestGuildAPI guildApi,
IDiscordRestUserAPI userApi)
@ -289,7 +288,7 @@ public class ToolsCommandGroup : CommandGroup
return await ShowGuildInfoAsync(bot, guild, CancellationToken);
}
private Task<Result> ShowGuildInfoAsync(IUser bot, IGuild guild, CancellationToken ct)
private Task<Result> ShowGuildInfoAsync(IUser bot, IGuild guild, CancellationToken ct = default)
{
var description = new StringBuilder().AppendLine($"## {guild.Name}");
@ -327,235 +326,4 @@ public class ToolsCommandGroup : CommandGroup
return _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
/// <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)
{
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);
}
private static readonly TimestampStyle[] AllStyles =
[
TimestampStyle.ShortDate,
TimestampStyle.LongDate,
TimestampStyle.ShortTime,
TimestampStyle.LongTime,
TimestampStyle.ShortDateTime,
TimestampStyle.LongDateTime,
TimestampStyle.RelativeTime
];
/// <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)
{
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 static readonly string[] AnswerTypes =
[
"Positive", "Questionable", "Neutral", "Negative"
];
private Task<Result> AnswerEightBallAsync(IUser bot, CancellationToken ct)
{
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,9 +1,6 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
@ -15,14 +12,17 @@ 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 Octobot.Commands;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the command to kick members of a guild: /kick.
/// </summary>
[UsedImplicitly]
public class KickCommandGroup : CommandGroup
public sealed class KickCommandGroup : CommandGroup
{
private readonly AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi;
@ -57,7 +57,7 @@ public class KickCommandGroup : CommandGroup
/// </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.
/// was kicked and vice versa.
/// </returns>
[Command("kick", "кик")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]

View file

@ -2,11 +2,6 @@ using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Parsers;
using Octobot.Services;
using Octobot.Services.Update;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
@ -19,14 +14,19 @@ 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 Octobot.Commands;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles commands related to mute management: /mute and /unmute.
/// </summary>
[UsedImplicitly]
public class MuteCommandGroup : CommandGroup
public sealed class MuteCommandGroup : CommandGroup
{
private readonly AccessControlService _access;
private readonly ICommandContext _context;
@ -59,7 +59,7 @@ public class MuteCommandGroup : CommandGroup
/// </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.
/// was muted and vice versa.
/// </returns>
/// <seealso cref="ExecuteUnmute" />
[Command("mute", "мут")]
@ -170,7 +170,7 @@ public class MuteCommandGroup : CommandGroup
private async Task<Result> SelectMuteMethodAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data,
IUser bot, DateTimeOffset until, CancellationToken ct)
IUser bot, DateTimeOffset until, CancellationToken ct = default)
{
var muteRole = GuildSettings.MuteRole.Get(data.Settings);
@ -186,7 +186,7 @@ public class MuteCommandGroup : CommandGroup
private async Task<Result> RoleMuteUserAsync(
IUser executor, IUser target, string reason, Snowflake guildId, GuildData data,
DateTimeOffset until, Snowflake muteRole, CancellationToken ct)
DateTimeOffset until, Snowflake muteRole, CancellationToken ct = default)
{
var assignRoles = new List<Snowflake> { muteRole };
var memberData = data.GetOrCreateMemberData(target.ID);
@ -208,7 +208,7 @@ public class MuteCommandGroup : CommandGroup
private async Task<Result> TimeoutUserAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId,
IUser bot, DateTimeOffset until, CancellationToken ct)
IUser bot, DateTimeOffset until, CancellationToken ct = default)
{
if (duration.TotalDays >= 28)
{
@ -235,7 +235,7 @@ public class MuteCommandGroup : CommandGroup
/// </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.
/// was unmuted and vice versa.
/// </returns>
/// <seealso cref="ExecuteMute" />
/// <seealso cref="MemberUpdateService.TickMemberDataAsync" />

View file

@ -1,8 +1,5 @@
using System.ComponentModel;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
@ -15,14 +12,17 @@ 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 Octobot.Commands;
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 class PingCommandGroup : CommandGroup
public sealed class PingCommandGroup : CommandGroup
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly DiscordGatewayClient _client;

View file

@ -2,9 +2,6 @@ using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
@ -17,21 +14,24 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using Octobot.Parsers;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Parsers;
using TeamOctolings.Octobot.Services;
namespace Octobot.Commands;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles commands to manage reminders: /remind, /listremind, /delremind
/// </summary>
[UsedImplicitly]
public class RemindCommandGroup : CommandGroup
public sealed class RemindCommandGroup : CommandGroup
{
private readonly IInteractionCommandContext _context;
private readonly IFeedbackService _feedback;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly IDiscordRestInteractionAPI _interactionApi;
private readonly IDiscordRestUserAPI _userApi;
public RemindCommandGroup(
IInteractionCommandContext context, GuildDataService guildData, IFeedbackService feedback,
@ -78,7 +78,7 @@ public class RemindCommandGroup : CommandGroup
return await ListRemindersAsync(data.GetOrCreateMemberData(executorId), guildId, executor, bot, CancellationToken);
}
private Task<Result> ListRemindersAsync(MemberData data, Snowflake guildId, IUser executor, IUser bot, CancellationToken ct)
private Task<Result> ListRemindersAsync(MemberData data, Snowflake guildId, IUser executor, IUser bot, CancellationToken ct = default)
{
if (data.Reminders.Count == 0)
{
@ -94,7 +94,7 @@ public class RemindCommandGroup : CommandGroup
{
var reminder = data.Reminders[i];
builder.AppendBulletPointLine(string.Format(Messages.ReminderPosition, Markdown.InlineCode((i + 1).ToString())))
.AppendSubBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(reminder.Text)))
.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}"));
}
@ -182,7 +182,7 @@ public class RemindCommandGroup : CommandGroup
});
var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(text)))
.AppendLine(MarkdownExtensions.Quote(text))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt)));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderCreated, executor.GetTag()), executor)
@ -279,7 +279,7 @@ public class RemindCommandGroup : CommandGroup
data.Reminders.RemoveAt(index);
var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(oldReminder.Text)))
.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)
@ -309,7 +309,7 @@ public class RemindCommandGroup : CommandGroup
data.Reminders.RemoveAt(index);
var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(value)))
.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)
@ -353,7 +353,7 @@ public class RemindCommandGroup : CommandGroup
}
private Task<Result> DeleteReminderAsync(MemberData data, int index, IUser bot,
CancellationToken ct)
CancellationToken ct = default)
{
if (index >= data.Reminders.Count)
{
@ -367,7 +367,7 @@ public class RemindCommandGroup : CommandGroup
var reminder = data.Reminders[index];
var description = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(reminder.Text)))
.AppendLine(MarkdownExtensions.Quote(reminder.Text))
.AppendBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At)));
data.Reminders.RemoveAt(index);

View file

@ -3,10 +3,6 @@ using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Json.Nodes;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Data.Options;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
@ -19,23 +15,27 @@ 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 Octobot.Commands;
namespace TeamOctolings.Octobot.Commands;
/// <summary>
/// Handles the commands to list and modify per-guild settings: /settings and /settings list.
/// </summary>
[UsedImplicitly]
public class SettingsCommandGroup : CommandGroup
public sealed class SettingsCommandGroup : CommandGroup
{
/// <summary>
/// Represents all options as an array of objects implementing <see cref="IOption" />.
/// 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 IOption[] AllOptions =
private static readonly IGuildOption[] AllOptions =
[
GuildSettings.Language,
GuildSettings.WelcomeMessage,
@ -199,9 +199,30 @@ public class SettingsCommandGroup : CommandGroup
}
private async Task<Result> EditSettingAsync(
IOption option, string value, GuildData data, Snowflake channelId, IUser executor, IUser bot,
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)
{
@ -270,7 +291,7 @@ public class SettingsCommandGroup : CommandGroup
}
private async Task<Result> ResetSingleSettingAsync(JsonNode cfg, IUser bot,
IOption option, CancellationToken ct = default)
IGuildOption option, CancellationToken ct = default)
{
var resetResult = option.Reset(cfg);
if (!resetResult.IsSuccess)

View file

@ -0,0 +1,272 @@
using System.ComponentModel;
using System.Text;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Parsers;
using TeamOctolings.Octobot.Services;
namespace TeamOctolings.Octobot.Commands;
/// <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,7 +1,7 @@
using System.Text.Json.Nodes;
using Remora.Rest.Core;
namespace Octobot.Data;
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Stores information about a guild. This information is not accessible via the Discord API.

View file

@ -1,8 +1,8 @@
using Octobot.Data.Options;
using Octobot.Responders;
using Remora.Discord.API.Abstractions.Objects;
using TeamOctolings.Octobot.Data.Options;
using TeamOctolings.Octobot.Responders;
namespace Octobot.Data;
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Contains all per-guild settings that can be set by a member
@ -22,7 +22,7 @@ public static class GuildSettings
/// </list>
/// </remarks>
/// <seealso cref="GuildMemberJoinedResponder" />
public static readonly Option<string> WelcomeMessage = new("WelcomeMessage", "default");
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.
@ -34,7 +34,7 @@ public static class GuildSettings
/// </list>
/// </remarks>
/// <seealso cref="GuildMemberLeftResponder" />
public static readonly Option<string> LeaveMessage = new("LeaveMessage", "default");
public static readonly GuildOption<string> LeaveMessage = new("LeaveMessage", "default");
/// <summary>
/// Controls whether or not the <see cref="Messages.Ready" /> message should be sent

View file

@ -1,4 +1,4 @@
namespace Octobot.Data;
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Stores information about a member

View file

@ -1,7 +1,7 @@
using JetBrains.Annotations;
using Octobot.Commands;
using TeamOctolings.Octobot.Commands;
namespace Octobot.Data.Options;
namespace TeamOctolings.Octobot.Data.Options;
/// <summary>
/// Represents all options as enums.

View file

@ -1,9 +1,9 @@
using System.Text.Json.Nodes;
using Remora.Results;
namespace Octobot.Data.Options;
namespace TeamOctolings.Octobot.Data.Options;
public sealed class BoolOption : Option<bool>
public sealed class BoolOption : GuildOption<bool>
{
public BoolOption(string name, bool defaultValue) : base(name, defaultValue) { }
@ -12,6 +12,16 @@ public sealed class BoolOption : Option<bool>
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))

View file

@ -2,18 +2,18 @@ using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
namespace Octobot.Data.Options;
namespace TeamOctolings.Octobot.Data.Options;
/// <summary>
/// Represents an per-guild option.
/// Represents a per-guild option.
/// </summary>
/// <typeparam name="T">The type of the option.</typeparam>
public class Option<T> : IOption
public class GuildOption<T> : IGuildOption
where T : notnull
{
protected readonly T DefaultValue;
public Option(string name, T defaultValue)
public GuildOption(string name, T defaultValue)
{
Name = name;
DefaultValue = defaultValue;
@ -21,9 +21,19 @@ public class Option<T> : IOption
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(Get(settings).ToString() ?? throw new InvalidOperationException());
return Markdown.InlineCode(Value(settings));
}
public virtual Result<bool> ValueEquals(JsonNode settings, string value)
{
return Value(settings).Equals(value);
}
/// <summary>

View file

@ -1,12 +1,13 @@
using System.Text.Json.Nodes;
using Remora.Results;
namespace Octobot.Data.Options;
namespace TeamOctolings.Octobot.Data.Options;
public interface IOption
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,25 +1,23 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
namespace Octobot.Data.Options;
namespace TeamOctolings.Octobot.Data.Options;
/// <inheritdoc />
public sealed class LanguageOption : Option<CultureInfo>
public sealed class LanguageOption : GuildOption<CultureInfo>
{
private static readonly Dictionary<string, CultureInfo> CultureInfoCache = new()
{
{ "en", new CultureInfo("en-US") },
{ "ru", new CultureInfo("ru-RU") },
{ "mctaylors-ru", new CultureInfo("tt-RU") }
{ "ru", new CultureInfo("ru-RU") }
};
public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { }
public override string Display(JsonNode settings)
protected override string Value(JsonNode settings)
{
return Markdown.InlineCode(settings[Name]?.GetValue<string>() ?? "en");
return settings[Name]?.GetValue<string>() ?? "en";
}
/// <inheritdoc />

View file

@ -1,13 +1,13 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Octobot.Extensions;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Extensions;
namespace Octobot.Data.Options;
namespace TeamOctolings.Octobot.Data.Options;
public sealed partial class SnowflakeOption : Option<Snowflake>
public sealed partial class SnowflakeOption : GuildOption<Snowflake>
{
public SnowflakeOption(string name) : base(name, 0UL.ToSnowflake()) { }

View file

@ -1,13 +1,23 @@
using System.Text.Json.Nodes;
using Octobot.Parsers;
using Remora.Results;
using TeamOctolings.Octobot.Parsers;
namespace Octobot.Data.Options;
namespace TeamOctolings.Octobot.Data.Options;
public sealed class TimeSpanOption : Option<TimeSpan>
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];

View file

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

View file

@ -1,7 +1,7 @@
using System.Text.Json.Serialization;
using Remora.Discord.API.Abstractions.Objects;
namespace Octobot.Data;
namespace TeamOctolings.Octobot.Data;
/// <summary>
/// Stores information about scheduled events. This information is not provided by the Discord API.

View file

@ -5,18 +5,19 @@ using Remora.Discord.API.Objects;
using Remora.Rest.Core;
using Remora.Results;
namespace Octobot.Extensions;
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> messageRefenence = 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, CancellationToken ct = 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))
{
@ -24,6 +25,6 @@ public static class ChannelApiExtensions
}
return (Result)await channelApi.CreateMessageAsync(channelId, message, nonce, isTextToSpeech, new[] { embed },
allowedMentions, messageRefenence, components, stickerIds, attachments, flags, ct);
allowedMentions, messageReference, components, stickerIds, attachments, flags, enforceNonce, poll, ct);
}
}

View file

@ -1,6 +1,6 @@
using Remora.Results;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class CollectionExtensions
{

View file

@ -2,7 +2,7 @@
using Remora.Discord.Commands.Extensions;
using Remora.Rest.Core;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class CommandContextExtensions
{

View file

@ -1,7 +1,7 @@
using System.Text;
using DiffPlex.DiffBuilder.Model;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class DiffPaneModelExtensions
{

View file

@ -4,7 +4,7 @@ using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Rest.Core;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class EmbedBuilderExtensions
{

View file

@ -3,7 +3,7 @@ using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Results;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class FeedbackServiceExtensions
{

View file

@ -2,7 +2,7 @@
using Remora.Rest.Core;
using Remora.Results;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class GuildScheduledEventExtensions
{
@ -10,7 +10,7 @@ public static class GuildScheduledEventExtensions
out string? location)
{
endTime = default;
location = default;
location = null;
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
{
return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata));

View file

@ -1,7 +1,7 @@
using Microsoft.Extensions.Logging;
using Remora.Results;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class LoggerExtensions
{
@ -25,7 +25,7 @@ public static class LoggerExtensions
if (result.Error is ExceptionError exe)
{
if (exe.Exception is TaskCanceledException)
if (exe.Exception is OperationCanceledException)
{
return;
}

View file

@ -1,4 +1,4 @@
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class MarkdownExtensions
{
@ -13,4 +13,16 @@ public static class MarkdownExtensions
{
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

@ -2,7 +2,7 @@
using Microsoft.Extensions.Logging;
using Remora.Results;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class ResultExtensions
{
@ -21,21 +21,25 @@ public static class ResultExtensions
return casted;
}
[Conditional("DEBUG")]
private static void LogResultStackTrace(Result result)
{
if (Octobot.StaticLogger is null || result.IsSuccess)
if (result.IsSuccess || result.Error is ExceptionError { Exception: OperationCanceledException })
{
return;
}
Octobot.StaticLogger.LogError("{ErrorType}: {ErrorMessage}{NewLine}{StackTrace}",
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 })
{
Octobot.StaticLogger.LogError("Caused by: {ResultType}: {ResultMessage}",
Utility.StaticLogger.LogError("Caused by: {ResultType}: {ResultMessage}",
inner.Error.GetType().FullName, inner.Error.Message);
inner = inner.Inner;

View file

@ -1,6 +1,6 @@
using Remora.Rest.Core;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class SnowflakeExtensions
{

View file

@ -1,6 +1,6 @@
using System.Text;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class StringBuilderExtensions
{

View file

@ -1,7 +1,7 @@
using System.Net;
using Remora.Discord.Extensions.Formatting;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class StringExtensions
{

View file

@ -1,7 +1,7 @@
using Remora.Discord.API;
using Remora.Rest.Core;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class UInt64Extensions
{

View file

@ -1,6 +1,6 @@
using Remora.Discord.API.Abstractions.Objects;
namespace Octobot.Extensions;
namespace TeamOctolings.Octobot.Extensions;
public static class UserExtensions
{

File diff suppressed because it is too large Load diff

View file

@ -399,8 +399,8 @@
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>Developers:</value>
</data>
<data name="ButtonOpenRepository" xml:space="preserve">
<value>Octobot's source code</value>
<data name="ButtonOpenWebsite" xml:space="preserve">
<value>Open Website</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>About {0}</value>
@ -681,4 +681,7 @@
<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

@ -399,8 +399,8 @@
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>Разработчики:</value>
</data>
<data name="ButtonOpenRepository" xml:space="preserve">
<value>Исходный код Octobot</value>
<data name="ButtonOpenWebsite" xml:space="preserve">
<value>Открыть веб-сайт</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>О боте {0}</value>
@ -681,4 +681,7 @@
<data name="SettingsModeratorRole" xml:space="preserve">
<value>Роль модератора</value>
</data>
<data name="SettingValueEquals" xml:space="preserve">
<value>Значение настройки такое же, как и вводное значение.</value>
</data>
</root>

View file

@ -4,7 +4,7 @@ using JetBrains.Annotations;
using Remora.Commands.Parsers;
using Remora.Results;
namespace Octobot.Parsers;
namespace TeamOctolings.Octobot.Parsers;
/// <summary>
/// Parses <see cref="TimeSpan"/>s.

View file

@ -2,13 +2,8 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Octobot.Attributes;
using Octobot.Commands.Events;
using Octobot.Services;
using Octobot.Services.Update;
using Remora.Discord.API.Abstractions.Gateway.Commands;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
using Remora.Discord.Caching.Extensions;
using Remora.Discord.Caching.Services;
using Remora.Discord.Commands.Extensions;
@ -16,24 +11,20 @@ using Remora.Discord.Commands.Services;
using Remora.Discord.Extensions.Extensions;
using Remora.Discord.Gateway;
using Remora.Discord.Hosting.Extensions;
using Remora.Rest.Core;
using Serilog.Extensions.Logging;
using TeamOctolings.Octobot.Commands.Events;
using TeamOctolings.Octobot.Services;
using TeamOctolings.Octobot.Services.Update;
namespace Octobot;
namespace TeamOctolings.Octobot;
public sealed class Octobot
public sealed class Program
{
public static readonly AllowedMentions NoMentions = new(
Array.Empty<MentionType>(), Array.Empty<Snowflake>(), Array.Empty<Snowflake>());
[StaticCallersOnly]
public static ILogger<Octobot>? StaticLogger { get; private set; }
public static async Task Main(string[] args)
{
var host = CreateHostBuilder(args).UseConsoleLifetime().Build();
var services = host.Services;
StaticLogger = services.GetRequiredService<ILogger<Octobot>>();
Utility.StaticLogger = services.GetRequiredService<ILogger<Program>>();
var slashService = services.GetRequiredService<SlashService>();
// Providing a guild ID to this call will result in command duplicates!
@ -48,8 +39,7 @@ public sealed class Octobot
private static IHostBuilder CreateHostBuilder(string[] args)
{
return Host.CreateDefaultBuilder(args)
.AddDiscordService(
services =>
.AddDiscordService(services =>
{
var configuration = services.GetRequiredService<IConfiguration>();
@ -58,32 +48,29 @@ public sealed class Octobot
"No bot token has been provided. Set the "
+ "BOT_TOKEN environment variable to a valid token.");
}
).ConfigureServices(
(_, services) =>
).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.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(Octobot).Assembly)
.AddCommandGroupsFromAssembly(typeof(Octobot).Assembly)
.AddRespondersFromAssembly(typeof(Program).Assembly)
.AddCommandGroupsFromAssembly(typeof(Program).Assembly)
// Slash command event handlers
.AddPreparationErrorEvent<LoggingPreparationErrorEvent>()
.AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>()
@ -96,14 +83,13 @@ public sealed class Octobot
.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)
).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,8 +1,5 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
@ -11,15 +8,18 @@ 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 Octobot.Responders;
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 class GuildLoadedResponder : IResponder<IGuildCreate>
public sealed class GuildLoadedResponder : IResponder<IGuildCreate>
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _guildData;
@ -94,7 +94,7 @@ public class GuildLoadedResponder : IResponder<IGuildCreate>
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, ct: ct);
}
private async Task<Result> SendDataLoadFailed(IGuild guild, GuildData data, IUser bot, CancellationToken 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))
@ -114,12 +114,12 @@ public class GuildLoadedResponder : IResponder<IGuildCreate>
BuildInfo.IsDirty
? Messages.ButtonDirty
: Messages.ButtonReportIssue,
new PartialEmoji(Name: "⚠️"),
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(new[] { issuesButton }) }, ct: ct);
components: new[] { new ActionRowComponent([issuesButton]) }, ct: ct);
}
}

View file

@ -1,16 +1,16 @@
using System.Text.Json.Nodes;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
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 Octobot.Responders;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles sending a guild's <see cref="GuildSettings.WelcomeMessage" /> if one is set.
@ -18,7 +18,7 @@ namespace Octobot.Responders;
/// </summary>
/// <seealso cref="GuildSettings.WelcomeMessage" />
[UsedImplicitly]
public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
public sealed class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildAPI _guildApi;
@ -77,11 +77,11 @@ public class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.WelcomeMessagesChannel.Get(cfg), embedResult: embed,
allowedMentions: Octobot.NoMentions, ct: ct);
allowedMentions: Utility.NoMentions, ct: ct);
}
private async Task<Result> TryReturnRolesAsync(
JsonNode cfg, MemberData memberData, Snowflake guildId, Snowflake userId, CancellationToken ct)
JsonNode cfg, MemberData memberData, Snowflake guildId, Snowflake userId, CancellationToken ct = default)
{
if (!GuildSettings.ReturnRolesOnRejoin.Get(cfg))
{

View file

@ -1,21 +1,21 @@
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
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 Octobot.Responders;
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 class GuildMemberLeftResponder : IResponder<IGuildMemberRemove>
public sealed class GuildMemberLeftResponder : IResponder<IGuildMemberRemove>
{
private readonly IDiscordRestChannelAPI _channelApi;
private readonly IDiscordRestGuildAPI _guildApi;
@ -36,13 +36,9 @@ public class GuildMemberLeftResponder : IResponder<IGuildMemberRemove>
var cfg = data.Settings;
var memberData = data.GetOrCreateMemberData(user.ID);
if (memberData.BannedUntil is not null || memberData.Kicked)
{
return Result.Success;
}
if (GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty()
|| GuildSettings.LeaveMessage.Get(cfg) is "off" or "disable" or "disabled")
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;
}
@ -67,6 +63,6 @@ public class GuildMemberLeftResponder : IResponder<IGuildMemberRemove>
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.WelcomeMessagesChannel.Get(cfg), embedResult: embed,
allowedMentions: Octobot.NoMentions, ct: ct);
allowedMentions: Utility.NoMentions, ct: ct);
}
}

View file

@ -1,18 +1,18 @@
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Octobot.Data;
using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Services;
namespace Octobot.Responders;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles removing guild ID from <see cref="GuildData" /> if the guild becomes unavailable.
/// </summary>
[UsedImplicitly]
public class GuildUnloadedResponder : IResponder<IGuildDelete>
public sealed class GuildUnloadedResponder : IResponder<IGuildDelete>
{
private readonly GuildDataService _guildData;
private readonly ILogger<GuildUnloadedResponder> _logger;

View file

@ -1,8 +1,5 @@
using System.Text;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
@ -10,15 +7,18 @@ 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 Octobot.Responders;
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 class MessageDeletedResponder : IResponder<IMessageDelete>
public sealed class MessageDeletedResponder : IResponder<IMessageDelete>
{
private readonly IDiscordRestAuditLogAPI _auditLogApi;
private readonly IDiscordRestChannelAPI _channelApi;
@ -66,10 +66,10 @@ public class MessageDeletedResponder : IResponder<IMessageDelete>
return ResultExtensions.FromError(auditLogResult);
}
var auditLog = auditLogPage.AuditLogEntries.Single();
var deleterResult = Result<IUser>.FromSuccess(message.Author);
if (auditLog.UserID is not null
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)
{
@ -102,6 +102,6 @@ public class MessageDeletedResponder : IResponder<IMessageDelete>
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed,
allowedMentions: Octobot.NoMentions, ct: ct);
allowedMentions: Utility.NoMentions, ct: ct);
}
}

View file

@ -1,9 +1,6 @@
using System.Text;
using DiffPlex.DiffBuilder;
using JetBrains.Annotations;
using Octobot.Data;
using Octobot.Extensions;
using Octobot.Services;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
@ -12,15 +9,18 @@ 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 Octobot.Responders;
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 class MessageEditedResponder : IResponder<IMessageUpdate>
public sealed class MessageEditedResponder : IResponder<IMessageUpdate>
{
private readonly CacheService _cacheService;
private readonly IDiscordRestChannelAPI _channelApi;
@ -36,40 +36,29 @@ public class MessageEditedResponder : IResponder<IMessageUpdate>
public async Task<Result> RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.ID.IsDefined(out var messageId))
{
return new ArgumentNullError(nameof(gatewayEvent.ID));
}
if (!gatewayEvent.ChannelID.IsDefined(out var channelId))
{
return new ArgumentNullError(nameof(gatewayEvent.ChannelID));
}
if (!gatewayEvent.GuildID.IsDefined(out var guildId)
|| !gatewayEvent.Author.IsDefined(out var author)
|| !gatewayEvent.EditedTimestamp.IsDefined(out var timestamp)
|| !gatewayEvent.Content.IsDefined(out var newContent))
|| !gatewayEvent.EditedTimestamp.HasValue
|| gatewayEvent.Author.IsBot.OrDefault(false))
{
return Result.Success;
}
var cfg = await _guildData.GetSettings(guildId, ct);
if (author.IsBot.OrDefault(false) || GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{
return Result.Success;
}
var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId);
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(channelId, messageId, ct);
_ = _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
return Result.Success;
}
if (message.Content == newContent)
if (message.Content == gatewayEvent.Content)
{
return Result.Success;
}
@ -83,27 +72,27 @@ public class MessageEditedResponder : IResponder<IMessageUpdate>
// 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(channelId, messageId, ct);
_ = _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
var diff = InlineDiffBuilder.Diff(message.Content, newContent);
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}/{channelId}/{messageId}")
$"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(timestamp.Value)
.WithTimestamp(gatewayEvent.EditedTimestamp.Value)
.WithColour(ColorsList.Yellow)
.Build();
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed,
allowedMentions: Octobot.NoMentions, ct: ct);
allowedMentions: Utility.NoMentions, ct: ct);
}
}

View file

@ -5,13 +5,13 @@ using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core;
using Remora.Results;
namespace Octobot.Responders;
namespace TeamOctolings.Octobot.Responders;
/// <summary>
/// Handles sending replies to easter egg messages.
/// </summary>
[UsedImplicitly]
public class MessageCreateResponder : IResponder<IMessageCreate>
public sealed class MessageCreateResponder : IResponder<IMessageCreate>
{
private readonly IDiscordRestChannelAPI _channelApi;

View file

@ -1,44 +1,39 @@
using Octobot.Data;
using Octobot.Extensions;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Results;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
namespace Octobot.Services;
namespace TeamOctolings.Octobot.Services;
public sealed class AccessControlService
{
private readonly GuildDataService _data;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly RequireDiscordPermissionCondition _permission;
private readonly IDiscordRestUserAPI _userApi;
public AccessControlService(GuildDataService data, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi,
RequireDiscordPermissionCondition permission)
public AccessControlService(GuildDataService data, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi)
{
_data = data;
_guildApi = guildApi;
_userApi = userApi;
_permission = permission;
}
private async Task<Result<bool>> CheckPermissionAsync(GuildData data, Snowflake memberId, IGuildMember member,
DiscordPermission permission, CancellationToken ct = default)
private static bool CheckPermission(IEnumerable<IRole> roles, GuildData data, MemberData memberData,
DiscordPermission permission)
{
var moderatorRole = GuildSettings.ModeratorRole.Get(data.Settings);
var result = await _permission.CheckAsync(new RequireDiscordPermissionAttribute([permission]), member, ct);
if (result.Error is not null and not PermissionDeniedError)
if (!moderatorRole.Empty() && memberData.Roles.Contains(moderatorRole.Value))
{
return Result<bool>.FromError(result);
return true;
}
var hasPermission = result.IsSuccess;
return hasPermission || (!moderatorRole.Empty() &&
data.GetOrCreateMemberData(memberId).Roles.Contains(moderatorRole.Value));
return roles
.Where(r => memberData.Roles.Contains(r.ID.Value))
.Any(r =>
r.Permissions.HasPermission(permission)
);
}
/// <summary>
@ -67,28 +62,21 @@ public sealed class AccessControlService
return Result<string?>.FromSuccess($"UserCannot{action}Themselves".Localized());
}
var botResult = await _userApi.GetCurrentUserAsync(ct);
if (!botResult.IsDefined(out var bot))
{
return Result<string?>.FromError(botResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct);
if (!guildResult.IsDefined(out var guild))
{
return Result<string?>.FromError(guildResult);
}
var targetMemberResult = await _guildApi.GetGuildMemberAsync(guildId, targetId, ct);
if (!targetMemberResult.IsDefined(out var targetMember))
if (interacterId == guild.OwnerID)
{
return Result<string?>.FromSuccess(null);
}
var botMemberResult = await _guildApi.GetGuildMemberAsync(guildId, bot.ID, ct);
if (!botMemberResult.IsDefined(out var botMember))
var botResult = await _userApi.GetCurrentUserAsync(ct);
if (!botResult.IsDefined(out var bot))
{
return Result<string?>.FromError(botMemberResult);
return Result<string?>.FromError(botResult);
}
var rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct);
@ -97,63 +85,46 @@ public sealed class AccessControlService
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, targetMember, botMember, botMember);
return CheckInteractions(action, guild, roles, targetData, botData, botData);
}
var interacterResult = await _guildApi.GetGuildMemberAsync(guildId, interacterId.Value, ct);
if (!interacterResult.IsDefined(out var interacter))
{
return Result<string?>.FromError(interacterResult);
}
var data = await _data.GetData(guildId, ct);
var permissionResult = await CheckPermissionAsync(data, interacterId.Value, interacter,
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()
}, ct);
if (!permissionResult.IsDefined(out var hasPermission))
{
return Result<string?>.FromError(permissionResult);
}
});
return hasPermission
? CheckInteractions(action, guild, roles, targetMember, botMember, interacter)
? 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, IGuildMember targetMember, IGuildMember botMember,
IGuildMember interacter)
string action, IGuild guild, IReadOnlyList<IRole> roles, MemberData targetData, MemberData botData,
MemberData interacterData)
{
if (!targetMember.User.IsDefined(out var targetUser))
{
return new ArgumentNullError(nameof(targetMember.User));
}
if (!interacter.User.IsDefined(out var interacterUser))
{
return new ArgumentNullError(nameof(interacter.User));
}
if (botMember.User == targetMember.User)
if (botData.Id == targetData.Id)
{
return Result<string?>.FromSuccess($"UserCannot{action}Bot".Localized());
}
if (targetUser.ID == guild.OwnerID)
if (targetData.Id == guild.OwnerID)
{
return Result<string?>.FromSuccess($"UserCannot{action}Owner".Localized());
}
var targetRoles = roles.Where(r => targetMember.Roles.Contains(r.ID)).ToList();
var botRoles = roles.Where(r => botMember.Roles.Contains(r.ID));
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)
@ -161,12 +132,7 @@ public sealed class AccessControlService
return Result<string?>.FromSuccess($"BotCannot{action}Target".Localized());
}
if (interacterUser.ID == guild.OwnerID)
{
return Result<string?>.FromSuccess(null);
}
var interacterRoles = roles.Where(r => interacter.Roles.Contains(r.ID));
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

View file

@ -0,0 +1,297 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Remora.Rest.Core;
using TeamOctolings.Octobot.Data;
namespace TeamOctolings.Octobot.Services;
/// <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

@ -2,16 +2,16 @@ using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Octobot.Data;
using Octobot.Extensions;
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 Octobot.Services.Update;
namespace TeamOctolings.Octobot.Services.Update;
public sealed partial class MemberUpdateService : BackgroundService
{
@ -62,7 +62,7 @@ public sealed partial class MemberUpdateService : BackgroundService
}
}
private async Task<Result> TickMemberDatasAsync(Snowflake guildId, CancellationToken ct)
private async Task<Result> TickMemberDatasAsync(Snowflake guildId, CancellationToken ct = default)
{
var guildData = await _guildData.GetData(guildId, ct);
var defaultRole = GuildSettings.DefaultRole.Get(guildData.Settings);
@ -79,7 +79,7 @@ public sealed partial class MemberUpdateService : BackgroundService
private async Task<Result> TickMemberDataAsync(Snowflake guildId, GuildData guildData, Snowflake defaultRole,
MemberData data,
CancellationToken ct)
CancellationToken ct = default)
{
var failedResults = new List<Result>();
var id = data.Id.ToSnowflake();
@ -144,7 +144,7 @@ public sealed partial class MemberUpdateService : BackgroundService
}
private async Task<Result> TryAutoUnbanAsync(
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct)
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct = default)
{
if (data.BannedUntil is null || DateTimeOffset.UtcNow <= data.BannedUntil)
{
@ -169,7 +169,7 @@ public sealed partial class MemberUpdateService : BackgroundService
}
private async Task<Result> TryAutoUnmuteAsync(
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct)
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct = default)
{
if (data.MutedUntil is null || DateTimeOffset.UtcNow <= data.MutedUntil)
{
@ -188,7 +188,7 @@ public sealed partial class MemberUpdateService : BackgroundService
}
private async Task<Result> FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member,
CancellationToken ct)
CancellationToken ct = default)
{
var currentNickname = member.Nickname.IsDefined(out var nickname)
? nickname
@ -226,7 +226,7 @@ public sealed partial class MemberUpdateService : BackgroundService
private static partial Regex IllegalChars();
private async Task<Result> TickReminderAsync(Reminder reminder, IUser user, MemberData data, Snowflake guildId,
CancellationToken ct)
CancellationToken ct = default)
{
if (DateTimeOffset.UtcNow < reminder.At)
{
@ -234,7 +234,7 @@ public sealed partial class MemberUpdateService : BackgroundService
}
var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text)))
.AppendLine(MarkdownExtensions.Quote(reminder.Text))
.AppendBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage,
$"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}"));

View file

@ -1,8 +1,6 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Octobot.Data;
using Octobot.Extensions;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
@ -10,8 +8,10 @@ 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 Octobot.Services.Update;
namespace TeamOctolings.Octobot.Services.Update;
public sealed class ScheduledEventUpdateService : BackgroundService
{
@ -46,7 +46,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
}
}
private async Task<Result> TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct)
private async Task<Result> TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct = default)
{
var failedResults = new List<Result>();
var data = await _guildData.GetData(guildId, ct);
@ -133,7 +133,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
private async Task<Result> TickScheduledEventAsync(
Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData,
CancellationToken ct)
CancellationToken ct = default)
{
if (GuildSettings.AutoStartEvents.Get(data.Settings)
&& DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime
@ -160,7 +160,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
}
private async Task<Result> AutoStartEventAsync(
Snowflake guildId, IGuildScheduledEvent scheduledEvent, CancellationToken ct)
Snowflake guildId, IGuildScheduledEvent scheduledEvent, CancellationToken ct = default)
{
return (Result)await _eventApi.ModifyGuildScheduledEventAsync(
guildId, scheduledEvent.ID,
@ -223,13 +223,13 @@ public sealed class ScheduledEventUpdateService : BackgroundService
var button = new ButtonComponent(
ButtonComponentStyle.Link,
Messages.ButtonOpenEventInfo,
new PartialEmoji(Name: "📋"),
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(new[] { button }) }, ct: ct);
components: new[] { new ActionRowComponent([button]) }, ct: ct);
}
private static Result<string> GetExternalScheduledEventCreatedEmbedDescription(
@ -319,7 +319,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
}
private async Task<Result> SendScheduledEventCompletedMessage(ScheduledEventData eventData, GuildData data,
CancellationToken ct)
CancellationToken ct = default)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
@ -351,7 +351,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
}
private async Task<Result> SendScheduledEventCancelledMessage(ScheduledEventData eventData, GuildData data,
CancellationToken ct)
CancellationToken ct = default)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
@ -405,7 +405,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
}
private async Task<Result> SendEarlyEventNotificationAsync(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct)
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{

View file

@ -4,7 +4,7 @@ using Remora.Discord.API.Gateway.Commands;
using Remora.Discord.API.Objects;
using Remora.Discord.Gateway;
namespace Octobot.Services.Update;
namespace TeamOctolings.Octobot.Services.Update;
public sealed class SongUpdateService : BackgroundService
{
@ -29,10 +29,19 @@ public sealed class SongUpdateService : BackgroundService
("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", "Fly Octo Fly ~ Ebb & Flow (Octo)", new TimeSpan(0, 3, 5))
("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 readonly List<Activity> _activityList = [new Activity("with Remora.Discord", ActivityType.Game)];
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;
@ -54,19 +63,33 @@ public sealed class SongUpdateService : BackgroundService
while (!ct.IsCancellationRequested)
{
var nextSong = SongList[_nextSongIndex];
var nextSong = NextSong();
_activityList[0] = new Activity($"{nextSong.Name} / {nextSong.Author}",
ActivityType.Listening);
_client.SubmitCommand(
new UpdatePresence(
UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList));
_nextSongIndex++;
if (_nextSongIndex >= SongList.Length)
{
_nextSongIndex = 0;
}
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,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
@ -16,31 +16,31 @@
<Company>TeamOctolings</Company>
<NeutralLanguage>en</NeutralLanguage>
<Description>A general-purpose Discord bot for moderation written in C#</Description>
<ApplicationIcon>docs/octobot.ico</ApplicationIcon>
<ApplicationIcon>../docs/octobot.ico</ApplicationIcon>
<GitVersion>false</GitVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DiffPlex" Version="1.7.2" />
<PackageReference Include="GitInfo" Version="3.3.4" />
<PackageReference Include="GitInfo" Version="3.3.5" />
<PackageReference Include="Humanizer.Core.ru" Version="2.14.1" />
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Remora.Commands" Version="10.0.5" />
<PackageReference Include="Remora.Discord.Caching" Version="38.0.1" />
<PackageReference Include="Remora.Discord.Extensions" Version="5.3.4" />
<PackageReference Include="Remora.Discord.Hosting" Version="6.0.9" />
<PackageReference Include="Remora.Discord.Interactivity" Version="4.5.3" />
<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="locale\Messages.resx">
<EmbeddedResource Update="Messages.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Messages.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="CodeAnalysis\BannedSymbols.txt" />
<AdditionalFiles Include="..\CodeAnalysis\BannedSymbols.txt" />
</ItemGroup>
</Project>

View file

@ -1,16 +1,19 @@
using System.Drawing;
using System.Text;
using System.Text.Json.Nodes;
using Octobot.Data;
using Octobot.Extensions;
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 Octobot.Services;
namespace TeamOctolings.Octobot;
/// <summary>
/// Provides utility methods that cannot be transformed to extension methods because they require usage
@ -18,6 +21,9 @@ namespace Octobot.Services;
/// </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;
@ -30,6 +36,9 @@ public sealed class Utility
_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
@ -58,8 +67,8 @@ public sealed class Utility
builder.Append($"{Mention.Role(role)} ");
}
builder = subscribers.Where(
subscriber => !data.GetOrCreateMemberData(subscriber.User.ID).Roles.Contains(role.Value))
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();
}
@ -116,7 +125,7 @@ public sealed class Utility
}
}
public async Task<Result<Snowflake>> GetEmergencyFeedbackChannel(IGuild guild, GuildData data, CancellationToken ct)
public async Task<Result<Snowflake>> GetEmergencyFeedbackChannel(IGuild guild, GuildData data, CancellationToken ct = default)
{
var privateFeedback = GuildSettings.PrivateFeedbackChannel.Get(data.Settings);
if (!privateFeedback.Empty())

17
compose.example.yaml Normal file
View file

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

View file

@ -15,23 +15,16 @@ Veemo! I'm a general-purpose bot for moderation (formerly known as Boyfriend) wr
* 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 music!
* Listen to Inkantation!
*...a-a-and more!*
## Building Octobot
1. Install [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
2. Go to the [Discord Developer Portal](https://discord.com/developers), create a new application and get a bot token. Don't forget to also enable all intents!
3. Clone this repository and open `Octobot` folder.
```
git clone https://github.com/TeamOctolings/Octobot
cd Octobot
```
4. Run Octobot using `dotnet` with `BOT_TOKEN` variable.
```
dotnet run BOT_TOKEN='ENTER_TOKEN_HERE'
```
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

View file

@ -1,684 +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="YouWereBanned" 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 дней таймаут не разрешает, вот настроишь роль мута, тогда поговорим</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 уважаемого (я забываю о шизоидах если они ливнули минимум месяц назад)</value>
</data>
<data name="SettingsDefaultRole" xml:space="preserve">
<value>дефолтное звание</value>
</data>
<data name="SettingsPrivateFeedbackChannel" xml:space="preserve">
<value>канал для секретных уведомлений</value>
</data>
<data name="SettingsPublicFeedbackChannel" 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="UserUnmuted" xml:space="preserve">
<value>{0} в размуте</value>
</data>
<data name="UserNotMuted" xml:space="preserve">
<value>этого шизоида никто не мутил.</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="MessageFrom" xml:space="preserve">
<value>от {0}</value>
</data>
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>девелоперы:</value>
</data>
<data name="ButtonOpenRepository" xml:space="preserve">
<value>репа Octobot (тык)</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>немного об {0}</value>
</data>
<data name="AboutDeveloper@mctaylors" xml:space="preserve">
<value>скучный девелопер + дизайнер создавший Octobot's Wiki</value>
</data>
<data name="AboutDeveloper@neroduckale" xml:space="preserve">
<value>ВАЖНЫЙ соучастник кодинг-стримов @Octol1ttle</value>
</data>
<data name="AboutDeveloper@Octol1ttle" xml:space="preserve">
<value>САМЫЙ ВАЖНЫЙ чел написавший кода больше всех (99.99%)</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>если я был бы html, я бы сказал 404</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>100% да</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>правильно пишут так: `1h30m`</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 (жмак)</value>
</data>
<data name="SettingsModeratorRole" xml:space="preserve">
<value>звание админа</value>
</data>
</root>

View file

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

View file

@ -1,186 +0,0 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Octobot.Data;
using Remora.Rest.Core;
namespace 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)
{
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)
{
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";
MigrateGuildData(guildId, path);
Directory.CreateDirectory(path);
if (!File.Exists(settingsPath))
{
await File.WriteAllTextAsync(settingsPath, "{}", ct);
}
if (!File.Exists(scheduledEventsPath))
{
await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct);
}
var dataLoadFailed = false;
await using var settingsStream = File.OpenRead(settingsPath);
JsonNode? jsonSettings = null;
try
{
jsonSettings = await JsonNode.ParseAsync(settingsStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Guild settings load failed: {Path}", settingsPath);
dataLoadFailed = true;
}
await using var eventsStream = File.OpenRead(scheduledEventsPath);
Dictionary<ulong, ScheduledEventData>? events = null;
try
{
events = await JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
eventsStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Guild scheduled events load failed: {Path}", scheduledEventsPath);
dataLoadFailed = true;
}
var memberData = new Dictionary<ulong, MemberData>();
foreach (var dataFileInfo in Directory.CreateDirectory(memberDataPath).GetFiles())
{
await using var dataStream = dataFileInfo.OpenRead();
MemberData? data;
try
{
data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Member data load failed: {MemberDataPath}/{FileName}", memberDataPath,
dataFileInfo.Name);
dataLoadFailed = true;
continue;
}
if (data is null)
{
continue;
}
memberData.Add(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 void MigrateGuildData(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);
}
}
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 _);
}
}