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.

24 commits
warn ... master

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
43 changed files with 430 additions and 217 deletions

View file

@ -36,5 +36,6 @@ updates:
- "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.10
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

@ -2,7 +2,9 @@
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

@ -106,9 +106,9 @@ public sealed class AboutCommandGroup : CommandGroup
var repositoryButton = new ButtonComponent(
ButtonComponentStyle.Link,
Messages.ButtonOpenRepository,
Messages.ButtonOpenWebsite,
new PartialEmoji(Name: "\ud83c\udf10"), // 'GLOBE WITH MERIDIANS' (U+1F310)
URL: BuildInfo.RepositoryUrl
URL: BuildInfo.WebsiteUrl
);
var wikiButton = new ButtonComponent(
@ -131,7 +131,7 @@ public sealed 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

@ -62,7 +62,7 @@ public sealed 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 sealed 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

@ -51,7 +51,7 @@ public sealed 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)]

View file

@ -81,7 +81,7 @@ public sealed class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
return ResultExtensions.FromError(await _feedback.SendContextualEmbedResultAsync(embed,
new FeedbackMessageOptions(MessageComponents: new[]
{
new ActionRowComponent(new[] { issuesButton })
new ActionRowComponent([issuesButton])
}), ct)
);
}

View file

@ -288,7 +288,7 @@ public sealed class InfoCommandGroup : 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}");

View file

@ -57,7 +57,7 @@ public sealed 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

@ -59,7 +59,7 @@ public sealed 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 sealed 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 sealed 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 sealed 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 sealed 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

@ -78,7 +78,7 @@ public sealed 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 sealed 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 sealed 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 sealed 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 sealed 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 sealed 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 sealed 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

@ -202,6 +202,27 @@ public sealed class SettingsCommandGroup : CommandGroup
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)
{

View file

@ -90,7 +90,7 @@ public sealed class ToolsCommandGroup : CommandGroup
}
private Task<Result> SendRandomNumberAsync(long first, long? secondNullable,
IUser executor, CancellationToken ct)
IUser executor, CancellationToken ct = default)
{
const long secondDefault = 0;
var second = secondNullable ?? secondDefault;
@ -187,7 +187,7 @@ public sealed class ToolsCommandGroup : CommandGroup
return await SendTimestampAsync(offset, executor, CancellationToken);
}
private Task<Result> SendTimestampAsync(TimeSpan? offset, IUser executor, CancellationToken ct)
private Task<Result> SendTimestampAsync(TimeSpan? offset, IUser executor, CancellationToken ct = default)
{
var timestamp = DateTimeOffset.UtcNow.Add(offset ?? TimeSpan.Zero).ToUnixTimeSeconds();
@ -249,7 +249,7 @@ public sealed class ToolsCommandGroup : CommandGroup
return await AnswerEightBallAsync(bot, CancellationToken);
}
private Task<Result> AnswerEightBallAsync(IUser bot, CancellationToken ct)
private Task<Result> AnswerEightBallAsync(IUser bot, CancellationToken ct = default)
{
var typeNumber = Random.Shared.Next(0, 4);
var embedColor = typeNumber switch

View file

@ -12,6 +12,16 @@ public sealed class BoolOption : GuildOption<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

@ -21,9 +21,19 @@ public class GuildOption<T> : IGuildOption
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

@ -7,6 +7,7 @@ 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,6 +1,5 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
@ -16,9 +15,9 @@ public sealed class LanguageOption : GuildOption<CultureInfo>
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

@ -8,6 +8,16 @@ 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

@ -1,9 +1,9 @@
namespace TeamOctolings.Octobot.Data;
public struct Reminder
public sealed record Reminder
{
public DateTimeOffset At { get; init; }
public string Text { get; init; }
public ulong ChannelId { get; init; }
public ulong MessageId { get; init; }
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

@ -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

@ -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

@ -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

@ -23,7 +23,7 @@ public static class ResultExtensions
private static void LogResultStackTrace(Result result)
{
if (result.IsSuccess)
if (result.IsSuccess || result.Error is ExceptionError { Exception: OperationCanceledException })
{
return;
}

View file

@ -633,9 +633,9 @@ namespace TeamOctolings.Octobot {
}
}
internal static string ButtonOpenRepository {
internal static string ButtonOpenWebsite {
get {
return ResourceManager.GetString("ButtonOpenRepository", resourceCulture);
return ResourceManager.GetString("ButtonOpenWebsite", resourceCulture);
}
}
@ -1196,5 +1196,11 @@ namespace TeamOctolings.Octobot {
return ResourceManager.GetString("SettingsModeratorRole", resourceCulture);
}
}
internal static string SettingValueEquals {
get {
return ResourceManager.GetString("SettingValueEquals", resourceCulture);
}
}
}
}

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

@ -39,8 +39,7 @@ public sealed class Program
private static IHostBuilder CreateHostBuilder(string[] args)
{
return Host.CreateDefaultBuilder(args)
.AddDiscordService(
services =>
.AddDiscordService(services =>
{
var configuration = services.GetRequiredService<IConfiguration>();
@ -49,25 +48,22 @@ public sealed class Program
"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
@ -87,14 +83,13 @@ public sealed class Program
.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

@ -94,7 +94,7 @@ public sealed 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))
@ -120,6 +120,6 @@ public sealed class GuildLoadedResponder : IResponder<IGuildCreate>
);
return await _channelApi.CreateMessageWithEmbedResultAsync(channel, embedResult: errorEmbed,
components: new[] { new ActionRowComponent(new[] { issuesButton }) }, ct: ct);
components: new[] { new ActionRowComponent([issuesButton]) }, ct: ct);
}
}

View file

@ -81,7 +81,7 @@ public sealed class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
}
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

@ -36,13 +36,9 @@ public sealed 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;
}

View file

@ -66,10 +66,10 @@ public sealed 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)
{

View file

@ -36,40 +36,29 @@ public sealed 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,22 +72,22 @@ public sealed 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();

View file

@ -27,7 +27,7 @@ public sealed class GuildDataService : BackgroundService
return SaveAsync(ct);
}
private Task SaveAsync(CancellationToken ct)
private Task SaveAsync(CancellationToken ct = default)
{
var tasks = new List<Task>();
var datas = _datas.Values.ToArray();
@ -44,7 +44,7 @@ public sealed class GuildDataService : BackgroundService
return Task.WhenAll(tasks);
}
private static async Task SerializeObjectSafelyAsync<T>(T obj, string path, CancellationToken ct)
private static async Task SerializeObjectSafelyAsync<T>(T obj, string path, CancellationToken ct = default)
{
var tempFilePath = path + ".tmp";
await using (var tempFileStream = File.Create(tempFilePath))
@ -75,78 +75,48 @@ public sealed class GuildDataService : BackgroundService
{
var path = $"GuildData/{guildId}";
var memberDataPath = $"{path}/MemberData";
var settingsPath = $"{path}/Settings.json";
var scheduledEventsPath = $"{path}/ScheduledEvents.json";
MigrateDataDirectory(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;
}
var jsonSettings = await LoadGuildSettings(settingsPath, ct);
if (jsonSettings is not null)
{
FixJsonSettings(jsonSettings);
}
await using var eventsStream = File.OpenRead(scheduledEventsPath);
Dictionary<ulong, ScheduledEventData>? events = null;
try
else
{
events = await JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
eventsStream, cancellationToken: ct);
dataLoadFailed = true;
}
catch (Exception e)
var events = await LoadScheduledEvents(scheduledEventsPath, ct);
if (events is null)
{
_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())
foreach (var dataFileInfo in Directory.CreateDirectory(memberDataPath).GetFiles()
.Where(dataFileInfo =>
!memberData.ContainsKey(
ulong.Parse(dataFileInfo.Name.Replace(".json", "").Replace(".tmp", "")))))
{
await using var dataStream = dataFileInfo.OpenRead();
MemberData? data;
try
var data = await LoadMemberData(dataFileInfo, memberDataPath, true, ct);
if (data == null)
{
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);
memberData.TryAdd(data.Id, data);
}
var finalData = new GuildData(
@ -160,6 +130,133 @@ public sealed class GuildDataService : BackgroundService
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}";

View file

@ -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

@ -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,
@ -229,7 +229,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
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

@ -29,7 +29,11 @@ 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 static readonly (string Author, string Name, TimeSpan Duration)[] SpecialSongList =
@ -37,7 +41,7 @@ public sealed class SongUpdateService : BackgroundService
("Squid Sisters", "Maritime Memory", new TimeSpan(0, 2, 47))
];
private readonly List<Activity> _activityList = [new Activity("with Remora.Discord", ActivityType.Game)];
private readonly List<Activity> _activityList = [new("with Remora.Discord", ActivityType.Game)];
private readonly DiscordGatewayClient _client;
private readonly GuildDataService _guildData;

View file

@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
@ -24,14 +24,14 @@
<PackageReference Include="DiffPlex" Version="1.7.2" />
<PackageReference Include="GitInfo" Version="3.3.5" />
<PackageReference Include="Humanizer.Core.ru" Version="2.14.1" />
<PackageReference Include="JetBrains.Annotations" Version="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="39.0.0" />
<PackageReference Include="Remora.Discord.Extensions" Version="5.3.5" />
<PackageReference Include="Remora.Discord.Hosting" Version="6.0.10" />
<PackageReference Include="Remora.Discord.Interactivity" Version="4.5.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0"/>
<PackageReference Include="Remora.Commands" Version="11.0.1"/>
<PackageReference Include="Remora.Discord.Caching" Version="40.0.0" />
<PackageReference Include="Remora.Discord.Extensions" Version="6.0.0"/>
<PackageReference Include="Remora.Discord.Hosting" Version="7.0.0" />
<PackageReference Include="Remora.Discord.Interactivity" Version="6.0.0"/>
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
</ItemGroup>
<ItemGroup>

View file

@ -67,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();
}
@ -125,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